@ubundi/openclaw-cortex 2.9.1 → 2.9.2
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/cortex/client.d.ts +21 -0
- package/dist/cortex/client.d.ts.map +1 -1
- package/dist/cortex/client.js +4 -0
- package/dist/cortex/client.js.map +1 -1
- package/dist/features/bridge/handler.d.ts +71 -0
- package/dist/features/bridge/handler.d.ts.map +1 -0
- package/dist/features/bridge/handler.js +677 -0
- package/dist/features/bridge/handler.js.map +1 -0
- package/dist/features/recall/context-profile.d.ts +9 -0
- package/dist/features/recall/context-profile.d.ts.map +1 -1
- package/dist/features/recall/context-profile.js +12 -1
- package/dist/features/recall/context-profile.js.map +1 -1
- package/dist/features/recall/formatter.d.ts +4 -0
- package/dist/features/recall/formatter.d.ts.map +1 -1
- package/dist/features/recall/formatter.js +9 -4
- package/dist/features/recall/formatter.js.map +1 -1
- package/dist/features/recall/handler.d.ts.map +1 -1
- package/dist/features/recall/handler.js +17 -3
- package/dist/features/recall/handler.js.map +1 -1
- package/dist/internal/agent-instructions.d.ts.map +1 -1
- package/dist/internal/agent-instructions.js +2 -1
- package/dist/internal/agent-instructions.js.map +1 -1
- package/dist/plugin/cli.js +6 -6
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +20 -1
- package/dist/plugin/index.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +38 -171
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isHeartbeatTurn } from "../../internal/heartbeat-detect.js";
|
|
3
|
+
import { filterConversationMessagesForMemory, shouldUseUserMessageForMemory, } from "../../internal/message-provenance.js";
|
|
4
|
+
import { isLowSignal, sanitizeConversationText } from "../capture/filter.js";
|
|
5
|
+
const LINK_STATUS_TTL_MS = 60_000;
|
|
6
|
+
const HANDLED_REQUEST_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
7
|
+
const HANDLED_REQUEST_MAX = 1000;
|
|
8
|
+
const MIN_ANSWER_CHARS = 5;
|
|
9
|
+
const MAX_QUESTION_CHARS = 400;
|
|
10
|
+
const MAX_ANSWER_CHARS = 5000;
|
|
11
|
+
const MIN_REFLECTIVE_MESSAGE_CHARS = 24;
|
|
12
|
+
const MIN_REFLECTIVE_MESSAGE_WORDS = 5;
|
|
13
|
+
const BRIDGE_QUESTION_COOLDOWN_TURNS = 4;
|
|
14
|
+
const BRIDGE_ANSWER_COOLDOWN_TURNS = 8;
|
|
15
|
+
const BRIDGE_QUESTION_COOLDOWN_MS = 10 * 60 * 1000;
|
|
16
|
+
const LOOKUP_SHAPE_RE = /^(?:what (?:is|are|does|do)|which|where|when|how (?:many|much|long))\b/i;
|
|
17
|
+
const TECHNICAL_LOOKUP_RE = /\b(?:file|files|repo|repository|package|dependency|dependencies|endpoint|api|port|timeout|ttl|database|schema|migration|commit|branch|log(?:s)?|stack trace|error|test(?:s)?|env|environment variable|config|setting|settings|version|runtime|token|cache|enum|function|method|class|module|library|table|column|redis)\b/i;
|
|
18
|
+
const LOW_SIGNAL_ANSWER_RE = /^(?:ok|okay|yes|no|maybe|not sure|i don't know|idk|sure|sounds good|thanks|thank you)[.!?]*$/i;
|
|
19
|
+
const CLARIFYING_QUESTION_RE = /^(?:what|why|how|who|where|when|which|can|could|would|do|does|did|is|are|am|should|will|have|has)\b/i;
|
|
20
|
+
const REFLECTIVE_OPENING_RE = /\b(?:i(?:'m| am)?\s+(?:rethinking|wondering|feeling|struggling|stuck|lost|burned out|burnt out|questioning)|i(?:'ve| have) been\s+(?:rethinking|wondering|feeling|struggling)|i(?:'m| am) trying to figure out\b|what matters to me\b|what i want from\b|who i am\b|my future\b|i want my (?:life|work|future)\b)\b/i;
|
|
21
|
+
const REFLECTIVE_SIGNAL_RE = /\b(?:value|values|believe|belief|care about|important|meaningful|purpose|aligned|future|dream|dreams|hope|aspire|fear|afraid|stuck|burned out|burnt out|lost|curious|wondering|rethinking|figuring out|non-negotiable|remembered|fulfilling|matters most)\b/i;
|
|
22
|
+
const PERSONAL_DISCOVERY_RE = /\b(?:about myself|for me|to me|who i am|my (?:life|work|future|career|purpose|values|identity)|what matters to me|what i want from|what would feel (?:meaningful|aligned)|how i want to be remembered)\b/i;
|
|
23
|
+
const FIRST_PERSON_RE = /\b(?:i|i'm|i’ve|i've|me|my|myself)\b/i;
|
|
24
|
+
const DIRECT_TASKING_RE = /\b(?:fix|debug|solve|implement|write|update|edit|change|refactor|add|remove|delete|check|review|look at|show me|tell me|give me|explain|run|test|build|deploy)\b/i;
|
|
25
|
+
const TOOLING_HINT_RE = /\b(?:npm|pnpm|yarn|uv|pytest|vitest|tsc|git|docker|kubernetes|postgres|redis)\b/i;
|
|
26
|
+
const CODEISH_HINT_RE = /`[^`]+`|\b[A-Za-z_][\w]*\([^)]*\)|=>|(?:^|\s)(?:\/|\.\/|\.\.\/)[\w./-]+/;
|
|
27
|
+
const SECTION_HINTS = [
|
|
28
|
+
{
|
|
29
|
+
section: "coreValues",
|
|
30
|
+
patterns: [
|
|
31
|
+
/\bwhat do you value(?: most)?\b/i,
|
|
32
|
+
/\bwhat matters most to you\b/i,
|
|
33
|
+
/\bwhat do you care about(?: most)?\b/i,
|
|
34
|
+
/\bwhat feels most important to you\b/i,
|
|
35
|
+
/\bwhat makes .* feel meaningful\b/i,
|
|
36
|
+
],
|
|
37
|
+
keywords: [
|
|
38
|
+
"value",
|
|
39
|
+
"values",
|
|
40
|
+
"matters most",
|
|
41
|
+
"care about",
|
|
42
|
+
"important to you",
|
|
43
|
+
"meaningful",
|
|
44
|
+
"worthwhile",
|
|
45
|
+
"drive",
|
|
46
|
+
"drives you",
|
|
47
|
+
"motivates",
|
|
48
|
+
"motivate",
|
|
49
|
+
"purpose",
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
section: "beliefs",
|
|
54
|
+
patterns: [
|
|
55
|
+
/\bwhat do you believe\b/i,
|
|
56
|
+
/\bwhat belief(?:s)? shape\b/i,
|
|
57
|
+
/\bwhat feels true to you\b/i,
|
|
58
|
+
/\bwhat assumption(?:s)? do you carry\b/i,
|
|
59
|
+
/\bwhat worldview\b/i,
|
|
60
|
+
],
|
|
61
|
+
keywords: ["believe", "belief", "beliefs", "true to you", "assumption", "assumptions", "worldview", "conviction"],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
section: "principles",
|
|
65
|
+
patterns: [
|
|
66
|
+
/\bwhat principle(?:s)? guide you\b/i,
|
|
67
|
+
/\bwhat rule(?:s)? do you live by\b/i,
|
|
68
|
+
/\bwhat line won't you cross\b/i,
|
|
69
|
+
/\bwhat standard do you hold yourself to\b/i,
|
|
70
|
+
/\bwhat principle(?:s)? matter most\b/i,
|
|
71
|
+
],
|
|
72
|
+
keywords: [
|
|
73
|
+
"principle",
|
|
74
|
+
"principles",
|
|
75
|
+
"rule",
|
|
76
|
+
"rules",
|
|
77
|
+
"standard",
|
|
78
|
+
"line won't",
|
|
79
|
+
"won't cross",
|
|
80
|
+
"boundary",
|
|
81
|
+
"boundaries",
|
|
82
|
+
"non-negotiable",
|
|
83
|
+
"non-negotiables",
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
section: "ideas",
|
|
88
|
+
patterns: [
|
|
89
|
+
/\bwhat idea(?:s)? are you exploring\b/i,
|
|
90
|
+
/\bwhat idea(?:s)? keep pulling at you\b/i,
|
|
91
|
+
/\bwhat are you curious about\b/i,
|
|
92
|
+
/\bwhat do you want to (?:create|build|explore|learn)\b/i,
|
|
93
|
+
/\bwhat possibility excites you\b/i,
|
|
94
|
+
],
|
|
95
|
+
keywords: ["idea", "ideas", "curious", "curiosity", "explore", "exploring", "build", "create", "possibility", "learn"],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
section: "dreams",
|
|
99
|
+
patterns: [
|
|
100
|
+
/\bwhat do you dream about\b/i,
|
|
101
|
+
/\bwhat do you hope for\b/i,
|
|
102
|
+
/\bwhat do you want your (?:life|work|future)\b/i,
|
|
103
|
+
/\bwhat would your ideal\b/i,
|
|
104
|
+
/\bwhat future are you trying to build\b/i,
|
|
105
|
+
],
|
|
106
|
+
keywords: ["dream", "dreams", "hope", "future", "ideal", "aspire", "aspiration", "want your life", "want your work"],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
section: "practices",
|
|
110
|
+
patterns: [
|
|
111
|
+
/\bwhat practice(?:s)? keep you grounded\b/i,
|
|
112
|
+
/\bwhat habit(?:s)? help you\b/i,
|
|
113
|
+
/\bwhat routine(?:s)? matter most\b/i,
|
|
114
|
+
/\bhow do you stay grounded\b/i,
|
|
115
|
+
/\bhow do you keep yourself aligned\b/i,
|
|
116
|
+
/\bwhat helps you reset\b/i,
|
|
117
|
+
],
|
|
118
|
+
keywords: ["practice", "practices", "habit", "habits", "routine", "routines", "ritual", "grounded", "aligned", "reset"],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
section: "shadows",
|
|
122
|
+
patterns: [
|
|
123
|
+
/\bwhat fear(?:s)? keep showing up\b/i,
|
|
124
|
+
/\bwhat do you avoid\b/i,
|
|
125
|
+
/\bwhat gets in your own way\b/i,
|
|
126
|
+
/\bwhere do you hold yourself back\b/i,
|
|
127
|
+
/\bwhat insecurity\b/i,
|
|
128
|
+
/\bwhat part of yourself is hard to face\b/i,
|
|
129
|
+
],
|
|
130
|
+
keywords: [
|
|
131
|
+
"fear",
|
|
132
|
+
"fears",
|
|
133
|
+
"avoid",
|
|
134
|
+
"avoiding",
|
|
135
|
+
"hold yourself back",
|
|
136
|
+
"gets in your own way",
|
|
137
|
+
"stuck",
|
|
138
|
+
"insecurity",
|
|
139
|
+
"shadow",
|
|
140
|
+
"self-sabotage",
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
section: "legacy",
|
|
145
|
+
patterns: [
|
|
146
|
+
/\bhow do you want to be remembered\b/i,
|
|
147
|
+
/\bwhat impact do you want to leave\b/i,
|
|
148
|
+
/\bwhat do you want to leave behind\b/i,
|
|
149
|
+
/\bwhat contribution do you hope to make\b/i,
|
|
150
|
+
/\bwhat legacy\b/i,
|
|
151
|
+
],
|
|
152
|
+
keywords: ["remembered", "impact", "leave behind", "legacy", "contribution", "outlast"],
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
function extractContent(content) {
|
|
156
|
+
if (typeof content === "string")
|
|
157
|
+
return content;
|
|
158
|
+
if (Array.isArray(content)) {
|
|
159
|
+
return content
|
|
160
|
+
.map((block) => {
|
|
161
|
+
if (typeof block !== "object" || block === null)
|
|
162
|
+
return "";
|
|
163
|
+
const typed = block;
|
|
164
|
+
if (typed.type === "text" && typeof typed.text === "string")
|
|
165
|
+
return typed.text;
|
|
166
|
+
if (typed.type === "tool_result")
|
|
167
|
+
return extractContent(typed.content);
|
|
168
|
+
return "";
|
|
169
|
+
})
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.join("\n");
|
|
172
|
+
}
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
function normalizeRequestText(text) {
|
|
176
|
+
return text.toLowerCase().replace(/\s+/g, " ").trim();
|
|
177
|
+
}
|
|
178
|
+
function trimHandledRequests(handledRequestIds) {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
for (const [requestId, seenAt] of handledRequestIds) {
|
|
181
|
+
if (now - seenAt > HANDLED_REQUEST_TTL_MS)
|
|
182
|
+
handledRequestIds.delete(requestId);
|
|
183
|
+
}
|
|
184
|
+
if (handledRequestIds.size <= HANDLED_REQUEST_MAX)
|
|
185
|
+
return;
|
|
186
|
+
const stale = [...handledRequestIds.entries()]
|
|
187
|
+
.sort((a, b) => a[1] - b[1])
|
|
188
|
+
.slice(0, handledRequestIds.size - HANDLED_REQUEST_MAX);
|
|
189
|
+
for (const [requestId] of stale)
|
|
190
|
+
handledRequestIds.delete(requestId);
|
|
191
|
+
}
|
|
192
|
+
function countKeywordMatches(text, keywords) {
|
|
193
|
+
return keywords.reduce((total, keyword) => total + (text.includes(keyword) ? 1 : 0), 0);
|
|
194
|
+
}
|
|
195
|
+
function countWords(text) {
|
|
196
|
+
return text
|
|
197
|
+
.trim()
|
|
198
|
+
.split(/\s+/)
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
.length;
|
|
201
|
+
}
|
|
202
|
+
export function extractLastQuestion(text) {
|
|
203
|
+
const lines = text
|
|
204
|
+
.replace(/\r\n/g, "\n")
|
|
205
|
+
.split("\n")
|
|
206
|
+
.map((line) => line.trim())
|
|
207
|
+
.filter(Boolean);
|
|
208
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
209
|
+
const line = lines[i];
|
|
210
|
+
const lastQuestionIndex = line.lastIndexOf("?");
|
|
211
|
+
if (lastQuestionIndex === -1)
|
|
212
|
+
continue;
|
|
213
|
+
let candidate = line.slice(0, lastQuestionIndex + 1).trim();
|
|
214
|
+
const previousQuestionIndex = candidate.lastIndexOf("?", candidate.length - 2);
|
|
215
|
+
if (previousQuestionIndex !== -1) {
|
|
216
|
+
candidate = candidate.slice(previousQuestionIndex + 1).trim();
|
|
217
|
+
}
|
|
218
|
+
const boundary = Math.max(candidate.lastIndexOf(". "), candidate.lastIndexOf("! "), candidate.lastIndexOf(": "), candidate.lastIndexOf("; "));
|
|
219
|
+
if (boundary !== -1) {
|
|
220
|
+
candidate = candidate.slice(boundary + 2).trim();
|
|
221
|
+
}
|
|
222
|
+
candidate = candidate.replace(/^[>\-*•\d.)\s]+/, "").replace(/\s+/g, " ").trim();
|
|
223
|
+
if (candidate.endsWith("?"))
|
|
224
|
+
return candidate;
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
export function inferTargetSection(question) {
|
|
229
|
+
const normalized = question.replace(/\s+/g, " ").trim();
|
|
230
|
+
if (!normalized.endsWith("?"))
|
|
231
|
+
return undefined;
|
|
232
|
+
if (normalized.length === 0 || normalized.length > MAX_QUESTION_CHARS)
|
|
233
|
+
return undefined;
|
|
234
|
+
if (LOOKUP_SHAPE_RE.test(normalized) && TECHNICAL_LOOKUP_RE.test(normalized))
|
|
235
|
+
return undefined;
|
|
236
|
+
const scored = SECTION_HINTS
|
|
237
|
+
.map(({ section, patterns, keywords }, priority) => ({
|
|
238
|
+
section,
|
|
239
|
+
priority,
|
|
240
|
+
score: patterns.reduce((total, pattern) => total + (pattern.test(normalized) ? 2 : 0), 0) +
|
|
241
|
+
countKeywordMatches(normalized.toLowerCase(), keywords),
|
|
242
|
+
}))
|
|
243
|
+
.filter((entry) => entry.score > 0)
|
|
244
|
+
.sort((a, b) => b.score - a.score || a.priority - b.priority);
|
|
245
|
+
return scored[0]?.section;
|
|
246
|
+
}
|
|
247
|
+
function isExplicitAnswer(answer) {
|
|
248
|
+
const normalized = answer.replace(/\s+/g, " ").trim();
|
|
249
|
+
const withoutLeadIn = normalized.replace(/^(?:and|also|but|plus|so)[,\s]+/i, "");
|
|
250
|
+
if (normalized.length < MIN_ANSWER_CHARS || normalized.length > MAX_ANSWER_CHARS)
|
|
251
|
+
return false;
|
|
252
|
+
if (LOW_SIGNAL_ANSWER_RE.test(normalized))
|
|
253
|
+
return false;
|
|
254
|
+
if (isLowSignal(normalized))
|
|
255
|
+
return false;
|
|
256
|
+
if (withoutLeadIn.endsWith("?") && CLARIFYING_QUESTION_RE.test(withoutLeadIn))
|
|
257
|
+
return false;
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
function normalizeConversationMessages(messages) {
|
|
261
|
+
const candidates = filterConversationMessagesForMemory(messages.flatMap((message, index) => {
|
|
262
|
+
if (typeof message !== "object" || message === null)
|
|
263
|
+
return [];
|
|
264
|
+
const typed = message;
|
|
265
|
+
if (typed.role !== "assistant" && typed.role !== "user")
|
|
266
|
+
return [];
|
|
267
|
+
if (!("content" in typed))
|
|
268
|
+
return [];
|
|
269
|
+
return [{
|
|
270
|
+
role: typed.role,
|
|
271
|
+
content: typed.content,
|
|
272
|
+
provenance: typed.provenance,
|
|
273
|
+
originalIndex: index,
|
|
274
|
+
}];
|
|
275
|
+
}));
|
|
276
|
+
return candidates
|
|
277
|
+
.map((message) => ({
|
|
278
|
+
role: message.role,
|
|
279
|
+
content: sanitizeConversationText(extractContent(message.content)).replace(/\s+/g, " ").trim(),
|
|
280
|
+
originalIndex: message.originalIndex,
|
|
281
|
+
provenance: message.provenance,
|
|
282
|
+
}))
|
|
283
|
+
.filter((message) => message.content.length > 0);
|
|
284
|
+
}
|
|
285
|
+
function getLatestEligibleUserMessage(messages) {
|
|
286
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
287
|
+
if (messages[i].role === "user")
|
|
288
|
+
return messages[i];
|
|
289
|
+
}
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
function looksTechnicalTurn(text) {
|
|
293
|
+
return (TECHNICAL_LOOKUP_RE.test(text) ||
|
|
294
|
+
TOOLING_HINT_RE.test(text) ||
|
|
295
|
+
CODEISH_HINT_RE.test(text));
|
|
296
|
+
}
|
|
297
|
+
function isReflectiveOpportunity(latestUserText, messages) {
|
|
298
|
+
const normalized = latestUserText.replace(/\s+/g, " ").trim();
|
|
299
|
+
if (normalized.length < MIN_REFLECTIVE_MESSAGE_CHARS)
|
|
300
|
+
return false;
|
|
301
|
+
if (countWords(normalized) < MIN_REFLECTIVE_MESSAGE_WORDS)
|
|
302
|
+
return false;
|
|
303
|
+
if (LOW_SIGNAL_ANSWER_RE.test(normalized))
|
|
304
|
+
return false;
|
|
305
|
+
if (isLowSignal(normalized))
|
|
306
|
+
return false;
|
|
307
|
+
if (looksTechnicalTurn(normalized))
|
|
308
|
+
return false;
|
|
309
|
+
if (DIRECT_TASKING_RE.test(normalized) && !REFLECTIVE_OPENING_RE.test(normalized))
|
|
310
|
+
return false;
|
|
311
|
+
if (REFLECTIVE_OPENING_RE.test(normalized))
|
|
312
|
+
return true;
|
|
313
|
+
if (FIRST_PERSON_RE.test(normalized) && (REFLECTIVE_SIGNAL_RE.test(normalized) || PERSONAL_DISCOVERY_RE.test(normalized))) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
const recentContext = messages
|
|
317
|
+
.slice(-4)
|
|
318
|
+
.map((message) => `${message.role}: ${message.content}`)
|
|
319
|
+
.join("\n");
|
|
320
|
+
return FIRST_PERSON_RE.test(recentContext) && PERSONAL_DISCOVERY_RE.test(recentContext) && REFLECTIVE_SIGNAL_RE.test(recentContext);
|
|
321
|
+
}
|
|
322
|
+
function buildBridgeQuestionId(input) {
|
|
323
|
+
return createHash("sha256")
|
|
324
|
+
.update(JSON.stringify({
|
|
325
|
+
v: 1,
|
|
326
|
+
sessionKey: input.sessionKey,
|
|
327
|
+
assistantIndex: input.assistantIndex,
|
|
328
|
+
question: normalizeRequestText(input.question),
|
|
329
|
+
}))
|
|
330
|
+
.digest("hex")
|
|
331
|
+
.slice(0, 32);
|
|
332
|
+
}
|
|
333
|
+
function detectBridgeQuestions(input) {
|
|
334
|
+
const normalized = normalizeConversationMessages(input.messages);
|
|
335
|
+
if (normalized.length === 0)
|
|
336
|
+
return [];
|
|
337
|
+
const questions = [];
|
|
338
|
+
const seenQuestionIds = new Set();
|
|
339
|
+
for (const message of normalized) {
|
|
340
|
+
if (message.role !== "assistant")
|
|
341
|
+
continue;
|
|
342
|
+
const question = extractLastQuestion(message.content);
|
|
343
|
+
if (!question)
|
|
344
|
+
continue;
|
|
345
|
+
const targetSection = inferTargetSection(question);
|
|
346
|
+
if (!targetSection)
|
|
347
|
+
continue;
|
|
348
|
+
const questionId = buildBridgeQuestionId({
|
|
349
|
+
sessionKey: input.sessionKey,
|
|
350
|
+
assistantIndex: message.originalIndex,
|
|
351
|
+
question,
|
|
352
|
+
});
|
|
353
|
+
if (seenQuestionIds.has(questionId))
|
|
354
|
+
continue;
|
|
355
|
+
seenQuestionIds.add(questionId);
|
|
356
|
+
questions.push({
|
|
357
|
+
question,
|
|
358
|
+
targetSection,
|
|
359
|
+
assistantIndex: message.originalIndex,
|
|
360
|
+
questionId,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return questions.sort((a, b) => a.assistantIndex - b.assistantIndex);
|
|
364
|
+
}
|
|
365
|
+
export function buildBridgeRequestId(input) {
|
|
366
|
+
const digest = createHash("sha256")
|
|
367
|
+
.update(JSON.stringify({
|
|
368
|
+
v: 1,
|
|
369
|
+
agentUserId: input.agentUserId,
|
|
370
|
+
sessionKey: input.sessionKey,
|
|
371
|
+
assistantIndex: input.assistantIndex,
|
|
372
|
+
userIndex: input.userIndex,
|
|
373
|
+
question: normalizeRequestText(input.question),
|
|
374
|
+
answer: normalizeRequestText(input.answer),
|
|
375
|
+
}))
|
|
376
|
+
.digest("hex")
|
|
377
|
+
.slice(0, 32);
|
|
378
|
+
return `openclaw-bridge-${digest}`;
|
|
379
|
+
}
|
|
380
|
+
export function detectBridgeExchanges(input) {
|
|
381
|
+
const normalized = normalizeConversationMessages(input.messages);
|
|
382
|
+
if (normalized.length < 2)
|
|
383
|
+
return [];
|
|
384
|
+
const exchanges = [];
|
|
385
|
+
const seenRequestIds = new Set();
|
|
386
|
+
for (let userCursor = normalized.length - 1; userCursor >= 0; userCursor--) {
|
|
387
|
+
if (normalized[userCursor].role !== "user")
|
|
388
|
+
continue;
|
|
389
|
+
const answer = normalized[userCursor].content;
|
|
390
|
+
if (!isExplicitAnswer(answer))
|
|
391
|
+
continue;
|
|
392
|
+
let priorAssistantIndex = -1;
|
|
393
|
+
for (let assistantCursor = userCursor - 1; assistantCursor >= 0; assistantCursor--) {
|
|
394
|
+
if (normalized[assistantCursor].role === "assistant") {
|
|
395
|
+
priorAssistantIndex = assistantCursor;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (priorAssistantIndex === -1)
|
|
400
|
+
continue;
|
|
401
|
+
const question = extractLastQuestion(normalized[priorAssistantIndex].content);
|
|
402
|
+
if (!question)
|
|
403
|
+
continue;
|
|
404
|
+
const targetSection = inferTargetSection(question);
|
|
405
|
+
if (!targetSection)
|
|
406
|
+
continue;
|
|
407
|
+
const assistantIndex = normalized[priorAssistantIndex].originalIndex;
|
|
408
|
+
const userIndex = normalized[userCursor].originalIndex;
|
|
409
|
+
const requestId = buildBridgeRequestId({
|
|
410
|
+
agentUserId: input.agentUserId,
|
|
411
|
+
sessionKey: input.sessionKey,
|
|
412
|
+
assistantIndex,
|
|
413
|
+
userIndex,
|
|
414
|
+
question,
|
|
415
|
+
answer,
|
|
416
|
+
});
|
|
417
|
+
if (seenRequestIds.has(requestId))
|
|
418
|
+
continue;
|
|
419
|
+
seenRequestIds.add(requestId);
|
|
420
|
+
exchanges.push({
|
|
421
|
+
question,
|
|
422
|
+
answer,
|
|
423
|
+
targetSection,
|
|
424
|
+
requestId,
|
|
425
|
+
assistantIndex,
|
|
426
|
+
userIndex,
|
|
427
|
+
sessionKey: input.sessionKey,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return exchanges.sort((a, b) => a.userIndex - b.userIndex);
|
|
431
|
+
}
|
|
432
|
+
export function detectBridgeExchange(input) {
|
|
433
|
+
return detectBridgeExchanges(input).at(-1);
|
|
434
|
+
}
|
|
435
|
+
export function buildTooTooBridgePrompt() {
|
|
436
|
+
return [
|
|
437
|
+
"<tootoo_bridge>",
|
|
438
|
+
"[NOTE: The current user linked this agent to TooToo. Treat this as behavioral guidance.]",
|
|
439
|
+
"When it fits the moment, you may ask one natural discovery question that grows out of the current conversation.",
|
|
440
|
+
"Keep it grounded in what the user is already discussing and do not derail practical help.",
|
|
441
|
+
"Prefer reflective questions about values, beliefs, principles, ideas, dreams, practices, shadows, or legacy.",
|
|
442
|
+
"Do not force a questionnaire or ask repeated discovery questions in back-to-back turns.",
|
|
443
|
+
"Only explicit user answers count. Do not infer or restate personal content the user did not clearly say.",
|
|
444
|
+
"</tootoo_bridge>",
|
|
445
|
+
].join("\n");
|
|
446
|
+
}
|
|
447
|
+
function extractErrorStatusCode(err) {
|
|
448
|
+
const match = /\bfailed:\s*(\d{3})\b/.exec(String(err));
|
|
449
|
+
return match ? Number(match[1]) : undefined;
|
|
450
|
+
}
|
|
451
|
+
function isRetryableBridgeError(err) {
|
|
452
|
+
if (typeof err === "object" && err !== null && "name" in err && err.name === "AbortError") {
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
const message = String(err);
|
|
456
|
+
if (/AbortError/.test(message) ||
|
|
457
|
+
/Failed to fetch/i.test(message) ||
|
|
458
|
+
/fetch failed/i.test(message) ||
|
|
459
|
+
/network/i.test(message) ||
|
|
460
|
+
/ECONN|ENOTFOUND|ETIMEDOUT/i.test(message)) {
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
const status = extractErrorStatusCode(err);
|
|
464
|
+
return status === 429 || (status !== undefined && status >= 500);
|
|
465
|
+
}
|
|
466
|
+
export function createBridgeHandler(client, options) {
|
|
467
|
+
const { logger, retryQueue, getUserId, userIdReady, pluginSessionId, auditLogger, } = options;
|
|
468
|
+
let linkStatus = {
|
|
469
|
+
linked: false,
|
|
470
|
+
checkedAt: 0,
|
|
471
|
+
};
|
|
472
|
+
let pendingLinkStatusCheck = null;
|
|
473
|
+
const handledRequestIds = new Map();
|
|
474
|
+
const handledQuestionIds = new Map();
|
|
475
|
+
const sessionStates = new Map();
|
|
476
|
+
function resolveBridgeSessionKey(event) {
|
|
477
|
+
return event.sessionKey ?? event.sessionId ?? pluginSessionId ?? "__default__";
|
|
478
|
+
}
|
|
479
|
+
function getSessionState(sessionKey) {
|
|
480
|
+
let state = sessionStates.get(sessionKey);
|
|
481
|
+
if (!state) {
|
|
482
|
+
state = { userTurns: 0 };
|
|
483
|
+
sessionStates.set(sessionKey, state);
|
|
484
|
+
}
|
|
485
|
+
return state;
|
|
486
|
+
}
|
|
487
|
+
async function refreshLinkStatus(force = false) {
|
|
488
|
+
if (userIdReady)
|
|
489
|
+
await userIdReady;
|
|
490
|
+
const agentUserId = getUserId();
|
|
491
|
+
if (!agentUserId) {
|
|
492
|
+
linkStatus = { linked: false, checkedAt: Date.now() };
|
|
493
|
+
return linkStatus;
|
|
494
|
+
}
|
|
495
|
+
if (!force && pendingLinkStatusCheck)
|
|
496
|
+
return pendingLinkStatusCheck;
|
|
497
|
+
if (!force && linkStatus.checkedAt > 0 && Date.now() - linkStatus.checkedAt < LINK_STATUS_TTL_MS) {
|
|
498
|
+
return linkStatus;
|
|
499
|
+
}
|
|
500
|
+
pendingLinkStatusCheck = client.getLinkStatus(agentUserId)
|
|
501
|
+
.then((result) => {
|
|
502
|
+
const next = {
|
|
503
|
+
linked: result.linked,
|
|
504
|
+
checkedAt: Date.now(),
|
|
505
|
+
};
|
|
506
|
+
if (next.linked !== linkStatus.linked) {
|
|
507
|
+
logger.info(`Cortex bridge: TooToo link ${next.linked ? "active" : "inactive"}`);
|
|
508
|
+
}
|
|
509
|
+
linkStatus = next;
|
|
510
|
+
return next;
|
|
511
|
+
})
|
|
512
|
+
.catch((err) => {
|
|
513
|
+
logger.debug?.(`Cortex bridge: link status check failed: ${String(err)}`);
|
|
514
|
+
linkStatus = { ...linkStatus, checkedAt: Date.now() };
|
|
515
|
+
return linkStatus;
|
|
516
|
+
})
|
|
517
|
+
.finally(() => {
|
|
518
|
+
pendingLinkStatusCheck = null;
|
|
519
|
+
});
|
|
520
|
+
return pendingLinkStatusCheck;
|
|
521
|
+
}
|
|
522
|
+
async function getPromptContext() {
|
|
523
|
+
const status = await refreshLinkStatus();
|
|
524
|
+
return status.linked ? buildTooTooBridgePrompt() : undefined;
|
|
525
|
+
}
|
|
526
|
+
async function shouldInjectPrompt(event) {
|
|
527
|
+
if (isHeartbeatTurn(event.prompt ?? ""))
|
|
528
|
+
return false;
|
|
529
|
+
if (!Array.isArray(event.messages) || event.messages.length === 0)
|
|
530
|
+
return false;
|
|
531
|
+
const latestRawUser = [...event.messages]
|
|
532
|
+
.reverse()
|
|
533
|
+
.find((message) => (typeof message === "object" &&
|
|
534
|
+
message !== null &&
|
|
535
|
+
shouldUseUserMessageForMemory(message)));
|
|
536
|
+
if (!latestRawUser)
|
|
537
|
+
return false;
|
|
538
|
+
const normalizedMessages = normalizeConversationMessages(event.messages);
|
|
539
|
+
const latestUser = getLatestEligibleUserMessage(normalizedMessages);
|
|
540
|
+
if (!latestUser)
|
|
541
|
+
return false;
|
|
542
|
+
const sessionKey = resolveBridgeSessionKey(event);
|
|
543
|
+
const sessionState = getSessionState(sessionKey);
|
|
544
|
+
sessionState.userTurns += 1;
|
|
545
|
+
if (sessionState.lastAnsweredTurn != null &&
|
|
546
|
+
sessionState.userTurns - sessionState.lastAnsweredTurn < BRIDGE_ANSWER_COOLDOWN_TURNS) {
|
|
547
|
+
logger.debug?.(`Cortex bridge: prompt suppressed by answer cooldown sessionId=${sessionKey}`);
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
if (sessionState.lastQuestionTurn != null &&
|
|
551
|
+
sessionState.userTurns - sessionState.lastQuestionTurn < BRIDGE_QUESTION_COOLDOWN_TURNS) {
|
|
552
|
+
logger.debug?.(`Cortex bridge: prompt suppressed by turn cooldown sessionId=${sessionKey}`);
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
if (sessionState.lastQuestionAt != null &&
|
|
556
|
+
Date.now() - sessionState.lastQuestionAt < BRIDGE_QUESTION_COOLDOWN_MS) {
|
|
557
|
+
logger.debug?.(`Cortex bridge: prompt suppressed by time cooldown sessionId=${sessionKey}`);
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
if (!isReflectiveOpportunity(latestUser.content, normalizedMessages))
|
|
561
|
+
return false;
|
|
562
|
+
const status = await refreshLinkStatus();
|
|
563
|
+
if (!status.linked)
|
|
564
|
+
return false;
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
async function handleAgentEnd(event) {
|
|
568
|
+
if (event.aborted)
|
|
569
|
+
return false;
|
|
570
|
+
if (!Array.isArray(event.messages) || event.messages.length === 0)
|
|
571
|
+
return false;
|
|
572
|
+
if (userIdReady)
|
|
573
|
+
await userIdReady;
|
|
574
|
+
const agentUserId = getUserId();
|
|
575
|
+
if (!agentUserId)
|
|
576
|
+
return false;
|
|
577
|
+
const status = await refreshLinkStatus();
|
|
578
|
+
if (!status.linked)
|
|
579
|
+
return false;
|
|
580
|
+
trimHandledRequests(handledRequestIds);
|
|
581
|
+
trimHandledRequests(handledQuestionIds);
|
|
582
|
+
const sessionKey = resolveBridgeSessionKey(event);
|
|
583
|
+
const sessionState = getSessionState(sessionKey);
|
|
584
|
+
const questions = detectBridgeQuestions({
|
|
585
|
+
messages: event.messages,
|
|
586
|
+
sessionKey,
|
|
587
|
+
});
|
|
588
|
+
const latestNewQuestion = [...questions].reverse().find((question) => !handledQuestionIds.has(question.questionId));
|
|
589
|
+
if (latestNewQuestion) {
|
|
590
|
+
handledQuestionIds.set(latestNewQuestion.questionId, Date.now());
|
|
591
|
+
sessionState.lastQuestionTurn = sessionState.userTurns;
|
|
592
|
+
sessionState.lastQuestionAt = Date.now();
|
|
593
|
+
}
|
|
594
|
+
const exchanges = detectBridgeExchanges({
|
|
595
|
+
messages: event.messages,
|
|
596
|
+
agentUserId,
|
|
597
|
+
sessionKey,
|
|
598
|
+
});
|
|
599
|
+
const pendingExchanges = exchanges.filter((exchange) => {
|
|
600
|
+
if (handledRequestIds.has(exchange.requestId)) {
|
|
601
|
+
logger.debug?.(`Cortex bridge: duplicate exchange skipped requestId=${exchange.requestId}`);
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
return true;
|
|
605
|
+
});
|
|
606
|
+
if (pendingExchanges.length === 0)
|
|
607
|
+
return false;
|
|
608
|
+
const performSubmit = async (exchange) => {
|
|
609
|
+
const request = {
|
|
610
|
+
user_id: agentUserId,
|
|
611
|
+
request_id: exchange.requestId,
|
|
612
|
+
entries: [
|
|
613
|
+
{
|
|
614
|
+
question: exchange.question,
|
|
615
|
+
answer: exchange.answer,
|
|
616
|
+
target_section: exchange.targetSection,
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
};
|
|
620
|
+
if (auditLogger) {
|
|
621
|
+
await auditLogger.log({
|
|
622
|
+
feature: "bridge-qa",
|
|
623
|
+
method: "POST",
|
|
624
|
+
endpoint: "/v1/bridge/qa",
|
|
625
|
+
payload: JSON.stringify(request, null, 2),
|
|
626
|
+
sessionId: exchange.sessionKey,
|
|
627
|
+
userId: agentUserId,
|
|
628
|
+
messageCount: 2,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
const response = await client.submitBridgeQA(request);
|
|
632
|
+
if (!response.accepted) {
|
|
633
|
+
throw new Error(`Cortex bridge/qa failed: accepted=false requestId=${exchange.requestId}`);
|
|
634
|
+
}
|
|
635
|
+
logger.info(`Cortex bridge: accepted requestId=${exchange.requestId} forwarded=${response.forwarded} queuedForRetry=${response.queued_for_retry} entries=${response.entries_sent}`);
|
|
636
|
+
};
|
|
637
|
+
let handledAny = false;
|
|
638
|
+
for (const exchange of pendingExchanges) {
|
|
639
|
+
handledRequestIds.set(exchange.requestId, Date.now());
|
|
640
|
+
logger.info(`Cortex bridge: detected discovery exchange requestId=${exchange.requestId} sessionId=${exchange.sessionKey} section=${exchange.targetSection}`);
|
|
641
|
+
try {
|
|
642
|
+
await performSubmit(exchange);
|
|
643
|
+
handledAny = true;
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
const statusCode = extractErrorStatusCode(err);
|
|
647
|
+
if (statusCode === 404) {
|
|
648
|
+
linkStatus = {
|
|
649
|
+
linked: false,
|
|
650
|
+
checkedAt: Date.now(),
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
if (retryQueue && isRetryableBridgeError(err)) {
|
|
654
|
+
logger.warn(`Cortex bridge failed, queuing retry requestId=${exchange.requestId}: ${String(err)}`);
|
|
655
|
+
retryQueue.enqueue(() => performSubmit(exchange), `bridge-${exchange.requestId}`);
|
|
656
|
+
handledAny = true;
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
logger.warn(`Cortex bridge failed requestId=${exchange.requestId}: ${String(err)}`);
|
|
660
|
+
if (!linkStatus.linked)
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (handledAny) {
|
|
665
|
+
sessionState.userTurns = Math.max(sessionState.userTurns, 1);
|
|
666
|
+
sessionState.lastAnsweredTurn = sessionState.userTurns;
|
|
667
|
+
}
|
|
668
|
+
return handledAny;
|
|
669
|
+
}
|
|
670
|
+
return {
|
|
671
|
+
refreshLinkStatus,
|
|
672
|
+
getPromptContext,
|
|
673
|
+
shouldInjectPrompt,
|
|
674
|
+
handleAgentEnd,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
//# sourceMappingURL=handler.js.map
|