clementine-agent 1.15.0 → 1.16.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.
@@ -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.16.0",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",