clementine-agent 1.0.23 → 1.0.25

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.
@@ -11,8 +11,10 @@
11
11
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import pino from 'pino';
14
- import { GOALS_DIR, BASE_DIR } from '../config.js';
14
+ import { GOALS_DIR, BASE_DIR, MEMORY_DB_PATH, VAULT_DIR } from '../config.js';
15
15
  import { listAllGoals } from '../tools/shared.js';
16
+ import { computeBrokenJobs } from '../gateway/failure-monitor.js';
17
+ import { MemoryStore } from '../memory/store.js';
16
18
  const logger = pino({ name: 'clementine.insight-engine' });
17
19
  const BASE_COOLDOWN_MS = 2 * 60 * 60 * 1000; // 2 hours
18
20
  const MAX_DAILY_INSIGHTS = 3;
@@ -172,9 +174,7 @@ export function gatherInsightSignals(gateway) {
172
174
  // with a diagnosis is a real, actionable signal the owner should
173
175
  // see proactively rather than stumble across in the dashboard.
174
176
  try {
175
- // eslint-disable-next-line @typescript-eslint/no-require-imports
176
- const fm = require('../gateway/failure-monitor.js');
177
- const broken = fm.computeBrokenJobs();
177
+ const broken = computeBrokenJobs();
178
178
  for (const b of broken.slice(0, 3)) {
179
179
  const hint = b.diagnosis?.rootCause
180
180
  ? ` — ${b.diagnosis.rootCause.slice(0, 120)}`
@@ -185,14 +185,13 @@ export function gatherInsightSignals(gateway) {
185
185
  }
186
186
  }
187
187
  }
188
- catch { /* failure-monitor may not be loadable; fine */ }
188
+ catch (err) {
189
+ logger.debug({ err }, 'Failed to pull broken-jobs signals');
190
+ }
189
191
  // 6. Claim tracker — failed claims in the last N hours erode trust.
190
192
  // Surface them so the owner sees "Clementine said she'd do X; she
191
193
  // didn't" instead of silently swallowing the miss.
192
194
  try {
193
- // eslint-disable-next-line @typescript-eslint/no-require-imports
194
- const { MemoryStore } = require('../memory/store.js');
195
- const { MEMORY_DB_PATH, VAULT_DIR } = require('../config.js');
196
195
  if (existsSync(MEMORY_DB_PATH)) {
197
196
  const store = new MemoryStore(MEMORY_DB_PATH, VAULT_DIR);
198
197
  store.initialize();
@@ -209,7 +208,9 @@ export function gatherInsightSignals(gateway) {
209
208
  store.close();
210
209
  }
211
210
  }
212
- catch { /* non-fatal */ }
211
+ catch (err) {
212
+ logger.debug({ err }, 'Failed to pull failed-claims signals');
213
+ }
213
214
  return signals;
214
215
  }
215
216
  /**
@@ -43,7 +43,7 @@ export interface RouteDecision {
43
43
  * - `slack:agent:*`, `slack:channel:*:{slug}:*`
44
44
  * - `team:*` — inter-agent messages travel via team-bus, never route
45
45
  */
46
- export declare function isRoutable(sessionKey: string, ownerAgentSlugs: Set<string>): boolean;
46
+ export declare function isRoutable(sessionKey: string, _ownerAgentSlugs: Set<string>): boolean;
47
47
  /**
48
48
  * Classify a user message. Returns null if the call fails — caller
49
49
  * should fall back to Clementine handling.
@@ -38,31 +38,37 @@ const logger = pino({ name: 'clementine.route-classifier' });
38
38
  * - `slack:agent:*`, `slack:channel:*:{slug}:*`
39
39
  * - `team:*` — inter-agent messages travel via team-bus, never route
40
40
  */
41
- export function isRoutable(sessionKey, ownerAgentSlugs) {
41
+ export function isRoutable(sessionKey, _ownerAgentSlugs) {
42
42
  if (!sessionKey)
43
43
  return false;
44
44
  const parts = sessionKey.split(':');
45
+ // Structural rule: any 5+ part channel key has an agent slug embedded
46
+ // (e.g. `discord:channel:{channelId}:{slug}:{userId}`). We reject this
47
+ // regardless of whether the slug appears in a passed-in roster — the
48
+ // ownerAgentSlugs list can be stale during agent-hire/rename events,
49
+ // and the key SHAPE is the safer source of truth.
50
+ //
51
+ // `_ownerAgentSlugs` is kept in the signature for future use but the
52
+ // current implementation is structure-only.
45
53
  // Agent-bot DMs and member sessions are always agent-scoped
46
54
  if (parts[0] === 'discord') {
47
55
  const kind = parts[1];
48
56
  if (kind === 'agent' || kind === 'member' || kind === 'member-dm')
49
57
  return false;
50
- // 5-part discord:channel:{channelId}:{slug}:{userId} means agent in team chat
51
- if (kind === 'channel' && parts.length >= 5 && ownerAgentSlugs.has(parts[3] ?? '')) {
58
+ // Any 5+ part channel key agent-scoped, never route
59
+ if (kind === 'channel' && parts.length >= 5)
52
60
  return false;
53
- }
54
61
  // discord:user:* and the 4-part discord:channel:{channelId}:{userId} pass
55
- return kind === 'user' || kind === 'channel';
62
+ return kind === 'user' || (kind === 'channel' && parts.length === 4);
56
63
  }
57
64
  if (parts[0] === 'slack') {
58
65
  const kind = parts[1];
59
66
  if (kind === 'agent')
60
67
  return false;
61
- // slack:channel:{channelId}:{slug}:{userId} agent-scoped
62
- if (kind === 'channel' && parts.length >= 5 && ownerAgentSlugs.has(parts[3] ?? '')) {
68
+ // Any 5+ part channel key agent-scoped
69
+ if (kind === 'channel' && parts.length >= 5)
63
70
  return false;
64
- }
65
- return kind === 'user' || kind === 'dm' || kind === 'channel';
71
+ return kind === 'user' || kind === 'dm' || (kind === 'channel' && parts.length === 4);
66
72
  }
67
73
  if (parts[0] === 'telegram')
68
74
  return parts[1] === 'user' || /^\d+$/.test(parts[1] ?? '');
@@ -123,13 +123,24 @@ const PATTERNS = [
123
123
  * Bounded to prevent memory growth — oldest entries are evicted.
124
124
  */
125
125
  const MAX_PENDING_LLM = 20;
126
+ const PENDING_LLM_TTL_MS = 6 * 60 * 60 * 1000; // 6h — after that a claim is stale anyway
126
127
  const pendingLLMExtraction = [];
128
+ function pruneExpiredPending(now = Date.now()) {
129
+ while (pendingLLMExtraction.length > 0) {
130
+ const oldest = pendingLLMExtraction[0];
131
+ if (now - oldest.queuedAt <= PENDING_LLM_TTL_MS)
132
+ break;
133
+ pendingLLMExtraction.shift();
134
+ }
135
+ }
127
136
  function enqueueForLLM(text, sessionKey, agentSlug) {
137
+ const now = Date.now();
138
+ pruneExpiredPending(now);
128
139
  // De-dup by text hash within the queue — don't re-enqueue the same DM.
129
140
  const hash = sha1(text);
130
- if (pendingLLMExtraction.some(e => sha1(e.text) === hash))
141
+ if (pendingLLMExtraction.some(e => e.hash === hash))
131
142
  return;
132
- pendingLLMExtraction.push({ text, sessionKey, agentSlug, queuedAt: Date.now() });
143
+ pendingLLMExtraction.push({ text, hash, sessionKey, agentSlug, queuedAt: now });
133
144
  while (pendingLLMExtraction.length > MAX_PENDING_LLM)
134
145
  pendingLLMExtraction.shift();
135
146
  }
@@ -208,11 +219,16 @@ export function extractClaims(text, sessionKey, agentSlug) {
208
219
  * the next sweep.
209
220
  */
210
221
  export async function drainLLMFallback(gateway, maxPerSweep = 3) {
222
+ pruneExpiredPending();
211
223
  let drained = 0;
212
- const batch = pendingLLMExtraction.splice(0, Math.min(maxPerSweep, pendingLLMExtraction.length));
224
+ // Peek don't remove yet. We only splice on successful processing so a
225
+ // transient LLM failure doesn't silently drop the candidate.
226
+ const batch = pendingLLMExtraction.slice(0, Math.min(maxPerSweep, pendingLLMExtraction.length));
227
+ const toRemove = new Set();
213
228
  for (const item of batch) {
214
229
  try {
215
230
  const claims = await llmExtractClaims(item.text, gateway);
231
+ toRemove.add(item.hash); // success (or "no claims" — not worth re-trying)
216
232
  if (claims.length === 0)
217
233
  continue;
218
234
  const toRecord = claims.map(c => ({
@@ -229,9 +245,18 @@ export async function drainLLMFallback(gateway, maxPerSweep = 3) {
229
245
  drained += claims.length;
230
246
  }
231
247
  catch (err) {
248
+ // Don't add to toRemove — leave in queue for next sweep. TTL eventually
249
+ // evicts permanently-failing entries.
232
250
  logger.debug({ err }, 'LLM fallback extraction failed for one DM');
233
251
  }
234
252
  }
253
+ // Remove successfully-processed entries in one pass
254
+ if (toRemove.size > 0) {
255
+ for (let i = pendingLLMExtraction.length - 1; i >= 0; i--) {
256
+ if (toRemove.has(pendingLLMExtraction[i].hash))
257
+ pendingLLMExtraction.splice(i, 1);
258
+ }
259
+ }
235
260
  return drained;
236
261
  }
237
262
  async function llmExtractClaims(text, gateway) {
@@ -10,7 +10,7 @@
10
10
  * so the response to a silent failure is "here's what's wrong and
11
11
  * here's what to change" rather than "go investigate."
12
12
  */
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import pino from 'pino';
16
16
  import { AGENTS_DIR, BASE_DIR, CRON_FILE } from '../config.js';
@@ -52,7 +52,9 @@ function loadCache() {
52
52
  function saveCache(cache) {
53
53
  try {
54
54
  mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
55
- writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
55
+ const tmp = CACHE_FILE + '.tmp';
56
+ writeFileSync(tmp, JSON.stringify(cache, null, 2));
57
+ renameSync(tmp, CACHE_FILE);
56
58
  }
57
59
  catch (err) {
58
60
  logger.warn({ err }, 'Failed to persist diagnostic cache');
@@ -15,10 +15,11 @@
15
15
  * Read-only with respect to the cron run logs and advisor events; mutates
16
16
  * only its own state file (cron/failure-monitor.json).
17
17
  */
18
- import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
18
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from 'node:fs';
19
19
  import path from 'node:path';
20
+ import Database from 'better-sqlite3';
20
21
  import pino from 'pino';
21
- import { BASE_DIR } from '../config.js';
22
+ import { BASE_DIR, MEMORY_DB_PATH } from '../config.js';
22
23
  const logger = pino({ name: 'clementine.failure-monitor' });
23
24
  const RUNS_DIR = path.join(BASE_DIR, 'cron', 'runs');
24
25
  const ADVISOR_EVENTS_FILE = path.join(BASE_DIR, 'cron', 'advisor-events.jsonl');
@@ -50,7 +51,11 @@ function loadState() {
50
51
  function saveState(state) {
51
52
  try {
52
53
  mkdirSync(path.dirname(STATE_FILE), { recursive: true });
53
- writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
54
+ // Atomic write — write to temp file then rename. Prevents partial
55
+ // writes from corrupting the state if the process is killed mid-write.
56
+ const tmp = STATE_FILE + '.tmp';
57
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
58
+ renameSync(tmp, STATE_FILE);
54
59
  }
55
60
  catch (err) {
56
61
  logger.warn({ err }, 'Failed to persist failure-monitor state');
@@ -100,11 +105,8 @@ function isFailure(entry, gradeCache) {
100
105
  function loadGradeCache() {
101
106
  const cache = new Map();
102
107
  try {
103
- const { MEMORY_DB_PATH } = require('../config.js');
104
108
  if (!existsSync(MEMORY_DB_PATH))
105
109
  return cache;
106
- // eslint-disable-next-line @typescript-eslint/no-require-imports
107
- const Database = require('better-sqlite3');
108
110
  const db = new Database(MEMORY_DB_PATH, { readonly: true });
109
111
  try {
110
112
  const rows = db.prepare(`SELECT job_name, started_at, passed FROM graded_runs
@@ -116,7 +118,9 @@ function loadGradeCache() {
116
118
  catch { /* graded_runs may not exist on older DBs */ }
117
119
  db.close();
118
120
  }
119
- catch { /* non-fatal */ }
121
+ catch (err) {
122
+ logger.warn({ err }, 'Failed to load grade cache');
123
+ }
120
124
  return cache;
121
125
  }
122
126
  /**
@@ -7,7 +7,7 @@
7
7
  * owner with the verdict — succeeded or still failing — so a self-reported
8
8
  * "fix" can't go untested again.
9
9
  */
10
- import { existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import crypto from 'node:crypto';
13
13
  import pino from 'pino';
@@ -29,7 +29,9 @@ function loadState() {
29
29
  function saveState(state) {
30
30
  try {
31
31
  mkdirSync(path.dirname(STATE_FILE), { recursive: true });
32
- writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
32
+ const tmp = STATE_FILE + '.tmp';
33
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
34
+ renameSync(tmp, STATE_FILE);
33
35
  }
34
36
  catch (err) {
35
37
  logger.warn({ err }, 'Failed to persist fix-verification state');
@@ -5,7 +5,7 @@
5
5
  * Manages per-user/channel sessions for conversation continuity.
6
6
  */
7
7
  import path from 'node:path';
8
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import pino from 'pino';
10
10
  import { PersonalAssistant } from '../agent/assistant.js';
11
11
  import { SelfImproveLoop } from '../agent/self-improve.js';
@@ -743,7 +743,15 @@ export class Gateway {
743
743
  // specific agent profile, classify whether the message should go
744
744
  // to a specialist. Direct-to-agent-bot sessions bypass this entirely.
745
745
  // Small-talk and meta queries stay with Clementine by default.
746
- const routingResult = !isInternalMsg && !sess?.profile && !text.startsWith('!')
746
+ //
747
+ // Also bypass structured workflow messages — button clicks, approvals,
748
+ // and other system-injected interactions are not free-form chat and
749
+ // shouldn't be reclassified. They already have an intended flow.
750
+ const isStructuredWorkflowMsg = text.startsWith('[Button clicked:')
751
+ || text.startsWith('[Approval:')
752
+ || text.startsWith('[Reaction:')
753
+ || text.startsWith('[System:');
754
+ const routingResult = !isInternalMsg && !sess?.profile && !text.startsWith('!') && !isStructuredWorkflowMsg
747
755
  ? await this._maybeRouteToSpecialist(sessionKey, text, onText)
748
756
  : null;
749
757
  if (routingResult?.delegated) {
@@ -1511,8 +1519,6 @@ function logRouteDecision(opts) {
1511
1519
  while (_routeAuditBuffer.length > 200)
1512
1520
  _routeAuditBuffer.shift();
1513
1521
  try {
1514
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1515
- const { appendFileSync } = require('node:fs');
1516
1522
  appendFileSync(Gateway.routeAuditLogPath(), JSON.stringify(entry) + '\n');
1517
1523
  }
1518
1524
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",