clementine-agent 1.0.10 → 1.0.12

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.
@@ -13,11 +13,11 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { query as rawQuery, listSubagents, getSubagentMessages, } from '@anthropic-ai/claude-agent-sdk';
15
15
  import pino from 'pino';
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';
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, 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';
18
18
  import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, setProfileTier, setProfileAllowedTools, setAgentDir, setSendPolicy, setInteractionSource, } from './hooks.js';
19
19
  import { scanner } from '../security/scanner.js';
20
- import { agentWorkingMemoryFile } from '../tools/shared.js';
20
+ import { agentWorkingMemoryFile, listAllGoals } from '../tools/shared.js';
21
21
  import { AgentManager } from './agent-manager.js';
22
22
  import { extractLinks } from './link-extractor.js';
23
23
  import { StallGuard } from './stall-guard.js';
@@ -1491,17 +1491,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1491
1491
  }
1492
1492
  const goals = [];
1493
1493
  try {
1494
- if (!fs.existsSync(GOALS_DIR))
1495
- return goals;
1496
- for (const f of fs.readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'))) {
1497
- try {
1498
- const goal = JSON.parse(fs.readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
1499
- if (goal.status === 'active')
1500
- goals.push({ goal, file: f });
1501
- }
1502
- catch {
1503
- continue;
1504
- }
1494
+ for (const { goal, filePath } of listAllGoals()) {
1495
+ if (goal.status === 'active')
1496
+ goals.push({ goal, file: path.basename(filePath) });
1505
1497
  }
1506
1498
  }
1507
1499
  catch { /* non-fatal */ }
@@ -2041,11 +2033,21 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2041
2033
  catch (e) {
2042
2034
  const errStr = String(e).toLowerCase();
2043
2035
  if (errStr.includes('abort') || errStr.includes('cancel')) {
2044
- // Query was aborted. Three sources: timeout, user cancel, or
2045
- // StallGuard tripped (runaway loop detected).
2036
+ // Query was aborted. Four sources: timeout, user cancel, StallGuard
2037
+ // tripped (runaway loop), or interrupted by a new user message.
2046
2038
  const stallAbort = !!stallGuard?.isBreakerActive();
2047
- logger.warn({ sessionKey, stallAbort }, 'Chat query aborted');
2048
- if (stallAbort) {
2039
+ const abortReason = abortController?.signal.reason;
2040
+ const interruptAbort = abortReason === 'interrupted-by-new-message';
2041
+ logger.warn({ sessionKey, stallAbort, interruptAbort }, 'Chat query aborted');
2042
+ if (interruptAbort) {
2043
+ // New message came in — let the next query answer. Just mark
2044
+ // the partial response so the user knows this one was cut off.
2045
+ // (The next handleMessage call will fold this partial into its prompt.)
2046
+ responseText = responseText
2047
+ ? responseText + '\n\n*(interrupted — answering your new message…)*'
2048
+ : '*(interrupted — switching to your new message…)*';
2049
+ }
2050
+ else if (stallAbort) {
2049
2051
  const reason = stallGuard?.getBreakerReason() ?? 'runaway loop';
2050
2052
  const stallMsg = `I got stuck in a loop — ${reason} ` +
2051
2053
  `I stopped to save budget. Options:\n` +
@@ -3029,15 +3031,9 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3029
3031
  // ── Goal context: inject linked goal info ───────────────────────
3030
3032
  let goalContext = '';
3031
3033
  try {
3032
- if (fs.existsSync(GOALS_DIR)) {
3033
- const goalFiles = fs.readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
3034
- const linkedGoals = goalFiles
3035
- .map(f => { try {
3036
- return JSON.parse(fs.readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
3037
- }
3038
- catch {
3039
- return null;
3040
- } })
3034
+ {
3035
+ const linkedGoals = listAllGoals()
3036
+ .map(({ goal }) => goal)
3041
3037
  .filter(g => g && g.status === 'active' && g.linkedCronJobs?.includes(jobName));
3042
3038
  if (linkedGoals.length > 0) {
3043
3039
  const goalLines = linkedGoals.map((g) => {
@@ -8,7 +8,8 @@
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import pino from 'pino';
11
- import { BASE_DIR, GOALS_DIR, CRON_REFLECTIONS_DIR, TASKS_FILE, INBOX_DIR, MODELS, } from '../config.js';
11
+ import { BASE_DIR, CRON_REFLECTIONS_DIR, TASKS_FILE, INBOX_DIR, MODELS, } from '../config.js';
12
+ import { listAllGoals } from '../tools/shared.js';
12
13
  const logger = pino({ name: 'clementine.daily-planner' });
13
14
  const PLANS_DIR = path.join(BASE_DIR, 'plans', 'daily');
14
15
  // ── Helpers ──────────────────────────────────────────────────────────
@@ -95,21 +96,10 @@ export class DailyPlanner {
95
96
  return sections.join('\n\n') || 'No context available — all clear.';
96
97
  }
97
98
  loadActiveGoals() {
98
- if (!existsSync(GOALS_DIR))
99
- return [];
100
99
  try {
101
- const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
102
- const goals = [];
103
- for (const f of files) {
104
- try {
105
- const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
106
- if (goal.status === 'active')
107
- goals.push(goal);
108
- }
109
- catch {
110
- continue;
111
- }
112
- }
100
+ const goals = listAllGoals()
101
+ .map(({ goal }) => goal)
102
+ .filter(g => g && g.status === 'active');
113
103
  return goals.sort((a, b) => {
114
104
  const p = { high: 0, medium: 1, low: 2 };
115
105
  return (p[a.priority] ?? 2) - (p[b.priority] ?? 2);
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import pino from 'pino';
14
14
  import { GOALS_DIR, BASE_DIR } from '../config.js';
15
+ import { listAllGoals } from '../tools/shared.js';
15
16
  const logger = pino({ name: 'clementine.insight-engine' });
16
17
  const BASE_COOLDOWN_MS = 2 * 60 * 60 * 1000; // 2 hours
17
18
  const MAX_DAILY_INSIGHTS = 3;
@@ -133,20 +134,9 @@ export function gatherInsightSignals(gateway) {
133
134
  if (sessionCount === 0) {
134
135
  // No recent activity — could note quiet period if there are pending goals
135
136
  try {
136
- if (existsSync(GOALS_DIR)) {
137
- const goals = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
138
- const activeHighPriority = goals.filter(f => {
139
- try {
140
- const g = JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
141
- return g.status === 'active' && g.priority === 'high';
142
- }
143
- catch {
144
- return false;
145
- }
146
- });
147
- if (activeHighPriority.length > 0) {
148
- signals.push(`Quiet period: ${activeHighPriority.length} high-priority goal(s) active but no recent user interaction`);
149
- }
137
+ const activeHighPriority = listAllGoals().filter(({ goal }) => goal.status === 'active' && goal.priority === 'high');
138
+ if (activeHighPriority.length > 0) {
139
+ signals.push(`Quiet period: ${activeHighPriority.length} high-priority goal(s) active but no recent user interaction`);
150
140
  }
151
141
  }
152
142
  catch { /* non-fatal */ }
@@ -5,10 +5,11 @@
5
5
  * reflections, progress state, and goal context. Returns the enrichment
6
6
  * string to be appended to the original prompt.
7
7
  */
8
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import pino from 'pino';
11
- import { BASE_DIR, CRON_REFLECTIONS_DIR, CRON_PROGRESS_DIR, GOALS_DIR } from '../config.js';
11
+ import { BASE_DIR, CRON_REFLECTIONS_DIR, CRON_PROGRESS_DIR } from '../config.js';
12
+ import { listAllGoals } from '../tools/shared.js';
12
13
  const logger = pino({ name: 'clementine.prompt-evolver' });
13
14
  /**
14
15
  * Evolve a static cron prompt by enriching it with lessons from reflections,
@@ -225,22 +226,13 @@ function extractProgressInsights(jobName) {
225
226
  * Find goals that reference this cron job and inject alignment guidance.
226
227
  */
227
228
  function extractGoalGuidance(jobName) {
228
- if (!existsSync(GOALS_DIR))
229
- return null;
230
229
  try {
231
- const files = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
232
230
  const linkedGoals = [];
233
- for (const f of files) {
234
- try {
235
- const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
236
- if (goal.status !== 'active')
237
- continue;
238
- if (goal.linkedCronJobs?.includes(jobName)) {
239
- linkedGoals.push(goal);
240
- }
241
- }
242
- catch {
231
+ for (const { goal } of listAllGoals()) {
232
+ if (goal.status !== 'active')
243
233
  continue;
234
+ if (goal.linkedCronJobs?.includes(jobName)) {
235
+ linkedGoals.push(goal);
244
236
  }
245
237
  }
246
238
  if (linkedGoals.length === 0)
@@ -14,6 +14,7 @@ import matter from 'gray-matter';
14
14
  import path from 'node:path';
15
15
  import pino from 'pino';
16
16
  import { BASE_DIR, SELF_IMPROVE_DIR, SOUL_FILE, AGENTS_FILE, CRON_FILE, WORKFLOWS_DIR, VAULT_DIR, MEMORY_DB_PATH, AGENTS_DIR, PKG_DIR, CRON_REFLECTIONS_DIR, GOALS_DIR, } from '../config.js';
17
+ import { listAllGoals } from '../tools/shared.js';
17
18
  const logger = pino({ name: 'clementine.self-improve' });
18
19
  // ── Defaults ─────────────────────────────────────────────────────────
19
20
  const DEFAULT_CONFIG = {
@@ -481,34 +482,27 @@ export class SelfImproveLoop {
481
482
  }
482
483
  }
483
484
  catch { /* non-fatal */ }
484
- // Gather goal health data
485
+ // Gather goal health data (walks global + per-agent goals dirs)
485
486
  const goalHealth = [];
486
487
  try {
487
- if (existsSync(GOALS_DIR)) {
488
- const goalFiles = readdirSync(GOALS_DIR).filter(f => f.endsWith('.json'));
489
- const now = Date.now();
490
- const DAY_MS = 86_400_000;
491
- for (const file of goalFiles) {
492
- try {
493
- const goal = JSON.parse(readFileSync(path.join(GOALS_DIR, file), 'utf-8'));
494
- const lastUpdate = goal.updatedAt ? new Date(goal.updatedAt).getTime() : 0;
495
- const daysSinceUpdate = Math.floor((now - lastUpdate) / DAY_MS);
496
- const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
497
- goalHealth.push({
498
- id: goal.id,
499
- title: goal.title,
500
- status: goal.status,
501
- owner: goal.owner,
502
- priority: goal.priority,
503
- daysSinceUpdate,
504
- reviewFrequency: goal.reviewFrequency,
505
- isStale: goal.status === 'active' && daysSinceUpdate > staleThreshold,
506
- linkedCronJobs: goal.linkedCronJobs || [],
507
- progressCount: goal.progressNotes?.length ?? 0,
508
- });
509
- }
510
- catch { /* skip malformed */ }
511
- }
488
+ const now = Date.now();
489
+ const DAY_MS = 86_400_000;
490
+ for (const { goal, owner } of listAllGoals()) {
491
+ const lastUpdate = goal.updatedAt ? new Date(goal.updatedAt).getTime() : 0;
492
+ const daysSinceUpdate = Math.floor((now - lastUpdate) / DAY_MS);
493
+ const staleThreshold = goal.reviewFrequency === 'daily' ? 1 : goal.reviewFrequency === 'weekly' ? 7 : 30;
494
+ goalHealth.push({
495
+ id: goal.id,
496
+ title: goal.title,
497
+ status: goal.status,
498
+ owner: owner,
499
+ priority: goal.priority,
500
+ daysSinceUpdate,
501
+ reviewFrequency: goal.reviewFrequency,
502
+ isStale: goal.status === 'active' && daysSinceUpdate > staleThreshold,
503
+ linkedCronJobs: goal.linkedCronJobs || [],
504
+ progressCount: goal.progressNotes?.length ?? 0,
505
+ });
512
506
  }
513
507
  }
514
508
  catch { /* non-fatal */ }
@@ -13,6 +13,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
13
13
  import path from 'node:path';
14
14
  import pino from 'pino';
15
15
  import { BASE_DIR, GOALS_DIR, MODELS } from '../config.js';
16
+ import { listAllGoals } from '../tools/shared.js';
16
17
  const logger = pino({ name: 'clementine.strategic-planner' });
17
18
  const DAILY_PLANS_DIR = path.join(BASE_DIR, 'plans', 'daily');
18
19
  const WEEKLY_PLANS_DIR = path.join(BASE_DIR, 'plans', 'weekly');
@@ -265,34 +266,12 @@ export class StrategicPlanner {
265
266
  }).filter(Boolean);
266
267
  }
267
268
  loadActiveGoals() {
268
- if (!existsSync(GOALS_DIR))
269
- return [];
270
- return readdirSync(GOALS_DIR)
271
- .filter(f => f.endsWith('.json'))
272
- .map(f => {
273
- try {
274
- return JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
275
- }
276
- catch {
277
- return null;
278
- }
279
- })
280
- .filter((g) => g && g.status === 'active');
269
+ return listAllGoals()
270
+ .map(({ goal }) => goal)
271
+ .filter(g => g && g.status === 'active');
281
272
  }
282
273
  loadAllGoals() {
283
- if (!existsSync(GOALS_DIR))
284
- return [];
285
- return readdirSync(GOALS_DIR)
286
- .filter(f => f.endsWith('.json'))
287
- .map(f => {
288
- try {
289
- return JSON.parse(readFileSync(path.join(GOALS_DIR, f), 'utf-8'));
290
- }
291
- catch {
292
- return null;
293
- }
294
- })
295
- .filter(Boolean);
274
+ return listAllGoals().map(({ goal }) => goal);
296
275
  }
297
276
  }
298
277
  // ── Date helpers ───────────────────────────────────────────────────────
@@ -5,10 +5,10 @@
5
5
  * Logs to JSONL and optionally mirrors to a Discord channel.
6
6
  */
7
7
  import { createHash, randomBytes } from 'node:crypto';
8
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
9
- import os from 'node:os';
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
9
  import path from 'node:path';
11
10
  import pino from 'pino';
11
+ import { listAllGoals } from '../tools/shared.js';
12
12
  const logger = pino({ name: 'clementine.team-bus' });
13
13
  /** Max inter-agent message depth before rejection (anti-loop). */
14
14
  const MAX_DEPTH = 3;
@@ -247,19 +247,10 @@ export class TeamBus {
247
247
  }
248
248
  /** Broadcast a message to all team agents (optionally scoped to a goal). */
249
249
  async broadcast(fromSlug, content, goalId, _sessionKey) {
250
- const goalsDir = path.join(process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine'), 'goals');
251
250
  let targetSlugs = [];
252
- if (existsSync(goalsDir)) {
253
- for (const f of readdirSync(goalsDir).filter(f => f.endsWith('.json'))) {
254
- try {
255
- const goal = JSON.parse(readFileSync(path.join(goalsDir, f), 'utf-8'));
256
- if (goal.id === goalId && goal.owner && goal.owner !== fromSlug) {
257
- targetSlugs.push(goal.owner);
258
- }
259
- }
260
- catch {
261
- continue;
262
- }
251
+ for (const { goal, owner } of listAllGoals()) {
252
+ if (goal.id === goalId && owner && owner !== fromSlug) {
253
+ targetSlugs.push(owner);
263
254
  }
264
255
  }
265
256
  const allAgents = this.teamRouter.listTeamAgents();
@@ -161,20 +161,37 @@ export class DiscordStreamingMessage {
161
161
  this.progressTimer = null;
162
162
  }
163
163
  if (!text)
164
- text = '*(no response)*';
164
+ text = "*(I didn't have anything to respond with — try rephrasing or giving me more context.)*";
165
165
  text = sanitizeResponse(text);
166
- if (this.message) {
167
- if (text.length <= 1900) {
168
- await this.message.edit(text);
169
- this.messageId = this.message.id;
166
+ try {
167
+ if (this.message) {
168
+ if (text.length <= 1900) {
169
+ await this.message.edit(text);
170
+ this.messageId = this.message.id;
171
+ }
172
+ else {
173
+ await this.message.delete().catch(() => { });
174
+ await sendChunked(this.channel, text);
175
+ }
170
176
  }
171
177
  else {
172
- await this.message.delete().catch(() => { });
173
178
  await sendChunked(this.channel, text);
174
179
  }
175
180
  }
176
- else {
177
- await sendChunked(this.channel, text);
181
+ catch (err) {
182
+ // Delivery failed after the agent already generated a response.
183
+ // Log loudly + persist the response text to the daily note so it isn't
184
+ // lost silently. Don't re-throw — the callers don't have try/catch
185
+ // around finalize() and we don't want to introduce crashes.
186
+ const errMsg = err instanceof Error ? err.message : String(err);
187
+ try {
188
+ const pino = (await import('pino')).default;
189
+ pino({ name: 'clementine.discord' }).warn({ err: errMsg, channelId: this.channel.id }, 'Discord delivery failed — response text saved to daily note');
190
+ const { logToDailyNote } = await import('../gateway/cron-scheduler.js');
191
+ const preview = text.slice(0, 1500);
192
+ logToDailyNote(`**[Discord delivery failed]** Channel \`${this.channel.id ?? 'unknown'}\` — response was:\n\n${preview}`);
193
+ }
194
+ catch { /* best-effort */ }
178
195
  }
179
196
  }
180
197
  /** Format elapsed milliseconds as human-readable duration. */
@@ -5684,15 +5684,14 @@ export async function cmdDashboard(opts) {
5684
5684
  res.json(analytics);
5685
5685
  });
5686
5686
  // ── Goals, Delegations, Workflows, Digest — extracted routers ──
5687
- const GOALS_DIR = path.join(BASE_DIR, 'goals');
5688
5687
  const CRON_RUNS_DIR = path.join(BASE_DIR, 'cron', 'runs');
5689
5688
  const AGENTS_BASE = path.join(VAULT_DIR, '00-System', 'agents');
5690
5689
  const WORKFLOWS_DIR = path.join(VAULT_DIR, '00-System', 'workflows');
5691
5690
  const WORKFLOW_RUNS_DIR = path.join(BASE_DIR, 'workflows', 'runs');
5692
- app.use('/api/goals', goalsRouter({ goalsDir: GOALS_DIR, cronRunsDir: CRON_RUNS_DIR, vaultDir: VAULT_DIR, cronFile: CRON_FILE, getGateway }));
5691
+ app.use('/api/goals', goalsRouter({ cronRunsDir: CRON_RUNS_DIR, vaultDir: VAULT_DIR, cronFile: CRON_FILE, getGateway }));
5693
5692
  app.use('/api/delegations', delegationsRouter({ agentsBase: AGENTS_BASE, getGateway, broadcastEvent }));
5694
5693
  app.use('/api/workflows', workflowsRouter({ workflowsDir: WORKFLOWS_DIR, workflowRunsDir: WORKFLOW_RUNS_DIR, agentsBase: AGENTS_BASE, getGateway, broadcastEvent, cachedAsync }));
5695
- app.use('/api/digest', digestRouter({ baseDir: BASE_DIR, vaultDir: VAULT_DIR, goalsDir: GOALS_DIR, memoryDbPath: MEMORY_DB_PATH, parseEnvFile, getGateway, cached }));
5694
+ app.use('/api/digest', digestRouter({ baseDir: BASE_DIR, vaultDir: VAULT_DIR, memoryDbPath: MEMORY_DB_PATH, parseEnvFile, getGateway, cached }));
5696
5695
  // Voice audio route (served from digest router but needs top-level mount for /api/voice/ path)
5697
5696
  app.get('/api/voice/audio/:hash', (req, res) => {
5698
5697
  const hash = req.params.hash.replace(/[^a-f0-9]/g, '');
@@ -6,7 +6,6 @@ import type { Gateway } from '../../gateway/router.js';
6
6
  export interface DigestRouterDeps {
7
7
  baseDir: string;
8
8
  vaultDir: string;
9
- goalsDir: string;
10
9
  memoryDbPath: string;
11
10
  parseEnvFile: () => Record<string, string>;
12
11
  getGateway: () => Promise<Gateway>;
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, } from
7
7
  import { randomBytes } from 'node:crypto';
8
8
  import path from 'node:path';
9
9
  import matter from 'gray-matter';
10
+ import { listAllGoals } from '../../tools/shared.js';
10
11
  export function getDigestPrefs(prefsFile) {
11
12
  const defaults = {
12
13
  enabled: false,
@@ -27,7 +28,7 @@ export function getDigestPrefs(prefsFile) {
27
28
  }
28
29
  export function digestRouter(deps) {
29
30
  const router = Router();
30
- const { baseDir, vaultDir, goalsDir, memoryDbPath, parseEnvFile, getGateway, cached } = deps;
31
+ const { baseDir, vaultDir, memoryDbPath, parseEnvFile, getGateway, cached } = deps;
31
32
  const prefsFile = path.join(baseDir, 'digest-preferences.json');
32
33
  const voiceCacheDir = path.join(baseDir, 'cache', 'voice');
33
34
  // Graph API helper for email
@@ -101,16 +102,10 @@ export function digestRouter(deps) {
101
102
  }
102
103
  if (secs.goals !== false) {
103
104
  try {
104
- if (existsSync(goalsDir)) {
105
- const files = readdirSync(goalsDir).filter(f => f.endsWith('.json'));
106
- const goals = files.map(f => { try {
107
- return JSON.parse(readFileSync(path.join(goalsDir, f), 'utf-8'));
108
- }
109
- catch {
110
- return null;
111
- } }).filter(Boolean);
112
- const active = goals.filter((g) => g.status === 'active');
113
- const blocked = goals.filter((g) => g.status === 'blocked');
105
+ {
106
+ const goals = listAllGoals().map(e => e.goal);
107
+ const active = goals.filter(g => g.status === 'active');
108
+ const blocked = goals.filter(g => g.status === 'blocked');
114
109
  let goalText = `${active.length} active, ${blocked.length} blocked\n`;
115
110
  active.slice(0, 5).forEach((g) => {
116
111
  goalText += ` - ${g.title} [${g.priority}]`;
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Goals API routes — extracted from dashboard.ts
2
+ * Goals API routes — extracted from dashboard.ts.
3
+ *
4
+ * Goals live per-owner: Clementine's at ~/.clementine/goals/, each agent's at
5
+ * ~/.clementine/vault/00-System/agents/{slug}/goals/. Uses the goal-store
6
+ * helpers in tools/shared.ts so the dashboard doesn't need to know the layout.
3
7
  */
4
8
  import { Router } from 'express';
5
9
  import type { Gateway } from '../../gateway/router.js';
6
10
  export interface GoalsRouterDeps {
7
- goalsDir: string;
8
11
  cronRunsDir: string;
9
12
  vaultDir: string;
10
13
  cronFile: string;