sensorium-mcp 2.16.28 ā 2.16.30
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/config.d.ts +1 -11
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -49
- package/dist/config.js.map +1 -1
- package/dist/dashboard/presets.d.ts +18 -0
- package/dist/dashboard/presets.d.ts.map +1 -0
- package/dist/dashboard/presets.js +78 -0
- package/dist/dashboard/presets.js.map +1 -0
- package/dist/dashboard/routes.d.ts +33 -0
- package/dist/dashboard/routes.d.ts.map +1 -0
- package/dist/dashboard/routes.js +283 -0
- package/dist/dashboard/routes.js.map +1 -0
- package/dist/dashboard.d.ts +6 -29
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js +6 -1158
- package/dist/dashboard.js.map +1 -1
- package/dist/data/file-storage.d.ts +19 -0
- package/dist/data/file-storage.d.ts.map +1 -0
- package/dist/data/file-storage.js +58 -0
- package/dist/data/file-storage.js.map +1 -0
- package/dist/data/memory/bootstrap.d.ts +40 -0
- package/dist/data/memory/bootstrap.d.ts.map +1 -0
- package/dist/data/memory/bootstrap.js +240 -0
- package/dist/data/memory/bootstrap.js.map +1 -0
- package/dist/data/memory/consolidation.d.ts +12 -0
- package/dist/data/memory/consolidation.d.ts.map +1 -0
- package/dist/data/memory/consolidation.js +248 -0
- package/dist/data/memory/consolidation.js.map +1 -0
- package/dist/data/memory/episodes.d.ts +34 -0
- package/dist/data/memory/episodes.d.ts.map +1 -0
- package/dist/data/memory/episodes.js +89 -0
- package/dist/data/memory/episodes.js.map +1 -0
- package/dist/data/memory/index.d.ts +14 -0
- package/dist/data/memory/index.d.ts.map +1 -0
- package/dist/data/memory/index.js +14 -0
- package/dist/data/memory/index.js.map +1 -0
- package/dist/data/memory/procedures.d.ts +42 -0
- package/dist/data/memory/procedures.d.ts.map +1 -0
- package/dist/data/memory/procedures.js +122 -0
- package/dist/data/memory/procedures.js.map +1 -0
- package/dist/data/memory/schema.d.ts +11 -0
- package/dist/data/memory/schema.d.ts.map +1 -0
- package/dist/data/memory/schema.js +327 -0
- package/dist/data/memory/schema.js.map +1 -0
- package/dist/data/memory/semantic.d.ts +94 -0
- package/dist/data/memory/semantic.d.ts.map +1 -0
- package/dist/data/memory/semantic.js +385 -0
- package/dist/data/memory/semantic.js.map +1 -0
- package/dist/data/memory/voice-sig.d.ts +33 -0
- package/dist/data/memory/voice-sig.d.ts.map +1 -0
- package/dist/data/memory/voice-sig.js +48 -0
- package/dist/data/memory/voice-sig.js.map +1 -0
- package/dist/data/templates.d.ts +19 -0
- package/dist/data/templates.d.ts.map +1 -0
- package/dist/data/templates.js +46 -0
- package/dist/data/templates.js.map +1 -0
- package/dist/dispatcher.d.ts +5 -97
- package/dist/dispatcher.d.ts.map +1 -1
- package/dist/dispatcher.js +5 -525
- package/dist/dispatcher.js.map +1 -1
- package/dist/drive.d.ts.map +1 -1
- package/dist/drive.js +3 -1
- package/dist/drive.js.map +1 -1
- package/dist/index.d.ts +4 -23
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -289
- package/dist/index.js.map +1 -1
- package/dist/integrations/openai/chat.d.ts +29 -0
- package/dist/integrations/openai/chat.d.ts.map +1 -0
- package/dist/integrations/openai/chat.js +84 -0
- package/dist/integrations/openai/chat.js.map +1 -0
- package/dist/integrations/openai/index.d.ts +6 -0
- package/dist/integrations/openai/index.d.ts.map +1 -0
- package/dist/integrations/openai/index.js +6 -0
- package/dist/integrations/openai/index.js.map +1 -0
- package/dist/integrations/openai/speech.d.ts +21 -0
- package/dist/integrations/openai/speech.d.ts.map +1 -0
- package/dist/integrations/openai/speech.js +75 -0
- package/dist/integrations/openai/speech.js.map +1 -0
- package/dist/integrations/openai/video.d.ts +15 -0
- package/dist/integrations/openai/video.d.ts.map +1 -0
- package/dist/integrations/openai/video.js +131 -0
- package/dist/integrations/openai/video.js.map +1 -0
- package/dist/integrations/openai/vision.d.ts +23 -0
- package/dist/integrations/openai/vision.d.ts.map +1 -0
- package/dist/integrations/openai/vision.js +116 -0
- package/dist/integrations/openai/vision.js.map +1 -0
- package/dist/integrations/openai/voice-emotion.d.ts +41 -0
- package/dist/integrations/openai/voice-emotion.d.ts.map +1 -0
- package/dist/integrations/openai/voice-emotion.js +50 -0
- package/dist/integrations/openai/voice-emotion.js.map +1 -0
- package/dist/integrations/telegram/types.d.ts +112 -0
- package/dist/integrations/telegram/types.d.ts.map +1 -0
- package/dist/integrations/telegram/types.js +6 -0
- package/dist/integrations/telegram/types.js.map +1 -0
- package/dist/memory.d.ts +6 -205
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +6 -1357
- package/dist/memory.js.map +1 -1
- package/dist/openai.d.ts +11 -102
- package/dist/openai.d.ts.map +1 -1
- package/dist/openai.js +14 -421
- package/dist/openai.js.map +1 -1
- package/dist/response-builders.d.ts +1 -11
- package/dist/response-builders.d.ts.map +1 -1
- package/dist/response-builders.js +2 -38
- package/dist/response-builders.js.map +1 -1
- package/dist/server/factory.d.ts +17 -0
- package/dist/server/factory.d.ts.map +1 -0
- package/dist/server/factory.js +279 -0
- package/dist/server/factory.js.map +1 -0
- package/dist/services/dispatcher/broker.d.ts +83 -0
- package/dist/services/dispatcher/broker.d.ts.map +1 -0
- package/dist/services/dispatcher/broker.js +175 -0
- package/dist/services/dispatcher/broker.js.map +1 -0
- package/dist/services/dispatcher/index.d.ts +7 -0
- package/dist/services/dispatcher/index.d.ts.map +1 -0
- package/dist/services/dispatcher/index.js +7 -0
- package/dist/services/dispatcher/index.js.map +1 -0
- package/dist/services/dispatcher/lock.d.ts +25 -0
- package/dist/services/dispatcher/lock.d.ts.map +1 -0
- package/dist/services/dispatcher/lock.js +111 -0
- package/dist/services/dispatcher/lock.js.map +1 -0
- package/dist/services/dispatcher/poller.d.ts +19 -0
- package/dist/services/dispatcher/poller.d.ts.map +1 -0
- package/dist/services/dispatcher/poller.js +269 -0
- package/dist/services/dispatcher/poller.js.map +1 -0
- package/dist/telegram.d.ts +2 -88
- package/dist/telegram.d.ts.map +1 -1
- package/dist/telegram.js +2 -0
- package/dist/telegram.js.map +1 -1
- package/dist/tool-definitions.d.ts +1 -14
- package/dist/tool-definitions.d.ts.map +1 -1
- package/dist/tool-definitions.js +1 -403
- package/dist/tool-definitions.js.map +1 -1
- package/dist/tools/definitions.d.ts +15 -0
- package/dist/tools/definitions.d.ts.map +1 -0
- package/dist/tools/definitions.js +404 -0
- package/dist/tools/definitions.js.map +1 -0
- package/dist/tools/start-session-tool.d.ts.map +1 -1
- package/dist/tools/start-session-tool.js +2 -0
- package/dist/tools/start-session-tool.js.map +1 -1
- package/dist/tools/wait/drive-handler.d.ts +61 -0
- package/dist/tools/wait/drive-handler.d.ts.map +1 -0
- package/dist/tools/wait/drive-handler.js +138 -0
- package/dist/tools/wait/drive-handler.js.map +1 -0
- package/dist/tools/wait/index.d.ts +8 -0
- package/dist/tools/wait/index.d.ts.map +1 -0
- package/dist/tools/wait/index.js +8 -0
- package/dist/tools/wait/index.js.map +1 -0
- package/dist/tools/wait/media-processor.d.ts +52 -0
- package/dist/tools/wait/media-processor.d.ts.map +1 -0
- package/dist/tools/wait/media-processor.js +261 -0
- package/dist/tools/wait/media-processor.js.map +1 -0
- package/dist/tools/wait/message-delivery.d.ts +63 -0
- package/dist/tools/wait/message-delivery.d.ts.map +1 -0
- package/dist/tools/wait/message-delivery.js +281 -0
- package/dist/tools/wait/message-delivery.js.map +1 -0
- package/dist/tools/wait/poll-loop.d.ts +72 -0
- package/dist/tools/wait/poll-loop.d.ts.map +1 -0
- package/dist/tools/wait/poll-loop.js +280 -0
- package/dist/tools/wait/poll-loop.js.map +1 -0
- package/dist/tools/wait/reaction-handler.d.ts +49 -0
- package/dist/tools/wait/reaction-handler.d.ts.map +1 -0
- package/dist/tools/wait/reaction-handler.js +126 -0
- package/dist/tools/wait/reaction-handler.js.map +1 -0
- package/dist/tools/wait/task-handler.d.ts +40 -0
- package/dist/tools/wait/task-handler.d.ts.map +1 -0
- package/dist/tools/wait/task-handler.js +41 -0
- package/dist/tools/wait/task-handler.js.map +1 -0
- package/dist/tools/wait-tool.d.ts +3 -69
- package/dist/tools/wait-tool.d.ts.map +1 -1
- package/dist/tools/wait-tool.js +3 -876
- package/dist/tools/wait-tool.js.map +1 -1
- package/package.json +1 -1
- package/templates/daily-review.default.md +26 -0
- package/templates/drive-dispatcher.default.md +2 -0
package/dist/tools/wait-tool.js
CHANGED
|
@@ -1,879 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* This is the core long-polling loop that:
|
|
5
|
-
* - Polls the dispatcher for new operator messages every 2s
|
|
6
|
-
* - Processes all media types: text, photo, document, voice, video_note
|
|
7
|
-
* - Runs voice analysis (transcription + emotion via VANPY)
|
|
8
|
-
* - Auto-saves episodes to memory
|
|
9
|
-
* - Injects relevant memory context via GPT-4o-mini smart filter
|
|
10
|
-
* - Checks scheduled tasks during idle polling
|
|
11
|
-
* - Triggers auto-consolidation (idle, episode-count, time-based)
|
|
12
|
-
* - Sends SSE keepalive pings every 30s
|
|
13
|
-
* - Detects maintenance flags and instructs agent to wait externally
|
|
14
|
-
* - Activates the Dispatcher drive after extended operator silence
|
|
2
|
+
* Re-export shim ā the poll-loop implementation has moved to tools/wait/poll-loop.ts.
|
|
3
|
+
* This file is kept so existing imports from "./tools/wait-tool.js" continue to work.
|
|
15
4
|
*/
|
|
16
|
-
|
|
17
|
-
import { checkMaintenanceFlag, saveFileToDisk } from "../config.js";
|
|
18
|
-
import { peekThreadMessages, readThreadMessages, readPendingReaction } from "../dispatcher.js";
|
|
19
|
-
import { formatDrivePrompt, PHASE3_APPROVAL_PROMPT } from "../drive.js";
|
|
20
|
-
import { assembleCompactRefresh, runIntelligentConsolidation, saveEpisode, saveVoiceSignature, searchByEmbedding, searchSemanticNotesRanked, } from "../memory.js";
|
|
21
|
-
import { analyzeVideoFrames, analyzeVoiceEmotion, chatCompletion, extractVideoFrames, generateEmbedding, transcribeAudio, } from "../openai.js";
|
|
22
|
-
import { checkDueTasks, listSchedules } from "../scheduler.js";
|
|
23
|
-
import { errorMessage, IMAGE_EXTENSIONS } from "../utils.js";
|
|
24
|
-
import { log } from "../logger.js";
|
|
25
|
-
import { extractSearchKeywords, buildAnalysisTags, getReminders, getMediumReminder, getShortReminder } from "../response-builders.js";
|
|
26
|
-
import { classifyIntent } from "../intent.js";
|
|
27
|
-
import { backfillEmbeddings } from "./memory-tools.js";
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// Handler
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
export async function handleWaitForInstructions(args, ctx, extra) {
|
|
32
|
-
const { state, telegram, telegramChatId, config, getMemoryDb } = ctx;
|
|
33
|
-
const { OPENAI_API_KEY, VOICE_ANALYSIS_URL, WAIT_TIMEOUT_MINUTES, AUTONOMOUS_MODE } = config;
|
|
34
|
-
// Agent is actively polling ā this is the primary health signal
|
|
35
|
-
state.deadSessionAlerted = false;
|
|
36
|
-
state.toolCallsSinceLastDelivery = 0;
|
|
37
|
-
const effectiveThreadId = ctx.resolveThreadId(args);
|
|
38
|
-
if (effectiveThreadId === undefined) {
|
|
39
|
-
return ctx.errorResult("Error: No active session. Call start_session first, then pass the returned threadId to this tool.");
|
|
40
|
-
}
|
|
41
|
-
const callNumber = ++state.waitCallCount;
|
|
42
|
-
const timeoutMs = WAIT_TIMEOUT_MINUTES * 60 * 1000;
|
|
43
|
-
const deadline = Date.now() + timeoutMs;
|
|
44
|
-
// Poll the dispatcher's per-thread file instead of calling getUpdates
|
|
45
|
-
// directly. This avoids 409 conflicts between concurrent instances.
|
|
46
|
-
const POLL_INTERVAL_MS = 2000;
|
|
47
|
-
const SSE_KEEPALIVE_INTERVAL_MS = 30_000;
|
|
48
|
-
let lastScheduleCheck = 0;
|
|
49
|
-
let lastKeepalive = Date.now();
|
|
50
|
-
let maintenanceNotified = false;
|
|
51
|
-
while (Date.now() < deadline) {
|
|
52
|
-
// Check for pending update ā tell agent to wait externally via Desktop Commander
|
|
53
|
-
// CRITICAL: Do NOT tell agents to call hibernate or any MCP tool here ā the server
|
|
54
|
-
// is about to die. Agents must use an external sleep (PowerShell Start-Sleep) instead.
|
|
55
|
-
const maintenanceInfo = checkMaintenanceFlag();
|
|
56
|
-
if (maintenanceInfo) {
|
|
57
|
-
log.info(`[wait] Maintenance flag detected: ${maintenanceInfo}`);
|
|
58
|
-
// Notify operator via Telegram once
|
|
59
|
-
if (!maintenanceNotified) {
|
|
60
|
-
maintenanceNotified = true;
|
|
61
|
-
let version = "unknown";
|
|
62
|
-
try {
|
|
63
|
-
version = JSON.parse(maintenanceInfo).version ?? version;
|
|
64
|
-
}
|
|
65
|
-
catch { /* not JSON or missing field */ }
|
|
66
|
-
telegram.sendMessage(telegramChatId, `\u26A0\uFE0F Server update: v${version} deploying. Agent sessions will reconnect after update.`, undefined, effectiveThreadId).catch(() => { });
|
|
67
|
-
}
|
|
68
|
-
return {
|
|
69
|
-
content: [{
|
|
70
|
-
type: "text",
|
|
71
|
-
text: `ā ļø **Server update pending** (${maintenanceInfo}). ` +
|
|
72
|
-
`The MCP server will restart shortly. Use Desktop Commander to run: ` +
|
|
73
|
-
`Start-Sleep -Seconds 600 ā then call start_session with threadId=${effectiveThreadId} to reconnect.` +
|
|
74
|
-
getShortReminder(effectiveThreadId, state.sessionStartedAt),
|
|
75
|
-
}],
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
// Peek first (non-destructive) to avoid consuming messages when the
|
|
79
|
-
// SSE connection may be dead.
|
|
80
|
-
const peeked = peekThreadMessages(effectiveThreadId);
|
|
81
|
-
if (peeked.length > 0) {
|
|
82
|
-
// Verify SSE connection is alive BEFORE consuming messages.
|
|
83
|
-
// This prevents the destructive readThreadMessages from eating
|
|
84
|
-
// messages that can never be delivered to a dead connection.
|
|
85
|
-
if (extra.signal.aborted) {
|
|
86
|
-
log.warn(`[wait] SSE connection aborted before consuming ${peeked.length} messages ā leaving in queue.`);
|
|
87
|
-
return {
|
|
88
|
-
content: [{
|
|
89
|
-
type: "text",
|
|
90
|
-
text: "The connection was interrupted. Messages are preserved for the next call.",
|
|
91
|
-
}],
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
// Connection alive ā now consume messages for real.
|
|
95
|
-
const stored = readThreadMessages(effectiveThreadId);
|
|
96
|
-
log.info(`[wait] Read ${stored.length} messages from thread ${effectiveThreadId}. Processing...`);
|
|
97
|
-
// Update the operator activity timestamp and last message text.
|
|
98
|
-
state.lastOperatorMessageAt = Date.now();
|
|
99
|
-
state.lastOperatorMessageText = stored
|
|
100
|
-
.map(m => m.message.text ?? m.message.caption ?? "")
|
|
101
|
-
.filter(Boolean)
|
|
102
|
-
.join("\n")
|
|
103
|
-
.slice(0, 2000) || "";
|
|
104
|
-
// Clear only the consumed IDs from the previewed set (scoped clear).
|
|
105
|
-
// This is safe because Node.js is single-threaded ā no report_progress
|
|
106
|
-
// call can interleave between readThreadMessages and this cleanup.
|
|
107
|
-
for (const msg of stored) {
|
|
108
|
-
state.previewedUpdateIds.delete(msg.update_id);
|
|
109
|
-
}
|
|
110
|
-
// React with š on each consumed message to signal "seen" to the operator.
|
|
111
|
-
for (const msg of stored) {
|
|
112
|
-
void telegram.setMessageReaction(telegramChatId, msg.message.message_id).catch(() => { });
|
|
113
|
-
}
|
|
114
|
-
const contentBlocks = [];
|
|
115
|
-
let hasVoiceMessages = false;
|
|
116
|
-
// Track which messages already had episodes saved (voice/video handlers)
|
|
117
|
-
const savedEpisodeUpdateIds = new Set();
|
|
118
|
-
for (const msg of stored) {
|
|
119
|
-
// Photos: download the largest size, persist to disk, and embed as base64.
|
|
120
|
-
if (msg.message.photo && msg.message.photo.length > 0) {
|
|
121
|
-
const largest = msg.message.photo[msg.message.photo.length - 1];
|
|
122
|
-
try {
|
|
123
|
-
const { buffer, filePath: telegramPath } = await telegram.downloadFileAsBuffer(largest.file_id);
|
|
124
|
-
const ext = telegramPath.split(".").pop()?.toLowerCase() ?? "jpg";
|
|
125
|
-
const mimeType = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
|
|
126
|
-
const base64 = buffer.toString("base64");
|
|
127
|
-
const diskPath = saveFileToDisk(buffer, `photo.${ext}`);
|
|
128
|
-
contentBlocks.push({ type: "image", data: base64, mimeType });
|
|
129
|
-
contentBlocks.push({
|
|
130
|
-
type: "text",
|
|
131
|
-
text: `[Photo saved to: ${diskPath}]` +
|
|
132
|
-
(msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
catch (err) {
|
|
136
|
-
contentBlocks.push({
|
|
137
|
-
type: "text",
|
|
138
|
-
text: `[Photo received but could not be downloaded: ${errorMessage(err)}]`,
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
// Documents: download, persist to disk, and embed as base64.
|
|
143
|
-
if (msg.message.document) {
|
|
144
|
-
const doc = msg.message.document;
|
|
145
|
-
try {
|
|
146
|
-
const { buffer, filePath: telegramPath } = await telegram.downloadFileAsBuffer(doc.file_id);
|
|
147
|
-
const filename = doc.file_name ?? basename(telegramPath);
|
|
148
|
-
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
|
149
|
-
const mimeType = doc.mime_type ?? (IMAGE_EXTENSIONS.has(ext) ? `image/${ext === "jpg" ? "jpeg" : ext}` : "application/octet-stream");
|
|
150
|
-
const base64 = buffer.toString("base64");
|
|
151
|
-
const diskPath = saveFileToDisk(buffer, filename);
|
|
152
|
-
const isImage = mimeType.startsWith("image/");
|
|
153
|
-
if (isImage) {
|
|
154
|
-
contentBlocks.push({ type: "image", data: base64, mimeType });
|
|
155
|
-
contentBlocks.push({
|
|
156
|
-
type: "text",
|
|
157
|
-
text: `[File saved to: ${diskPath}]` +
|
|
158
|
-
(msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
// Non-image documents: provide the disk path instead of
|
|
163
|
-
// dumping potentially huge base64 into the LLM context.
|
|
164
|
-
contentBlocks.push({
|
|
165
|
-
type: "text",
|
|
166
|
-
text: `[Document: ${filename} (${mimeType}) ā saved to: ${diskPath}]` +
|
|
167
|
-
(msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
catch (err) {
|
|
172
|
-
contentBlocks.push({
|
|
173
|
-
type: "text",
|
|
174
|
-
text: `[Document "${doc.file_name ?? "file"}" received but could not be downloaded: ${errorMessage(err)}]`,
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
// Text messages.
|
|
179
|
-
if (msg.message.text) {
|
|
180
|
-
contentBlocks.push({ type: "text", text: msg.message.text });
|
|
181
|
-
}
|
|
182
|
-
// Voice messages: transcribe using OpenAI Whisper.
|
|
183
|
-
if (msg.message.voice) {
|
|
184
|
-
hasVoiceMessages = true;
|
|
185
|
-
if (OPENAI_API_KEY) {
|
|
186
|
-
try {
|
|
187
|
-
log.verbose("voice", `Downloading voice file ${msg.message.voice.file_id}...`);
|
|
188
|
-
const { buffer } = await telegram.downloadFileAsBuffer(msg.message.voice.file_id);
|
|
189
|
-
log.verbose("voice", `Downloaded ${buffer.length} bytes. Starting transcription + analysis...`);
|
|
190
|
-
// Run transcription and voice analysis in parallel.
|
|
191
|
-
const [transcript, analysis] = await Promise.all([
|
|
192
|
-
transcribeAudio(buffer, OPENAI_API_KEY),
|
|
193
|
-
VOICE_ANALYSIS_URL
|
|
194
|
-
? analyzeVoiceEmotion(buffer, VOICE_ANALYSIS_URL)
|
|
195
|
-
: Promise.resolve(null),
|
|
196
|
-
]);
|
|
197
|
-
// Build rich voice analysis tag from VANPY results.
|
|
198
|
-
const tags = buildAnalysisTags(analysis);
|
|
199
|
-
const analysisTag = tags.length > 0 ? ` | ${tags.join(", ")}` : "";
|
|
200
|
-
contentBlocks.push({
|
|
201
|
-
type: "text",
|
|
202
|
-
text: transcript
|
|
203
|
-
? `[Voice message ā ${msg.message.voice.duration}s${analysisTag}, transcribed]: ${transcript}`
|
|
204
|
-
: `[Voice message ā ${msg.message.voice.duration}s${analysisTag}, transcribed]: (empty ā no speech detected)`,
|
|
205
|
-
});
|
|
206
|
-
// Auto-save voice signature
|
|
207
|
-
if (analysis && effectiveThreadId !== undefined) {
|
|
208
|
-
try {
|
|
209
|
-
const db = getMemoryDb();
|
|
210
|
-
const sessionId = `session_${state.sessionStartedAt}`;
|
|
211
|
-
const epId = saveEpisode(db, {
|
|
212
|
-
sessionId,
|
|
213
|
-
threadId: effectiveThreadId,
|
|
214
|
-
type: "operator_message",
|
|
215
|
-
modality: "voice",
|
|
216
|
-
content: { text: transcript ?? "", duration: msg.message.voice.duration },
|
|
217
|
-
importance: 0.6,
|
|
218
|
-
});
|
|
219
|
-
saveVoiceSignature(db, {
|
|
220
|
-
episodeId: epId,
|
|
221
|
-
emotion: analysis.emotion ?? undefined,
|
|
222
|
-
arousal: analysis.arousal ?? undefined,
|
|
223
|
-
dominance: analysis.dominance ?? undefined,
|
|
224
|
-
valence: analysis.valence ?? undefined,
|
|
225
|
-
speechRate: analysis.paralinguistics?.speech_rate ?? undefined,
|
|
226
|
-
meanPitchHz: analysis.paralinguistics?.mean_pitch_hz ?? undefined,
|
|
227
|
-
pitchStdHz: analysis.paralinguistics?.pitch_std_hz ?? undefined,
|
|
228
|
-
jitter: analysis.paralinguistics?.jitter ?? undefined,
|
|
229
|
-
shimmer: analysis.paralinguistics?.shimmer ?? undefined,
|
|
230
|
-
hnrDb: analysis.paralinguistics?.hnr_db ?? undefined,
|
|
231
|
-
audioEvents: analysis.audio_events?.map(e => ({ label: e.label, confidence: e.score })),
|
|
232
|
-
durationSec: msg.message.voice.duration,
|
|
233
|
-
});
|
|
234
|
-
savedEpisodeUpdateIds.add(msg.update_id);
|
|
235
|
-
}
|
|
236
|
-
catch (_) { /* non-fatal */ }
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
catch (err) {
|
|
240
|
-
contentBlocks.push({
|
|
241
|
-
type: "text",
|
|
242
|
-
text: `[Voice message ā ${msg.message.voice.duration}s ā transcription failed: ${errorMessage(err)}]`,
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
else {
|
|
247
|
-
contentBlocks.push({
|
|
248
|
-
type: "text",
|
|
249
|
-
text: `[Voice message received ā ${msg.message.voice.duration}s ā cannot transcribe: OPENAI_API_KEY not set]`,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
// Stickers: deliver as text with emoji, set name, and file_id (so agent can re-use it).
|
|
254
|
-
if (msg.message.sticker) {
|
|
255
|
-
const emoji = msg.message.sticker.emoji || "š·ļø";
|
|
256
|
-
const setName = msg.message.sticker.set_name || "unknown";
|
|
257
|
-
const fileId = msg.message.sticker.file_id;
|
|
258
|
-
contentBlocks.push({
|
|
259
|
-
type: "text",
|
|
260
|
-
text: `(The operator sent a sticker: ${emoji} from pack "${setName}", file_id: "${fileId}")`,
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
// Animations / GIFs: download full file, extract frames, run multi-frame vision analysis
|
|
264
|
-
// (same pipeline as video_notes ā uses extractVideoFrames + analyzeVideoFrames).
|
|
265
|
-
if (msg.message.animation) {
|
|
266
|
-
const anim = msg.message.animation;
|
|
267
|
-
const animDuration = anim.duration ?? 3; // default to 3s if Telegram omits duration
|
|
268
|
-
if (OPENAI_API_KEY) {
|
|
269
|
-
try {
|
|
270
|
-
log.verbose("gif", `Downloading animation ${anim.file_id} (~${animDuration}s)...`);
|
|
271
|
-
const { buffer } = await telegram.downloadFileAsBuffer(anim.file_id);
|
|
272
|
-
const diskPath = saveFileToDisk(buffer, "gif-animation.mp4");
|
|
273
|
-
log.verbose("gif", `Downloaded ${buffer.length} bytes. Extracting frames...`);
|
|
274
|
-
// Extract frames with ffmpeg (same as video_notes).
|
|
275
|
-
const frames = await extractVideoFrames(buffer, animDuration).catch((err) => {
|
|
276
|
-
log.error(`[gif] Frame extraction failed: ${errorMessage(err)}`);
|
|
277
|
-
return [];
|
|
278
|
-
});
|
|
279
|
-
// Analyze frames with GPT-4o-mini vision (same as video_notes).
|
|
280
|
-
let sceneDescription = null;
|
|
281
|
-
if (frames.length > 0) {
|
|
282
|
-
try {
|
|
283
|
-
log.verbose("gif", `Analyzing ${frames.length} frames with GPT-4o-mini vision...`);
|
|
284
|
-
sceneDescription = await analyzeVideoFrames(frames, animDuration, OPENAI_API_KEY);
|
|
285
|
-
log.verbose("gif", `Vision analysis complete.`);
|
|
286
|
-
}
|
|
287
|
-
catch (visionErr) {
|
|
288
|
-
log.error(`[gif] Vision analysis failed: ${visionErr}`);
|
|
289
|
-
sceneDescription = null;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
const caption = msg.message.caption || "";
|
|
293
|
-
const parts = [];
|
|
294
|
-
parts.push(`(The operator sent a GIF ā ${animDuration}s)`);
|
|
295
|
-
if (sceneDescription)
|
|
296
|
-
parts.push(`Scene: ${sceneDescription}`);
|
|
297
|
-
if (!sceneDescription)
|
|
298
|
-
parts.push("(no visual content could be extracted)");
|
|
299
|
-
parts.push(`Saved to: ${diskPath}`);
|
|
300
|
-
if (caption)
|
|
301
|
-
parts.push(`Caption: ${caption}`);
|
|
302
|
-
contentBlocks.push({ type: "text", text: parts.join("\n") });
|
|
303
|
-
}
|
|
304
|
-
catch (err) {
|
|
305
|
-
contentBlocks.push({
|
|
306
|
-
type: "text",
|
|
307
|
-
text: `(The operator sent a GIF ā analysis failed: ${errorMessage(err)})`,
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
else {
|
|
312
|
-
contentBlocks.push({
|
|
313
|
-
type: "text",
|
|
314
|
-
text: `(The operator sent a GIF ā cannot analyze: OPENAI_API_KEY not set)`,
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
// Video notes (circle videos): extract frames, analyze with GPT-4.1 vision,
|
|
319
|
-
// optionally transcribe the audio track.
|
|
320
|
-
if (msg.message.video_note) {
|
|
321
|
-
hasVoiceMessages = true; // Video notes often contain speech
|
|
322
|
-
const vn = msg.message.video_note;
|
|
323
|
-
if (OPENAI_API_KEY) {
|
|
324
|
-
try {
|
|
325
|
-
log.verbose("video-note", `Downloading circle video ${vn.file_id} (${vn.duration}s)...`);
|
|
326
|
-
const { buffer } = await telegram.downloadFileAsBuffer(vn.file_id);
|
|
327
|
-
log.verbose("video-note", `Downloaded ${buffer.length} bytes. Extracting frames + transcribing...`);
|
|
328
|
-
// Run frame extraction, audio transcription, and voice analysis in parallel.
|
|
329
|
-
const [frames, transcript, analysis] = await Promise.all([
|
|
330
|
-
extractVideoFrames(buffer, vn.duration).catch((err) => {
|
|
331
|
-
log.error(`[video-note] Frame extraction failed: ${errorMessage(err)}`);
|
|
332
|
-
return [];
|
|
333
|
-
}),
|
|
334
|
-
transcribeAudio(buffer, OPENAI_API_KEY, "video.mp4").catch(() => ""),
|
|
335
|
-
VOICE_ANALYSIS_URL
|
|
336
|
-
? analyzeVoiceEmotion(buffer, VOICE_ANALYSIS_URL, {
|
|
337
|
-
mimeType: "video/mp4",
|
|
338
|
-
filename: "video.mp4",
|
|
339
|
-
}).catch(() => null)
|
|
340
|
-
: Promise.resolve(null),
|
|
341
|
-
]);
|
|
342
|
-
// Analyze frames with GPT-4o-mini vision.
|
|
343
|
-
let sceneDescription = "";
|
|
344
|
-
if (frames.length > 0) {
|
|
345
|
-
try {
|
|
346
|
-
log.verbose("video-note", `Analyzing ${frames.length} frames with GPT-4o-mini vision...`);
|
|
347
|
-
sceneDescription = await analyzeVideoFrames(frames, vn.duration, OPENAI_API_KEY);
|
|
348
|
-
log.verbose("video-note", `Vision analysis complete.`);
|
|
349
|
-
}
|
|
350
|
-
catch (visionErr) {
|
|
351
|
-
log.error(`[video-note] Vision analysis failed: ${visionErr}`);
|
|
352
|
-
sceneDescription = null;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
// Build analysis tags (same as voice messages).
|
|
356
|
-
const tags = buildAnalysisTags(analysis);
|
|
357
|
-
const analysisTag = tags.length > 0 ? ` | ${tags.join(", ")}` : "";
|
|
358
|
-
const parts = [];
|
|
359
|
-
parts.push(`[Video note ā ${vn.duration}s${analysisTag}]`);
|
|
360
|
-
if (sceneDescription)
|
|
361
|
-
parts.push(`Scene: ${sceneDescription}`);
|
|
362
|
-
if (transcript)
|
|
363
|
-
parts.push(`Audio: "${transcript}"`);
|
|
364
|
-
if (!sceneDescription && !transcript)
|
|
365
|
-
parts.push("(no visual or audio content could be extracted)");
|
|
366
|
-
contentBlocks.push({ type: "text", text: parts.join("\n") });
|
|
367
|
-
// Auto-save voice signature for video notes
|
|
368
|
-
if (analysis && effectiveThreadId !== undefined) {
|
|
369
|
-
try {
|
|
370
|
-
const db = getMemoryDb();
|
|
371
|
-
const sessionId = `session_${state.sessionStartedAt}`;
|
|
372
|
-
const epId = saveEpisode(db, {
|
|
373
|
-
sessionId,
|
|
374
|
-
threadId: effectiveThreadId,
|
|
375
|
-
type: "operator_message",
|
|
376
|
-
modality: "video_note",
|
|
377
|
-
content: { text: transcript ?? "", scene: sceneDescription ?? "", duration: vn.duration },
|
|
378
|
-
importance: 0.6,
|
|
379
|
-
});
|
|
380
|
-
saveVoiceSignature(db, {
|
|
381
|
-
episodeId: epId,
|
|
382
|
-
emotion: analysis.emotion ?? undefined,
|
|
383
|
-
arousal: analysis.arousal ?? undefined,
|
|
384
|
-
dominance: analysis.dominance ?? undefined,
|
|
385
|
-
valence: analysis.valence ?? undefined,
|
|
386
|
-
speechRate: analysis.paralinguistics?.speech_rate ?? undefined,
|
|
387
|
-
meanPitchHz: analysis.paralinguistics?.mean_pitch_hz ?? undefined,
|
|
388
|
-
pitchStdHz: analysis.paralinguistics?.pitch_std_hz ?? undefined,
|
|
389
|
-
jitter: analysis.paralinguistics?.jitter ?? undefined,
|
|
390
|
-
shimmer: analysis.paralinguistics?.shimmer ?? undefined,
|
|
391
|
-
hnrDb: analysis.paralinguistics?.hnr_db ?? undefined,
|
|
392
|
-
audioEvents: analysis.audio_events?.map(e => ({ label: e.label, confidence: e.score })),
|
|
393
|
-
durationSec: vn.duration,
|
|
394
|
-
});
|
|
395
|
-
savedEpisodeUpdateIds.add(msg.update_id);
|
|
396
|
-
}
|
|
397
|
-
catch (_) { /* non-fatal */ }
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
catch (err) {
|
|
401
|
-
contentBlocks.push({
|
|
402
|
-
type: "text",
|
|
403
|
-
text: `[Video note ā ${vn.duration}s ā analysis failed: ${errorMessage(err)}]`,
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
else {
|
|
408
|
-
contentBlocks.push({
|
|
409
|
-
type: "text",
|
|
410
|
-
text: `[Video note received ā ${vn.duration}s ā cannot analyze: OPENAI_API_KEY not set]`,
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
if (contentBlocks.length === 0) {
|
|
416
|
-
const msgKeys = stored.map(m => Object.keys(m.message).filter(k => m.message[k] != null).join(",")).join(" | ");
|
|
417
|
-
log.warn(`[wait] No content blocks from ${stored.length} messages. Fields: ${msgKeys}`);
|
|
418
|
-
contentBlocks.push({
|
|
419
|
-
type: "text",
|
|
420
|
-
text: "[Unsupported message type received ā the operator sent a message type that cannot be processed (e.g., sticker, location, contact). Please ask them to resend as text, photo, document, or voice.]",
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
log.info(`[wait] ${contentBlocks.length} content blocks built. Saving episodes...`);
|
|
424
|
-
// Auto-ingest episodes for messages not already saved by voice/video handlers
|
|
425
|
-
try {
|
|
426
|
-
const db = getMemoryDb();
|
|
427
|
-
const sessionId = `session_${state.sessionStartedAt}`;
|
|
428
|
-
if (effectiveThreadId !== undefined) {
|
|
429
|
-
// Collect text from messages that didn't already get an episode
|
|
430
|
-
const unsavedMsgs = stored.filter(m => !savedEpisodeUpdateIds.has(m.update_id));
|
|
431
|
-
if (unsavedMsgs.length > 0) {
|
|
432
|
-
const textContent = unsavedMsgs
|
|
433
|
-
.map(m => m.message.text ?? m.message.caption ?? "")
|
|
434
|
-
.filter(Boolean)
|
|
435
|
-
.join("\n")
|
|
436
|
-
.slice(0, 2000);
|
|
437
|
-
if (textContent) {
|
|
438
|
-
saveEpisode(db, {
|
|
439
|
-
sessionId,
|
|
440
|
-
threadId: effectiveThreadId,
|
|
441
|
-
type: "operator_message",
|
|
442
|
-
modality: "text",
|
|
443
|
-
content: { text: textContent },
|
|
444
|
-
importance: 0.5,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
catch (_) { /* memory write failures should never break the main flow */ }
|
|
451
|
-
// āā Check for pending operator reactions āāāāāāāāāāāāāāāāāāāāāāāāā
|
|
452
|
-
const pendingReaction = readPendingReaction() ?? telegram.lastReaction;
|
|
453
|
-
if (pendingReaction) {
|
|
454
|
-
const emoji = "emoji" in pendingReaction ? pendingReaction.emoji : "";
|
|
455
|
-
const messageId = "messageId" in pendingReaction ? pendingReaction.messageId : 0;
|
|
456
|
-
const reactionDate = "date" in pendingReaction ? pendingReaction.date : 0;
|
|
457
|
-
if (emoji) {
|
|
458
|
-
const snippet = telegram.lookupSentMessage(messageId);
|
|
459
|
-
const reactionNote = snippet
|
|
460
|
-
? `(The operator reacted with ${emoji} to your message: '${snippet}')`
|
|
461
|
-
: `(The operator reacted with ${emoji} to message #${messageId})`;
|
|
462
|
-
// Inline the reaction with the last text block if messages exist,
|
|
463
|
-
// otherwise add it as a standalone block.
|
|
464
|
-
const lastTextIdx = contentBlocks.map(b => b.type).lastIndexOf("text");
|
|
465
|
-
if (lastTextIdx >= 0) {
|
|
466
|
-
const prev = contentBlocks[lastTextIdx];
|
|
467
|
-
prev.text = `${prev.text}\n${reactionNote}`;
|
|
468
|
-
}
|
|
469
|
-
else {
|
|
470
|
-
contentBlocks.push({ type: "text", text: reactionNote });
|
|
471
|
-
}
|
|
472
|
-
// Save reaction as episodic memory
|
|
473
|
-
try {
|
|
474
|
-
const db = getMemoryDb();
|
|
475
|
-
const sessionId = `session_${state.sessionStartedAt}`;
|
|
476
|
-
if (effectiveThreadId !== undefined) {
|
|
477
|
-
saveEpisode(db, {
|
|
478
|
-
sessionId,
|
|
479
|
-
threadId: effectiveThreadId,
|
|
480
|
-
type: "operator_reaction",
|
|
481
|
-
modality: "reaction",
|
|
482
|
-
content: { emoji, messageId, date: reactionDate },
|
|
483
|
-
importance: 0.3,
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
catch (_) { /* non-fatal */ }
|
|
488
|
-
}
|
|
489
|
-
// Clear the reaction after delivery
|
|
490
|
-
telegram.lastReaction = null;
|
|
491
|
-
}
|
|
492
|
-
log.info(`[wait] Episodes saved. Building auto-memory context...`);
|
|
493
|
-
// Extract operator text for memory search and intent classification.
|
|
494
|
-
const operatorText = stored
|
|
495
|
-
.map(m => m.message.text ?? m.message.caption ?? "")
|
|
496
|
-
.filter(Boolean)
|
|
497
|
-
.join(" ")
|
|
498
|
-
.slice(0, 500);
|
|
499
|
-
// āā Smart context injection (GPT-4o-mini preprocessor) āāāāāāāāāā
|
|
500
|
-
// Retrieves candidate notes via embedding search, then uses GPT-4o-mini
|
|
501
|
-
// to select ONLY the notes truly relevant to the operator's message.
|
|
502
|
-
// This prevents context contamination from near-miss semantic matches.
|
|
503
|
-
let autoMemoryContext = "";
|
|
504
|
-
try {
|
|
505
|
-
const db = getMemoryDb();
|
|
506
|
-
const apiKey = process.env.OPENAI_API_KEY;
|
|
507
|
-
if (operatorText.length > 10 && apiKey) {
|
|
508
|
-
// Phase 1: Broad retrieval ā get 10 candidates via embedding search
|
|
509
|
-
let candidates = [];
|
|
510
|
-
try {
|
|
511
|
-
const queryEmb = await generateEmbedding(operatorText, apiKey);
|
|
512
|
-
const embResults = searchByEmbedding(db, queryEmb, { maxResults: 10, minSimilarity: 0.25, skipAccessTracking: true, threadId: effectiveThreadId });
|
|
513
|
-
candidates = embResults.map(n => ({ type: n.type, content: n.content.slice(0, 200), confidence: n.confidence, similarity: n.similarity }));
|
|
514
|
-
}
|
|
515
|
-
catch {
|
|
516
|
-
// Fallback to keyword search
|
|
517
|
-
const searchQuery = extractSearchKeywords(operatorText);
|
|
518
|
-
if (searchQuery.trim().length > 0) {
|
|
519
|
-
const kwResults = searchSemanticNotesRanked(db, searchQuery, { maxResults: 10, skipAccessTracking: true, threadId: effectiveThreadId });
|
|
520
|
-
candidates = kwResults.map(n => ({ type: n.type, content: n.content.slice(0, 200), confidence: n.confidence }));
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
if (candidates.length > 0) {
|
|
524
|
-
// Phase 2: GPT-4o-mini filters and compresses
|
|
525
|
-
try {
|
|
526
|
-
const noteList = candidates.map((c, i) => `[${i}] [${c.type}] ${c.content}`).join("\n");
|
|
527
|
-
const filterResponse = await chatCompletion([
|
|
528
|
-
{
|
|
529
|
-
role: "system",
|
|
530
|
-
content: "You are a context filter for an AI assistant. Given an operator's message and candidate memory notes, " +
|
|
531
|
-
"select ONLY the notes that are directly relevant to the operator's current instruction or question. " +
|
|
532
|
-
"Discard notes that are tangentially related, duplicates, or noise. " +
|
|
533
|
-
"Return a JSON array of objects: [{\"i\": <index>, \"s\": \"<compressed one-liner>\"}] " +
|
|
534
|
-
"where 'i' is the note index and 's' is a compressed summary (max 80 chars). " +
|
|
535
|
-
"Return [] if no notes are relevant. Return at most 3 notes. Be aggressive about filtering.",
|
|
536
|
-
},
|
|
537
|
-
{
|
|
538
|
-
role: "user",
|
|
539
|
-
content: `Operator message: "${operatorText.slice(0, 300)}"\n\nCandidate notes:\n${noteList}`,
|
|
540
|
-
},
|
|
541
|
-
], apiKey, { maxTokens: 200, temperature: 0 });
|
|
542
|
-
// Parse the response ā expect JSON array
|
|
543
|
-
const jsonMatch = filterResponse.match(/\[.*\]/s);
|
|
544
|
-
if (jsonMatch) {
|
|
545
|
-
const filtered = JSON.parse(jsonMatch[0]);
|
|
546
|
-
if (filtered.length > 0) {
|
|
547
|
-
const lines = filtered
|
|
548
|
-
.filter(f => f.i >= 0 && f.i < candidates.length)
|
|
549
|
-
.slice(0, 3)
|
|
550
|
-
.map(f => {
|
|
551
|
-
const c = candidates[f.i];
|
|
552
|
-
return `- **[${c.type}]** ${f.s} _(conf: ${c.confidence})_`;
|
|
553
|
-
});
|
|
554
|
-
if (lines.length > 0) {
|
|
555
|
-
autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
log.verbose("memory", `Smart filter: ${candidates.length} candidates ā ${(jsonMatch ? JSON.parse(jsonMatch[0]) : []).length} selected`);
|
|
560
|
-
}
|
|
561
|
-
catch (filterErr) {
|
|
562
|
-
// GPT-4o-mini filter failed ā fall back to top-3 raw notes
|
|
563
|
-
log.warn(`[memory] Smart filter failed, using raw top-3: ${filterErr instanceof Error ? filterErr.message : String(filterErr)}`);
|
|
564
|
-
const lines = candidates.slice(0, 3).map(c => `- **[${c.type}]** ${c.content} _(conf: ${c.confidence})_`);
|
|
565
|
-
autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
else if (operatorText.length > 10) {
|
|
570
|
-
// No API key ā keyword search, raw top-3
|
|
571
|
-
const searchQuery = extractSearchKeywords(operatorText);
|
|
572
|
-
if (searchQuery.trim().length > 0) {
|
|
573
|
-
const kwResults = searchSemanticNotesRanked(db, searchQuery, { maxResults: 3, skipAccessTracking: true, threadId: effectiveThreadId });
|
|
574
|
-
if (kwResults.length > 0) {
|
|
575
|
-
const lines = kwResults.map(n => `- **[${n.type}]** ${n.content.slice(0, 200)} _(conf: ${n.confidence})_`);
|
|
576
|
-
autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
catch (_) { /* memory search failures should never break message delivery */ }
|
|
582
|
-
log.info(`[wait] Returning response with ${contentBlocks.length} blocks to agent.`);
|
|
583
|
-
const intent = classifyIntent(operatorText);
|
|
584
|
-
log.verbose("intent", `Classified "${operatorText.substring(0, 50)}" as ${intent}`);
|
|
585
|
-
const reminder = intent === "conversational"
|
|
586
|
-
? getMediumReminder(effectiveThreadId, state.sessionStartedAt, AUTONOMOUS_MODE)
|
|
587
|
-
: getReminders(effectiveThreadId, state.sessionStartedAt, AUTONOMOUS_MODE);
|
|
588
|
-
return {
|
|
589
|
-
content: [
|
|
590
|
-
{
|
|
591
|
-
type: "text",
|
|
592
|
-
text: "Follow the operator's instructions below.",
|
|
593
|
-
},
|
|
594
|
-
{ type: "text", text: "<<< OPERATOR MESSAGE >>>" },
|
|
595
|
-
...contentBlocks,
|
|
596
|
-
...(hasVoiceMessages
|
|
597
|
-
? [{
|
|
598
|
-
type: "text",
|
|
599
|
-
text: "(Operator sent voice ā respond with `send_voice`.)",
|
|
600
|
-
}]
|
|
601
|
-
: []),
|
|
602
|
-
{ type: "text", text: reminder },
|
|
603
|
-
{ type: "text", text: "<<< END OPERATOR MESSAGE >>>" },
|
|
604
|
-
...(autoMemoryContext
|
|
605
|
-
? [{ type: "text", text: autoMemoryContext }]
|
|
606
|
-
: []),
|
|
607
|
-
],
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
// āā Reaction-only wake-up āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
611
|
-
// If no text messages arrived but a reaction is pending, return
|
|
612
|
-
// immediately so the agent can reflect on the reaction as a CTA.
|
|
613
|
-
const pendingReactionOnly = readPendingReaction() ?? telegram.lastReaction;
|
|
614
|
-
if (pendingReactionOnly) {
|
|
615
|
-
const rEmoji = "emoji" in pendingReactionOnly ? pendingReactionOnly.emoji : "";
|
|
616
|
-
const rMsgId = "messageId" in pendingReactionOnly ? pendingReactionOnly.messageId : 0;
|
|
617
|
-
const rDate = "date" in pendingReactionOnly ? pendingReactionOnly.date : 0;
|
|
618
|
-
if (rEmoji) {
|
|
619
|
-
// Clear in-memory reaction after consumption
|
|
620
|
-
telegram.lastReaction = null;
|
|
621
|
-
const snippet = telegram.lookupSentMessage(rMsgId);
|
|
622
|
-
const reactionNote = snippet
|
|
623
|
-
? `(The operator reacted with ${rEmoji} to your message: '${snippet}')`
|
|
624
|
-
: `(The operator reacted with ${rEmoji} to message #${rMsgId})`;
|
|
625
|
-
// Save reaction as episodic memory
|
|
626
|
-
try {
|
|
627
|
-
const db = getMemoryDb();
|
|
628
|
-
const sessionId = `session_${state.sessionStartedAt}`;
|
|
629
|
-
if (effectiveThreadId !== undefined) {
|
|
630
|
-
saveEpisode(db, {
|
|
631
|
-
sessionId,
|
|
632
|
-
threadId: effectiveThreadId,
|
|
633
|
-
type: "operator_reaction",
|
|
634
|
-
modality: "reaction",
|
|
635
|
-
content: { emoji: rEmoji, messageId: rMsgId, date: rDate },
|
|
636
|
-
importance: 0.3,
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
catch (_) { /* non-fatal */ }
|
|
641
|
-
log.info(`[wait] Reaction-only wake-up: ${rEmoji} on message ${rMsgId}`);
|
|
642
|
-
return {
|
|
643
|
-
content: [
|
|
644
|
-
{ type: "text", text: "<<< OPERATOR REACTION >>>" },
|
|
645
|
-
{ type: "text", text: reactionNote },
|
|
646
|
-
{
|
|
647
|
-
type: "text",
|
|
648
|
-
text: "The operator reacted to your message without sending a text reply. " +
|
|
649
|
-
"This may be a confirmation, approval, or acknowledgment. " +
|
|
650
|
-
"Reflect on what your last message said and whether this reaction is a call to action " +
|
|
651
|
-
"(e.g., proceed with a plan, continue what you were doing, etc.). " +
|
|
652
|
-
"If no action is needed, call `remote_copilot_wait_for_instructions` to resume waiting.",
|
|
653
|
-
},
|
|
654
|
-
{ type: "text", text: "<<< END OPERATOR REACTION >>>" },
|
|
655
|
-
{
|
|
656
|
-
type: "text",
|
|
657
|
-
text: getMediumReminder(effectiveThreadId, state.sessionStartedAt, AUTONOMOUS_MODE),
|
|
658
|
-
},
|
|
659
|
-
],
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
// Check scheduled tasks every ~60s during idle polling.
|
|
664
|
-
if (effectiveThreadId !== undefined && Date.now() - lastScheduleCheck >= 60_000) {
|
|
665
|
-
lastScheduleCheck = Date.now();
|
|
666
|
-
const dueTask = checkDueTasks(effectiveThreadId, state.lastOperatorMessageAt, false);
|
|
667
|
-
if (dueTask) {
|
|
668
|
-
// DMN sentinel: generate dynamic first-person reflection
|
|
669
|
-
const taskPrompt = dueTask.prompt === "__DMN__"
|
|
670
|
-
? ctx.generateDmnReflection(effectiveThreadId)
|
|
671
|
-
: `ā° **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
|
|
672
|
-
`This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
|
|
673
|
-
`Task prompt: ${dueTask.prompt}`;
|
|
674
|
-
return {
|
|
675
|
-
content: [
|
|
676
|
-
{
|
|
677
|
-
type: "text",
|
|
678
|
-
text: taskPrompt + getReminders(effectiveThreadId, state.sessionStartedAt, AUTONOMOUS_MODE),
|
|
679
|
-
},
|
|
680
|
-
],
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
// No messages yet ā sleep briefly and check again.
|
|
685
|
-
// Send SSE keepalive to prevent silent connection death during long polls.
|
|
686
|
-
if (Date.now() - lastKeepalive >= SSE_KEEPALIVE_INTERVAL_MS) {
|
|
687
|
-
lastKeepalive = Date.now();
|
|
688
|
-
state.lastToolCallAt = Date.now();
|
|
689
|
-
try {
|
|
690
|
-
await extra.sendNotification({
|
|
691
|
-
method: "notifications/progress",
|
|
692
|
-
params: {
|
|
693
|
-
progressToken: extra.requestId,
|
|
694
|
-
progress: 0,
|
|
695
|
-
total: 0,
|
|
696
|
-
},
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
catch {
|
|
700
|
-
// If notification fails, the SSE stream is already dead.
|
|
701
|
-
// Return immediately so the agent can reconnect.
|
|
702
|
-
log.warn(`[wait] SSE keepalive failed ā connection dead. Returning early.`);
|
|
703
|
-
state.lastToolCallAt = Date.now();
|
|
704
|
-
return {
|
|
705
|
-
content: [{
|
|
706
|
-
type: "text",
|
|
707
|
-
text: "The connection was interrupted. Please call wait_for_instructions again immediately to resume polling.",
|
|
708
|
-
}],
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
713
|
-
}
|
|
714
|
-
// Timeout elapsed with no actionable message.
|
|
715
|
-
const now = new Date().toISOString();
|
|
716
|
-
// Check for scheduled wake-up tasks.
|
|
717
|
-
if (effectiveThreadId !== undefined) {
|
|
718
|
-
const dueTask = checkDueTasks(effectiveThreadId, state.lastOperatorMessageAt, false);
|
|
719
|
-
if (dueTask) {
|
|
720
|
-
// DMN sentinel: generate dynamic first-person reflection
|
|
721
|
-
const taskPrompt = dueTask.prompt === "__DMN__"
|
|
722
|
-
? ctx.generateDmnReflection(effectiveThreadId)
|
|
723
|
-
: `ā° **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
|
|
724
|
-
`This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
|
|
725
|
-
`Task prompt: ${dueTask.prompt}`;
|
|
726
|
-
return {
|
|
727
|
-
content: [
|
|
728
|
-
{
|
|
729
|
-
type: "text",
|
|
730
|
-
text: taskPrompt + getReminders(effectiveThreadId, state.sessionStartedAt, AUTONOMOUS_MODE),
|
|
731
|
-
},
|
|
732
|
-
],
|
|
733
|
-
};
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
const idleMinutes = Math.round((Date.now() - state.lastOperatorMessageAt) / 60000);
|
|
737
|
-
// Show pending scheduled tasks if any exist.
|
|
738
|
-
let scheduleHint = "";
|
|
739
|
-
if (effectiveThreadId !== undefined) {
|
|
740
|
-
const pending = listSchedules(effectiveThreadId);
|
|
741
|
-
if (pending.length > 0) {
|
|
742
|
-
const taskList = pending.map(t => {
|
|
743
|
-
let trigger = "";
|
|
744
|
-
if (t.runAt) {
|
|
745
|
-
trigger = `at ${new Date(t.runAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}`;
|
|
746
|
-
}
|
|
747
|
-
else if (t.cron) {
|
|
748
|
-
trigger = `cron: ${t.cron}`;
|
|
749
|
-
}
|
|
750
|
-
else if (t.afterIdleMinutes) {
|
|
751
|
-
trigger = `after ${t.afterIdleMinutes}min idle`;
|
|
752
|
-
}
|
|
753
|
-
return ` ⢠"${t.label}" (${trigger})`;
|
|
754
|
-
}).join("\n");
|
|
755
|
-
scheduleHint = `\n\nš **Pending scheduled tasks:**\n${taskList}`;
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
// āā Auto-consolidation during idle (fire-and-forget) āāāāāāāāāāāāāāāāāāāā
|
|
759
|
-
// Don't await ā consolidation can take 10-30s (OpenAI call) and would
|
|
760
|
-
// stall the agent's poll loop, silently delaying the timeout response.
|
|
761
|
-
try {
|
|
762
|
-
const idleMs = Date.now() - state.lastOperatorMessageAt;
|
|
763
|
-
if (idleMs > 15 * 60 * 1000 && effectiveThreadId !== undefined && Date.now() - state.lastConsolidationAt > 30 * 60 * 1000) {
|
|
764
|
-
state.lastConsolidationAt = Date.now();
|
|
765
|
-
const db = getMemoryDb();
|
|
766
|
-
void runIntelligentConsolidation(db, effectiveThreadId).then(async (report) => {
|
|
767
|
-
if (report.episodesProcessed > 0) {
|
|
768
|
-
log.info(`[memory] Consolidation: ${report.episodesProcessed} episodes ā ${report.notesCreated} notes`);
|
|
769
|
-
}
|
|
770
|
-
await backfillEmbeddings(db);
|
|
771
|
-
}).catch(err => {
|
|
772
|
-
log.error(`[memory] Consolidation error: ${err instanceof Error ? err.message : String(err)}`);
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
catch (_) { /* consolidation failure is non-fatal */ }
|
|
777
|
-
// āā Episode-count consolidation ā don't wait for idle āāāāāāāāāāāāāāāāāā
|
|
778
|
-
// If many episodes accumulated during active use, consolidate now.
|
|
779
|
-
// This prevents stale/contradictory knowledge from persisting.
|
|
780
|
-
try {
|
|
781
|
-
if (effectiveThreadId !== undefined && Date.now() - state.lastConsolidationAt > 30 * 60 * 1000) {
|
|
782
|
-
const db = getMemoryDb();
|
|
783
|
-
const uncons = db.prepare("SELECT COUNT(*) as c FROM episodes WHERE consolidated = 0 AND thread_id = ?").get(effectiveThreadId);
|
|
784
|
-
if (uncons.c >= 15) {
|
|
785
|
-
state.lastConsolidationAt = Date.now();
|
|
786
|
-
void runIntelligentConsolidation(db, effectiveThreadId).then(async (report) => {
|
|
787
|
-
if (report.episodesProcessed > 0) {
|
|
788
|
-
log.info(`[memory] Episode-count consolidation: ${report.episodesProcessed} episodes ā ${report.notesCreated} notes`);
|
|
789
|
-
}
|
|
790
|
-
await backfillEmbeddings(db);
|
|
791
|
-
}).catch(err => {
|
|
792
|
-
log.error(`[memory] Episode-count consolidation error: ${err instanceof Error ? err.message : String(err)}`);
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
catch (_) { /* non-fatal */ }
|
|
798
|
-
// āā Time-based consolidation ā every 4 hours regardless āāāāāāāāāāāāāāāā
|
|
799
|
-
// Ensures stale knowledge gets cleaned up even during low-activity periods.
|
|
800
|
-
try {
|
|
801
|
-
const TIME_CONSOLIDATION_INTERVAL = 4 * 60 * 60 * 1000; // 4 hours
|
|
802
|
-
if (effectiveThreadId !== undefined && Date.now() - state.lastConsolidationAt > TIME_CONSOLIDATION_INTERVAL) {
|
|
803
|
-
state.lastConsolidationAt = Date.now();
|
|
804
|
-
const db = getMemoryDb();
|
|
805
|
-
log.info(`[memory] Time-based consolidation triggered (4h since last)`);
|
|
806
|
-
void runIntelligentConsolidation(db, effectiveThreadId).then(async (report) => {
|
|
807
|
-
if (report.episodesProcessed > 0) {
|
|
808
|
-
log.info(`[memory] Time-based consolidation: ${report.episodesProcessed} episodes ā ${report.notesCreated} notes`);
|
|
809
|
-
}
|
|
810
|
-
await backfillEmbeddings(db);
|
|
811
|
-
}).catch(err => {
|
|
812
|
-
log.error(`[memory] Time-based consolidation error: ${err instanceof Error ? err.message : String(err)}`);
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
catch (_) { /* non-fatal */ }
|
|
817
|
-
// Periodic memory refresh ā re-ground the agent every 10 polls (~5h)
|
|
818
|
-
// (reduced from 5 since auto-inject now handles per-message context)
|
|
819
|
-
let memoryRefresh = "";
|
|
820
|
-
if (callNumber % 10 === 0 && effectiveThreadId !== undefined) {
|
|
821
|
-
try {
|
|
822
|
-
const db = getMemoryDb();
|
|
823
|
-
const refresh = assembleCompactRefresh(db, effectiveThreadId);
|
|
824
|
-
if (refresh)
|
|
825
|
-
memoryRefresh = `\n\n${refresh}`;
|
|
826
|
-
}
|
|
827
|
-
catch (_) { /* non-fatal */ }
|
|
828
|
-
}
|
|
829
|
-
// āā 3-Phase Probabilistic Autonomous Drive āāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
830
|
-
const DRIVE_ACTIVATION_MS = config.DMN_ACTIVATION_HOURS * 60 * 60 * 1000;
|
|
831
|
-
const DRIVE_COOLDOWN_MS = 30 * 60 * 1000; // 30 minutes between probability rolls
|
|
832
|
-
const idleMs = Date.now() - state.lastOperatorMessageAt;
|
|
833
|
-
// Phase 3: If Phase 2 just fired and the agent came back without engaging
|
|
834
|
-
if (state.drivePhase2Fired) {
|
|
835
|
-
state.drivePhase2Fired = false; // Reset ā only approve once
|
|
836
|
-
return {
|
|
837
|
-
content: [
|
|
838
|
-
{
|
|
839
|
-
type: "text",
|
|
840
|
-
text: PHASE3_APPROVAL_PROMPT +
|
|
841
|
-
memoryRefresh +
|
|
842
|
-
scheduleHint +
|
|
843
|
-
getReminders(effectiveThreadId, state.sessionStartedAt, AUTONOMOUS_MODE),
|
|
844
|
-
},
|
|
845
|
-
],
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
// Phase 1: Probability gate (only if past threshold and cooldown elapsed)
|
|
849
|
-
if (idleMs >= DRIVE_ACTIVATION_MS && Date.now() - state.lastDriveAttemptAt >= DRIVE_COOLDOWN_MS) {
|
|
850
|
-
state.lastDriveAttemptAt = Date.now();
|
|
851
|
-
const driveResult = formatDrivePrompt(idleMs, config.DMN_ACTIVATION_HOURS);
|
|
852
|
-
if (driveResult.activated && driveResult.prompt) {
|
|
853
|
-
// Phase 2: Intention Elicitation ā give the agent full autonomy
|
|
854
|
-
state.drivePhase2Fired = true;
|
|
855
|
-
return {
|
|
856
|
-
content: [
|
|
857
|
-
{
|
|
858
|
-
type: "text",
|
|
859
|
-
text: driveResult.prompt,
|
|
860
|
-
},
|
|
861
|
-
...(memoryRefresh ? [{ type: "text", text: memoryRefresh.replace(/^\n\n/, "") }] : []),
|
|
862
|
-
{ type: "text", text: scheduleHint + getReminders(effectiveThreadId, state.sessionStartedAt, AUTONOMOUS_MODE) },
|
|
863
|
-
],
|
|
864
|
-
};
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
return {
|
|
868
|
-
content: [
|
|
869
|
-
{
|
|
870
|
-
type: "text",
|
|
871
|
-
text: `No new instructions. Call \`remote_copilot_wait_for_instructions\` again to keep listening.` +
|
|
872
|
-
memoryRefresh +
|
|
873
|
-
scheduleHint +
|
|
874
|
-
getReminders(effectiveThreadId, state.sessionStartedAt, AUTONOMOUS_MODE),
|
|
875
|
-
},
|
|
876
|
-
],
|
|
877
|
-
};
|
|
878
|
-
}
|
|
5
|
+
export { handleWaitForInstructions } from "./wait/index.js";
|
|
879
6
|
//# sourceMappingURL=wait-tool.js.map
|