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.
- package/dist/agent/assistant.d.ts +21 -0
- package/dist/agent/assistant.js +151 -13
- package/dist/agent/complexity-classifier.js +23 -1
- package/dist/memory/store.d.ts +7 -0
- package/dist/memory/store.js +24 -0
- package/package.json +1 -1
|
@@ -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.
|
package/dist/agent/assistant.js
CHANGED
|
@@ -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
|
-
|
|
2377
|
-
|
|
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
|
|
2842
|
-
logger.warn({ sessionKey }, 'Stale session ID —
|
|
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
|
|
2951
|
-
logger.warn({ sessionKey }, 'Stale session ID (exception) —
|
|
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
|
-
|
|
3316
|
-
|
|
3323
|
+
return this.buildLocalSummaryFromTurns(this.lastExchanges.get(sessionKey) ?? []);
|
|
3324
|
+
}
|
|
3325
|
+
buildLocalSummaryFromTurns(turns, opts) {
|
|
3326
|
+
if (turns.length === 0)
|
|
3317
3327
|
return '';
|
|
3318
|
-
const
|
|
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,
|
|
3321
|
-
const assistantSnippet = ex.assistant.slice(0,
|
|
3322
|
-
return `- Exchange ${
|
|
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
|
-
|
|
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,
|
package/dist/memory/store.d.ts
CHANGED
|
@@ -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).
|
package/dist/memory/store.js
CHANGED
|
@@ -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).
|