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.
- package/dist/agent/insight-engine.js +10 -9
- package/dist/agent/route-classifier.d.ts +1 -1
- package/dist/agent/route-classifier.js +15 -9
- package/dist/gateway/claim-tracker.js +28 -3
- package/dist/gateway/failure-diagnostics.js +4 -2
- package/dist/gateway/failure-monitor.js +11 -7
- package/dist/gateway/fix-verification.js +4 -2
- package/dist/gateway/router.js +10 -4
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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 {
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
|
51
|
-
if (kind === 'channel' && parts.length >= 5
|
|
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
|
-
//
|
|
62
|
-
if (kind === 'channel' && parts.length >= 5
|
|
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 =>
|
|
141
|
+
if (pendingLLMExtraction.some(e => e.hash === hash))
|
|
131
142
|
return;
|
|
132
|
-
pendingLLMExtraction.push({ text, sessionKey, agentSlug, queuedAt:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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');
|
package/dist/gateway/router.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|