clay-server 2.26.0-beta.4 → 2.26.0-beta.5
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/lib/project-mate-interaction.js +760 -0
- package/lib/project-memory.js +677 -0
- package/lib/project.js +65 -1371
- package/lib/public/app.js +178 -1
- package/lib/public/css/title-bar.css +186 -0
- package/lib/sdk-bridge.js +19 -0
- package/lib/sdk-worker.js +13 -1
- package/lib/sessions.js +16 -1
- package/package.json +2 -2
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
var fs = require("fs");
|
|
2
|
+
var path = require("path");
|
|
3
|
+
var crypto = require("crypto");
|
|
4
|
+
|
|
5
|
+
function attachMateInteraction(ctx) {
|
|
6
|
+
var cwd = ctx.cwd;
|
|
7
|
+
var sm = ctx.sm;
|
|
8
|
+
var sdk = ctx.sdk;
|
|
9
|
+
var sendTo = ctx.sendTo;
|
|
10
|
+
var sendToSession = ctx.sendToSession;
|
|
11
|
+
var sendToSessionOthers = ctx.sendToSessionOthers;
|
|
12
|
+
var matesModule = ctx.matesModule;
|
|
13
|
+
var isMate = ctx.isMate;
|
|
14
|
+
var projectOwnerId = ctx.projectOwnerId;
|
|
15
|
+
var getSessionForWs = ctx.getSessionForWs;
|
|
16
|
+
var getLinuxUserForSession = ctx.getLinuxUserForSession;
|
|
17
|
+
var saveImageFile = ctx.saveImageFile;
|
|
18
|
+
var hydrateImageRefs = ctx.hydrateImageRefs;
|
|
19
|
+
var onProcessingChanged = ctx.onProcessingChanged;
|
|
20
|
+
var loadMateDigests = ctx.loadMateDigests;
|
|
21
|
+
var updateMemorySummary = ctx.updateMemorySummary;
|
|
22
|
+
var initMemorySummary = ctx.initMemorySummary;
|
|
23
|
+
// checkForDmDebateBrief is accessed via ctx at call time because
|
|
24
|
+
// it comes from the debate module initialized after this one.
|
|
25
|
+
|
|
26
|
+
// --- @Mention handler ---
|
|
27
|
+
var MENTION_WINDOW = 20; // turns to check for session continuity
|
|
28
|
+
|
|
29
|
+
function getRecentTurns(session, n) {
|
|
30
|
+
var turns = [];
|
|
31
|
+
var history = session.history;
|
|
32
|
+
// Walk backwards through history, collect user/assistant/mention text turns
|
|
33
|
+
var assistantBuffer = "";
|
|
34
|
+
for (var i = history.length - 1; i >= 0 && turns.length < n; i--) {
|
|
35
|
+
var entry = history[i];
|
|
36
|
+
if (entry.type === "user_message") {
|
|
37
|
+
if (assistantBuffer) {
|
|
38
|
+
turns.push({ role: "assistant", text: assistantBuffer.trim() });
|
|
39
|
+
assistantBuffer = "";
|
|
40
|
+
}
|
|
41
|
+
turns.push({ role: "user", text: entry.text || "" });
|
|
42
|
+
} else if (entry.type === "delta" || entry.type === "text") {
|
|
43
|
+
assistantBuffer = (entry.text || "") + assistantBuffer;
|
|
44
|
+
} else if (entry.type === "mention_response") {
|
|
45
|
+
if (assistantBuffer) {
|
|
46
|
+
turns.push({ role: "assistant", text: assistantBuffer.trim() });
|
|
47
|
+
assistantBuffer = "";
|
|
48
|
+
}
|
|
49
|
+
turns.push({ role: "@" + (entry.mateName || "Mate"), text: entry.text || "", mateId: entry.mateId });
|
|
50
|
+
} else if (entry.type === "mention_user") {
|
|
51
|
+
if (assistantBuffer) {
|
|
52
|
+
turns.push({ role: "assistant", text: assistantBuffer.trim() });
|
|
53
|
+
assistantBuffer = "";
|
|
54
|
+
}
|
|
55
|
+
turns.push({ role: "user", text: "@" + (entry.mateName || "Mate") + " " + (entry.text || ""), mateId: entry.mateId });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (assistantBuffer) {
|
|
59
|
+
turns.push({ role: "assistant", text: assistantBuffer.trim() });
|
|
60
|
+
}
|
|
61
|
+
turns.reverse();
|
|
62
|
+
return turns;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check if the given mate has a mention response in the recent window
|
|
66
|
+
function hasMateInWindow(recentTurns, mateId) {
|
|
67
|
+
for (var i = 0; i < recentTurns.length; i++) {
|
|
68
|
+
if (recentTurns[i].mateId === mateId && recentTurns[i].role.charAt(0) === "@") {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Build context for continued (follow-up) mention sessions
|
|
76
|
+
function buildMiddleContext(recentTurns, mateId) {
|
|
77
|
+
var lines = [];
|
|
78
|
+
var found = false;
|
|
79
|
+
for (var i = recentTurns.length - 1; i >= 0; i--) {
|
|
80
|
+
var t = recentTurns[i];
|
|
81
|
+
if (t.mateId === mateId && t.role.charAt(0) === "@") { found = true; break; }
|
|
82
|
+
if (t.role === "user") {
|
|
83
|
+
lines.unshift("[User said after your last reply: " + t.text.substring(0, 500) + "]");
|
|
84
|
+
} else if (t.role === "assistant") {
|
|
85
|
+
lines.unshift("[Session agent replied: " + t.text.substring(0, 500) + "]");
|
|
86
|
+
} else if (t.role.charAt(0) === "@") {
|
|
87
|
+
lines.unshift("[@" + t.role.substring(1) + " replied: " + t.text.substring(0, 500) + "]");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!found || lines.length === 0) return "";
|
|
91
|
+
return "[Conversation since your last reply]\n" + lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Build initial mention context from recent turns
|
|
95
|
+
function buildMentionContext(userName, recentTurns) {
|
|
96
|
+
if (recentTurns.length === 0) return "";
|
|
97
|
+
var lines = ["[Recent conversation context]"];
|
|
98
|
+
for (var i = 0; i < recentTurns.length; i++) {
|
|
99
|
+
var t = recentTurns[i];
|
|
100
|
+
var label = t.role === "user" ? userName : t.role === "assistant" ? "Session Agent" : t.role;
|
|
101
|
+
lines.push(label + ": " + t.text.substring(0, 500));
|
|
102
|
+
}
|
|
103
|
+
return lines.join("\n") + "\n\n";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Shared digest worker: one reusable Haiku session for gate+digest ---
|
|
107
|
+
// Combines gate check and digest generation into a single prompt,
|
|
108
|
+
// processes jobs sequentially from a queue, reuses the session across calls.
|
|
109
|
+
// Session is recycled after DIGEST_WORKER_MAX_TURNS to prevent context bloat.
|
|
110
|
+
var _digestWorker = null;
|
|
111
|
+
var _digestQueue = [];
|
|
112
|
+
var _digestBusy = false;
|
|
113
|
+
var _digestWorkerTurns = 0;
|
|
114
|
+
var DIGEST_WORKER_MAX_TURNS = 20;
|
|
115
|
+
|
|
116
|
+
function enqueueDigest(job) {
|
|
117
|
+
_digestQueue.push(job);
|
|
118
|
+
if (!_digestBusy) processDigestQueue();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function processDigestQueue() {
|
|
122
|
+
if (_digestQueue.length === 0) { _digestBusy = false; return; }
|
|
123
|
+
_digestBusy = true;
|
|
124
|
+
var job = _digestQueue.shift();
|
|
125
|
+
|
|
126
|
+
var mateDir = matesModule.getMateDir(job.mateCtx, job.mateId);
|
|
127
|
+
var knowledgeDir = path.join(mateDir, "knowledge");
|
|
128
|
+
|
|
129
|
+
// Load mate role for gate context
|
|
130
|
+
var mateRole = "";
|
|
131
|
+
try {
|
|
132
|
+
var yamlRaw = fs.readFileSync(path.join(mateDir, "mate.yaml"), "utf8");
|
|
133
|
+
var roleMatch = yamlRaw.match(/^relationship:\s*(.+)$/m);
|
|
134
|
+
if (roleMatch) mateRole = roleMatch[1].trim();
|
|
135
|
+
} catch (e) {}
|
|
136
|
+
|
|
137
|
+
// Combined gate + digest in one prompt (saves a full round-trip vs separate gate)
|
|
138
|
+
var prompt = [
|
|
139
|
+
"[SYSTEM: Memory Gate + Digest]",
|
|
140
|
+
"You are a memory system for an AI Mate (role: " + (mateRole || "assistant") + ").",
|
|
141
|
+
"",
|
|
142
|
+
"Conversation (" + job.type + "):",
|
|
143
|
+
job.conversationContent,
|
|
144
|
+
"",
|
|
145
|
+
"STEP 1: Should this be saved to memory?",
|
|
146
|
+
'Answer "no" ONLY if the entire conversation is trivial (e.g. just "hi"/"hello").',
|
|
147
|
+
"When in doubt, save it.",
|
|
148
|
+
"",
|
|
149
|
+
'STEP 2: If yes, output a JSON digest. If no, output exactly: {"skip":true}',
|
|
150
|
+
"",
|
|
151
|
+
"JSON schema (output ONLY the JSON, no markdown, no fences):",
|
|
152
|
+
"{",
|
|
153
|
+
' "date": "YYYY-MM-DD",',
|
|
154
|
+
' "type": "' + job.type + '",',
|
|
155
|
+
' "topic": "short topic description",',
|
|
156
|
+
' "summary": "2-3 sentence summary",',
|
|
157
|
+
' "key_quotes": ["user quotes, verbatim, max 5"],',
|
|
158
|
+
' "user_context": "personal/project context or null",',
|
|
159
|
+
' "my_position": "what I said/recommended",',
|
|
160
|
+
job.type === "dm" ? ' "user_intent": "what the user wanted",' : ' "other_perspectives": "key points from others",',
|
|
161
|
+
' "decisions": "what was decided or null",',
|
|
162
|
+
' "open_items": "what remains unresolved",',
|
|
163
|
+
' "user_sentiment": "how user felt",',
|
|
164
|
+
' "confidence": "high|medium|low",',
|
|
165
|
+
' "revisit_later": true/false,',
|
|
166
|
+
' "tags": ["topic", "tags"],',
|
|
167
|
+
' "user_observations": [{"category":"pattern|decision|reaction|preference","observation":"...","evidence":"..."}]',
|
|
168
|
+
"}",
|
|
169
|
+
"",
|
|
170
|
+
"user_observations: OPTIONAL array. Include ONLY if you noticed meaningful patterns about the USER themselves (not the topic).",
|
|
171
|
+
"Categories: pattern (repeated behavior 2+ times), decision (explicit choice with reasoning), reaction (emotional/attitude signal), preference (tool/style/communication preference).",
|
|
172
|
+
"Omit the field entirely if nothing notable about the user.",
|
|
173
|
+
].join("\n");
|
|
174
|
+
|
|
175
|
+
function handleResult(text) {
|
|
176
|
+
var cleaned = text.trim();
|
|
177
|
+
if (cleaned.indexOf("```") === 0) {
|
|
178
|
+
cleaned = cleaned.replace(/^```[a-z]*\n?/, "").replace(/\n?```$/, "").trim();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
var digestObj = null;
|
|
182
|
+
try { digestObj = JSON.parse(cleaned); } catch (e) {
|
|
183
|
+
console.error("[digest-worker] Parse failed for " + job.mateId + ":", e.message);
|
|
184
|
+
digestObj = { date: new Date().toISOString().slice(0, 10), topic: "parse_failed", raw: text.substring(0, 500) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (digestObj && digestObj.skip) {
|
|
188
|
+
console.log("[digest-worker] Gate declined for " + job.mateId);
|
|
189
|
+
if (job.onDone) job.onDone();
|
|
190
|
+
processDigestQueue();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
fs.mkdirSync(knowledgeDir, { recursive: true });
|
|
196
|
+
var digestFile = path.join(knowledgeDir, "session-digests.jsonl");
|
|
197
|
+
fs.appendFileSync(digestFile, JSON.stringify(digestObj) + "\n");
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error("[digest-worker] Write failed for " + job.mateId + ":", e.message);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Write user observations if present
|
|
203
|
+
if (digestObj.user_observations && digestObj.user_observations.length > 0) {
|
|
204
|
+
try {
|
|
205
|
+
var obsFile = path.join(knowledgeDir, "user-observations.jsonl");
|
|
206
|
+
var obsMate = matesModule.getMate(job.mateCtx, job.mateId);
|
|
207
|
+
var obsMateName = (obsMate && obsMate.name) || job.mateId;
|
|
208
|
+
var obsLines = [];
|
|
209
|
+
for (var oi = 0; oi < digestObj.user_observations.length; oi++) {
|
|
210
|
+
var obs = digestObj.user_observations[oi];
|
|
211
|
+
obsLines.push(JSON.stringify({
|
|
212
|
+
date: digestObj.date || new Date().toISOString().slice(0, 10),
|
|
213
|
+
category: obs.category || "pattern",
|
|
214
|
+
observation: obs.observation || "",
|
|
215
|
+
evidence: obs.evidence || "",
|
|
216
|
+
confidence: digestObj.confidence || "medium",
|
|
217
|
+
mateName: obsMateName,
|
|
218
|
+
mateId: job.mateId
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
fs.appendFileSync(obsFile, obsLines.join("\n") + "\n");
|
|
222
|
+
} catch (e) {
|
|
223
|
+
console.error("[digest-worker] Observations write failed for " + job.mateId + ":", e.message);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
updateMemorySummary(job.mateCtx, job.mateId, digestObj);
|
|
228
|
+
maybeSynthesizeUserProfile(job.mateCtx, job.mateId);
|
|
229
|
+
if (job.onDone) job.onDone();
|
|
230
|
+
processDigestQueue();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Recycle worker session if it has exceeded max turns
|
|
234
|
+
if (_digestWorker && _digestWorkerTurns >= DIGEST_WORKER_MAX_TURNS) {
|
|
235
|
+
try { _digestWorker.close(); } catch (e) {}
|
|
236
|
+
_digestWorker = null;
|
|
237
|
+
_digestWorkerTurns = 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
var responseText = "";
|
|
241
|
+
if (_digestWorker && _digestWorker.isAlive()) {
|
|
242
|
+
_digestWorkerTurns++;
|
|
243
|
+
_digestWorker.pushMessage(prompt, {
|
|
244
|
+
onActivity: function () {},
|
|
245
|
+
onDelta: function (d) { responseText += d; },
|
|
246
|
+
onDone: function () { handleResult(responseText); },
|
|
247
|
+
onError: function (err) {
|
|
248
|
+
console.error("[digest-worker] Error:", err);
|
|
249
|
+
_digestWorker = null;
|
|
250
|
+
_digestWorkerTurns = 0;
|
|
251
|
+
if (job.onDone) job.onDone();
|
|
252
|
+
processDigestQueue();
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
} else {
|
|
256
|
+
sdk.createMentionSession({
|
|
257
|
+
claudeMd: "",
|
|
258
|
+
model: "haiku",
|
|
259
|
+
initialContext: "[Digest Worker] You generate memory digests. Respond with ONLY JSON.",
|
|
260
|
+
initialMessage: prompt,
|
|
261
|
+
onActivity: function () {},
|
|
262
|
+
onDelta: function (d) { responseText += d; },
|
|
263
|
+
onDone: function () { handleResult(responseText); },
|
|
264
|
+
onError: function (err) {
|
|
265
|
+
console.error("[digest-worker] Create error:", err);
|
|
266
|
+
_digestWorker = null;
|
|
267
|
+
if (job.onDone) job.onDone();
|
|
268
|
+
processDigestQueue();
|
|
269
|
+
},
|
|
270
|
+
}).then(function (ws) { _digestWorker = ws; _digestWorkerTurns = 1; }).catch(function () {
|
|
271
|
+
if (job.onDone) job.onDone();
|
|
272
|
+
processDigestQueue();
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function digestMentionSession(session, mateId, mateCtx, mateResponse, userQuestion) {
|
|
278
|
+
if (!session._mentionSessions || !session._mentionSessions[mateId]) return;
|
|
279
|
+
var mentionSession = session._mentionSessions[mateId];
|
|
280
|
+
if (!mentionSession.isAlive()) return;
|
|
281
|
+
|
|
282
|
+
mentionSession._digesting = true;
|
|
283
|
+
|
|
284
|
+
var mateDir = matesModule.getMateDir(mateCtx, mateId);
|
|
285
|
+
var knowledgeDir = path.join(mateDir, "knowledge");
|
|
286
|
+
|
|
287
|
+
// Migration: generate initial summary if missing
|
|
288
|
+
var summaryFile = path.join(knowledgeDir, "memory-summary.md");
|
|
289
|
+
var digestFile = path.join(knowledgeDir, "session-digests.jsonl");
|
|
290
|
+
if (!fs.existsSync(summaryFile) && fs.existsSync(digestFile)) {
|
|
291
|
+
initMemorySummary(mateCtx, mateId, function () {});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
var userQ = userQuestion || "(unknown)";
|
|
295
|
+
var mateR = mateResponse || "(unknown)";
|
|
296
|
+
var conversationContent = "User: " + (userQ.length > 2000 ? userQ.substring(0, 2000) + "..." : userQ) +
|
|
297
|
+
"\nMate: " + (mateR.length > 2000 ? mateR.substring(0, 2000) + "..." : mateR);
|
|
298
|
+
|
|
299
|
+
enqueueDigest({
|
|
300
|
+
mateCtx: mateCtx,
|
|
301
|
+
mateId: mateId,
|
|
302
|
+
type: "mention",
|
|
303
|
+
conversationContent: conversationContent,
|
|
304
|
+
onDone: function () { mentionSession._digesting = false; },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Digest DM turn for mate projects - uses shared digest worker
|
|
309
|
+
var _dmDigestPending = false;
|
|
310
|
+
function digestDmTurn(session, responsePreview) {
|
|
311
|
+
if (!isMate || _dmDigestPending) return;
|
|
312
|
+
var mateId = path.basename(cwd);
|
|
313
|
+
var mateCtx = matesModule.buildMateCtx(projectOwnerId);
|
|
314
|
+
if (!matesModule.isMate(mateCtx, mateId)) return;
|
|
315
|
+
|
|
316
|
+
// Collect full conversation from session history (all user + mate turns)
|
|
317
|
+
var conversationParts = [];
|
|
318
|
+
var totalLen = 0;
|
|
319
|
+
var CONV_CAP = 6000;
|
|
320
|
+
for (var hi = 0; hi < session.history.length; hi++) {
|
|
321
|
+
var entry = session.history[hi];
|
|
322
|
+
if (entry.type === "user_message" && entry.text) {
|
|
323
|
+
var uText = entry.text;
|
|
324
|
+
if (totalLen + uText.length > CONV_CAP) {
|
|
325
|
+
uText = uText.substring(0, Math.max(200, CONV_CAP - totalLen)) + "...";
|
|
326
|
+
}
|
|
327
|
+
conversationParts.push("User: " + uText);
|
|
328
|
+
totalLen += uText.length;
|
|
329
|
+
} else if (entry.type === "assistant_message" && entry.text) {
|
|
330
|
+
var aText = entry.text;
|
|
331
|
+
if (totalLen + aText.length > CONV_CAP) {
|
|
332
|
+
aText = aText.substring(0, Math.max(200, CONV_CAP - totalLen)) + "...";
|
|
333
|
+
}
|
|
334
|
+
conversationParts.push("Mate: " + aText);
|
|
335
|
+
totalLen += aText.length;
|
|
336
|
+
}
|
|
337
|
+
if (totalLen >= CONV_CAP) break;
|
|
338
|
+
}
|
|
339
|
+
var lastResponseText = responsePreview || "";
|
|
340
|
+
if (lastResponseText && conversationParts.length > 0) {
|
|
341
|
+
var lastPart = conversationParts[conversationParts.length - 1];
|
|
342
|
+
if (lastPart.indexOf("Mate:") !== 0 || lastPart.indexOf(lastResponseText.substring(0, 50)) === -1) {
|
|
343
|
+
var rText = lastResponseText;
|
|
344
|
+
if (totalLen + rText.length > CONV_CAP) {
|
|
345
|
+
rText = rText.substring(0, Math.max(200, CONV_CAP - totalLen)) + "...";
|
|
346
|
+
}
|
|
347
|
+
conversationParts.push("Mate: " + rText);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (conversationParts.length === 0) return;
|
|
351
|
+
|
|
352
|
+
var mateDir = matesModule.getMateDir(mateCtx, mateId);
|
|
353
|
+
var knowledgeDir = path.join(mateDir, "knowledge");
|
|
354
|
+
|
|
355
|
+
// Migration: if memory-summary.md missing but digests exist, generate initial summary
|
|
356
|
+
var summaryFile = path.join(knowledgeDir, "memory-summary.md");
|
|
357
|
+
var digestFile = path.join(knowledgeDir, "session-digests.jsonl");
|
|
358
|
+
if (!fs.existsSync(summaryFile) && fs.existsSync(digestFile)) {
|
|
359
|
+
initMemorySummary(mateCtx, mateId, function () {
|
|
360
|
+
console.log("[memory-migrate] Initial summary generated for mate " + mateId);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
_dmDigestPending = true;
|
|
365
|
+
|
|
366
|
+
enqueueDigest({
|
|
367
|
+
mateCtx: mateCtx,
|
|
368
|
+
mateId: mateId,
|
|
369
|
+
type: "dm",
|
|
370
|
+
conversationContent: conversationParts.join("\n"),
|
|
371
|
+
onDone: function () { _dmDigestPending = false; },
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function handleMention(ws, msg) {
|
|
376
|
+
if (!msg.mateId) return;
|
|
377
|
+
if (!msg.text && (!msg.images || msg.images.length === 0) && (!msg.pastes || msg.pastes.length === 0)) return;
|
|
378
|
+
|
|
379
|
+
var session = getSessionForWs(ws);
|
|
380
|
+
if (!session) return;
|
|
381
|
+
|
|
382
|
+
// Block mentions during an active debate
|
|
383
|
+
if (session._debate && session._debate.phase === "live") {
|
|
384
|
+
sendTo(ws, { type: "mention_error", mateId: msg.mateId, error: "Cannot use @mentions during an active debate." });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Check if a mention is already in progress for this session
|
|
389
|
+
if (session._mentionInProgress) {
|
|
390
|
+
sendTo(ws, { type: "mention_error", mateId: msg.mateId, error: "A mention is already in progress." });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
var userId = ws._clayUser ? ws._clayUser.id : null;
|
|
395
|
+
var mateCtx = matesModule.buildMateCtx(userId);
|
|
396
|
+
var mate = matesModule.getMate(mateCtx, msg.mateId);
|
|
397
|
+
if (!mate) {
|
|
398
|
+
sendTo(ws, { type: "mention_error", mateId: msg.mateId, error: "Mate not found" });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
var mateName = (mate.profile && mate.profile.displayName) || mate.name || "Mate";
|
|
403
|
+
var avatarColor = (mate.profile && mate.profile.avatarColor) || "#6c5ce7";
|
|
404
|
+
var avatarStyle = (mate.profile && mate.profile.avatarStyle) || "bottts";
|
|
405
|
+
var avatarSeed = (mate.profile && mate.profile.avatarSeed) || mate.id;
|
|
406
|
+
|
|
407
|
+
// Build full mention text (include pasted content)
|
|
408
|
+
var mentionFullInput = msg.text || "";
|
|
409
|
+
if (msg.pastes && msg.pastes.length > 0) {
|
|
410
|
+
for (var pi = 0; pi < msg.pastes.length; pi++) {
|
|
411
|
+
if (mentionFullInput) mentionFullInput += "\n\n";
|
|
412
|
+
mentionFullInput += msg.pastes[pi];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Save images to disk (same pattern as regular messages)
|
|
417
|
+
var imageRefs = [];
|
|
418
|
+
if (msg.images && msg.images.length > 0) {
|
|
419
|
+
for (var imgIdx = 0; imgIdx < msg.images.length; imgIdx++) {
|
|
420
|
+
var img = msg.images[imgIdx];
|
|
421
|
+
var savedName = saveImageFile(img.mediaType, img.data, getLinuxUserForSession(session));
|
|
422
|
+
if (savedName) {
|
|
423
|
+
imageRefs.push({ mediaType: img.mediaType, file: savedName });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Save mention user message to session history
|
|
429
|
+
var mentionUserEntry = { type: "mention_user", text: msg.text, mateId: msg.mateId, mateName: mateName };
|
|
430
|
+
if (msg.pastes && msg.pastes.length > 0) mentionUserEntry.pastes = msg.pastes;
|
|
431
|
+
if (imageRefs.length > 0) mentionUserEntry.imageRefs = imageRefs;
|
|
432
|
+
session.history.push(mentionUserEntry);
|
|
433
|
+
sm.appendToSessionFile(session, mentionUserEntry);
|
|
434
|
+
sendToSessionOthers(ws, session.localId, hydrateImageRefs(mentionUserEntry));
|
|
435
|
+
|
|
436
|
+
// Extract recent turns for continuity check
|
|
437
|
+
var recentTurns = getRecentTurns(session, MENTION_WINDOW);
|
|
438
|
+
|
|
439
|
+
// Determine user name for context
|
|
440
|
+
var userName = "User";
|
|
441
|
+
if (ws._clayUser) {
|
|
442
|
+
var p = ws._clayUser.profile || {};
|
|
443
|
+
userName = p.name || ws._clayUser.displayName || ws._clayUser.username || "User";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
session._mentionInProgress = true;
|
|
447
|
+
|
|
448
|
+
// Send mention start indicator
|
|
449
|
+
sendToSession(session.localId, {
|
|
450
|
+
type: "mention_start",
|
|
451
|
+
mateId: msg.mateId,
|
|
452
|
+
mateName: mateName,
|
|
453
|
+
avatarColor: avatarColor,
|
|
454
|
+
avatarStyle: avatarStyle,
|
|
455
|
+
avatarSeed: avatarSeed,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Shared callbacks for both new and continued sessions
|
|
459
|
+
var mentionCallbacks = {
|
|
460
|
+
onActivity: function (activity) {
|
|
461
|
+
sendToSession(session.localId, {
|
|
462
|
+
type: "mention_activity",
|
|
463
|
+
mateId: msg.mateId,
|
|
464
|
+
activity: activity,
|
|
465
|
+
});
|
|
466
|
+
},
|
|
467
|
+
onDelta: function (delta) {
|
|
468
|
+
sendToSession(session.localId, {
|
|
469
|
+
type: "mention_stream",
|
|
470
|
+
mateId: msg.mateId,
|
|
471
|
+
mateName: mateName,
|
|
472
|
+
delta: delta,
|
|
473
|
+
});
|
|
474
|
+
},
|
|
475
|
+
onDone: function (fullText) {
|
|
476
|
+
session._mentionInProgress = false;
|
|
477
|
+
|
|
478
|
+
// Save mention response to session history
|
|
479
|
+
var mentionResponseEntry = {
|
|
480
|
+
type: "mention_response",
|
|
481
|
+
mateId: msg.mateId,
|
|
482
|
+
mateName: mateName,
|
|
483
|
+
text: fullText,
|
|
484
|
+
avatarColor: avatarColor,
|
|
485
|
+
avatarStyle: avatarStyle,
|
|
486
|
+
avatarSeed: avatarSeed,
|
|
487
|
+
};
|
|
488
|
+
session.history.push(mentionResponseEntry);
|
|
489
|
+
sm.appendToSessionFile(session, mentionResponseEntry);
|
|
490
|
+
|
|
491
|
+
// Queue mention context for injection into the current agent's next turn
|
|
492
|
+
if (!session.pendingMentionContexts) session.pendingMentionContexts = [];
|
|
493
|
+
session.pendingMentionContexts.push(
|
|
494
|
+
"[Context: @" + mateName + " was mentioned and responded]\n\n" +
|
|
495
|
+
"User asked @" + mateName + ": " + msg.text + "\n" +
|
|
496
|
+
mateName + " responded: " + fullText + "\n\n" +
|
|
497
|
+
"[End of @mention context. This is for your reference only. Do not re-execute or repeat this response.]"
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
sendToSession(session.localId, { type: "mention_done", mateId: msg.mateId });
|
|
501
|
+
|
|
502
|
+
// Check if the mate wrote a debate brief during this turn
|
|
503
|
+
ctx.checkForDmDebateBrief(session, msg.mateId, mateCtx);
|
|
504
|
+
|
|
505
|
+
// Generate session digest for mate's long-term memory
|
|
506
|
+
digestMentionSession(session, msg.mateId, mateCtx, fullText, msg.text);
|
|
507
|
+
},
|
|
508
|
+
onError: function (errMsg) {
|
|
509
|
+
session._mentionInProgress = false;
|
|
510
|
+
// Clean up dead session
|
|
511
|
+
if (session._mentionSessions && session._mentionSessions[msg.mateId]) {
|
|
512
|
+
delete session._mentionSessions[msg.mateId];
|
|
513
|
+
}
|
|
514
|
+
console.error("[mention] Error for mate " + msg.mateId + ":", errMsg);
|
|
515
|
+
sendToSession(session.localId, { type: "mention_error", mateId: msg.mateId, error: errMsg });
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// Initialize mention sessions map if needed
|
|
520
|
+
if (!session._mentionSessions) session._mentionSessions = {};
|
|
521
|
+
|
|
522
|
+
// Session continuity: check if this mate has a response in the recent window
|
|
523
|
+
var existingSession = session._mentionSessions[msg.mateId];
|
|
524
|
+
// Don't reuse a session that's still generating a digest (would mix digest output into mention stream)
|
|
525
|
+
var canContinue = existingSession && existingSession.isAlive() && !existingSession._digesting && hasMateInWindow(recentTurns, msg.mateId);
|
|
526
|
+
|
|
527
|
+
if (canContinue) {
|
|
528
|
+
// Continue existing mention session with middle context
|
|
529
|
+
var middleContext = buildMiddleContext(recentTurns, msg.mateId);
|
|
530
|
+
var continuationText = middleContext ? middleContext + "\n\n" + mentionFullInput : mentionFullInput;
|
|
531
|
+
existingSession.pushMessage(continuationText, mentionCallbacks, msg.images);
|
|
532
|
+
} else {
|
|
533
|
+
// Clean up old session if it exists
|
|
534
|
+
if (existingSession) {
|
|
535
|
+
existingSession.close();
|
|
536
|
+
delete session._mentionSessions[msg.mateId];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Load Mate CLAUDE.md
|
|
540
|
+
var mateDir = matesModule.getMateDir(mateCtx, msg.mateId);
|
|
541
|
+
var claudeMd = "";
|
|
542
|
+
try {
|
|
543
|
+
claudeMd = fs.readFileSync(path.join(mateDir, "CLAUDE.md"), "utf8");
|
|
544
|
+
} catch (e) {
|
|
545
|
+
// CLAUDE.md may not exist for new mates
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Load session digests (unified: uses memory-summary.md if available)
|
|
549
|
+
// Pass user's message as query for BM25 search of relevant past sessions
|
|
550
|
+
var recentDigests = loadMateDigests(mateCtx, msg.mateId, mentionFullInput);
|
|
551
|
+
|
|
552
|
+
// Build initial mention context
|
|
553
|
+
var mentionContext = buildMentionContext(userName, recentTurns) + recentDigests;
|
|
554
|
+
|
|
555
|
+
// Create new persistent mention session
|
|
556
|
+
sdk.createMentionSession({
|
|
557
|
+
claudeMd: claudeMd,
|
|
558
|
+
initialContext: mentionContext,
|
|
559
|
+
initialMessage: mentionFullInput,
|
|
560
|
+
initialImages: msg.images || null,
|
|
561
|
+
onActivity: mentionCallbacks.onActivity,
|
|
562
|
+
onDelta: mentionCallbacks.onDelta,
|
|
563
|
+
onDone: mentionCallbacks.onDone,
|
|
564
|
+
onError: mentionCallbacks.onError,
|
|
565
|
+
canUseTool: function (toolName, input, toolOpts) {
|
|
566
|
+
var autoAllow = { Read: true, Glob: true, Grep: true, WebFetch: true, WebSearch: true };
|
|
567
|
+
if (autoAllow[toolName]) {
|
|
568
|
+
return Promise.resolve({ behavior: "allow", updatedInput: input });
|
|
569
|
+
}
|
|
570
|
+
// Route through the project session's permission system
|
|
571
|
+
return new Promise(function (resolve) {
|
|
572
|
+
var requestId = crypto.randomUUID();
|
|
573
|
+
session.pendingPermissions[requestId] = {
|
|
574
|
+
resolve: resolve,
|
|
575
|
+
requestId: requestId,
|
|
576
|
+
toolName: toolName,
|
|
577
|
+
toolInput: input,
|
|
578
|
+
toolUseId: toolOpts ? toolOpts.toolUseID : undefined,
|
|
579
|
+
decisionReason: (toolOpts && toolOpts.decisionReason) || "",
|
|
580
|
+
mateId: msg.mateId,
|
|
581
|
+
};
|
|
582
|
+
sendToSession(session.localId, {
|
|
583
|
+
type: "permission_request",
|
|
584
|
+
requestId: requestId,
|
|
585
|
+
toolName: toolName,
|
|
586
|
+
toolInput: input,
|
|
587
|
+
toolUseId: toolOpts ? toolOpts.toolUseID : undefined,
|
|
588
|
+
decisionReason: (toolOpts && toolOpts.decisionReason) || "",
|
|
589
|
+
mateId: msg.mateId,
|
|
590
|
+
});
|
|
591
|
+
onProcessingChanged();
|
|
592
|
+
if (toolOpts && toolOpts.signal) {
|
|
593
|
+
toolOpts.signal.addEventListener("abort", function () {
|
|
594
|
+
delete session.pendingPermissions[requestId];
|
|
595
|
+
sendToSession(session.localId, { type: "permission_cancel", requestId: requestId });
|
|
596
|
+
onProcessingChanged();
|
|
597
|
+
resolve({ behavior: "deny", message: "Request cancelled" });
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
},
|
|
602
|
+
}).then(function (mentionSession) {
|
|
603
|
+
if (mentionSession) {
|
|
604
|
+
session._mentionSessions[msg.mateId] = mentionSession;
|
|
605
|
+
}
|
|
606
|
+
}).catch(function (err) {
|
|
607
|
+
session._mentionInProgress = false;
|
|
608
|
+
console.error("[mention] Failed to create session for mate " + msg.mateId + ":", err.message || err);
|
|
609
|
+
sendToSession(session.localId, { type: "mention_error", mateId: msg.mateId, error: "Failed to create mention session." });
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// --- Shared mate helpers (used by debate module and other code) ---
|
|
615
|
+
|
|
616
|
+
function getMateProfile(mateCtx, mateId) {
|
|
617
|
+
var mate = matesModule.getMate(mateCtx, mateId);
|
|
618
|
+
if (!mate) return { name: "Mate", avatarColor: "#6c5ce7", avatarStyle: "bottts", avatarSeed: mateId };
|
|
619
|
+
return {
|
|
620
|
+
name: (mate.profile && mate.profile.displayName) || mate.name || "Mate",
|
|
621
|
+
avatarColor: (mate.profile && mate.profile.avatarColor) || "#6c5ce7",
|
|
622
|
+
avatarStyle: (mate.profile && mate.profile.avatarStyle) || "bottts",
|
|
623
|
+
avatarSeed: (mate.profile && mate.profile.avatarSeed) || mateId,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function loadMateClaudeMd(mateCtx, mateId) {
|
|
628
|
+
var mateDir = matesModule.getMateDir(mateCtx, mateId);
|
|
629
|
+
try {
|
|
630
|
+
return fs.readFileSync(path.join(mateDir, "CLAUDE.md"), "utf8");
|
|
631
|
+
} catch (e) {
|
|
632
|
+
return "";
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// User profile synthesis: collect observations from all mates, synthesize unified profile
|
|
637
|
+
var USER_PROFILE_SYNTHESIS_THRESHOLD = 8;
|
|
638
|
+
|
|
639
|
+
function maybeSynthesizeUserProfile(mateCtx, mateId) {
|
|
640
|
+
var mate = matesModule.getMate(mateCtx, mateId);
|
|
641
|
+
if (!mate || !mate.globalSearch) return; // Only primary/globalSearch mates synthesize
|
|
642
|
+
|
|
643
|
+
var matesRoot = matesModule.resolveMatesRoot(mateCtx);
|
|
644
|
+
var profilePath = path.join(matesRoot, "user-profile.md");
|
|
645
|
+
var obsCountPath = path.join(matesRoot, ".user-profile-obs-count");
|
|
646
|
+
|
|
647
|
+
// Check if enough new observations have accumulated
|
|
648
|
+
var lastObsCount = 0;
|
|
649
|
+
try {
|
|
650
|
+
if (fs.existsSync(obsCountPath)) {
|
|
651
|
+
lastObsCount = parseInt(fs.readFileSync(obsCountPath, "utf8").trim(), 10) || 0;
|
|
652
|
+
}
|
|
653
|
+
} catch (e) {}
|
|
654
|
+
|
|
655
|
+
// Collect all observations from all mates
|
|
656
|
+
var allObs = [];
|
|
657
|
+
try {
|
|
658
|
+
var allMates = matesModule.getAllMates(mateCtx);
|
|
659
|
+
for (var mi = 0; mi < allMates.length; mi++) {
|
|
660
|
+
var moDir = matesModule.getMateDir(mateCtx, allMates[mi].id);
|
|
661
|
+
var moFile = path.join(moDir, "knowledge", "user-observations.jsonl");
|
|
662
|
+
try {
|
|
663
|
+
if (fs.existsSync(moFile)) {
|
|
664
|
+
var lines = fs.readFileSync(moFile, "utf8").trim().split("\n").filter(function (l) { return l.trim(); });
|
|
665
|
+
for (var li = 0; li < lines.length; li++) {
|
|
666
|
+
try { allObs.push(JSON.parse(lines[li])); } catch (e) {}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
} catch (e) {}
|
|
670
|
+
}
|
|
671
|
+
} catch (e) {}
|
|
672
|
+
|
|
673
|
+
if (allObs.length - lastObsCount < USER_PROFILE_SYNTHESIS_THRESHOLD) return;
|
|
674
|
+
|
|
675
|
+
// Sort by date descending, cap at 100
|
|
676
|
+
allObs.sort(function (a, b) { return (b.date || "").localeCompare(a.date || ""); });
|
|
677
|
+
var recentObs = allObs.slice(0, 100);
|
|
678
|
+
|
|
679
|
+
var existingProfile = "";
|
|
680
|
+
try {
|
|
681
|
+
if (fs.existsSync(profilePath)) {
|
|
682
|
+
existingProfile = fs.readFileSync(profilePath, "utf8").trim();
|
|
683
|
+
}
|
|
684
|
+
} catch (e) {}
|
|
685
|
+
|
|
686
|
+
var synthesisContext = [
|
|
687
|
+
"[SYSTEM: User Profile Synthesis]",
|
|
688
|
+
"You are synthesizing a unified user profile from observations collected by multiple AI Mates.",
|
|
689
|
+
"",
|
|
690
|
+
existingProfile ? "Current profile:\n" + existingProfile : "No existing profile yet.",
|
|
691
|
+
"",
|
|
692
|
+
"Recent observations (" + recentObs.length + "):",
|
|
693
|
+
recentObs.map(function (o) {
|
|
694
|
+
return "- [" + (o.date || "?") + "] [@" + (o.mateName || "?") + "] [" + (o.category || "?") + "] " +
|
|
695
|
+
(o.observation || "") + (o.evidence ? " (evidence: " + o.evidence + ")" : "");
|
|
696
|
+
}).join("\n"),
|
|
697
|
+
].join("\n");
|
|
698
|
+
|
|
699
|
+
var synthesisPrompt = [
|
|
700
|
+
"Create or update the user profile. Structure:",
|
|
701
|
+
"",
|
|
702
|
+
"# User Profile",
|
|
703
|
+
"Last updated: YYYY-MM-DD",
|
|
704
|
+
"",
|
|
705
|
+
"## Who They Are",
|
|
706
|
+
"(role, background, what they work on)",
|
|
707
|
+
"## Communication Style",
|
|
708
|
+
"(how they communicate, language preferences)",
|
|
709
|
+
"## Work Patterns",
|
|
710
|
+
"(when they work, how they approach tasks)",
|
|
711
|
+
"## Preferences",
|
|
712
|
+
"(tools, frameworks, styles they prefer)",
|
|
713
|
+
"## Values & Priorities",
|
|
714
|
+
"(what they care about, what frustrates them)",
|
|
715
|
+
"## Key Relationships",
|
|
716
|
+
"(team members, collaborators mentioned)",
|
|
717
|
+
"",
|
|
718
|
+
"Keep it factual and evidence-based. Max 8 bullet points per section.",
|
|
719
|
+
"Merge new observations with existing profile, removing contradictions.",
|
|
720
|
+
"Output ONLY the markdown. Nothing else.",
|
|
721
|
+
].join("\n");
|
|
722
|
+
|
|
723
|
+
sdk.createMentionSession({
|
|
724
|
+
claudeMd: "",
|
|
725
|
+
model: "haiku",
|
|
726
|
+
initialContext: synthesisContext,
|
|
727
|
+
initialMessage: synthesisPrompt,
|
|
728
|
+
onActivity: function () {},
|
|
729
|
+
onDelta: function () {},
|
|
730
|
+
onDone: function (fullText) {
|
|
731
|
+
try {
|
|
732
|
+
var cleaned = fullText.trim();
|
|
733
|
+
if (cleaned.indexOf("```") === 0) {
|
|
734
|
+
cleaned = cleaned.replace(/^```[a-z]*\n?/, "").replace(/\n?```$/, "").trim();
|
|
735
|
+
}
|
|
736
|
+
fs.writeFileSync(profilePath, cleaned + "\n", "utf8");
|
|
737
|
+
fs.writeFileSync(obsCountPath, String(allObs.length), "utf8");
|
|
738
|
+
console.log("[user-profile] Synthesized user profile from " + allObs.length + " observations");
|
|
739
|
+
} catch (e) {
|
|
740
|
+
console.error("[user-profile] Failed to write user-profile.md:", e.message);
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
onError: function (err) {
|
|
744
|
+
console.error("[user-profile] Synthesis failed:", err);
|
|
745
|
+
},
|
|
746
|
+
}).catch(function (err) {
|
|
747
|
+
console.error("[user-profile] Failed to create synthesis session:", err);
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
handleMention: handleMention,
|
|
753
|
+
getMateProfile: getMateProfile,
|
|
754
|
+
loadMateClaudeMd: loadMateClaudeMd,
|
|
755
|
+
digestDmTurn: digestDmTurn,
|
|
756
|
+
enqueueDigest: enqueueDigest,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
module.exports = { attachMateInteraction };
|