clementine-agent 1.0.6 → 1.0.7
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.
|
@@ -37,6 +37,7 @@ export declare class PersonalAssistant {
|
|
|
37
37
|
private sessionTimestamps;
|
|
38
38
|
private lastExchanges;
|
|
39
39
|
private pendingContext;
|
|
40
|
+
private saveSessionsTimer?;
|
|
40
41
|
private restoredSessions;
|
|
41
42
|
private profileManager;
|
|
42
43
|
private promptCache;
|
|
@@ -79,7 +80,15 @@ export declare class PersonalAssistant {
|
|
|
79
80
|
injectPendingContext(sessionKey: string, userPrompt: string, result: string): void;
|
|
80
81
|
private initMemoryStore;
|
|
81
82
|
private loadSessions;
|
|
83
|
+
/**
|
|
84
|
+
* Schedule a debounced session persist. Multiple calls within 500ms collapse
|
|
85
|
+
* into a single write, eliminating synchronous disk I/O from the per-turn
|
|
86
|
+
* hot path. On shutdown, call flushSessions() to write any pending state.
|
|
87
|
+
*/
|
|
82
88
|
private saveSessions;
|
|
89
|
+
/** Flush any pending debounced save synchronously. Call on shutdown. */
|
|
90
|
+
flushSessions(): void;
|
|
91
|
+
private saveSessionsNow;
|
|
83
92
|
getExchangeCount(sessionKey: string): number;
|
|
84
93
|
getMemoryChunkCount(): number;
|
|
85
94
|
private buildSystemPrompt;
|
package/dist/agent/assistant.js
CHANGED
|
@@ -532,6 +532,7 @@ export class PersonalAssistant {
|
|
|
532
532
|
sessionTimestamps = new Map();
|
|
533
533
|
lastExchanges = new Map();
|
|
534
534
|
pendingContext = new Map();
|
|
535
|
+
saveSessionsTimer;
|
|
535
536
|
restoredSessions = new Set();
|
|
536
537
|
profileManager;
|
|
537
538
|
promptCache;
|
|
@@ -695,7 +696,28 @@ export class PersonalAssistant {
|
|
|
695
696
|
logger.warn({ err }, 'Session restore failed — starting fresh');
|
|
696
697
|
}
|
|
697
698
|
}
|
|
699
|
+
/**
|
|
700
|
+
* Schedule a debounced session persist. Multiple calls within 500ms collapse
|
|
701
|
+
* into a single write, eliminating synchronous disk I/O from the per-turn
|
|
702
|
+
* hot path. On shutdown, call flushSessions() to write any pending state.
|
|
703
|
+
*/
|
|
698
704
|
saveSessions() {
|
|
705
|
+
if (this.saveSessionsTimer)
|
|
706
|
+
return;
|
|
707
|
+
this.saveSessionsTimer = setTimeout(() => {
|
|
708
|
+
this.saveSessionsTimer = undefined;
|
|
709
|
+
this.saveSessionsNow();
|
|
710
|
+
}, 500);
|
|
711
|
+
}
|
|
712
|
+
/** Flush any pending debounced save synchronously. Call on shutdown. */
|
|
713
|
+
flushSessions() {
|
|
714
|
+
if (this.saveSessionsTimer) {
|
|
715
|
+
clearTimeout(this.saveSessionsTimer);
|
|
716
|
+
this.saveSessionsTimer = undefined;
|
|
717
|
+
}
|
|
718
|
+
this.saveSessionsNow();
|
|
719
|
+
}
|
|
720
|
+
saveSessionsNow() {
|
|
699
721
|
try {
|
|
700
722
|
const data = {};
|
|
701
723
|
// Collect all keys that have any state worth saving
|
|
@@ -1301,7 +1323,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1301
1323
|
// terminal, so 'auto' mode (which requires plan support + human approval) doesn't apply.
|
|
1302
1324
|
const effectivePermissionMode = 'bypassPermissions';
|
|
1303
1325
|
return {
|
|
1304
|
-
systemPrompt:
|
|
1326
|
+
systemPrompt: fullSystemPrompt,
|
|
1305
1327
|
model: resolvedModel,
|
|
1306
1328
|
...(fallback ? { fallbackModel: fallback } : {}),
|
|
1307
1329
|
permissionMode: effectivePermissionMode,
|
|
@@ -1562,10 +1584,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1562
1584
|
this.exchangeCounts.set(key, 0);
|
|
1563
1585
|
sessionRotated = true;
|
|
1564
1586
|
}
|
|
1565
|
-
//
|
|
1566
|
-
|
|
1567
|
-
// causing 400 "no low surrogate in string" errors from the Claude API.
|
|
1568
|
-
let effectivePrompt = stripLoneSurrogates(text);
|
|
1587
|
+
// Lone-surrogate sanitization happens at the SDK boundary (see query() wrapper).
|
|
1588
|
+
let effectivePrompt = text;
|
|
1569
1589
|
// If session rotated, use instant local summary + handoff + kick off LLM summary in background
|
|
1570
1590
|
if (sessionRotated && key) {
|
|
1571
1591
|
const summary = this.buildLocalSummary(key);
|
|
@@ -1684,7 +1704,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1684
1704
|
const effectiveMaxTurns = maxTurns;
|
|
1685
1705
|
const CHAT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
1686
1706
|
const guard = new StallGuard();
|
|
1687
|
-
let [responseText, sessionId] = await this.runQuery(
|
|
1707
|
+
let [responseText, sessionId] = await this.runQuery(effectivePrompt, key, onText, model, profile, securityAnnotation, effectiveMaxTurns, projectOverride, onToolActivity, verboseLevel, abortController, guard, CHAT_TIMEOUT_MS, intent);
|
|
1688
1708
|
// If we got a context-length / prompt-too-long error, retry with a fresh session
|
|
1689
1709
|
const errLower = responseText.toLowerCase();
|
|
1690
1710
|
const isContextOverflow = errLower.includes('prompt is too long') ||
|
|
@@ -1695,7 +1715,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1695
1715
|
logger.warn({ sessionKey: key }, 'Context overflow detected — rotating session');
|
|
1696
1716
|
this.sessions.delete(key);
|
|
1697
1717
|
this.exchangeCounts.set(key, 0);
|
|
1698
|
-
let retryPrompt =
|
|
1718
|
+
let retryPrompt = text;
|
|
1699
1719
|
const summary = await this.summarizeSession(key);
|
|
1700
1720
|
if (summary) {
|
|
1701
1721
|
retryPrompt =
|
|
@@ -1703,7 +1723,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1703
1723
|
`Here is a summary of what we were discussing:\n${summary}]\n\n` +
|
|
1704
1724
|
`IMPORTANT: The previous attempt overflowed the context window, likely from large tool responses. ` +
|
|
1705
1725
|
`If this task involves pulling data for multiple entities, delegate each to a sub-agent using the Agent tool ` +
|
|
1706
|
-
`instead of calling data-heavy tools directly.\n\n${
|
|
1726
|
+
`instead of calling data-heavy tools directly.\n\n${text}`;
|
|
1707
1727
|
}
|
|
1708
1728
|
[responseText, sessionId] = await this.runQuery(retryPrompt, key, onText, model, profile, securityAnnotation, maxTurns, undefined, onToolActivity, verboseLevel, abortController);
|
|
1709
1729
|
}
|
|
@@ -1715,7 +1735,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1715
1735
|
this.exchangeCounts.set(key, (this.exchangeCounts.get(key) ?? 0) + 1);
|
|
1716
1736
|
this.sessionTimestamps.set(key, new Date());
|
|
1717
1737
|
const history = this.lastExchanges.get(key) ?? [];
|
|
1718
|
-
history.push({ user:
|
|
1738
|
+
history.push({ user: text, assistant: responseText });
|
|
1719
1739
|
if (history.length > SESSION_EXCHANGE_HISTORY_SIZE) {
|
|
1720
1740
|
this.lastExchanges.set(key, history.slice(-SESSION_EXCHANGE_HISTORY_SIZE));
|
|
1721
1741
|
}
|
|
@@ -1734,7 +1754,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1734
1754
|
// Save transcript turns
|
|
1735
1755
|
if (key && this.memoryStore) {
|
|
1736
1756
|
try {
|
|
1737
|
-
this.memoryStore.saveTurn(key, 'user',
|
|
1757
|
+
this.memoryStore.saveTurn(key, 'user', text);
|
|
1738
1758
|
this.memoryStore.saveTurn(key, 'assistant', responseText, model ?? MODEL);
|
|
1739
1759
|
}
|
|
1740
1760
|
catch (err) {
|
|
@@ -46,15 +46,25 @@ export declare class HeartbeatScheduler {
|
|
|
46
46
|
* agent knows what happened since the last beat.
|
|
47
47
|
*/
|
|
48
48
|
private getRecentActivitySummary;
|
|
49
|
+
/**
|
|
50
|
+
* Read and parse all goal JSON files from GOALS_DIR once. Callers that
|
|
51
|
+
* need filtered subsets (active only, priority-based, etc.) do their own
|
|
52
|
+
* filtering over the returned array. Used by heartbeatTick to avoid
|
|
53
|
+
* repeating the readdirSync+readFileSync pass for every goal-consuming
|
|
54
|
+
* method.
|
|
55
|
+
*/
|
|
56
|
+
static loadAllGoals(): Array<any>;
|
|
49
57
|
/**
|
|
50
58
|
* Load active goal summaries for injection into heartbeat prompts.
|
|
51
|
-
* Returns null if no active goals exist.
|
|
59
|
+
* Returns null if no active goals exist. Pass `preloadedGoals` to reuse
|
|
60
|
+
* an already-read goal list and skip disk I/O.
|
|
52
61
|
*/
|
|
53
|
-
static loadGoalSummary(): string | null;
|
|
62
|
+
static loadGoalSummary(preloadedGoals?: Array<any>): string | null;
|
|
54
63
|
/**
|
|
55
64
|
* Enrich top active goals with relevant memory snippets.
|
|
56
65
|
* Searches FTS5 memory for each goal's title+description to surface
|
|
57
66
|
* recent conversations and facts the heartbeat agent can act on.
|
|
67
|
+
* Pass `preloadedGoals` to reuse an already-read goal list.
|
|
58
68
|
*/
|
|
59
69
|
private enrichGoalsWithMemory;
|
|
60
70
|
/**
|
|
@@ -312,16 +312,17 @@ export class HeartbeatScheduler {
|
|
|
312
312
|
if (activitySummary) {
|
|
313
313
|
changesSummary += `\n\nRecent activity:\n${activitySummary}`;
|
|
314
314
|
}
|
|
315
|
+
// Load all goals once for this tick — loadGoalSummary, enrichGoalsWithMemory,
|
|
316
|
+
// and advanceGoals all read the same set from disk. One readdirSync + N
|
|
317
|
+
// readFileSyncs, reused by each consumer below.
|
|
318
|
+
const tickGoals = HeartbeatScheduler.loadAllGoals();
|
|
315
319
|
// Inject active goal summaries so the heartbeat can flag goals needing attention
|
|
316
|
-
const goalSummary = HeartbeatScheduler.loadGoalSummary();
|
|
320
|
+
const goalSummary = HeartbeatScheduler.loadGoalSummary(tickGoals);
|
|
317
321
|
if (goalSummary) {
|
|
318
322
|
changesSummary += `\n\n${goalSummary}`;
|
|
319
323
|
}
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
if (goalMemoryContext) {
|
|
323
|
-
changesSummary += `\n\n${goalMemoryContext}`;
|
|
324
|
-
}
|
|
324
|
+
// Note: enrichGoalsWithMemory() runs below, inside the non-silent branch —
|
|
325
|
+
// silent heartbeats discard the result, so we skip the work entirely.
|
|
325
326
|
// Inject daily plan summary if available
|
|
326
327
|
try {
|
|
327
328
|
const todayPlan = dailyPlanner?.getPlan();
|
|
@@ -391,11 +392,17 @@ export class HeartbeatScheduler {
|
|
|
391
392
|
this.saveState();
|
|
392
393
|
logger.info({ silentBeats: this.lastState.consecutiveSilentBeats }, 'Heartbeat silent — nothing new');
|
|
393
394
|
// Still run housekeeping
|
|
394
|
-
this.advanceGoals();
|
|
395
|
+
this.advanceGoals(tickGoals);
|
|
395
396
|
this.processInbox();
|
|
396
397
|
// Fall through to nightly tasks below — don't return early
|
|
397
398
|
}
|
|
398
399
|
else {
|
|
400
|
+
// Enrich active goals with relevant memory snippets — only done when
|
|
401
|
+
// we're actually going to invoke the agent (silent ticks discarded the result).
|
|
402
|
+
const goalMemoryContext = this.enrichGoalsWithMemory(tickGoals);
|
|
403
|
+
if (goalMemoryContext) {
|
|
404
|
+
changesSummary += `\n\n${goalMemoryContext}`;
|
|
405
|
+
}
|
|
399
406
|
// Build dedup context from previously reported topics
|
|
400
407
|
const dedupContext = this.buildDedupContext();
|
|
401
408
|
// Build work summary for completed items
|
|
@@ -492,7 +499,7 @@ export class HeartbeatScheduler {
|
|
|
492
499
|
logger.error({ err }, 'Heartbeat tick failed');
|
|
493
500
|
}
|
|
494
501
|
// Fire-and-forget: advance active goals by writing trigger files
|
|
495
|
-
this.advanceGoals();
|
|
502
|
+
this.advanceGoals(tickGoals);
|
|
496
503
|
// Fire-and-forget: process inbox items
|
|
497
504
|
this.processInbox();
|
|
498
505
|
// ── Confidence-based escalations from cron jobs ──────────────────
|
|
@@ -780,24 +787,42 @@ export class HeartbeatScheduler {
|
|
|
780
787
|
return lines.join('\n');
|
|
781
788
|
}
|
|
782
789
|
/**
|
|
783
|
-
*
|
|
784
|
-
*
|
|
790
|
+
* Read and parse all goal JSON files from GOALS_DIR once. Callers that
|
|
791
|
+
* need filtered subsets (active only, priority-based, etc.) do their own
|
|
792
|
+
* filtering over the returned array. Used by heartbeatTick to avoid
|
|
793
|
+
* repeating the readdirSync+readFileSync pass for every goal-consuming
|
|
794
|
+
* method.
|
|
785
795
|
*/
|
|
786
|
-
static
|
|
796
|
+
static loadAllGoals() {
|
|
787
797
|
try {
|
|
788
798
|
if (!existsSync(GOALS_DIR))
|
|
789
|
-
return
|
|
799
|
+
return [];
|
|
790
800
|
const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
|
|
791
|
-
|
|
792
|
-
return null;
|
|
793
|
-
const activeGoals = files
|
|
801
|
+
return files
|
|
794
802
|
.map(f => { try {
|
|
795
803
|
return JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
|
|
796
804
|
}
|
|
797
805
|
catch {
|
|
798
806
|
return null;
|
|
799
807
|
} })
|
|
800
|
-
.filter((g) => g
|
|
808
|
+
.filter((g) => g !== null);
|
|
809
|
+
}
|
|
810
|
+
catch (err) {
|
|
811
|
+
logger.warn({ err }, 'loadAllGoals failed');
|
|
812
|
+
return [];
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Load active goal summaries for injection into heartbeat prompts.
|
|
817
|
+
* Returns null if no active goals exist. Pass `preloadedGoals` to reuse
|
|
818
|
+
* an already-read goal list and skip disk I/O.
|
|
819
|
+
*/
|
|
820
|
+
static loadGoalSummary(preloadedGoals) {
|
|
821
|
+
try {
|
|
822
|
+
const allGoals = preloadedGoals ?? HeartbeatScheduler.loadAllGoals();
|
|
823
|
+
if (allGoals.length === 0)
|
|
824
|
+
return null;
|
|
825
|
+
const activeGoals = allGoals.filter((g) => g && g.status === 'active');
|
|
801
826
|
if (activeGoals.length === 0)
|
|
802
827
|
return null;
|
|
803
828
|
const now = Date.now();
|
|
@@ -837,22 +862,14 @@ export class HeartbeatScheduler {
|
|
|
837
862
|
* Enrich top active goals with relevant memory snippets.
|
|
838
863
|
* Searches FTS5 memory for each goal's title+description to surface
|
|
839
864
|
* recent conversations and facts the heartbeat agent can act on.
|
|
865
|
+
* Pass `preloadedGoals` to reuse an already-read goal list.
|
|
840
866
|
*/
|
|
841
|
-
enrichGoalsWithMemory() {
|
|
867
|
+
enrichGoalsWithMemory(preloadedGoals) {
|
|
842
868
|
try {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
|
|
846
|
-
if (files.length === 0)
|
|
847
|
-
return null;
|
|
848
|
-
const goals = files
|
|
849
|
-
.map(f => { try {
|
|
850
|
-
return JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
|
|
851
|
-
}
|
|
852
|
-
catch {
|
|
869
|
+
const allGoals = preloadedGoals ?? HeartbeatScheduler.loadAllGoals();
|
|
870
|
+
if (allGoals.length === 0)
|
|
853
871
|
return null;
|
|
854
|
-
|
|
855
|
-
.filter((g) => g && g.status === 'active' && g.priority !== 'low');
|
|
872
|
+
const goals = allGoals.filter((g) => g && g.status === 'active' && g.priority !== 'low');
|
|
856
873
|
if (goals.length === 0)
|
|
857
874
|
return null;
|
|
858
875
|
// Sort by priority (high first) and take top 3
|
|
@@ -888,7 +905,7 @@ export class HeartbeatScheduler {
|
|
|
888
905
|
*
|
|
889
906
|
* Conservative: max 2 triggers per tick, skips if triggers are already pending.
|
|
890
907
|
*/
|
|
891
|
-
advanceGoals() {
|
|
908
|
+
advanceGoals(preloadedGoals) {
|
|
892
909
|
try {
|
|
893
910
|
const goalTriggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
|
|
894
911
|
mkdirSync(goalTriggerDir, { recursive: true });
|
|
@@ -896,10 +913,8 @@ export class HeartbeatScheduler {
|
|
|
896
913
|
const pending = readdirSync(goalTriggerDir).filter(f => f.endsWith('.trigger.json'));
|
|
897
914
|
if (pending.length > 0)
|
|
898
915
|
return;
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
|
|
902
|
-
if (files.length === 0)
|
|
916
|
+
const allGoals = preloadedGoals ?? HeartbeatScheduler.loadAllGoals();
|
|
917
|
+
if (allGoals.length === 0)
|
|
903
918
|
return;
|
|
904
919
|
const now = Date.now();
|
|
905
920
|
const DAY_MS = 86_400_000;
|
|
@@ -965,9 +980,8 @@ export class HeartbeatScheduler {
|
|
|
965
980
|
// Score ALL active goals — stale goals get urgency bonus, but
|
|
966
981
|
// non-stale high-priority goals with pending work also qualify.
|
|
967
982
|
const scoredGoals = [];
|
|
968
|
-
for (const
|
|
983
|
+
for (const goal of allGoals) {
|
|
969
984
|
try {
|
|
970
|
-
const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
|
|
971
985
|
if (goal.status !== 'active')
|
|
972
986
|
continue;
|
|
973
987
|
// Skip goals in cooldown (failed recently or producing stale output)
|
package/dist/index.js
CHANGED
|
@@ -880,6 +880,13 @@ async function asyncMain() {
|
|
|
880
880
|
if (restartRequested) {
|
|
881
881
|
await drainActiveSessions(gateway);
|
|
882
882
|
}
|
|
883
|
+
// Flush any pending debounced session writes before exit.
|
|
884
|
+
try {
|
|
885
|
+
assistant.flushSessions();
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
logger.warn({ err }, 'Session flush on shutdown failed');
|
|
889
|
+
}
|
|
883
890
|
// Now safe to tear down remaining infrastructure
|
|
884
891
|
heartbeat.stop();
|
|
885
892
|
cronScheduler.stop();
|