clementine-agent 1.0.5 → 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
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import { query, listSubagents, getSubagentMessages, } from '@anthropic-ai/claude-agent-sdk';
|
|
14
|
+
import { query as rawQuery, listSubagents, getSubagentMessages, } from '@anthropic-ai/claude-agent-sdk';
|
|
15
15
|
import pino from 'pino';
|
|
16
16
|
import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, PROFILES_DIR, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, GOALS_DIR, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, ENABLE_1M_CONTEXT, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, } from '../config.js';
|
|
17
17
|
import { DEFAULT_CHANNEL_CAPABILITIES } from '../types.js';
|
|
@@ -111,6 +111,34 @@ function stripLoneSurrogates(s) {
|
|
|
111
111
|
// Replace any surrogate not properly paired with the Unicode replacement char
|
|
112
112
|
return s.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\uFFFD');
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Wrapper around the SDK's query() that sanitizes lone Unicode surrogates in
|
|
116
|
+
* prompt, systemPrompt, and appendSystemPrompt. Covers every call site in one
|
|
117
|
+
* place so new injection points (history, summaries, tool output) can't leak
|
|
118
|
+
* lone surrogates into the JSON body. Non-string prompts (streaming inputs)
|
|
119
|
+
* pass through untouched.
|
|
120
|
+
*/
|
|
121
|
+
const query = ((args) => {
|
|
122
|
+
if (args && typeof args === 'object') {
|
|
123
|
+
const cleaned = { ...args };
|
|
124
|
+
if (typeof cleaned.prompt === 'string') {
|
|
125
|
+
cleaned.prompt = stripLoneSurrogates(cleaned.prompt);
|
|
126
|
+
}
|
|
127
|
+
if (cleaned.options && typeof cleaned.options === 'object') {
|
|
128
|
+
const opts = cleaned.options;
|
|
129
|
+
const newOpts = { ...opts };
|
|
130
|
+
if (typeof opts.systemPrompt === 'string') {
|
|
131
|
+
newOpts.systemPrompt = stripLoneSurrogates(opts.systemPrompt);
|
|
132
|
+
}
|
|
133
|
+
if (typeof opts.appendSystemPrompt === 'string') {
|
|
134
|
+
newOpts.appendSystemPrompt = stripLoneSurrogates(opts.appendSystemPrompt);
|
|
135
|
+
}
|
|
136
|
+
cleaned.options = newOpts;
|
|
137
|
+
}
|
|
138
|
+
return rawQuery(cleaned);
|
|
139
|
+
}
|
|
140
|
+
return rawQuery(args);
|
|
141
|
+
});
|
|
114
142
|
/** Format a millisecond duration as a human-friendly "X ago" string. */
|
|
115
143
|
function formatTimeAgo(ms) {
|
|
116
144
|
const minutes = Math.floor(ms / 60_000);
|
|
@@ -504,6 +532,7 @@ export class PersonalAssistant {
|
|
|
504
532
|
sessionTimestamps = new Map();
|
|
505
533
|
lastExchanges = new Map();
|
|
506
534
|
pendingContext = new Map();
|
|
535
|
+
saveSessionsTimer;
|
|
507
536
|
restoredSessions = new Set();
|
|
508
537
|
profileManager;
|
|
509
538
|
promptCache;
|
|
@@ -667,7 +696,28 @@ export class PersonalAssistant {
|
|
|
667
696
|
logger.warn({ err }, 'Session restore failed — starting fresh');
|
|
668
697
|
}
|
|
669
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
|
+
*/
|
|
670
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() {
|
|
671
721
|
try {
|
|
672
722
|
const data = {};
|
|
673
723
|
// Collect all keys that have any state worth saving
|
|
@@ -1273,7 +1323,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1273
1323
|
// terminal, so 'auto' mode (which requires plan support + human approval) doesn't apply.
|
|
1274
1324
|
const effectivePermissionMode = 'bypassPermissions';
|
|
1275
1325
|
return {
|
|
1276
|
-
systemPrompt:
|
|
1326
|
+
systemPrompt: fullSystemPrompt,
|
|
1277
1327
|
model: resolvedModel,
|
|
1278
1328
|
...(fallback ? { fallbackModel: fallback } : {}),
|
|
1279
1329
|
permissionMode: effectivePermissionMode,
|
|
@@ -1534,10 +1584,8 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1534
1584
|
this.exchangeCounts.set(key, 0);
|
|
1535
1585
|
sessionRotated = true;
|
|
1536
1586
|
}
|
|
1537
|
-
//
|
|
1538
|
-
|
|
1539
|
-
// causing 400 "no low surrogate in string" errors from the Claude API.
|
|
1540
|
-
let effectivePrompt = stripLoneSurrogates(text);
|
|
1587
|
+
// Lone-surrogate sanitization happens at the SDK boundary (see query() wrapper).
|
|
1588
|
+
let effectivePrompt = text;
|
|
1541
1589
|
// If session rotated, use instant local summary + handoff + kick off LLM summary in background
|
|
1542
1590
|
if (sessionRotated && key) {
|
|
1543
1591
|
const summary = this.buildLocalSummary(key);
|
|
@@ -1656,7 +1704,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1656
1704
|
const effectiveMaxTurns = maxTurns;
|
|
1657
1705
|
const CHAT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
1658
1706
|
const guard = new StallGuard();
|
|
1659
|
-
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);
|
|
1660
1708
|
// If we got a context-length / prompt-too-long error, retry with a fresh session
|
|
1661
1709
|
const errLower = responseText.toLowerCase();
|
|
1662
1710
|
const isContextOverflow = errLower.includes('prompt is too long') ||
|
|
@@ -1667,7 +1715,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1667
1715
|
logger.warn({ sessionKey: key }, 'Context overflow detected — rotating session');
|
|
1668
1716
|
this.sessions.delete(key);
|
|
1669
1717
|
this.exchangeCounts.set(key, 0);
|
|
1670
|
-
let retryPrompt =
|
|
1718
|
+
let retryPrompt = text;
|
|
1671
1719
|
const summary = await this.summarizeSession(key);
|
|
1672
1720
|
if (summary) {
|
|
1673
1721
|
retryPrompt =
|
|
@@ -1675,7 +1723,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1675
1723
|
`Here is a summary of what we were discussing:\n${summary}]\n\n` +
|
|
1676
1724
|
`IMPORTANT: The previous attempt overflowed the context window, likely from large tool responses. ` +
|
|
1677
1725
|
`If this task involves pulling data for multiple entities, delegate each to a sub-agent using the Agent tool ` +
|
|
1678
|
-
`instead of calling data-heavy tools directly.\n\n${
|
|
1726
|
+
`instead of calling data-heavy tools directly.\n\n${text}`;
|
|
1679
1727
|
}
|
|
1680
1728
|
[responseText, sessionId] = await this.runQuery(retryPrompt, key, onText, model, profile, securityAnnotation, maxTurns, undefined, onToolActivity, verboseLevel, abortController);
|
|
1681
1729
|
}
|
|
@@ -1687,7 +1735,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1687
1735
|
this.exchangeCounts.set(key, (this.exchangeCounts.get(key) ?? 0) + 1);
|
|
1688
1736
|
this.sessionTimestamps.set(key, new Date());
|
|
1689
1737
|
const history = this.lastExchanges.get(key) ?? [];
|
|
1690
|
-
history.push({ user:
|
|
1738
|
+
history.push({ user: text, assistant: responseText });
|
|
1691
1739
|
if (history.length > SESSION_EXCHANGE_HISTORY_SIZE) {
|
|
1692
1740
|
this.lastExchanges.set(key, history.slice(-SESSION_EXCHANGE_HISTORY_SIZE));
|
|
1693
1741
|
}
|
|
@@ -1706,7 +1754,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
1706
1754
|
// Save transcript turns
|
|
1707
1755
|
if (key && this.memoryStore) {
|
|
1708
1756
|
try {
|
|
1709
|
-
this.memoryStore.saveTurn(key, 'user',
|
|
1757
|
+
this.memoryStore.saveTurn(key, 'user', text);
|
|
1710
1758
|
this.memoryStore.saveTurn(key, 'assistant', responseText, model ?? MODEL);
|
|
1711
1759
|
}
|
|
1712
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();
|