clementine-agent 1.0.6 → 1.0.8

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/README.md CHANGED
@@ -7,11 +7,15 @@
7
7
  ╚═════╝╚══════╝╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝
8
8
  ```
9
9
 
10
- A persistent, ever-learning personal AI assistant that runs as a background daemon on macOS.
10
+ A persistent, ever-learning personal AI assistant that runs as a background daemon on macOS and Linux.
11
11
  Built on the [Claude Code SDK](https://docs.anthropic.com/en/docs/claude-code-sdk), Obsidian-compatible vault, and SQLite FTS5.
12
12
 
13
13
  Connects to Discord, Slack, Telegram, WhatsApp, and webhooks. Remembers everything. Runs 24/7.
14
14
 
15
+ **Requirements:** Node.js 20+ (22 recommended) · [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) authenticated · macOS or Linux.
16
+
17
+ **Contents:** [How it works](#how-it-works) · [Install](#install-recommended) · [Architecture](#architecture) · [CLI](#cli-reference) · [Configuration](#configuration) · [Channels](#channels) · [Agents & Teams](#agents--teams) · [Cron](#scheduled-tasks--cron-jobs) · [Unleashed mode](#unleashed-mode) · [Self-improvement](#self-improvement) · [Vault](#vault) · [Development](#development) · [Troubleshooting](#troubleshooting)
18
+
15
19
  ---
16
20
 
17
21
  ## How it works
@@ -63,45 +67,55 @@ The result: Clementine gets better the more you talk to it.
63
67
 
64
68
  ---
65
69
 
66
- ## Prerequisites
70
+ ## Install (recommended)
67
71
 
68
- - **Node.js 20-24 LTS** `nvm install 22`
69
- - **Claude Code CLI** — already installed if you're reading this in Claude Code
72
+ Runtime: **Node.js 22 (recommended) or Node.js 20+**.
70
73
 
71
- ## Quick start
72
-
73
- **Option A — npm (recommended):**
74
74
  ```bash
75
- npm install -g clementine-agent
75
+ npm install -g clementine-agent@latest
76
76
  clementine setup
77
77
  ```
78
78
 
79
- **Option B — from source (for development):**
79
+ After setup:
80
+
80
81
  ```bash
81
- git clone https://github.com/Natebreynolds/Clementine-AI-Assistant.git clementine
82
- cd clementine
83
- bash install.sh
82
+ clementine launch # start as background daemon
83
+ clementine status # verify it's running
84
+ clementine dashboard # open the web command center
84
85
  ```
85
86
 
86
- The install script handles everything: system dependencies (redis, libomp, build tools), npm packages, TypeScript build, global CLI install, and launches the setup wizard. Safe to re-run — skips anything already installed.
87
+ Already installed? Update in place with `clementine update`.
87
88
 
88
- The setup wizard auto-generates a Discord bot invite URL from your token and offers to open it in your browser — no need to visit the Developer Portal manually.
89
+ ### Troubleshooting
89
90
 
90
- After setup:
91
+ **`EACCES: permission denied` on `npm install -g`.** Your Node was installed system-wide (`/usr/local/lib/...`) and npm can't write there without sudo. Fix it once, permanently:
91
92
 
92
93
  ```bash
93
- clementine launch # start as background daemon
94
- clementine status # verify it's running
95
- clementine dashboard # open the web command center
94
+ mkdir -p ~/.npm-global
95
+ npm config set prefix ~/.npm-global
96
+ echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc && source ~/.zshrc
97
+ npm install -g clementine-agent@latest
96
98
  ```
97
99
 
98
- Already have it? Update in place:
100
+ Or install Node via [nvm](https://github.com/nvm-sh/nvm) — user-scoped by default, no permission issues ever.
101
+
102
+ **`clementine: command not found` after install succeeded.** Run `npm config get prefix` — its `/bin` directory needs to be on your `PATH`. Add `export PATH="$(npm config get prefix)/bin:$PATH"` to your shell profile.
99
103
 
104
+ **Node version too old.** Install via nvm:
100
105
  ```bash
101
- clementine update
106
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
107
+ nvm install 22
108
+ ```
109
+
110
+ ### Install from source (for development)
111
+
112
+ ```bash
113
+ git clone https://github.com/Natebreynolds/Clementine-AI-Assistant.git clementine
114
+ cd clementine
115
+ bash install.sh
102
116
  ```
103
117
 
104
- That's it. Clementine is now running, connected to your configured channels, and learning.
118
+ Handles system dependencies (redis, libomp, build tools), npm packages, TypeScript build, global CLI install, and launches the setup wizard. Safe to re-run.
105
119
 
106
120
  ---
107
121
 
@@ -728,15 +742,6 @@ Key system files:
728
742
 
729
743
  ---
730
744
 
731
- ## Requirements
732
-
733
- - **Node.js 20-24 LTS** (for `--import` loader and `cpSync`)
734
- - **macOS** (Keychain integration, LaunchAgent — works on Linux without these)
735
- - **Claude Code CLI** installed and authenticated (`npm install -g @anthropic-ai/claude-code && claude login`)
736
- - No API key needed — authentication is handled by the Claude Code CLI
737
-
738
- ---
739
-
740
745
  ## Development
741
746
 
742
747
  ```bash
@@ -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;
@@ -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: stripLoneSurrogates(fullSystemPrompt),
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
- // Sanitize lone Unicode surrogates before any JSON serialization to the API.
1566
- // Lone surrogates (U+D800–U+DFFF) are valid JS strings but invalid JSON,
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(stripLoneSurrogates(effectivePrompt), key, onText, model, profile, securityAnnotation, effectiveMaxTurns, projectOverride, onToolActivity, verboseLevel, abortController, guard, CHAT_TIMEOUT_MS, intent);
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 = stripLoneSurrogates(text);
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${stripLoneSurrogates(text)}`;
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: stripLoneSurrogates(text), assistant: responseText });
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', stripLoneSurrogates(text));
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
- // Enrich active goals with relevant memory snippets
321
- const goalMemoryContext = this.enrichGoalsWithMemory();
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
- * Load active goal summaries for injection into heartbeat prompts.
784
- * Returns null if no active goals exist.
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 loadGoalSummary() {
796
+ static loadAllGoals() {
787
797
  try {
788
798
  if (!existsSync(GOALS_DIR))
789
- return null;
799
+ return [];
790
800
  const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
791
- if (files.length === 0)
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 && g.status === 'active');
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
- if (!existsSync(GOALS_DIR))
844
- return null;
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
- if (!existsSync(GOALS_DIR))
900
- return;
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 f of files) {
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",