@coda-rho-bot/oath-keeper 1.0.0
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/MOD.md +58 -0
- package/README.md +257 -0
- package/mods/index.ts +1197 -0
- package/mods/oath-keeper.ts +1197 -0
- package/package.json +41 -0
|
@@ -0,0 +1,1197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oath Keeper — Letta Code Mod
|
|
3
|
+
*
|
|
4
|
+
* "Cron is for things you plan. Oath Keeper is for things you promise."
|
|
5
|
+
*
|
|
6
|
+
* Architecture:
|
|
7
|
+
* - Detection: turn_end (CLI v0.27.25+) + setInterval polling (desktop/listener)
|
|
8
|
+
* - Delivery: queued state + API POST with 409 retry
|
|
9
|
+
* - State: local JSON file with builder-pattern StateStore
|
|
10
|
+
*
|
|
11
|
+
* CLI LIMITATION: Oath delivery fires into the conversation via API POST.
|
|
12
|
+
* The delivery appears in the desktop app. CLI may not display it until
|
|
13
|
+
* the next user message or CLI restart.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { execSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
const HOME = os.homedir();
|
|
22
|
+
const STATE_FILE = `${HOME}/.letta/mods/oath-keeper.state.json`;
|
|
23
|
+
const ENV_FILE = `${HOME}/.letta/extensions/oath-env.json`;
|
|
24
|
+
const DEBUG_FILE = `${HOME}/.letta/mods/oath-keeper-debug.json`;
|
|
25
|
+
const FALSE_POSITIVE_FILE = `${HOME}/.letta/mods/oath-keeper-false-positives.json`;
|
|
26
|
+
const POLL_INTERVAL_MS = 15_000;
|
|
27
|
+
const DEFAULT_DELAY_MS = 300_000; // 5 minutes fallback if LLM doesn't specify
|
|
28
|
+
const VERBOSE_FILE = `${HOME}/.letta/mods/oath-keeper.verbose`;
|
|
29
|
+
const CONFIG_FILE = `${HOME}/.letta/mods/oath-keeper.config.json`;
|
|
30
|
+
|
|
31
|
+
function isVerbose(): boolean {
|
|
32
|
+
try { return fs.existsSync(VERBOSE_FILE); } catch (e) { return false; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface OathConfig {
|
|
36
|
+
classifierAgentId?: string; // Agent ID to use for promise classification (defaults to same agent)
|
|
37
|
+
classifierModel?: string; // Model for classification LLM calls (default: letta/auto-fast)
|
|
38
|
+
negativeFilter?: boolean; // Enable negative filter — code-heavy/short messages (default: true)
|
|
39
|
+
ngramFilter?: boolean; // Enable n-gram pre-filter (default: true)
|
|
40
|
+
ngramThreshold?: number; // N-gram score threshold for LLM classification (default: 1.25)
|
|
41
|
+
llmConfirm?: boolean; // Enable LLM confirmation/dedup (default: true)
|
|
42
|
+
llmDedup?: boolean; // Enable LLM semantic dedup (default: true)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadConfig(): OathConfig {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Get the agent ID to use for LLM classification calls.
|
|
54
|
+
* Falls back to the env file's agent ID if not configured. */
|
|
55
|
+
function getClassifierAgentId(): string {
|
|
56
|
+
const config = loadConfig();
|
|
57
|
+
if (config.classifierAgentId) return config.classifierAgentId;
|
|
58
|
+
return getApiConfig().agentId;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Get the model to use for classification LLM calls (default: letta/auto-fast) */
|
|
62
|
+
function getClassifierModel(): string {
|
|
63
|
+
const config = loadConfig();
|
|
64
|
+
return config.classifierModel || "letta/auto-fast";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check if negative filter is enabled (default: true) */
|
|
68
|
+
function isNegativeFilterEnabled(): boolean {
|
|
69
|
+
const config = loadConfig();
|
|
70
|
+
return config.negativeFilter !== false; // default true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Check if n-gram pre-filter is enabled (default: true) */
|
|
74
|
+
function isNgramEnabled(): boolean {
|
|
75
|
+
const config = loadConfig();
|
|
76
|
+
return config.ngramFilter !== false; // default true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Get n-gram score threshold (default: 1.25) */
|
|
80
|
+
function getNgramThreshold(): number {
|
|
81
|
+
const config = loadConfig();
|
|
82
|
+
return typeof config.ngramThreshold === "number" ? config.ngramThreshold : 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Check if LLM confirmation is enabled (default: false) */
|
|
86
|
+
function isLlmConfirmEnabled(): boolean {
|
|
87
|
+
const config = loadConfig();
|
|
88
|
+
return config.llmConfirm === true; // default false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Check if LLM semantic dedup is enabled (default: false) */
|
|
92
|
+
function isLlmDedupEnabled(): boolean {
|
|
93
|
+
const config = loadConfig();
|
|
94
|
+
return config.llmDedup === true; // default false
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Check if at least one filter is active — if not, no oaths are created */
|
|
98
|
+
function filtersActive(): boolean {
|
|
99
|
+
return isNgramEnabled() || isLlmConfirmEnabled();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function log(msg: string) {
|
|
103
|
+
addDebugLog(msg);
|
|
104
|
+
if (isVerbose()) console.log("[oath-keeper] " + msg);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Debug log ───────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
interface DebugEntry { ts: number; msg: string; }
|
|
110
|
+
|
|
111
|
+
function addDebugLog(msg: string) {
|
|
112
|
+
try {
|
|
113
|
+
const entry: DebugEntry = { ts: Date.now(), msg };
|
|
114
|
+
const raw = fs.readFileSync(DEBUG_FILE, "utf8");
|
|
115
|
+
const entries: DebugEntry[] = JSON.parse(raw);
|
|
116
|
+
entries.push(entry);
|
|
117
|
+
while (entries.length > 500) entries.shift();
|
|
118
|
+
fs.writeFileSync(DEBUG_FILE, JSON.stringify(entries, null, 2));
|
|
119
|
+
} catch (e) {
|
|
120
|
+
try { fs.writeFileSync(DEBUG_FILE, JSON.stringify([{ ts: Date.now(), msg }], null, 2)); } catch (e2) {}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── State ───────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
interface Oath {
|
|
127
|
+
id: string;
|
|
128
|
+
conversationId: string;
|
|
129
|
+
agentId: string;
|
|
130
|
+
promise: string;
|
|
131
|
+
context: string;
|
|
132
|
+
sourceMessageId?: string;
|
|
133
|
+
deliveryMode?: "turn_end" | "rest_api" | "polling";
|
|
134
|
+
createdAt: number;
|
|
135
|
+
dueAt: number;
|
|
136
|
+
status: "pending" | "queued" | "delivering" | "delivered" | "failed" | "false_positive" | "prefilter_rejected";
|
|
137
|
+
result: string | null;
|
|
138
|
+
deliveredAt: number | null;
|
|
139
|
+
ngramScore?: number;
|
|
140
|
+
llmTokens?: { prompt: number; completion: number; total: number };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface StateData {
|
|
144
|
+
oaths: Oath[];
|
|
145
|
+
lastScannedMessageId: string | null;
|
|
146
|
+
_pollVer: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
class StateStore {
|
|
150
|
+
private data: StateData;
|
|
151
|
+
private dirty: boolean = false;
|
|
152
|
+
private saved: boolean = false;
|
|
153
|
+
private operation: string;
|
|
154
|
+
|
|
155
|
+
private constructor(data: StateData, operation: string) { this.data = data; this.operation = operation; }
|
|
156
|
+
|
|
157
|
+
static load(operation: string): StateStore {
|
|
158
|
+
let data: StateData;
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
|
|
161
|
+
data = { oaths: parsed.oaths || [], lastScannedMessageId: parsed.lastScannedMessageId || null, _pollVer: parsed._pollVer || "" };
|
|
162
|
+
} catch (e) {
|
|
163
|
+
data = { oaths: [], lastScannedMessageId: null, _pollVer: "" };
|
|
164
|
+
}
|
|
165
|
+
log(`StateStore.load("${operation}") — ${data.oaths.length} oaths`);
|
|
166
|
+
return new StateStore(data, operation);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
findOath(id: string): Oath | undefined { return this.data.oaths.find((o) => o.id === id); }
|
|
170
|
+
updateOath(id: string, updates: Partial<Oath>): StateStore {
|
|
171
|
+
const oath = this.findOath(id);
|
|
172
|
+
if (!oath) return this;
|
|
173
|
+
Object.assign(oath, updates);
|
|
174
|
+
this.dirty = true;
|
|
175
|
+
log(`StateStore.updateOath("${id}") — ${Object.keys(updates).join(",")}`);
|
|
176
|
+
return this;
|
|
177
|
+
}
|
|
178
|
+
addOath(oath: Oath): StateStore { this.data.oaths.push(oath); this.dirty = true; log(`StateStore.addOath("${oath.id}")`); return this; }
|
|
179
|
+
setScanned(msgId: string): StateStore { this.data.lastScannedMessageId = msgId; this.dirty = true; return this; }
|
|
180
|
+
setPollVer(ver: string): StateStore { this.data._pollVer = ver; this.dirty = true; return this; }
|
|
181
|
+
prune(now: number): StateStore {
|
|
182
|
+
const before = this.data.oaths.length;
|
|
183
|
+
this.data.oaths = this.data.oaths.filter((o) => {
|
|
184
|
+
// Always keep active oaths
|
|
185
|
+
if (o.status === "pending" || o.status === "queued" || o.status === "delivering") return true;
|
|
186
|
+
// Prune prefilter_rejected after 10 minutes (they're just debug noise)
|
|
187
|
+
if (o.status === "prefilter_rejected" && o.deliveredAt && (now - o.deliveredAt) > 600_000) return false;
|
|
188
|
+
// Prune false_positive after 30 minutes
|
|
189
|
+
if (o.status === "false_positive" && o.deliveredAt && (now - o.deliveredAt) > 1_800_000) return false;
|
|
190
|
+
// Prune delivered/failed after 24 hours
|
|
191
|
+
if (o.deliveredAt && (now - o.deliveredAt) > 86_400_000) return false;
|
|
192
|
+
return true;
|
|
193
|
+
});
|
|
194
|
+
if (this.data.oaths.length !== before) this.dirty = true;
|
|
195
|
+
return this;
|
|
196
|
+
}
|
|
197
|
+
get oaths(): Oath[] { return this.data.oaths; }
|
|
198
|
+
get lastScannedMessageId(): string | null { return this.data.lastScannedMessageId; }
|
|
199
|
+
get pollVer(): string { return this.data._pollVer; }
|
|
200
|
+
hasActiveOaths(): boolean { return this.data.oaths.some((o) => o.status === "pending" || o.status === "queued" || o.status === "delivering"); }
|
|
201
|
+
|
|
202
|
+
/** Get active oaths (pending, queued, or delivering) for LLM dedup comparison */
|
|
203
|
+
activeOaths(): Oath[] {
|
|
204
|
+
return this.data.oaths.filter((o) => o.status === "pending" || o.status === "queued" || o.status === "delivering");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Strong dedup: returns true if an oath with the same promise text exists from the last N minutes */
|
|
208
|
+
hasRecentPromise(promiseText: string, withinMs: number = 300_000): boolean {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
const snippet = promiseText.slice(0, 60).toLowerCase();
|
|
211
|
+
return this.data.oaths.some((o) =>
|
|
212
|
+
o.createdAt > (now - withinMs) &&
|
|
213
|
+
o.promise.toLowerCase().includes(snippet)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
save(): void {
|
|
218
|
+
if (!this.dirty) { this.saved = true; return; }
|
|
219
|
+
try { fs.writeFileSync(STATE_FILE, JSON.stringify(this.data, null, 2)); this.saved = true; log(`StateStore.save() — SAVED after "${this.operation}"`); }
|
|
220
|
+
catch (e) { log(`StateStore.save() — FAILED: ${e}`); }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** LLM dedup — checks if a new promise is semantically the same as any existing active oath */
|
|
225
|
+
async function isDuplicatePromise(newPromise: string, existingOaths: Oath[]): Promise<{ isDup: boolean; tokens?: { prompt: number; completion: number; total: number } }> {
|
|
226
|
+
if (existingOaths.length === 0) return false;
|
|
227
|
+
const { baseUrl, apiKey } = getApiConfig();
|
|
228
|
+
const classifierAgentId = getClassifierAgentId();
|
|
229
|
+
if (!classifierAgentId) return false;
|
|
230
|
+
|
|
231
|
+
const list = existingOaths.map((o, i) => `${i + 1}. "${o.promise}"`).join("\n");
|
|
232
|
+
const prompt =
|
|
233
|
+
'You are a duplicate detector. A new oath promise has been detected.\n'
|
|
234
|
+
+ 'Check if it is semantically the same promise as any existing active oath.\n\n'
|
|
235
|
+
+ 'New promise: "' + newPromise + '"\n\n'
|
|
236
|
+
+ 'Existing active oaths:\n' + list + '\n\n'
|
|
237
|
+
+ 'Respond with ONLY a JSON object:\n'
|
|
238
|
+
+ '- Duplicate: {"is_duplicate": true, "matching_index": <number>}\n'
|
|
239
|
+
+ '- Not duplicate: {"is_duplicate": false}';
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const classifierModel = getClassifierModel();
|
|
243
|
+
const convResp = await fetch(baseUrl + "/v1/conversations?agent_id=" + classifierAgentId, {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: "Bearer " + apiKey } : {}) },
|
|
246
|
+
body: JSON.stringify({ model: classifierModel }),
|
|
247
|
+
});
|
|
248
|
+
if (!convResp.ok) return { isDup: false };
|
|
249
|
+
const convData: any = await convResp.json();
|
|
250
|
+
const classConvId = convData.id || "";
|
|
251
|
+
|
|
252
|
+
const controller = new AbortController();
|
|
253
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
254
|
+
const resp = await fetch(baseUrl + "/v1/conversations/" + classConvId + "/messages", {
|
|
255
|
+
method: "POST",
|
|
256
|
+
headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: "Bearer " + apiKey } : {}) },
|
|
257
|
+
body: JSON.stringify({ input: prompt, role: "user" }),
|
|
258
|
+
signal: controller.signal,
|
|
259
|
+
});
|
|
260
|
+
clearTimeout(timeout);
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
await fetch(baseUrl + "/v1/conversations/" + classConvId, {
|
|
264
|
+
method: "DELETE",
|
|
265
|
+
headers: apiKey ? { Authorization: "Bearer " + apiKey } : {},
|
|
266
|
+
});
|
|
267
|
+
} catch (e) {}
|
|
268
|
+
|
|
269
|
+
if (!resp.ok) return false;
|
|
270
|
+
const respText = await resp.text();
|
|
271
|
+
let answer = "";
|
|
272
|
+
let tokenUsage: { prompt: number; completion: number; total: number } | undefined;
|
|
273
|
+
for (const line of respText.split("\n")) {
|
|
274
|
+
if (!line.startsWith("data: ")) continue;
|
|
275
|
+
const data = line.slice(6);
|
|
276
|
+
if (data === "[DONE]") break;
|
|
277
|
+
try {
|
|
278
|
+
const d = JSON.parse(data);
|
|
279
|
+
if (d.message_type === "usage_statistics") {
|
|
280
|
+
tokenUsage = { prompt: d.prompt_tokens || 0, completion: d.completion_tokens || 0, total: d.total_tokens || 0 };
|
|
281
|
+
}
|
|
282
|
+
if (d.message_type === "assistant_message" && d.content) {
|
|
283
|
+
answer = String(d.content).slice(0, 500);
|
|
284
|
+
}
|
|
285
|
+
} catch (e) {}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const jsonMatch = answer.match(/\{[^}]*\}/);
|
|
289
|
+
if (!jsonMatch) return { isDup: false, tokens: tokenUsage };
|
|
290
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
291
|
+
const isDup = parsed.is_duplicate === true;
|
|
292
|
+
if (isDup) log("isDuplicatePromise: DUPLICATE of oath #" + parsed.matching_index);
|
|
293
|
+
else log("isDuplicatePromise: not a duplicate");
|
|
294
|
+
return { isDup, tokens: tokenUsage };
|
|
295
|
+
} catch (e) {
|
|
296
|
+
log("isDuplicatePromise error: " + e);
|
|
297
|
+
return { isDup: false };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ─── Promise Detection ───────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
const PROMISE_PATTERNS: Array<[RegExp, number]> = [
|
|
304
|
+
// Strong signals (3.0)
|
|
305
|
+
[/i'll get back to/i, 3.0],
|
|
306
|
+
[/i'll follow up/i, 3.0],
|
|
307
|
+
[/i'll circle back/i, 3.0],
|
|
308
|
+
[/get back to you/i, 3.0],
|
|
309
|
+
[/follow up (?:on|with|in)/i, 3.0],
|
|
310
|
+
[/i'll let you know/i, 3.0],
|
|
311
|
+
[/i'll update you/i, 3.0],
|
|
312
|
+
[/check back (?:in|with|later|after)/i, 2.5],
|
|
313
|
+
|
|
314
|
+
// Moderate signals (2.0-2.5)
|
|
315
|
+
[/i'll (?:check|verify|look into|investigate|research|dig into|confirm)/i, 2.5],
|
|
316
|
+
[/let me (?:check|verify|look into|investigate|research|dig into|confirm)/i, 2.5],
|
|
317
|
+
[/i'll (?:send|provide|share|post|publish|deliver)/i, 2.0],
|
|
318
|
+
[/i'll (?:have|get) (?:an answer|results|something|a response)/i, 2.5],
|
|
319
|
+
[/i'll tell you.*(?:later|after|when)/i, 2.5],
|
|
320
|
+
|
|
321
|
+
// Weak signals (1.0-1.5)
|
|
322
|
+
[/i'll (?:try|attempt|see|find out|work on)/i, 1.5],
|
|
323
|
+
[/i (?:will|shall) (?:check|verify|look|investigate|research|test|review|analyze)/i, 2.0],
|
|
324
|
+
[/i'm going to (?:check|verify|look|investigate|research|test|review)/i, 2.0],
|
|
325
|
+
[/(?:in|after) (?:\d+|a few|some) (?:minutes|seconds|hours|moments)/i, 1.5],
|
|
326
|
+
[/\blater (?:today|this week|tonight)\b/i, 1.0],
|
|
327
|
+
];
|
|
328
|
+
|
|
329
|
+
function computeNgramScore(text: string): number {
|
|
330
|
+
let score = 0;
|
|
331
|
+
for (const [pattern, weight] of PROMISE_PATTERNS) {
|
|
332
|
+
if (pattern.test(text)) score += weight;
|
|
333
|
+
}
|
|
334
|
+
return score;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function detectPromiseRegex(text: string): { match: string; score: number } | null {
|
|
338
|
+
if (!text || typeof text !== "string") return null;
|
|
339
|
+
if (text.includes("[Oath Keeper]") || text.includes("[Oath Delivered]")) return null;
|
|
340
|
+
|
|
341
|
+
// Negative filter (Stage 0): skip short and code-heavy messages
|
|
342
|
+
if (isNegativeFilterEnabled()) {
|
|
343
|
+
if (text.trim().length < 15) return null;
|
|
344
|
+
const codeChars = (text.match(/[{}()[\];=]/g) || []).length;
|
|
345
|
+
if (text.length > 50 && codeChars / text.length > 0.05) return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const score = computeNgramScore(text);
|
|
349
|
+
const threshold = getNgramThreshold();
|
|
350
|
+
|
|
351
|
+
if (score > threshold) return { match: "ngram-score-" + score, score };
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* LLM confirmation — given a candidate message,
|
|
357
|
+
* ask the LLM to determine whether it's a genuine promise to follow up later.
|
|
358
|
+
* Returns the specific promise text or null if not a real promise.
|
|
359
|
+
*/
|
|
360
|
+
/** Log a false positive in the state file with its own status (deduplicated) */
|
|
361
|
+
function logFalsePositive(matchedPattern: string, text: string, source: string, ngramScore?: number, conversationId?: string, agentId?: string) {
|
|
362
|
+
try {
|
|
363
|
+
const store = StateStore.load("false-positive");
|
|
364
|
+
// Deduplicate — skip if a false positive with the same text already exists
|
|
365
|
+
const textSnippet = text.slice(0, 60);
|
|
366
|
+
const exists = store.oaths.some((o) =>
|
|
367
|
+
o.status === "false_positive" &&
|
|
368
|
+
o.promise.includes(textSnippet)
|
|
369
|
+
);
|
|
370
|
+
if (exists) { log("False positive already logged — skipping duplicate"); return; }
|
|
371
|
+
const now = Date.now();
|
|
372
|
+
store.addOath({
|
|
373
|
+
id: "fp-" + now + "-" + Math.random().toString(36).slice(2, 6),
|
|
374
|
+
conversationId: conversationId || "",
|
|
375
|
+
agentId: agentId || "",
|
|
376
|
+
promise: "[FALSE POSITIVE] " + matchedPattern + ": " + text.slice(0, 60),
|
|
377
|
+
context: text.slice(0, 200),
|
|
378
|
+
createdAt: now,
|
|
379
|
+
dueAt: now,
|
|
380
|
+
status: "false_positive",
|
|
381
|
+
result: "LLM rejected — not a genuine promise",
|
|
382
|
+
deliveredAt: now,
|
|
383
|
+
ngramScore,
|
|
384
|
+
});
|
|
385
|
+
store.save();
|
|
386
|
+
log("False positive logged: " + matchedPattern);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
log("Failed to log false positive: " + e);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Log a pre-filter rejection — message didn't score high enough for LLM classification */
|
|
393
|
+
function logPreFilterRejection(text: string, reason: string, ngramScore?: number, conversationId?: string, agentId?: string) {
|
|
394
|
+
try {
|
|
395
|
+
const store = StateStore.load("prefilter-reject");
|
|
396
|
+
const textSnippet = text.slice(0, 60);
|
|
397
|
+
// Deduplicate — don't log the same rejection repeatedly
|
|
398
|
+
const exists = store.oaths.some((o) =>
|
|
399
|
+
o.status === "prefilter_rejected" &&
|
|
400
|
+
o.promise.includes(textSnippet)
|
|
401
|
+
);
|
|
402
|
+
if (exists) return;
|
|
403
|
+
const now = Date.now();
|
|
404
|
+
store.addOath({
|
|
405
|
+
id: "pf-" + now + "-" + Math.random().toString(36).slice(2, 6),
|
|
406
|
+
conversationId: conversationId || "",
|
|
407
|
+
agentId: agentId || "",
|
|
408
|
+
promise: text.slice(0, 120),
|
|
409
|
+
context: reason,
|
|
410
|
+
createdAt: now,
|
|
411
|
+
dueAt: now,
|
|
412
|
+
status: "prefilter_rejected",
|
|
413
|
+
result: reason,
|
|
414
|
+
deliveredAt: now,
|
|
415
|
+
ngramScore,
|
|
416
|
+
});
|
|
417
|
+
store.save();
|
|
418
|
+
log("Pre-filter rejected: " + reason + " (score=" + (ngramScore ?? 0) + ") — " + textSnippet);
|
|
419
|
+
} catch (e) {
|
|
420
|
+
log("Failed to log pre-filter rejection: " + e);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* LLM confirmation — given a candidate message that matched the regex pre-filter,
|
|
426
|
+
* ask the LLM to confirm whether it's a genuine promise to follow up later.
|
|
427
|
+
* Returns the specific promise text or null if not a real promise.
|
|
428
|
+
*/
|
|
429
|
+
async function confirmPromise(text: string): Promise<{ promise: string; delayMs: number; tokens?: { prompt: number; completion: number; total: number } } | null> {
|
|
430
|
+
const { baseUrl, apiKey } = getApiConfig();
|
|
431
|
+
const classifierAgentId = getClassifierAgentId();
|
|
432
|
+
if (!classifierAgentId) return null;
|
|
433
|
+
|
|
434
|
+
// Truncate to keep the classification fast
|
|
435
|
+
const snippet = text.slice(0, 1000);
|
|
436
|
+
const classificationPrompt =
|
|
437
|
+
'You are a promise detector. Read this assistant message and determine:\n'
|
|
438
|
+
+ 'Does the assistant make a GENUINE promise to do something AFTER the current response?\n\n'
|
|
439
|
+
+ 'Rules:\n'
|
|
440
|
+
+ '- YES = agent commits to following up later (e.g., "I\'ll get back to you after I check")\n'
|
|
441
|
+
+ '- YES = agent says "I\'ll tell you X in 60 seconds" — the "in 60 seconds" means it will happen LATER\n'
|
|
442
|
+
+ '- YES = agent mentions a specific time delay ("in N minutes/seconds", "later", "after")\n'
|
|
443
|
+
+ '- NO = agent is doing it right now with no delay (e.g., "I\'ll tell you the time" immediately followed by the actual answer with no time gap)\n'
|
|
444
|
+
+ '- NO = quoting or explaining what someone else said\n'
|
|
445
|
+
+ '- NO = describing how the mod works\n'
|
|
446
|
+
+ '- NO = hypothetical examples\n\n'
|
|
447
|
+
+ 'Message:\n"""' + snippet + '"""\n\n'
|
|
448
|
+
+ 'Respond with ONLY a JSON object:\n'
|
|
449
|
+
+ '- Genuine promise: {"is_promise": true, "promise": "<what they specifically promise to do>", "delay_seconds": <integer>}\n'
|
|
450
|
+
+ ' - delay_seconds: how many seconds until the agent should deliver on this promise.\n'
|
|
451
|
+
+ ' - If the agent specified a time ("in 5 minutes" → 300, "in an hour" → 3600, "tomorrow" → 86400), use that.\n'
|
|
452
|
+
+ ' - If no specific time, estimate based on the task (quick check → 60-120, investigation → 300-600, deep work → 900+).\n'
|
|
453
|
+
+ ' - Any positive integer.\n'
|
|
454
|
+
+ '- Not a promise: {"is_promise": false}';
|
|
455
|
+
|
|
456
|
+
try {
|
|
457
|
+
// Create throwaway conversation for classification — use configured model
|
|
458
|
+
const classifierModel = getClassifierModel();
|
|
459
|
+
const convResp = await fetch(
|
|
460
|
+
baseUrl + "/v1/conversations?agent_id=" + classifierAgentId,
|
|
461
|
+
{
|
|
462
|
+
method: "POST",
|
|
463
|
+
headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: "Bearer " + apiKey } : {}) },
|
|
464
|
+
body: JSON.stringify({ model: classifierModel }),
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
if (!convResp.ok) { log("confirmPromise: could not create conversation"); return null; }
|
|
468
|
+
const convData: any = await convResp.json();
|
|
469
|
+
const classConvId = convData.id || "";
|
|
470
|
+
|
|
471
|
+
const controller = new AbortController();
|
|
472
|
+
const timeout = setTimeout(() => controller.abort(), 20_000);
|
|
473
|
+
const resp = await fetch(
|
|
474
|
+
baseUrl + "/v1/conversations/" + classConvId + "/messages",
|
|
475
|
+
{
|
|
476
|
+
method: "POST",
|
|
477
|
+
headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: "Bearer " + apiKey } : {}) },
|
|
478
|
+
body: JSON.stringify({ input: classificationPrompt, role: "user" }),
|
|
479
|
+
signal: controller.signal,
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
clearTimeout(timeout);
|
|
483
|
+
|
|
484
|
+
// Cleanup conversation regardless of result
|
|
485
|
+
try {
|
|
486
|
+
await fetch(baseUrl + "/v1/conversations/" + classConvId, {
|
|
487
|
+
method: "DELETE",
|
|
488
|
+
headers: apiKey ? { Authorization: "Bearer " + apiKey } : {},
|
|
489
|
+
});
|
|
490
|
+
} catch (e) {}
|
|
491
|
+
|
|
492
|
+
if (!resp.ok) { log("confirmPromise: classification API " + resp.status); return null; }
|
|
493
|
+
|
|
494
|
+
const respText = await resp.text();
|
|
495
|
+
let answer = "";
|
|
496
|
+
let tokenUsage: { prompt: number; completion: number; total: number } | undefined;
|
|
497
|
+
for (const line of respText.split("\n")) {
|
|
498
|
+
if (!line.startsWith("data: ")) continue;
|
|
499
|
+
const data = line.slice(6);
|
|
500
|
+
if (data === "[DONE]") break;
|
|
501
|
+
try {
|
|
502
|
+
const d = JSON.parse(data);
|
|
503
|
+
if (d.message_type === "usage_statistics") {
|
|
504
|
+
tokenUsage = {
|
|
505
|
+
prompt: d.prompt_tokens || 0,
|
|
506
|
+
completion: d.completion_tokens || 0,
|
|
507
|
+
total: d.total_tokens || 0,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (d.message_type === "assistant_message" && d.content) {
|
|
511
|
+
answer = String(d.content).slice(0, 2000);
|
|
512
|
+
}
|
|
513
|
+
} catch (e) {}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (!answer) { log("confirmPromise: no response from LLM"); return null; }
|
|
517
|
+
|
|
518
|
+
// Parse the JSON response
|
|
519
|
+
const jsonMatch = answer.match(/\{[^}]*\}/);
|
|
520
|
+
if (!jsonMatch) { log("confirmPromise: no JSON in response: " + answer.slice(0, 100)); return null; }
|
|
521
|
+
|
|
522
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
523
|
+
if (parsed.is_promise === true && parsed.promise && typeof parsed.promise === "string") {
|
|
524
|
+
// Parse delay from LLM, clamp to sane bounds, default to 5 min if missing/invalid
|
|
525
|
+
let delayMs = DEFAULT_DELAY_MS;
|
|
526
|
+
if (typeof parsed.delay_seconds === "number" && parsed.delay_seconds > 0) {
|
|
527
|
+
delayMs = parsed.delay_seconds * 1000;
|
|
528
|
+
}
|
|
529
|
+
log("confirmPromise: CONFIRMED — " + parsed.promise.slice(0, 60) + " (delay: " + (delayMs / 1000) + "s)");
|
|
530
|
+
return { promise: parsed.promise.slice(0, 300), delayMs, tokens: tokenUsage };
|
|
531
|
+
}
|
|
532
|
+
log("confirmPromise: REJECTED — not a genuine promise");
|
|
533
|
+
return null;
|
|
534
|
+
} catch (e) {
|
|
535
|
+
log("confirmPromise error: " + e);
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ─── Helpers ─────────────────────────────────────────────────────
|
|
541
|
+
|
|
542
|
+
function buildDeliveryPrompt(oath: Oath): string {
|
|
543
|
+
let prompt = '[Oath Keeper] You previously promised the user:\n"' + oath.promise + '"\n\n'
|
|
544
|
+
+ 'Deliver on that promise now. You have full tool access — use whatever tools you need to follow through.\n'
|
|
545
|
+
+ 'Start your response with "[Oath Delivered]".';
|
|
546
|
+
|
|
547
|
+
if (oath.context && oath.context !== "(turn_end)" && oath.context !== "(no context)") {
|
|
548
|
+
prompt += '\n\nFor context, the user originally said:\n"' + oath.context + '"';
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const now = new Date();
|
|
553
|
+
const timeStr = now.toLocaleString("en-US", { timeZone: "America/Chicago" });
|
|
554
|
+
prompt += '\n\nCurrent time: ' + timeStr + ' CDT';
|
|
555
|
+
} catch (e) {}
|
|
556
|
+
|
|
557
|
+
return prompt;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function createOath(promise: string, context: string, conversationId: string, agentId: string, sourceMessageId?: string, deliveryMode?: "turn_end" | "polling", delayMs?: number): Oath {
|
|
561
|
+
const now = Date.now();
|
|
562
|
+
const due = now + (delayMs || DEFAULT_DELAY_MS);
|
|
563
|
+
return { id: "oath-" + now + "-" + Math.random().toString(36).slice(2, 8), conversationId, agentId, promise, context, sourceMessageId, deliveryMode, createdAt: now, dueAt: due, status: "pending", result: null, deliveredAt: null };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Cache the last-known-good port to avoid ss on every call
|
|
567
|
+
let cachedBaseUrl: string | null = null;
|
|
568
|
+
let lastPortCheck: number = 0;
|
|
569
|
+
const PORT_CHECK_INTERVAL = 60_000; // re-verify port every 60s
|
|
570
|
+
|
|
571
|
+
/** Discover the current Letta Code server port via ss.
|
|
572
|
+
* Called on startup to self-heal the stale env file. */
|
|
573
|
+
function discoverPort(): string | null {
|
|
574
|
+
try {
|
|
575
|
+
const output = execSync("ss -tlnp 2>/dev/null | grep letta-code | head -1 | grep -oP '127\\\\.0\\\\.0\\\\.1:\\\\K\\\\d+' 2>/dev/null", { encoding: "utf8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
576
|
+
if (output) return "http://localhost:" + output;
|
|
577
|
+
} catch (e) {}
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Self-heal the env file on startup — discover the correct port and write it. */
|
|
582
|
+
function selfHealEnvFile() {
|
|
583
|
+
try {
|
|
584
|
+
let env: any = {};
|
|
585
|
+
try { env = JSON.parse(fs.readFileSync(ENV_FILE, "utf8")); } catch (e) {}
|
|
586
|
+
const currentPort = env.LETTA_BASE_URL || "";
|
|
587
|
+
// Check if the port in the env file is alive
|
|
588
|
+
let portAlive = false;
|
|
589
|
+
if (currentPort) {
|
|
590
|
+
try {
|
|
591
|
+
const code = execSync(`curl -s -o /dev/null -w '%{http_code}' '${currentPort}/v1/health' --max-time 1 2>/dev/null`, { encoding: "utf8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
592
|
+
portAlive = code === "200";
|
|
593
|
+
} catch (e) {}
|
|
594
|
+
}
|
|
595
|
+
if (!portAlive) {
|
|
596
|
+
const discovered = discoverPort();
|
|
597
|
+
if (discovered) {
|
|
598
|
+
env.LETTA_BASE_URL = discovered;
|
|
599
|
+
// Ensure directory exists
|
|
600
|
+
try { fs.mkdirSync(path.dirname(ENV_FILE), { recursive: true }); } catch (e) {}
|
|
601
|
+
fs.writeFileSync(ENV_FILE, JSON.stringify(env, null, 2));
|
|
602
|
+
log("selfHealEnvFile: updated port to " + discovered);
|
|
603
|
+
cachedBaseUrl = discovered;
|
|
604
|
+
lastPortCheck = Date.now();
|
|
605
|
+
}
|
|
606
|
+
} else {
|
|
607
|
+
cachedBaseUrl = currentPort;
|
|
608
|
+
lastPortCheck = Date.now();
|
|
609
|
+
}
|
|
610
|
+
} catch (e) {
|
|
611
|
+
log("selfHealEnvFile error: " + e);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function getApiConfig() {
|
|
616
|
+
let apiKey = process.env.LETTA_API_KEY;
|
|
617
|
+
if (apiKey === "unset") apiKey = undefined;
|
|
618
|
+
let agentId = "";
|
|
619
|
+
let convId = "";
|
|
620
|
+
const now = Date.now();
|
|
621
|
+
|
|
622
|
+
// Read agent/conv IDs from env file (these don't change across restarts)
|
|
623
|
+
try {
|
|
624
|
+
const env = JSON.parse(fs.readFileSync(ENV_FILE, "utf8"));
|
|
625
|
+
agentId = env.LETTA_AGENT_ID || "";
|
|
626
|
+
convId = env.LETTA_CONVERSATION_ID || "";
|
|
627
|
+
} catch (e) {}
|
|
628
|
+
|
|
629
|
+
// Priority: cached (if checked recently) → process.env → env file → ss discovery → default
|
|
630
|
+
// Re-verify the port every 60s to handle app restarts
|
|
631
|
+
if (cachedBaseUrl && (now - lastPortCheck) < PORT_CHECK_INTERVAL) {
|
|
632
|
+
return { baseUrl: cachedBaseUrl, apiKey, agentId, convId };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let baseUrl = "";
|
|
636
|
+
let envPort = process.env.LETTA_BASE_URL || "";
|
|
637
|
+
if (envPort && envPort !== "unset") baseUrl = envPort;
|
|
638
|
+
|
|
639
|
+
if (!baseUrl) {
|
|
640
|
+
try {
|
|
641
|
+
const env = JSON.parse(fs.readFileSync(ENV_FILE, "utf8"));
|
|
642
|
+
baseUrl = env.LETTA_BASE_URL || "";
|
|
643
|
+
} catch (e) {}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Verify the port is alive — if dead, discover via ss
|
|
647
|
+
if (baseUrl) {
|
|
648
|
+
try {
|
|
649
|
+
const alive = execSync(`curl -s -o /dev/null -w '%{http_code}' '${baseUrl}/v1/health' --max-time 1 2>/dev/null`, { encoding: "utf8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
650
|
+
if (alive !== "200") {
|
|
651
|
+
log("Port " + baseUrl + " is dead, discovering...");
|
|
652
|
+
baseUrl = ""; // fall through to ss
|
|
653
|
+
}
|
|
654
|
+
} catch (e) {
|
|
655
|
+
baseUrl = ""; // fall through to ss
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ss discovery
|
|
660
|
+
if (!baseUrl) {
|
|
661
|
+
baseUrl = discoverPort() || "";
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (!baseUrl) baseUrl = "http://localhost:8283";
|
|
665
|
+
|
|
666
|
+
cachedBaseUrl = baseUrl;
|
|
667
|
+
lastPortCheck = now;
|
|
668
|
+
|
|
669
|
+
addDebugLog("getApiConfig: baseUrl=" + baseUrl + " agentId=" + (agentId ? agentId.slice(0,12) : "NONE") + " convId=" + (convId ? convId.slice(0,12) : "NONE"));
|
|
670
|
+
return { baseUrl, apiKey, agentId, convId };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Check if the conversation has an active run by looking at recent messages.
|
|
674
|
+
* If the last message is an approval_request or tool_call without a matching
|
|
675
|
+
* return/response, the conversation is busy. */
|
|
676
|
+
async function isConversationBusy(baseUrl: string, apiKey: string | undefined, convId: string, agentId?: string): Promise<boolean> {
|
|
677
|
+
try {
|
|
678
|
+
const checkAgentId = agentId || getApiConfig().agentId;
|
|
679
|
+
if (!checkAgentId) return false;
|
|
680
|
+
const resp = await fetch(
|
|
681
|
+
baseUrl + "/v1/agents/" + checkAgentId + "/messages?conversation_id=" + convId + "&limit=3",
|
|
682
|
+
{ headers: apiKey ? { Authorization: "Bearer " + apiKey } : {} }
|
|
683
|
+
);
|
|
684
|
+
if (!resp.ok) return false;
|
|
685
|
+
const data: any = await resp.json();
|
|
686
|
+
const messages = Array.isArray(data) ? data : (data.messages || []);
|
|
687
|
+
if (!messages.length) return false;
|
|
688
|
+
|
|
689
|
+
// Check the most recent message type
|
|
690
|
+
const latest = messages[0];
|
|
691
|
+
const latestType = latest.message_type || "";
|
|
692
|
+
|
|
693
|
+
// If the latest message is an approval_request, tool_call, or assistant_message
|
|
694
|
+
// without a following tool_return, the conversation is likely busy
|
|
695
|
+
if (latestType === "approval_request_message") return true;
|
|
696
|
+
|
|
697
|
+
// Check if there's a pending run by looking at run_ids
|
|
698
|
+
// If the latest message has a run_id different from older messages,
|
|
699
|
+
// and there's no completion signal, the run might still be active
|
|
700
|
+
return false;
|
|
701
|
+
} catch (e) {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** Try to deliver an oath. Returns "busy" on 409 or empty response. */
|
|
707
|
+
async function tryDeliverOath(oath: Oath): Promise<{ status: "ok" | "busy" | "fail"; answer: string }> {
|
|
708
|
+
const { baseUrl, apiKey, convId } = getApiConfig();
|
|
709
|
+
const targetConv = (oath.conversationId && oath.conversationId !== "default") ? oath.conversationId : convId;
|
|
710
|
+
if (!targetConv || targetConv === "default") return { status: "fail", answer: "No conversation ID" };
|
|
711
|
+
|
|
712
|
+
// Check if conversation is busy before attempting delivery
|
|
713
|
+
if (await isConversationBusy(baseUrl, apiKey, targetConv, oath.agentId || undefined)) {
|
|
714
|
+
log("Oath " + oath.id + " delivery deferred — conversation has pending approval");
|
|
715
|
+
return { status: "busy", answer: "Conversation busy (pending approval)" };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const prompt = buildDeliveryPrompt(oath);
|
|
719
|
+
log("Attempting delivery for " + oath.id + " to " + targetConv);
|
|
720
|
+
|
|
721
|
+
try {
|
|
722
|
+
const controller = new AbortController();
|
|
723
|
+
const timeout = setTimeout(() => controller.abort(), 45_000);
|
|
724
|
+
const resp = await fetch(baseUrl + "/v1/conversations/" + targetConv + "/messages", {
|
|
725
|
+
method: "POST",
|
|
726
|
+
headers: { "Content-Type": "application/json", ...(apiKey ? { Authorization: "Bearer " + apiKey } : {}) },
|
|
727
|
+
body: JSON.stringify({ input: prompt, role: "user" }),
|
|
728
|
+
signal: controller.signal,
|
|
729
|
+
});
|
|
730
|
+
clearTimeout(timeout);
|
|
731
|
+
|
|
732
|
+
if (resp.status === 409 || resp.status === 429) { log("Delivery deferred (409/429)"); return { status: "busy", answer: "Conversation busy" }; }
|
|
733
|
+
if (!resp.ok) { log("Delivery HTTP " + resp.status); return { status: "fail", answer: "HTTP " + resp.status }; }
|
|
734
|
+
|
|
735
|
+
// Read SSE stream incrementally
|
|
736
|
+
const reader = resp.body?.getReader();
|
|
737
|
+
let answer = "", buffer = "", done = false;
|
|
738
|
+
if (reader) {
|
|
739
|
+
const readTimeout = setTimeout(() => { done = true; reader.cancel().catch(() => {}); }, 30_000);
|
|
740
|
+
while (!done) {
|
|
741
|
+
const { done: streamDone, value } = await reader.read();
|
|
742
|
+
if (streamDone) break;
|
|
743
|
+
buffer += new TextDecoder().decode(value);
|
|
744
|
+
const lines = buffer.split("\n");
|
|
745
|
+
buffer = lines.pop() || "";
|
|
746
|
+
for (const line of lines) {
|
|
747
|
+
if (!line.startsWith("data: ")) continue;
|
|
748
|
+
const data = line.slice(6);
|
|
749
|
+
if (data === "[DONE]") { done = true; break; }
|
|
750
|
+
try { const d = JSON.parse(data); if (d.message_type === "assistant_message" && d.content) { answer = String(d.content).slice(0, 2000); done = true; break; } } catch (e) {}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
clearTimeout(readTimeout);
|
|
754
|
+
reader.cancel().catch(() => {});
|
|
755
|
+
}
|
|
756
|
+
// If no assistant_message was captured, the conversation was busy.
|
|
757
|
+
// The POST was accepted (200) but the agent hasn't responded yet.
|
|
758
|
+
// Treat as "busy" so the oath retries on the next poll cycle.
|
|
759
|
+
if (answer.length === 0) {
|
|
760
|
+
log("Oath " + oath.id + " POST accepted but no response (conversation busy) — retrying");
|
|
761
|
+
return { status: "busy", answer: "No response in stream" };
|
|
762
|
+
}
|
|
763
|
+
log("Oath " + oath.id + " delivered, answer length: " + answer.length);
|
|
764
|
+
return { status: "ok", answer };
|
|
765
|
+
} catch (e) {
|
|
766
|
+
log("Delivery error for " + oath.id + ": " + e);
|
|
767
|
+
return { status: "fail", answer: "Error: " + e };
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ─── Polling ─────────────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
|
774
|
+
let turnEventsActive = false;
|
|
775
|
+
|
|
776
|
+
/** Delivery cycle — runs on every poll regardless of mode.
|
|
777
|
+
* Handles: delivery-check, queue transition, delivery, stuck recovery, prune.
|
|
778
|
+
* Does NOT scan for new promises (turn_end or pollCycle handles that). */
|
|
779
|
+
async function pollDeliveryCycle() {
|
|
780
|
+
const store = StateStore.load("deliveryCycle");
|
|
781
|
+
const now = Date.now();
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
// 1. Check if any queued/delivering oaths have already been delivered
|
|
785
|
+
const { convId: checkConvId } = getApiConfig();
|
|
786
|
+
if (checkConvId) {
|
|
787
|
+
const checkStore = StateStore.load("delivery-check");
|
|
788
|
+
let checkChanged = false;
|
|
789
|
+
try {
|
|
790
|
+
const { baseUrl, apiKey, agentId } = getApiConfig();
|
|
791
|
+
const resp = await fetch(
|
|
792
|
+
baseUrl + "/v1/agents/" + agentId + "/messages?conversation_id=" + checkConvId + "&limit=10",
|
|
793
|
+
{ headers: apiKey ? { Authorization: "Bearer " + apiKey } : {} }
|
|
794
|
+
);
|
|
795
|
+
if (resp.ok) {
|
|
796
|
+
const data: any = await resp.json();
|
|
797
|
+
const msgs = Array.isArray(data) ? data : (data.messages || []);
|
|
798
|
+
let recentText = "";
|
|
799
|
+
for (const m of msgs) {
|
|
800
|
+
const mt = m.message_type || "";
|
|
801
|
+
if (mt === "user_message") {
|
|
802
|
+
const parts = m.content;
|
|
803
|
+
const text = typeof parts === "string" ? parts
|
|
804
|
+
: Array.isArray(parts) ? parts.map((x: any) => typeof x === "string" ? x : (x?.text || "")).join(" ") : "";
|
|
805
|
+
recentText += " " + text;
|
|
806
|
+
} else if (mt === "assistant_message") {
|
|
807
|
+
const c = m.content;
|
|
808
|
+
const text = typeof c === "string" ? c : "";
|
|
809
|
+
recentText += " " + text;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
for (const oath of checkStore.oaths) {
|
|
813
|
+
if (oath.status === "queued" || oath.status === "delivering") {
|
|
814
|
+
const promptSnippet = oath.promise.slice(0, 40);
|
|
815
|
+
if (recentText.includes("[Oath Keeper]") && recentText.includes(promptSnippet)) {
|
|
816
|
+
if (recentText.includes("[Oath Delivered]")) {
|
|
817
|
+
checkStore.updateOath(oath.id, { status: "delivered", deliveryMode: "turn_end", result: recentText.slice(-500), deliveredAt: Date.now() });
|
|
818
|
+
checkChanged = true;
|
|
819
|
+
log("Oath " + oath.id + " confirmed delivered (found in conversation history)");
|
|
820
|
+
} else {
|
|
821
|
+
checkStore.updateOath(oath.id, { status: "delivering" });
|
|
822
|
+
checkChanged = true;
|
|
823
|
+
log("Oath " + oath.id + " delivery prompt found in history (waiting for response)");
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
} catch (e) {
|
|
830
|
+
log("Delivery check error: " + e);
|
|
831
|
+
}
|
|
832
|
+
if (checkChanged) checkStore.save();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// 2. Transition due oaths to queued
|
|
836
|
+
const queueStore = StateStore.load("queue-transition");
|
|
837
|
+
for (const oath of queueStore.oaths) {
|
|
838
|
+
if (oath.status === "pending" && oath.dueAt <= now) {
|
|
839
|
+
queueStore.updateOath(oath.id, { status: "queued" });
|
|
840
|
+
log("Oath " + oath.id + " → queued");
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
queueStore.save();
|
|
844
|
+
|
|
845
|
+
// 3. Try to deliver one queued oath
|
|
846
|
+
// turn_end { continue } is preferred, but if the oath has been queued
|
|
847
|
+
// for more than 60 seconds without a turn_end firing (user idle),
|
|
848
|
+
// fall back to REST API delivery.
|
|
849
|
+
const QUEUED_TIMEOUT = 60_000; // 60 seconds before REST fallback
|
|
850
|
+
let queuedOath = undefined;
|
|
851
|
+
|
|
852
|
+
if (turnEventsActive) {
|
|
853
|
+
// Check if any queued oath has been waiting too long
|
|
854
|
+
const overdue = queueStore.oaths.find((o) =>
|
|
855
|
+
o.status === "queued" && (now - o.dueAt) > QUEUED_TIMEOUT
|
|
856
|
+
);
|
|
857
|
+
if (overdue) {
|
|
858
|
+
log("Oath " + overdue.id + " queued for >60s without turn_end — falling back to REST API");
|
|
859
|
+
queuedOath = overdue;
|
|
860
|
+
} else {
|
|
861
|
+
log("Skipping REST delivery — turn_end will handle via { continue }");
|
|
862
|
+
}
|
|
863
|
+
} else {
|
|
864
|
+
queuedOath = queueStore.oaths.find((o) => o.status === "queued");
|
|
865
|
+
}
|
|
866
|
+
if (queuedOath) {
|
|
867
|
+
store.updateOath(queuedOath.id, { status: "delivering" });
|
|
868
|
+
store.save();
|
|
869
|
+
log("Oath " + queuedOath.id + " queued → delivering (locked)");
|
|
870
|
+
|
|
871
|
+
const result = await tryDeliverOath(queuedOath);
|
|
872
|
+
const updateStore = StateStore.load("delivery-result");
|
|
873
|
+
const currentOath = updateStore.findOath(queuedOath.id);
|
|
874
|
+
if (currentOath && currentOath.status === "delivered") {
|
|
875
|
+
log("Oath " + queuedOath.id + " already delivered (history check) — skipping result update");
|
|
876
|
+
} else if (result.status === "busy") {
|
|
877
|
+
updateStore.updateOath(queuedOath.id, { status: "queued" });
|
|
878
|
+
updateStore.save();
|
|
879
|
+
log("Oath " + queuedOath.id + " back to queued (busy)");
|
|
880
|
+
} else if (result.status === "ok") {
|
|
881
|
+
updateStore.updateOath(queuedOath.id, { status: "delivered", deliveryMode: "rest_api", result: result.answer.slice(0, 500), deliveredAt: Date.now() });
|
|
882
|
+
updateStore.save();
|
|
883
|
+
log("Oath " + queuedOath.id + " delivered");
|
|
884
|
+
} else {
|
|
885
|
+
updateStore.updateOath(queuedOath.id, { status: "failed", result: result.answer.slice(0, 500), deliveredAt: Date.now() });
|
|
886
|
+
updateStore.save();
|
|
887
|
+
log("Oath " + queuedOath.id + " failed: " + result.answer);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// 4. Reset stuck delivering oaths (>5 min)
|
|
892
|
+
const resetStore = StateStore.load("stuck-check");
|
|
893
|
+
const fiveMinAgo = now - 300_000;
|
|
894
|
+
for (const oath of resetStore.oaths) {
|
|
895
|
+
if (oath.status === "delivering" && oath.dueAt < fiveMinAgo) {
|
|
896
|
+
resetStore.updateOath(oath.id, { status: "queued" });
|
|
897
|
+
log("Oath " + oath.id + " stuck → queued");
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
resetStore.prune(now);
|
|
901
|
+
resetStore.save();
|
|
902
|
+
} catch (e) {
|
|
903
|
+
log("Delivery cycle error: " + e);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/** Full poll cycle — delivery + scanning. Only used when turn_end is NOT available. */
|
|
908
|
+
async function pollCycle() {
|
|
909
|
+
const now = Date.now();
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
// Run delivery logic first
|
|
913
|
+
await pollDeliveryCycle();
|
|
914
|
+
|
|
915
|
+
// Then scan for new promises — SKIP when turn_end handles detection
|
|
916
|
+
if (turnEventsActive) return;
|
|
917
|
+
const scanStore = StateStore.load("scan-phase");
|
|
918
|
+
if (scanStore.hasActiveOaths()) { log("Skipping scan — active oaths"); return; }
|
|
919
|
+
|
|
920
|
+
const { convId, agentId } = getApiConfig();
|
|
921
|
+
const latest = await fetchLatestAgentMessage();
|
|
922
|
+
if (latest && scanStore.lastScannedMessageId !== latest.id) {
|
|
923
|
+
if (latest.isDeliveryResponse) { log("Skipping — delivery response"); return; }
|
|
924
|
+
const preFilter = detectPromiseRegex(latest.text);
|
|
925
|
+
if (preFilter) {
|
|
926
|
+
log("Regex pre-filter matched: " + preFilter.match + " — confirming with LLM");
|
|
927
|
+
const confirmed = await confirmPromise(latest.text);
|
|
928
|
+
if (!confirmed) {
|
|
929
|
+
logFalsePositive(preFilter.match, latest.text, "polling", preFilter.score, convId, agentId);
|
|
930
|
+
scanStore.setScanned(latest.id);
|
|
931
|
+
scanStore.save();
|
|
932
|
+
}
|
|
933
|
+
if (confirmed) {
|
|
934
|
+
scanStore.setScanned(latest.id);
|
|
935
|
+
const alreadyExists = scanStore.hasRecentPromise(confirmed.promise) ||
|
|
936
|
+
scanStore.oaths.some((o) => o.sourceMessageId === latest.id);
|
|
937
|
+
if (!alreadyExists) {
|
|
938
|
+
const oath = createOath(confirmed.promise, latest.userContext, convId, agentId, latest.id, "polling", confirmed.delayMs);
|
|
939
|
+
scanStore.addOath(oath);
|
|
940
|
+
scanStore.save();
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
} else {
|
|
944
|
+
scanStore.setScanned(latest.id);
|
|
945
|
+
scanStore.save();
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
} catch (e) {
|
|
949
|
+
log("Poll error: " + e);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ─── Message fetching ────────────────────────────────────────────
|
|
954
|
+
|
|
955
|
+
async function fetchLatestAgentMessage(): Promise<{ id: string; text: string; userContext: string; isDeliveryResponse: boolean } | null> {
|
|
956
|
+
const { baseUrl, apiKey, agentId, convId } = getApiConfig();
|
|
957
|
+
if (!agentId || !convId) return null;
|
|
958
|
+
try {
|
|
959
|
+
const resp = await fetch(baseUrl + "/v1/agents/" + agentId + "/messages?conversation_id=" + convId + "&limit=50", { headers: apiKey ? { Authorization: "Bearer " + apiKey } : {} });
|
|
960
|
+
if (!resp.ok) return null;
|
|
961
|
+
const data: any = await resp.json();
|
|
962
|
+
const messages = Array.isArray(data) ? data : (data.messages || []);
|
|
963
|
+
if (!messages.length) return null;
|
|
964
|
+
messages.sort((a: any, b: any) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime());
|
|
965
|
+
let assistantMsg: { id: string; text: string } | null = null;
|
|
966
|
+
let userContext = "(no context)";
|
|
967
|
+
for (const m of messages) {
|
|
968
|
+
const mt = m.message_type || "";
|
|
969
|
+
if (!assistantMsg && mt === "assistant_message") {
|
|
970
|
+
const c = m.content;
|
|
971
|
+
const text = typeof c === "string" ? c : Array.isArray(c) ? c.map((x: any) => typeof x === "string" ? x : (x?.text || "")).join(" ") : "";
|
|
972
|
+
if (text.trim()) assistantMsg = { id: m.id || "", text };
|
|
973
|
+
}
|
|
974
|
+
if (userContext === "(no context)" && mt === "user_message") {
|
|
975
|
+
const c = m.content;
|
|
976
|
+
let text = typeof c === "string" ? c : Array.isArray(c) ? c.map((x: any) => typeof x === "string" ? x : (x?.text || "")).join(" ") : "";
|
|
977
|
+
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "").trim();
|
|
978
|
+
if (text) userContext = text.slice(0, 200);
|
|
979
|
+
}
|
|
980
|
+
if (assistantMsg && userContext !== "(no context)") break;
|
|
981
|
+
}
|
|
982
|
+
return assistantMsg ? { ...assistantMsg, userContext, isDeliveryResponse: userContext.includes("[Oath Keeper]") } : null;
|
|
983
|
+
} catch (e) { log("fetchLatestAgentMessage error: " + e); return null; }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ─── Mod Activation ──────────────────────────────────────────────
|
|
987
|
+
|
|
988
|
+
export default function activate(letta: any) {
|
|
989
|
+
const disposers: Array<() => void> = [];
|
|
990
|
+
|
|
991
|
+
// Self-heal the env file on startup — discover correct port
|
|
992
|
+
selfHealEnvFile();
|
|
993
|
+
|
|
994
|
+
const hasTurnEvents = letta.capabilities.events?.turns === true;
|
|
995
|
+
turnEventsActive = hasTurnEvents;
|
|
996
|
+
log("Capabilities: " + JSON.stringify(letta.capabilities));
|
|
997
|
+
log("hasTurnEvents: " + hasTurnEvents);
|
|
998
|
+
try { letta.diagnostics.report({ message: "Capabilities: " + JSON.stringify(letta.capabilities) + " hasTurnEvents: " + hasTurnEvents, severity: "warning" }); } catch (e) {}
|
|
999
|
+
|
|
1000
|
+
if (!letta.capabilities.tools) { log("No tools — inactive"); return () => {}; }
|
|
1001
|
+
|
|
1002
|
+
// ── turn_end — uses event context for conversation/agent scoping (no env file)
|
|
1003
|
+
if (hasTurnEvents) {
|
|
1004
|
+
disposers.push(
|
|
1005
|
+
letta.events.on("turn_end", async (event: any, ctx: any) => {
|
|
1006
|
+
log("turn_end FIRED");
|
|
1007
|
+
|
|
1008
|
+
// Extract conversation/agent IDs from event context — NOT env file
|
|
1009
|
+
const eventConvId = event.conversationId || ctx?.conversation?.id || "";
|
|
1010
|
+
const eventAgentId = event.agentId || ctx?.agent?.id || "";
|
|
1011
|
+
const lastMsg = event.assistantMessage || "";
|
|
1012
|
+
|
|
1013
|
+
// ── STEP 1: Check for queued oaths ready for delivery (via { continue })
|
|
1014
|
+
// This uses the mod event surface instead of REST API — tools work properly
|
|
1015
|
+
const deliverStore = StateStore.load("turn_end-deliver");
|
|
1016
|
+
const dueOath = deliverStore.oaths.find((o) =>
|
|
1017
|
+
(o.status === "queued") &&
|
|
1018
|
+
o.conversationId === eventConvId
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
if (dueOath) {
|
|
1022
|
+
log("turn_end: delivering oath via { continue } — " + dueOath.id);
|
|
1023
|
+
deliverStore.updateOath(dueOath.id, { status: "delivering", deliveryMode: "turn_end" });
|
|
1024
|
+
deliverStore.save();
|
|
1025
|
+
|
|
1026
|
+
const prompt = buildDeliveryPrompt(dueOath);
|
|
1027
|
+
return { continue: prompt };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// ── STEP 2: Mark delivered oaths if the response contains [Oath Delivered]
|
|
1031
|
+
if (lastMsg.includes("[Oath Delivered]")) {
|
|
1032
|
+
const store = StateStore.load("turn_end-mark");
|
|
1033
|
+
for (const oath of store.oaths) {
|
|
1034
|
+
if (oath.status === "delivering" || oath.status === "queued") {
|
|
1035
|
+
store.updateOath(oath.id, { status: "delivered", deliveryMode: "turn_end", result: lastMsg.slice(0, 500), deliveredAt: Date.now() });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
store.save();
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Skip detection for [Oath Keeper] delivery prompts
|
|
1043
|
+
if (lastMsg.includes("[Oath Keeper]")) return;
|
|
1044
|
+
|
|
1045
|
+
// ── STEP 3: Detect promises in the assistant message
|
|
1046
|
+
const msgText = event.assistantMessage || "";
|
|
1047
|
+
if (!msgText || !msgText.trim()) { log("turn_end: no assistant message in event"); return; }
|
|
1048
|
+
|
|
1049
|
+
// Safety check: if both n-gram and LLM confirmation are disabled, don't run any filters
|
|
1050
|
+
if (!filtersActive()) {
|
|
1051
|
+
log("turn_end: all filters disabled — skipping detection");
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const scanStore = StateStore.load("turn_end-detect");
|
|
1056
|
+
|
|
1057
|
+
// Stage 1: N-gram pre-filter (if enabled)
|
|
1058
|
+
let ngramScore: number | undefined;
|
|
1059
|
+
if (isNgramEnabled()) {
|
|
1060
|
+
const preFilter = detectPromiseRegex(msgText);
|
|
1061
|
+
if (!preFilter) {
|
|
1062
|
+
const rejectScore = computeNgramScore(msgText);
|
|
1063
|
+
logPreFilterRejection(msgText, "ngram score <= 1.5 or negative filter", rejectScore, eventConvId, eventAgentId);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
ngramScore = preFilter.score;
|
|
1067
|
+
log("turn_end: pre-filter passed (score=" + preFilter.score + ")");
|
|
1068
|
+
} else {
|
|
1069
|
+
ngramScore = computeNgramScore(msgText);
|
|
1070
|
+
log("turn_end: n-gram filter disabled (score=" + ngramScore + " — not used for filtering)");
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Stage 2: LLM confirmation (if enabled)
|
|
1074
|
+
let detection: { promise: string; delayMs: number; tokens?: { prompt: number; completion: number; total: number } } | null = null;
|
|
1075
|
+
let llmTokens: { prompt: number; completion: number; total: number } | undefined;
|
|
1076
|
+
if (isLlmConfirmEnabled()) {
|
|
1077
|
+
log("turn_end: sending to LLM...");
|
|
1078
|
+
detection = await confirmPromise(msgText);
|
|
1079
|
+
log("turn_end LLM: " + (detection ? "CONFIRMED: " + detection.promise.slice(0, 60) + " delay=" + (detection.delayMs/1000) + "s" : "REJECTED"));
|
|
1080
|
+
|
|
1081
|
+
if (detection?.tokens) llmTokens = detection.tokens;
|
|
1082
|
+
|
|
1083
|
+
if (!detection) {
|
|
1084
|
+
logFalsePositive("llm", msgText, "turn_end", ngramScore, eventConvId, eventAgentId);
|
|
1085
|
+
// Store tokens on the false positive entry
|
|
1086
|
+
if (llmTokens) {
|
|
1087
|
+
const fpStore = StateStore.load("turn_end-fp-tokens");
|
|
1088
|
+
const fpEntry = fpStore.oaths[fpStore.oaths.length - 1];
|
|
1089
|
+
if (fpEntry) {
|
|
1090
|
+
fpStore.updateOath(fpEntry.id, { llmTokens });
|
|
1091
|
+
fpStore.save();
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
scanStore.save();
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
} else {
|
|
1098
|
+
// LLM confirmation disabled — create oath directly from message text
|
|
1099
|
+
detection = { promise: msgText.slice(0, 300), delayMs: DEFAULT_DELAY_MS };
|
|
1100
|
+
log("turn_end: LLM confirm disabled — creating oath directly");
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Stage 3: LLM semantic dedup (if enabled)
|
|
1104
|
+
if (isLlmDedupEnabled()) {
|
|
1105
|
+
const existing = scanStore.activeOaths();
|
|
1106
|
+
const dedupResult = existing.length > 0 ? await isDuplicatePromise(detection.promise, existing) : { isDup: false };
|
|
1107
|
+
if (dedupResult.tokens) {
|
|
1108
|
+
// Accumulate dedup tokens
|
|
1109
|
+
if (!llmTokens) llmTokens = { prompt: 0, completion: 0, total: 0 };
|
|
1110
|
+
llmTokens.prompt += dedupResult.tokens.prompt;
|
|
1111
|
+
llmTokens.completion += dedupResult.tokens.completion;
|
|
1112
|
+
llmTokens.total += dedupResult.tokens.total;
|
|
1113
|
+
}
|
|
1114
|
+
if (dedupResult.isDup) {
|
|
1115
|
+
log("turn_end: duplicate promise — skipping");
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// String-based dedup (always runs as fast fallback)
|
|
1121
|
+
const alreadyExists = scanStore.hasRecentPromise(detection.promise);
|
|
1122
|
+
if (!alreadyExists) {
|
|
1123
|
+
const oath = createOath(detection.promise, "(turn_end)", eventConvId, eventAgentId, undefined, "turn_end", detection.delayMs);
|
|
1124
|
+
oath.ngramScore = ngramScore;
|
|
1125
|
+
oath.llmTokens = llmTokens;
|
|
1126
|
+
scanStore.addOath(oath);
|
|
1127
|
+
scanStore.save();
|
|
1128
|
+
log("turn_end: oath created — " + oath.id + " conv=" + eventConvId.slice(0,12) + " score=" + (ngramScore ?? "N/A") + " delay=" + (detection.delayMs/1000) + "s");
|
|
1129
|
+
}
|
|
1130
|
+
})
|
|
1131
|
+
);
|
|
1132
|
+
log("turn_end handler registered");
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ── Polling — always enabled for delivery timing
|
|
1136
|
+
// Scanning is disabled when turn_end handles detection (avoid duplicate oaths)
|
|
1137
|
+
if (intervalHandle) clearInterval(intervalHandle);
|
|
1138
|
+
intervalHandle = setInterval(pollCycle, POLL_INTERVAL_MS);
|
|
1139
|
+
pollCycle();
|
|
1140
|
+
log("Polling started (turn_end active: " + hasTurnEvents + ")");
|
|
1141
|
+
|
|
1142
|
+
// Write filter status to file for TUI to read
|
|
1143
|
+
const filterStatus = {
|
|
1144
|
+
negativeFilter: isNegativeFilterEnabled(),
|
|
1145
|
+
ngram: isNgramEnabled(),
|
|
1146
|
+
ngramThreshold: getNgramThreshold(),
|
|
1147
|
+
llmConfirm: isLlmConfirmEnabled(),
|
|
1148
|
+
llmDedup: isLlmDedupEnabled(),
|
|
1149
|
+
filtersActive: filtersActive(),
|
|
1150
|
+
classifierAgentId: getClassifierAgentId(),
|
|
1151
|
+
classifierModel: "",
|
|
1152
|
+
timestamp: Date.now(),
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
// Use the configured classifier model (not the agent's model)
|
|
1156
|
+
filterStatus.classifierModel = getClassifierModel();
|
|
1157
|
+
|
|
1158
|
+
try { fs.writeFileSync(`${HOME}/.letta/mods/oath-keeper-filter-status.json`, JSON.stringify(filterStatus, null, 2)); } catch (e) {}
|
|
1159
|
+
|
|
1160
|
+
// ── list_oaths tool ─────────────────────────────────────────
|
|
1161
|
+
disposers.push(
|
|
1162
|
+
letta.tools.register({
|
|
1163
|
+
name: "list_oaths",
|
|
1164
|
+
description: "List all pending and recently delivered oaths (promises tracked by Oath Keeper).",
|
|
1165
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
1166
|
+
requiresApproval: false,
|
|
1167
|
+
parallelSafe: true,
|
|
1168
|
+
async run() {
|
|
1169
|
+
const store = StateStore.load("list_oaths");
|
|
1170
|
+
const pending = store.oaths.filter((o) => o.status === "pending" || o.status === "queued");
|
|
1171
|
+
const delivering = store.oaths.filter((o) => o.status === "delivering");
|
|
1172
|
+
const recent = store.oaths.filter((o) => (o.status === "delivered" || o.status === "failed") && o.deliveredAt && Date.now() - o.deliveredAt < 3_600_000);
|
|
1173
|
+
const falsePositives = store.oaths.filter((o) => o.status === "false_positive" && o.deliveredAt && Date.now() - o.deliveredAt < 3_600_000);
|
|
1174
|
+
const prefiltered = store.oaths.filter((o) => o.status === "prefilter_rejected" && o.deliveredAt && Date.now() - o.deliveredAt < 3_600_000);
|
|
1175
|
+
if (pending.length === 0 && delivering.length === 0 && recent.length === 0 && falsePositives.length === 0 && prefiltered.length === 0) return "No oaths. Agents have kept their word.";
|
|
1176
|
+
const lines = [`Oath Keeper — ${pending.length} pending, ${delivering.length} delivering, ${recent.length} recent, ${falsePositives.length} false positive, ${prefiltered.length} prefiltered`];
|
|
1177
|
+
for (const o of [...pending, ...delivering]) {
|
|
1178
|
+
const secs = Math.max(0, Math.round((o.dueAt - Date.now()) / 1000));
|
|
1179
|
+
const score = o.ngramScore ? ` [${o.ngramScore}]` : "";
|
|
1180
|
+
lines.push(`${o.status.toUpperCase()} (${secs}s)${score}: "${o.promise.slice(0, 80)}"`);
|
|
1181
|
+
}
|
|
1182
|
+
for (const o of recent) lines.push(`${o.status === "delivered" ? "OK" : "FAIL"}: "${o.promise.slice(0, 80)}"`);
|
|
1183
|
+
for (const o of falsePositives) lines.push(`FP: "${o.promise.slice(0, 80)}"`);
|
|
1184
|
+
for (const o of prefiltered) lines.push(`PF: "${o.promise.slice(0, 80)}"`);
|
|
1185
|
+
return lines.join("\n");
|
|
1186
|
+
},
|
|
1187
|
+
})
|
|
1188
|
+
);
|
|
1189
|
+
|
|
1190
|
+
log("list_oaths registered");
|
|
1191
|
+
|
|
1192
|
+
return () => {
|
|
1193
|
+
for (const d of disposers.reverse()) { try { d(); } catch (e) {}
|
|
1194
|
+
}
|
|
1195
|
+
if (intervalHandle) { clearInterval(intervalHandle); intervalHandle = null; }
|
|
1196
|
+
};
|
|
1197
|
+
}
|