clementine-agent 1.15.0 → 1.17.0

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.
@@ -200,6 +200,27 @@ export declare class PersonalAssistant {
200
200
  * to avoid blocking the user's query.
201
201
  */
202
202
  private buildLocalSummary;
203
+ private buildLocalSummaryFromTurns;
204
+ /**
205
+ * Walk a chronological list of transcript turns and pair adjacent
206
+ * user→assistant rows. Drops 'system' rows and orphan tail user turns
207
+ * (which represent in-flight messages with no reply yet).
208
+ */
209
+ private pairTranscriptTurns;
210
+ /**
211
+ * Build a short summary of older turns (older than what's already cached
212
+ * in `lastExchanges`) for restart-restore prompt injection. Returns ''
213
+ * if there's nothing older or no memory store. Capped at 600 chars.
214
+ */
215
+ private buildOlderTurnsContext;
216
+ /**
217
+ * Reconstruct context after the SDK reports the session is dead
218
+ * ("no conversation found"). Pulls last N turns from the transcripts
219
+ * table (hydrating `lastExchanges` if the cache is too thin) and
220
+ * builds a recovery prefix that gets prepended to the retry prompt.
221
+ * Mirrors the buildContextRecoveredPrompt pattern used by autocompact.
222
+ */
223
+ private buildSessionDeathRecoveryPrompt;
203
224
  /**
204
225
  * Auto-save a lightweight handoff file when a session rotates.
205
226
  * Uses in-memory exchange history — no LLM call.
@@ -2368,13 +2368,19 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2368
2368
  if (key && this.restoredSessions.has(key)) {
2369
2369
  const exchanges = this.lastExchanges.get(key) ?? [];
2370
2370
  if (exchanges.length > 0) {
2371
+ const olderSummary = this.buildOlderTurnsContext(key, exchanges);
2371
2372
  const historyLines = [];
2372
2373
  for (const ex of exchanges.slice(-5)) {
2373
2374
  historyLines.push(`You said: ${ex.user.slice(0, 800)}`);
2374
2375
  historyLines.push(`I replied: ${ex.assistant.slice(0, 800)}`);
2375
2376
  }
2376
- effectivePrompt =
2377
- `[Conversation context from before restart (our recent messages):\n${historyLines.join('\n')}]\n\n${effectivePrompt}`;
2377
+ const blocks = [];
2378
+ if (olderSummary)
2379
+ blocks.push(`[Older session summary: ${olderSummary}]`);
2380
+ blocks.push(`[Conversation context from before restart (our recent messages):\n${historyLines.join('\n')}]`);
2381
+ const prefix = blocks.join('\n\n');
2382
+ logger.debug({ sessionKey: key, prefixLen: prefix.length, hasOlder: !!olderSummary }, 'Restart restore prefix assembled');
2383
+ effectivePrompt = `${prefix}\n\n${effectivePrompt}`;
2378
2384
  }
2379
2385
  this.restoredSessions.delete(key); // Only inject once per restored session
2380
2386
  }
@@ -2838,10 +2844,11 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2838
2844
  break;
2839
2845
  }
2840
2846
  else if (lower.includes('no conversation found') || lower.includes('conversation not found') || lower.includes('session not found')) {
2841
- // Stale session — clear and retry with fresh session
2842
- logger.warn({ sessionKey }, 'Stale session ID — clearing and retrying');
2847
+ // Stale session — clear and reconstruct context from transcripts before retrying
2848
+ logger.warn({ sessionKey }, 'Stale session ID — reconstructing context from transcripts');
2843
2849
  if (sessionKey) {
2844
2850
  this.sessions.delete(sessionKey);
2851
+ prompt = this.buildSessionDeathRecoveryPrompt(prompt, sessionKey);
2845
2852
  }
2846
2853
  staleSession = true;
2847
2854
  break; // Break inner stream loop — staleSession flag triggers retry
@@ -2947,10 +2954,11 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2947
2954
  "I've reset the session. Try again — I'll keep result sets smaller this time.");
2948
2955
  }
2949
2956
  else if (errStr.includes('no conversation found') || errStr.includes('conversation not found') || errStr.includes('session not found')) {
2950
- // Stale session — clear and retry
2951
- logger.warn({ sessionKey }, 'Stale session ID (exception) — clearing and retrying');
2957
+ // Stale session — clear and reconstruct context from transcripts before retrying
2958
+ logger.warn({ sessionKey }, 'Stale session ID (exception) — reconstructing context from transcripts');
2952
2959
  if (sessionKey) {
2953
2960
  this.sessions.delete(sessionKey);
2961
+ prompt = this.buildSessionDeathRecoveryPrompt(prompt, sessionKey);
2954
2962
  }
2955
2963
  continue; // Retry with fresh session
2956
2964
  }
@@ -3312,17 +3320,116 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3312
3320
  * to avoid blocking the user's query.
3313
3321
  */
3314
3322
  buildLocalSummary(sessionKey) {
3315
- const exchanges = this.lastExchanges.get(sessionKey) ?? [];
3316
- if (exchanges.length === 0)
3323
+ return this.buildLocalSummaryFromTurns(this.lastExchanges.get(sessionKey) ?? []);
3324
+ }
3325
+ buildLocalSummaryFromTurns(turns, opts) {
3326
+ if (turns.length === 0)
3317
3327
  return '';
3318
- const recent = exchanges.slice(-5);
3328
+ const take = opts?.take ?? 5;
3329
+ const userMax = opts?.userMax ?? 200;
3330
+ const assistantMax = opts?.assistantMax ?? 300;
3331
+ const recent = turns.slice(-take);
3332
+ const baseIndex = opts?.startIndex ?? (turns.length - recent.length);
3319
3333
  const lines = recent.map((ex, i) => {
3320
- const userSnippet = ex.user.slice(0, 200).replace(/\n/g, ' ');
3321
- const assistantSnippet = ex.assistant.slice(0, 300).replace(/\n/g, ' ');
3322
- return `- Exchange ${exchanges.length - recent.length + i + 1}: User asked about "${userSnippet}" / I responded "${assistantSnippet}"`;
3334
+ const userSnippet = ex.user.slice(0, userMax).replace(/\n/g, ' ');
3335
+ const assistantSnippet = ex.assistant.slice(0, assistantMax).replace(/\n/g, ' ');
3336
+ return `- Exchange ${baseIndex + i + 1}: User asked about "${userSnippet}" / I responded "${assistantSnippet}"`;
3323
3337
  });
3324
3338
  return lines.join('\n');
3325
3339
  }
3340
+ /**
3341
+ * Walk a chronological list of transcript turns and pair adjacent
3342
+ * user→assistant rows. Drops 'system' rows and orphan tail user turns
3343
+ * (which represent in-flight messages with no reply yet).
3344
+ */
3345
+ pairTranscriptTurns(turns) {
3346
+ const pairs = [];
3347
+ let pendingUser = null;
3348
+ for (const turn of turns) {
3349
+ if (turn.role === 'user') {
3350
+ pendingUser = turn.content;
3351
+ }
3352
+ else if (turn.role === 'assistant' && pendingUser !== null) {
3353
+ pairs.push({ user: pendingUser, assistant: turn.content });
3354
+ pendingUser = null;
3355
+ }
3356
+ }
3357
+ return pairs;
3358
+ }
3359
+ /**
3360
+ * Build a short summary of older turns (older than what's already cached
3361
+ * in `lastExchanges`) for restart-restore prompt injection. Returns ''
3362
+ * if there's nothing older or no memory store. Capped at 600 chars.
3363
+ */
3364
+ buildOlderTurnsContext(sessionKey, cachedExchanges) {
3365
+ if (cachedExchanges.length === 0)
3366
+ return '';
3367
+ if (!this.memoryStore || typeof this.memoryStore.getTranscriptTail !== 'function')
3368
+ return '';
3369
+ try {
3370
+ const skipTurns = cachedExchanges.length * 2;
3371
+ const older = this.memoryStore.getTranscriptTail(sessionKey, skipTurns, 40);
3372
+ if (!older || older.length === 0)
3373
+ return '';
3374
+ const pairs = this.pairTranscriptTurns(older);
3375
+ if (pairs.length === 0)
3376
+ return '';
3377
+ const summary = this.buildLocalSummaryFromTurns(pairs, {
3378
+ take: pairs.length,
3379
+ userMax: 120,
3380
+ assistantMax: 180,
3381
+ });
3382
+ return summary.slice(0, 600);
3383
+ }
3384
+ catch (err) {
3385
+ logger.debug({ err, sessionKey }, 'buildOlderTurnsContext failed — non-fatal');
3386
+ return '';
3387
+ }
3388
+ }
3389
+ /**
3390
+ * Reconstruct context after the SDK reports the session is dead
3391
+ * ("no conversation found"). Pulls last N turns from the transcripts
3392
+ * table (hydrating `lastExchanges` if the cache is too thin) and
3393
+ * builds a recovery prefix that gets prepended to the retry prompt.
3394
+ * Mirrors the buildContextRecoveredPrompt pattern used by autocompact.
3395
+ */
3396
+ buildSessionDeathRecoveryPrompt(prompt, sessionKey) {
3397
+ if (!sessionKey || !this.memoryStore)
3398
+ return prompt;
3399
+ try {
3400
+ let exchanges = this.lastExchanges.get(sessionKey) ?? [];
3401
+ // Hydrate from transcripts if the cache is too thin
3402
+ if (exchanges.length < 3 && typeof this.memoryStore.getTranscriptTail === 'function') {
3403
+ const recent = this.memoryStore.getTranscriptTail(sessionKey, 0, SESSION_EXCHANGE_HISTORY_SIZE * 2);
3404
+ const hydrated = this.pairTranscriptTurns(recent ?? []);
3405
+ if (hydrated.length > exchanges.length) {
3406
+ exchanges = hydrated;
3407
+ this.lastExchanges.set(sessionKey, hydrated.slice(-SESSION_EXCHANGE_HISTORY_SIZE));
3408
+ }
3409
+ }
3410
+ const olderSummary = this.buildOlderTurnsContext(sessionKey, exchanges);
3411
+ const recentLines = [];
3412
+ for (const ex of exchanges.slice(-5)) {
3413
+ recentLines.push(`You said: ${ex.user.slice(0, 800)}`);
3414
+ recentLines.push(`I replied: ${ex.assistant.slice(0, 800)}`);
3415
+ }
3416
+ if (!olderSummary && recentLines.length === 0)
3417
+ return prompt;
3418
+ const blocks = ['[Recovering context after session expired.'];
3419
+ if (olderSummary)
3420
+ blocks.push(`Older session summary: ${olderSummary}`);
3421
+ if (recentLines.length > 0) {
3422
+ blocks.push(`Recent messages:\n${recentLines.join('\n')}`);
3423
+ }
3424
+ const prefix = blocks.join('\n') + ']';
3425
+ logger.debug({ sessionKey, prefixLen: prefix.length }, 'Session-death recovery prefix assembled');
3426
+ return `${prefix}\n\n${prompt}`;
3427
+ }
3428
+ catch (err) {
3429
+ logger.debug({ err, sessionKey }, 'buildSessionDeathRecoveryPrompt failed — non-fatal');
3430
+ return prompt;
3431
+ }
3432
+ }
3326
3433
  /**
3327
3434
  * Auto-save a lightweight handoff file when a session rotates.
3328
3435
  * Uses in-memory exchange history — no LLM call.
@@ -4662,13 +4769,28 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4662
4769
  // Periodic progress beacon — sends a status update every 5 minutes
4663
4770
  // so the user knows the task is still alive during long phases.
4664
4771
  // Capped at 3 messages per phase to prevent notification spam.
4772
+ // Also refreshes status.json so dashboard polls see liveness even
4773
+ // when the SDK stream hasn't emitted a result yet.
4665
4774
  const BEACON_INTERVAL_MS = 5 * 60 * 1000;
4666
4775
  const MAX_BEACONS_PER_PHASE = 3;
4667
4776
  let beaconCount = 0;
4668
4777
  const beaconTimer = setInterval(() => {
4778
+ const mins = Math.round((Date.now() - phaseStart) / 60_000);
4779
+ try {
4780
+ writeStatus({
4781
+ jobName,
4782
+ status: 'running',
4783
+ phase,
4784
+ startedAt,
4785
+ maxHours: effectiveMaxHours,
4786
+ phaseStartedAt: new Date(phaseStart).toISOString(),
4787
+ phaseElapsedMin: mins,
4788
+ toolCallsThisPhase: phaseToolCount,
4789
+ });
4790
+ }
4791
+ catch { /* non-fatal */ }
4669
4792
  if (this.onPhaseProgress && beaconCount < MAX_BEACONS_PER_PHASE) {
4670
4793
  beaconCount++;
4671
- const mins = Math.round((Date.now() - phaseStart) / 60_000);
4672
4794
  try {
4673
4795
  // Conversational beacon — no technical jargon
4674
4796
  const msg = mins < 3
@@ -4719,6 +4841,22 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4719
4841
  // Capture terminal reason for execution advisor
4720
4842
  this._lastTerminalReason = result.terminal_reason ?? undefined;
4721
4843
  this.logQueryResult(result, 'unleashed', `unleashed:${jobName}`, jobName);
4844
+ // Refresh status.json the moment the SDK reports result —
4845
+ // even if the underlying stream stalls afterward, the dashboard
4846
+ // sees liveness instead of a frozen "phase 0 / running" row.
4847
+ try {
4848
+ writeStatus({
4849
+ jobName,
4850
+ status: 'running',
4851
+ phase,
4852
+ startedAt,
4853
+ maxHours: effectiveMaxHours,
4854
+ lastResultAt: new Date().toISOString(),
4855
+ lastResultIsError: !!result.is_error,
4856
+ toolCallsThisPhase: phaseToolCount,
4857
+ });
4858
+ }
4859
+ catch { /* non-fatal */ }
4722
4860
  // Detect dollar-budget exceeded (strict marker — see cron
4723
4861
  // handler above for the reasoning).
4724
4862
  if (result.is_error && 'result' in result) {
@@ -50,6 +50,21 @@ const CHAIN_MARKERS = [
50
50
  /\bonce\s+(that|you)\b.*,/i,
51
51
  /\bnext\b.*,/i,
52
52
  ];
53
+ /**
54
+ * Patterns that look like a pasted error message or stack trace.
55
+ * Error pastes are long and entity-heavy (file paths, quoted strings,
56
+ * "Error:" prefixes), which previously tripped the deepWorthy gate
57
+ * even when the user was just asking "what's wrong with this?". We
58
+ * still allow the plan-first directive to fire; we just don't auto-spawn
59
+ * an expensive multi-phase background task on a debug request.
60
+ */
61
+ const ERROR_PASTE_MARKERS = [
62
+ /\b(Error|Exception|Traceback|Stack ?trace):\s/i,
63
+ /^\s*at\s+[\w.$<>]+\s*\(/m, // JS/TS stack frame: "at foo.bar (file:line)"
64
+ /\bfailed:\s*Error\b/i,
65
+ /Reached maximum number of turns/i,
66
+ /\bENOENT\b|\bECONNREFUSED\b|\bETIMEDOUT\b/,
67
+ ];
53
68
  /**
54
69
  * Phrasings that explicitly ask for plan-first behavior. Triggers
55
70
  * regardless of other heuristics.
@@ -148,7 +163,14 @@ export function classifyComplexity(text) {
148
163
  isLong,
149
164
  entities >= 3,
150
165
  ].filter(Boolean).length;
151
- const deepWorthy = strongCount >= 2;
166
+ // Suppress deepWorthy on pasted error messages. They're long and
167
+ // entity-heavy (file paths, quoted strings) but the user is asking
168
+ // "what's wrong here?", not requesting sustained autonomous work.
169
+ // The plan-first path still fires when complex=true.
170
+ const looksLikeErrorPaste = ERROR_PASTE_MARKERS.some((re) => re.test(trimmed));
171
+ if (looksLikeErrorPaste)
172
+ signals.push('error-paste');
173
+ const deepWorthy = strongCount >= 2 && !looksLikeErrorPaste;
152
174
  if (complex) {
153
175
  return {
154
176
  complex: true,
@@ -473,6 +473,13 @@ export declare class MemoryStore {
473
473
  * Get all turns for a given session, ordered chronologically.
474
474
  */
475
475
  getSessionTranscript(sessionKey: string): TranscriptTurn[];
476
+ /**
477
+ * Get user/assistant transcript turns from the tail of a session, skipping
478
+ * the most recent `skipFromTail` turns. Returned chronologically. Used to
479
+ * reconstruct context older than what's already in the in-memory cache,
480
+ * for restart restore and SDK session-death recovery.
481
+ */
482
+ getTranscriptTail(sessionKey: string, skipFromTail: number, limit?: number): TranscriptTurn[];
476
483
  /**
477
484
  * Get recent transcript activity across all sessions since a given timestamp.
478
485
  * Returns a compact summary of what happened (sessions, message counts, snippets).
@@ -2393,6 +2393,30 @@ export class MemoryStore {
2393
2393
  createdAt: row.created_at,
2394
2394
  }));
2395
2395
  }
2396
+ /**
2397
+ * Get user/assistant transcript turns from the tail of a session, skipping
2398
+ * the most recent `skipFromTail` turns. Returned chronologically. Used to
2399
+ * reconstruct context older than what's already in the in-memory cache,
2400
+ * for restart restore and SDK session-death recovery.
2401
+ */
2402
+ getTranscriptTail(sessionKey, skipFromTail, limit = 40) {
2403
+ const rows = this.conn
2404
+ .prepare(`SELECT session_key, role, content, model, created_at
2405
+ FROM transcripts
2406
+ WHERE session_key = ? AND role IN ('user','assistant')
2407
+ ORDER BY created_at DESC, id DESC
2408
+ LIMIT ? OFFSET ?`)
2409
+ .all(sessionKey, limit, Math.max(0, skipFromTail));
2410
+ return rows
2411
+ .map((row) => ({
2412
+ sessionKey: row.session_key,
2413
+ role: row.role,
2414
+ content: row.content,
2415
+ model: row.model,
2416
+ createdAt: row.created_at,
2417
+ }))
2418
+ .reverse();
2419
+ }
2396
2420
  /**
2397
2421
  * Get recent transcript activity across all sessions since a given timestamp.
2398
2422
  * Returns a compact summary of what happened (sessions, message counts, snippets).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",