@rubytech/create-realagent-code 0.1.5 → 0.1.6
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/package.json +1 -1
- package/payload/platform/services/claude-session-manager/dist/config.d.ts +0 -1
- package/payload/platform/services/claude-session-manager/dist/config.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/config.js +0 -2
- package/payload/platform/services/claude-session-manager/dist/config.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +2 -10
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/server/server.js +0 -14
- package/payload/server/chunk-5FM432JB.js +0 -4148
- package/payload/server/chunk-6S5JTXAN.js +0 -1544
- package/payload/server/chunk-6YL26HQW.js +0 -4148
- package/payload/server/chunk-RNW625CL.js +0 -759
- package/payload/server/cloudflare-task-tracker-VC7QVU5H.js +0 -22
|
@@ -1,1544 +0,0 @@
|
|
|
1
|
-
// app/lib/neo4j-store.ts
|
|
2
|
-
import neo4j from "neo4j-driver";
|
|
3
|
-
import { randomUUID } from "crypto";
|
|
4
|
-
import { spawn } from "child_process";
|
|
5
|
-
import { readFileSync, readdirSync, existsSync, openSync, readSync, closeSync, statSync, rmSync } from "fs";
|
|
6
|
-
import { resolve } from "path";
|
|
7
|
-
|
|
8
|
-
// ../lib/models/src/index.ts
|
|
9
|
-
var OPUS_MODEL = "claude-opus-4-7";
|
|
10
|
-
var SONNET_MODEL = "claude-sonnet-4-6";
|
|
11
|
-
var HAIKU_MODEL = "claude-haiku-4-5";
|
|
12
|
-
var MODEL_CONTEXT_WINDOW = {
|
|
13
|
-
[OPUS_MODEL]: 2e5,
|
|
14
|
-
[SONNET_MODEL]: 2e5,
|
|
15
|
-
[HAIKU_MODEL]: 2e5
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
// app/lib/neo4j-store.ts
|
|
19
|
-
var PLATFORM_ROOT = process.env.MAXY_PLATFORM_ROOT ?? resolve(process.cwd(), "..");
|
|
20
|
-
var driver = null;
|
|
21
|
-
function readPassword() {
|
|
22
|
-
if (process.env.NEO4J_PASSWORD) return process.env.NEO4J_PASSWORD;
|
|
23
|
-
const passwordFile = resolve(PLATFORM_ROOT, "config/.neo4j-password");
|
|
24
|
-
try {
|
|
25
|
-
return readFileSync(passwordFile, "utf-8").trim();
|
|
26
|
-
} catch {
|
|
27
|
-
throw new Error(
|
|
28
|
-
`Neo4j password not found. Expected at ${passwordFile} or in NEO4J_PASSWORD env var.`
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
function getDriver() {
|
|
33
|
-
if (!driver) {
|
|
34
|
-
const uri = process.env.NEO4J_URI;
|
|
35
|
-
if (!uri) {
|
|
36
|
-
throw new Error(
|
|
37
|
-
"[ui/neo4j-store] NEO4J_URI unset \u2014 refusing to default to bolt://localhost:7687"
|
|
38
|
-
);
|
|
39
|
-
}
|
|
40
|
-
const user = process.env.NEO4J_USER ?? "neo4j";
|
|
41
|
-
const password = readPassword();
|
|
42
|
-
console.error(`[ui/neo4j-store] resolved neo4j_uri=${uri}`);
|
|
43
|
-
driver = neo4j.driver(uri, neo4j.auth.basic(user, password), {
|
|
44
|
-
maxConnectionPoolSize: 5
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
return driver;
|
|
48
|
-
}
|
|
49
|
-
function getSession() {
|
|
50
|
-
return getDriver().session();
|
|
51
|
-
}
|
|
52
|
-
async function runAdminUserSelfHeal(args) {
|
|
53
|
-
const { selfHealAdminUser } = await import("./adminuser-self-heal-QAWOZ3JV.js");
|
|
54
|
-
return selfHealAdminUser({ driver: getDriver(), ...args });
|
|
55
|
-
}
|
|
56
|
-
process.on("SIGINT", async () => {
|
|
57
|
-
if (driver) {
|
|
58
|
-
await driver.close();
|
|
59
|
-
driver = null;
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
var OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
|
63
|
-
var EMBED_MODEL = process.env.EMBED_MODEL ?? "nomic-embed-text";
|
|
64
|
-
async function embed(text) {
|
|
65
|
-
const res = await fetch(`${OLLAMA_URL}/api/embed`, {
|
|
66
|
-
method: "POST",
|
|
67
|
-
headers: { "Content-Type": "application/json" },
|
|
68
|
-
body: JSON.stringify({ model: EMBED_MODEL, input: text }),
|
|
69
|
-
signal: AbortSignal.timeout(5e3)
|
|
70
|
-
});
|
|
71
|
-
if (!res.ok) {
|
|
72
|
-
const body = await res.text();
|
|
73
|
-
throw new Error(`Ollama embedding failed (${res.status}): ${body}`);
|
|
74
|
-
}
|
|
75
|
-
const data = await res.json();
|
|
76
|
-
return data.embeddings[0];
|
|
77
|
-
}
|
|
78
|
-
var sessionStoreRef = null;
|
|
79
|
-
function getCachedConversationId(cacheKey) {
|
|
80
|
-
return sessionStoreRef?.get(cacheKey)?.conversationId;
|
|
81
|
-
}
|
|
82
|
-
function cacheConversationId(cacheKey, conversationId) {
|
|
83
|
-
const session = sessionStoreRef?.get(cacheKey);
|
|
84
|
-
if (session) {
|
|
85
|
-
session.conversationId = conversationId;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
var GREETING_DIRECTIVE = "[New session. Greet the visitor.]";
|
|
89
|
-
var DIRECTIVE_PREFIX = "[New session.";
|
|
90
|
-
async function ensureConversation(accountId, agentType, cacheKey, visitorId, agentSlug, userId, channelOverride, channelAddressOverride) {
|
|
91
|
-
const cached = getCachedConversationId(cacheKey);
|
|
92
|
-
if (cached) return { conversationId: cached, created: false };
|
|
93
|
-
const conversationId = randomUUID();
|
|
94
|
-
const channel = channelOverride ?? (cacheKey.startsWith("whatsapp:") ? "whatsapp" : cacheKey.startsWith("telegram:") ? "telegram" : "webchat");
|
|
95
|
-
const channelAddress = channelAddressOverride ?? (cacheKey.startsWith("whatsapp:") ? cacheKey.slice("whatsapp:".length) : cacheKey.startsWith("telegram:") ? cacheKey.slice("telegram:".length) : conversationId);
|
|
96
|
-
const conversationSublabel = agentType === "admin" ? "AdminConversation" : "PublicConversation";
|
|
97
|
-
const handlerSlug = agentSlug ?? (agentType === "admin" ? "admin" : null);
|
|
98
|
-
const wantsHandledByEdge = !!handlerSlug;
|
|
99
|
-
const handledByClause = wantsHandledByEdge ? `WITH c, created
|
|
100
|
-
OPTIONAL MATCH (a:Agent {accountId: $accountId, slug: $handlerSlug})
|
|
101
|
-
FOREACH (_ IN CASE WHEN a IS NULL THEN [] ELSE [1] END | MERGE (c)-[:HANDLED_BY]->(a))
|
|
102
|
-
RETURN c.conversationId AS conversationId, created AS created, a IS NOT NULL AS handledBy` : `RETURN c.conversationId AS conversationId, created AS created, false AS handledBy`;
|
|
103
|
-
const session = getSession();
|
|
104
|
-
try {
|
|
105
|
-
const result = await session.run(
|
|
106
|
-
`OPTIONAL MATCH (prior:Conversation {accountId: $accountId, channel: $channel, channelAddress: $channelAddress})
|
|
107
|
-
WITH prior, coalesce(prior.conversationId, $conversationId) AS finalConvId, prior IS NULL AS created
|
|
108
|
-
MERGE (c:Conversation {conversationId: finalConvId})
|
|
109
|
-
ON CREATE SET
|
|
110
|
-
c:${conversationSublabel},
|
|
111
|
-
c.accountId = $accountId,
|
|
112
|
-
c.agentType = $agentType,
|
|
113
|
-
c.channel = $channel,
|
|
114
|
-
c.channelAddress = $channelAddress,
|
|
115
|
-
${visitorId ? "c.visitorId = $visitorId," : ""}
|
|
116
|
-
${agentSlug ? "c.agentSlug = $agentSlug," : ""}
|
|
117
|
-
${userId ? "c.userId = $userId," : ""}
|
|
118
|
-
c.createdAt = datetime(),
|
|
119
|
-
c.updatedAt = datetime()
|
|
120
|
-
ON MATCH SET
|
|
121
|
-
c.updatedAt = datetime()
|
|
122
|
-
${handledByClause}`,
|
|
123
|
-
{
|
|
124
|
-
conversationId,
|
|
125
|
-
accountId,
|
|
126
|
-
agentType,
|
|
127
|
-
channel,
|
|
128
|
-
channelAddress,
|
|
129
|
-
...visitorId ? { visitorId } : {},
|
|
130
|
-
...agentSlug ? { agentSlug } : {},
|
|
131
|
-
...handlerSlug ? { handlerSlug } : {},
|
|
132
|
-
...userId ? { userId } : {}
|
|
133
|
-
}
|
|
134
|
-
);
|
|
135
|
-
const id = result.records[0]?.get("conversationId");
|
|
136
|
-
const created = result.records[0]?.get("created") === true;
|
|
137
|
-
if (id) {
|
|
138
|
-
cacheConversationId(cacheKey, id);
|
|
139
|
-
console.error(`[session] ${(/* @__PURE__ */ new Date()).toISOString()} conversation attributed: conversationId=${id.slice(0, 8)}\u2026 userId=${userId ?? "none"} ${agentType}/${accountId.slice(0, 8)}\u2026 sublabel=${conversationSublabel}`);
|
|
140
|
-
if (wantsHandledByEdge) {
|
|
141
|
-
const handled = result.records[0]?.get("handledBy");
|
|
142
|
-
const id8 = id.slice(0, 8);
|
|
143
|
-
if (handled === true) {
|
|
144
|
-
console.error(`[agent-graph] handled-by-write conversationId=${id8} slug=${handlerSlug}`);
|
|
145
|
-
} else {
|
|
146
|
-
console.error(`[agent-graph] handled-by-skip reason=no-agent-node conversationId=${id8} slug=${handlerSlug}`);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return { conversationId: id ?? null, created };
|
|
151
|
-
} catch (err) {
|
|
152
|
-
console.error(`[persist] ensureConversation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
153
|
-
return { conversationId: null, created: false };
|
|
154
|
-
} finally {
|
|
155
|
-
await session.close();
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
async function findRecentConversation(visitorId, accountId, agentSlug, maxAgeHours = 24) {
|
|
159
|
-
const session = getSession();
|
|
160
|
-
try {
|
|
161
|
-
const result = await session.run(
|
|
162
|
-
`MATCH (c:Conversation {visitorId: $visitorId, accountId: $accountId, agentType: 'public'})
|
|
163
|
-
WHERE c.agentSlug = $agentSlug
|
|
164
|
-
AND c.updatedAt > datetime() - duration({hours: $maxAgeHours})
|
|
165
|
-
AND NOT c:Trashed
|
|
166
|
-
RETURN c.conversationId AS conversationId
|
|
167
|
-
ORDER BY c.updatedAt DESC
|
|
168
|
-
LIMIT 1`,
|
|
169
|
-
{ visitorId, accountId, agentSlug, maxAgeHours: neo4j.int(maxAgeHours) },
|
|
170
|
-
{ timeout: 2e3 }
|
|
171
|
-
);
|
|
172
|
-
const record = result.records[0];
|
|
173
|
-
if (!record) return null;
|
|
174
|
-
const conversationId = record.get("conversationId");
|
|
175
|
-
if (!conversationId) return null;
|
|
176
|
-
console.log(`[persist] found recent conversation ${conversationId.slice(0, 8)}\u2026 for visitor ${visitorId.slice(0, 8)}\u2026`);
|
|
177
|
-
return { conversationId };
|
|
178
|
-
} catch (err) {
|
|
179
|
-
console.error(`[persist] findRecentConversation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
180
|
-
return null;
|
|
181
|
-
} finally {
|
|
182
|
-
await session.close();
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
async function findGroupBySlug(groupSlug, accountId) {
|
|
186
|
-
const session = getSession();
|
|
187
|
-
try {
|
|
188
|
-
const result = await session.run(
|
|
189
|
-
`MATCH (c:Conversation {groupSlug: $groupSlug, accountId: $accountId, type: 'group'})
|
|
190
|
-
WHERE NOT c:Trashed
|
|
191
|
-
RETURN c.conversationId AS conversationId, c.groupName AS groupName, c.agentSlug AS agentSlug`,
|
|
192
|
-
{ groupSlug, accountId },
|
|
193
|
-
{ timeout: 2e3 }
|
|
194
|
-
);
|
|
195
|
-
const record = result.records[0];
|
|
196
|
-
if (!record) return null;
|
|
197
|
-
return {
|
|
198
|
-
conversationId: record.get("conversationId"),
|
|
199
|
-
groupName: record.get("groupName"),
|
|
200
|
-
agentSlug: record.get("agentSlug")
|
|
201
|
-
};
|
|
202
|
-
} catch (err) {
|
|
203
|
-
console.error(`[group] findGroupBySlug failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
204
|
-
return null;
|
|
205
|
-
} finally {
|
|
206
|
-
await session.close();
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
async function getGroupParticipants(conversationId) {
|
|
210
|
-
const session = getSession();
|
|
211
|
-
try {
|
|
212
|
-
const result = await session.run(
|
|
213
|
-
`MATCH (p:Person)-[r:PARTICIPATES_IN]->(c:Conversation {conversationId: $conversationId})
|
|
214
|
-
RETURN p.givenName AS givenName, p.familyName AS familyName,
|
|
215
|
-
r.displayName AS displayName, r.joinedAt AS joinedAt, r.visitorId AS visitorId`,
|
|
216
|
-
{ conversationId }
|
|
217
|
-
);
|
|
218
|
-
return result.records.map((r) => ({
|
|
219
|
-
displayName: r.get("displayName") || r.get("givenName"),
|
|
220
|
-
givenName: r.get("givenName"),
|
|
221
|
-
familyName: r.get("familyName"),
|
|
222
|
-
joinedAt: String(r.get("joinedAt")),
|
|
223
|
-
visitorId: r.get("visitorId")
|
|
224
|
-
}));
|
|
225
|
-
} catch (err) {
|
|
226
|
-
console.error(`[group] getGroupParticipants failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
227
|
-
return [];
|
|
228
|
-
} finally {
|
|
229
|
-
await session.close();
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
async function checkGroupMembership(conversationId, visitorId) {
|
|
233
|
-
const session = getSession();
|
|
234
|
-
try {
|
|
235
|
-
const result = await session.run(
|
|
236
|
-
`MATCH (p:Person)-[r:PARTICIPATES_IN]->(c:Conversation {conversationId: $conversationId})
|
|
237
|
-
WHERE r.visitorId = $visitorId
|
|
238
|
-
RETURN r.displayName AS displayName
|
|
239
|
-
LIMIT 1`,
|
|
240
|
-
{ conversationId, visitorId }
|
|
241
|
-
);
|
|
242
|
-
return result.records[0]?.get("displayName") ?? null;
|
|
243
|
-
} catch (err) {
|
|
244
|
-
console.error(`[group] checkGroupMembership failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
245
|
-
return null;
|
|
246
|
-
} finally {
|
|
247
|
-
await session.close();
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
async function bindVisitorToGroup(conversationId, visitorId, personEmail, personPhone) {
|
|
251
|
-
const session = getSession();
|
|
252
|
-
try {
|
|
253
|
-
const result = await session.run(
|
|
254
|
-
`MATCH (p:Person)-[r:PARTICIPATES_IN]->(c:Conversation {conversationId: $conversationId})
|
|
255
|
-
WHERE ($email IS NOT NULL AND p.email = $email)
|
|
256
|
-
OR ($phone IS NOT NULL AND p.telephone = $phone)
|
|
257
|
-
SET r.visitorId = $visitorId
|
|
258
|
-
RETURN r.displayName AS displayName
|
|
259
|
-
LIMIT 1`,
|
|
260
|
-
{
|
|
261
|
-
conversationId,
|
|
262
|
-
visitorId,
|
|
263
|
-
email: personEmail ?? null,
|
|
264
|
-
phone: personPhone ?? null
|
|
265
|
-
}
|
|
266
|
-
);
|
|
267
|
-
const name = result.records[0]?.get("displayName");
|
|
268
|
-
if (name) {
|
|
269
|
-
console.error(`[group] joined id=${conversationId.slice(0, 8)}\u2026 visitor=${visitorId.slice(0, 8)}\u2026`);
|
|
270
|
-
} else {
|
|
271
|
-
console.error(`[group] auth-denied id=${conversationId.slice(0, 8)}\u2026 visitor=${visitorId.slice(0, 8)}\u2026`);
|
|
272
|
-
}
|
|
273
|
-
return name ? { displayName: name } : null;
|
|
274
|
-
} catch (err) {
|
|
275
|
-
console.error(`[group] bindVisitorToGroup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
276
|
-
return null;
|
|
277
|
-
} finally {
|
|
278
|
-
await session.close();
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
async function getMessagesSince(conversationId, since, limit = 100) {
|
|
282
|
-
const session = getSession();
|
|
283
|
-
try {
|
|
284
|
-
const result = await session.run(
|
|
285
|
-
`MATCH (m:Message)-[:PART_OF]->(c:Conversation {conversationId: $conversationId})
|
|
286
|
-
WHERE m.createdAt > datetime($since)
|
|
287
|
-
RETURN m.messageId AS messageId, m.role AS role, m.content AS content,
|
|
288
|
-
m.senderName AS senderName, m.senderVisitorId AS senderVisitorId,
|
|
289
|
-
m.createdAt AS createdAt
|
|
290
|
-
ORDER BY m.createdAt ASC
|
|
291
|
-
LIMIT $limit`,
|
|
292
|
-
{ conversationId, since, limit: neo4j.int(limit) },
|
|
293
|
-
{ timeout: 3e3 }
|
|
294
|
-
);
|
|
295
|
-
return result.records.map((r) => ({
|
|
296
|
-
messageId: r.get("messageId"),
|
|
297
|
-
role: r.get("role"),
|
|
298
|
-
content: r.get("content"),
|
|
299
|
-
senderName: r.get("senderName"),
|
|
300
|
-
senderVisitorId: r.get("senderVisitorId"),
|
|
301
|
-
createdAt: String(r.get("createdAt"))
|
|
302
|
-
}));
|
|
303
|
-
} catch (err) {
|
|
304
|
-
console.error(`[group] getMessagesSince failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
305
|
-
return [];
|
|
306
|
-
} finally {
|
|
307
|
-
await session.close();
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
async function backfillConversationChannelAddress() {
|
|
311
|
-
const session = getSession();
|
|
312
|
-
try {
|
|
313
|
-
const LEGACY_PROP = "cacheKey";
|
|
314
|
-
const result = await session.run(
|
|
315
|
-
`MATCH (c:Conversation)
|
|
316
|
-
WHERE c.channelAddress IS NULL
|
|
317
|
-
SET c.channel = coalesce(c.channel, CASE
|
|
318
|
-
WHEN c.${LEGACY_PROP} STARTS WITH 'whatsapp:' THEN 'whatsapp'
|
|
319
|
-
WHEN c.${LEGACY_PROP} STARTS WITH 'telegram:' THEN 'telegram'
|
|
320
|
-
ELSE 'webchat'
|
|
321
|
-
END),
|
|
322
|
-
c.channelAddress = CASE
|
|
323
|
-
WHEN c.${LEGACY_PROP} STARTS WITH 'whatsapp:' THEN substring(c.${LEGACY_PROP}, 9)
|
|
324
|
-
WHEN c.${LEGACY_PROP} STARTS WITH 'telegram:' THEN substring(c.${LEGACY_PROP}, 9)
|
|
325
|
-
ELSE coalesce(c.conversationId, c.${LEGACY_PROP})
|
|
326
|
-
END
|
|
327
|
-
RETURN count(c) AS updated`
|
|
328
|
-
);
|
|
329
|
-
const updatedRaw = result.records[0]?.get("updated")?.toNumber?.() ?? result.records[0]?.get("updated") ?? 0;
|
|
330
|
-
const updated = typeof updatedRaw === "number" ? updatedRaw : 0;
|
|
331
|
-
if (updated > 0) {
|
|
332
|
-
console.log(`[session-985] backfill channelAddress: updated ${updated} legacy Conversation rows`);
|
|
333
|
-
} else {
|
|
334
|
-
console.log(`[session-985] backfill channelAddress: no legacy rows needed migration`);
|
|
335
|
-
}
|
|
336
|
-
const dropRes = await session.run(
|
|
337
|
-
`MATCH (c:Conversation)
|
|
338
|
-
WHERE c.${LEGACY_PROP} IS NOT NULL AND c.channelAddress IS NOT NULL
|
|
339
|
-
REMOVE c.${LEGACY_PROP}
|
|
340
|
-
RETURN count(c) AS dropped`
|
|
341
|
-
);
|
|
342
|
-
const droppedRaw = dropRes.records[0]?.get("dropped")?.toNumber?.() ?? dropRes.records[0]?.get("dropped") ?? 0;
|
|
343
|
-
const legacyPropDropped = typeof droppedRaw === "number" ? droppedRaw : 0;
|
|
344
|
-
console.log(`[session-985] cacheKey-property-drop count=${legacyPropDropped}`);
|
|
345
|
-
return { updated, legacyPropDropped };
|
|
346
|
-
} catch (err) {
|
|
347
|
-
console.error(`[session-985] backfillConversationChannelAddress failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
348
|
-
return { updated: 0, legacyPropDropped: 0 };
|
|
349
|
-
} finally {
|
|
350
|
-
await session.close();
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
async function backfillNullUserIdConversations(userId) {
|
|
354
|
-
if (!userId) {
|
|
355
|
-
console.warn(`[session] ${(/* @__PURE__ */ new Date()).toISOString()} backfill: skipped \u2014 no userId provided (no Owner in users.json?)`);
|
|
356
|
-
return 0;
|
|
357
|
-
}
|
|
358
|
-
const session = getSession();
|
|
359
|
-
try {
|
|
360
|
-
const result = await session.run(
|
|
361
|
-
`MATCH (c:Conversation {agentType: 'admin'})
|
|
362
|
-
WHERE c.userId IS NULL
|
|
363
|
-
SET c.userId = $userId
|
|
364
|
-
RETURN count(c) AS updated`,
|
|
365
|
-
{ userId }
|
|
366
|
-
);
|
|
367
|
-
const updated = result.records[0]?.get("updated")?.toNumber?.() ?? result.records[0]?.get("updated") ?? 0;
|
|
368
|
-
if (updated > 0) {
|
|
369
|
-
console.log(`[session] ${(/* @__PURE__ */ new Date()).toISOString()} backfill: set userId on ${updated} admin conversations`);
|
|
370
|
-
} else {
|
|
371
|
-
console.log(`[session] ${(/* @__PURE__ */ new Date()).toISOString()} backfill: no orphaned admin conversations found`);
|
|
372
|
-
}
|
|
373
|
-
return typeof updated === "number" ? updated : 0;
|
|
374
|
-
} catch (err) {
|
|
375
|
-
console.error(`[session] backfillNullUserIdConversations failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
376
|
-
return 0;
|
|
377
|
-
} finally {
|
|
378
|
-
await session.close();
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
var HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
|
|
382
|
-
async function fetchBranding(accountId) {
|
|
383
|
-
const session = getSession();
|
|
384
|
-
try {
|
|
385
|
-
const result = await session.run(
|
|
386
|
-
`MATCH (b:LocalBusiness {accountId: $accountId})
|
|
387
|
-
OPTIONAL MATCH (b)-[:HAS_BRAND_ASSET]->(logo:ImageObject {purpose: "logo"})
|
|
388
|
-
OPTIONAL MATCH (b)-[:HAS_BRAND_ASSET]->(icon:ImageObject {purpose: "icon"})
|
|
389
|
-
RETURN b.name AS name,
|
|
390
|
-
b.primaryColor AS primaryColor,
|
|
391
|
-
b.accentColor AS accentColor,
|
|
392
|
-
b.backgroundColor AS backgroundColor,
|
|
393
|
-
b.tagline AS tagline,
|
|
394
|
-
logo.contentUrl AS logoUrl,
|
|
395
|
-
icon.contentUrl AS faviconUrl`,
|
|
396
|
-
{ accountId },
|
|
397
|
-
{ timeout: 2e3 }
|
|
398
|
-
);
|
|
399
|
-
const record = result.records[0];
|
|
400
|
-
if (!record) return null;
|
|
401
|
-
const name = record.get("name");
|
|
402
|
-
if (!name) return null;
|
|
403
|
-
const primaryColor = record.get("primaryColor");
|
|
404
|
-
const accentColor = record.get("accentColor");
|
|
405
|
-
const backgroundColor = record.get("backgroundColor");
|
|
406
|
-
const tagline = record.get("tagline");
|
|
407
|
-
const logoUrl = record.get("logoUrl");
|
|
408
|
-
const faviconUrl = record.get("faviconUrl");
|
|
409
|
-
const hasBranding = primaryColor || accentColor || backgroundColor || tagline || logoUrl || faviconUrl;
|
|
410
|
-
if (!hasBranding) return null;
|
|
411
|
-
const branding = { name };
|
|
412
|
-
if (primaryColor && HEX_COLOR_RE.test(primaryColor)) branding.primaryColor = primaryColor;
|
|
413
|
-
if (accentColor && HEX_COLOR_RE.test(accentColor)) branding.accentColor = accentColor;
|
|
414
|
-
if (backgroundColor && HEX_COLOR_RE.test(backgroundColor)) branding.backgroundColor = backgroundColor;
|
|
415
|
-
if (tagline) branding.tagline = tagline;
|
|
416
|
-
if (logoUrl) branding.logoUrl = logoUrl;
|
|
417
|
-
if (faviconUrl) branding.faviconUrl = faviconUrl;
|
|
418
|
-
console.error(`[branding] resolved for accountId=${accountId.slice(0, 8)}\u2026: primary=${branding.primaryColor ?? "\u2013"} logo=${branding.logoUrl ? "yes" : "no"}`);
|
|
419
|
-
return branding;
|
|
420
|
-
} catch (err) {
|
|
421
|
-
console.error(`[branding] fetchBranding failed for accountId=${accountId.slice(0, 8)}\u2026: ${err instanceof Error ? err.message : String(err)}`);
|
|
422
|
-
return null;
|
|
423
|
-
} finally {
|
|
424
|
-
await session.close();
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
var SUMMARY_MAX_LEN = 200;
|
|
428
|
-
var persistMessageLocks = /* @__PURE__ */ new Map();
|
|
429
|
-
async function persistMessage(conversationId, role, content, accountId, tokens, createdAt, sender, components, attachments) {
|
|
430
|
-
if (!content) return null;
|
|
431
|
-
const messageId = randomUUID();
|
|
432
|
-
const summary = role === "user" ? content.slice(0, SUMMARY_MAX_LEN).trim() : "";
|
|
433
|
-
let embedding = null;
|
|
434
|
-
try {
|
|
435
|
-
embedding = await embed(content);
|
|
436
|
-
} catch (err) {
|
|
437
|
-
console.error(`[persist] Embedding failed, storing without: ${err instanceof Error ? err.message : String(err)}`);
|
|
438
|
-
}
|
|
439
|
-
const prev = persistMessageLocks.get(conversationId);
|
|
440
|
-
const waited = prev !== void 0;
|
|
441
|
-
let release;
|
|
442
|
-
const mine = new Promise((resolve2) => {
|
|
443
|
-
release = resolve2;
|
|
444
|
-
});
|
|
445
|
-
const chained = (prev ?? Promise.resolve()).then(() => mine);
|
|
446
|
-
persistMessageLocks.set(conversationId, chained);
|
|
447
|
-
await prev;
|
|
448
|
-
const session = getSession();
|
|
449
|
-
try {
|
|
450
|
-
const result = await session.run(
|
|
451
|
-
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
452
|
-
OPTIONAL MATCH (tail:Message)-[:PART_OF]->(c)
|
|
453
|
-
WHERE NOT (tail)-[:NEXT]->(:Message)
|
|
454
|
-
AND (tail:UserMessage OR tail:AssistantMessage)
|
|
455
|
-
// Task 857 \u2014 sublabel-scope the tail to the agent-path's own sublabels.
|
|
456
|
-
// Without this, a Task-857 :WhatsAppMessage row (no outgoing :NEXT)
|
|
457
|
-
// would be picked as tail and the agent-path would chain
|
|
458
|
-
// :WhatsAppMessage\u2192:UserMessage, crossing the live-channel chain.
|
|
459
|
-
// The live writer's tail predicate is sublabel-scoped to
|
|
460
|
-
// :WhatsAppMessage; this clause is the matching defence on the
|
|
461
|
-
// agent side. Backward-compatible \u2014 pre-Task-857 graphs only carry
|
|
462
|
-
// UserMessage/AssistantMessage sublabels under :Message.
|
|
463
|
-
// Capture whether THIS write's guard will fire. Read c.summary
|
|
464
|
-
// before the SET so the boolean reflects the pre-state, not the
|
|
465
|
-
// post-state \u2014 a false positive otherwise when a new user message
|
|
466
|
-
// happens to equal an already-set summary (verbatim retry, short
|
|
467
|
-
// catchphrase) and fooled a post-state equality check.
|
|
468
|
-
WITH c, tail, (c.summary IS NULL AND $role = 'user' AND $summary <> '') AS summarySetThisWrite
|
|
469
|
-
CREATE (m:Message:${role === "user" ? "UserMessage" : "AssistantMessage"} {
|
|
470
|
-
messageId: $messageId,
|
|
471
|
-
conversationId: $conversationId,
|
|
472
|
-
accountId: $accountId,
|
|
473
|
-
role: $role,
|
|
474
|
-
content: $content,
|
|
475
|
-
createdAt: ${createdAt ? "datetime($createdAt)" : "datetime()"}
|
|
476
|
-
${embedding ? ", embedding: $embedding" : ""}
|
|
477
|
-
${sender ? ", senderVisitorId: $senderVisitorId, senderName: $senderName" : ""}
|
|
478
|
-
${tokens?.inputTokens != null ? ", inputTokens: $inputTokens" : ""}
|
|
479
|
-
${tokens?.outputTokens != null ? ", outputTokens: $outputTokens" : ""}
|
|
480
|
-
${tokens?.cacheReadTokens != null ? ", cacheReadTokens: $cacheReadTokens" : ""}
|
|
481
|
-
${tokens?.cacheCreationTokens != null ? ", cacheCreationTokens: $cacheCreationTokens" : ""}
|
|
482
|
-
})
|
|
483
|
-
SET c.updatedAt = datetime()
|
|
484
|
-
CREATE (m)-[:PART_OF]->(c)
|
|
485
|
-
FOREACH (prev IN CASE WHEN tail IS NULL THEN [] ELSE [tail] END |
|
|
486
|
-
CREATE (prev)-[:NEXT]->(m)
|
|
487
|
-
)
|
|
488
|
-
FOREACH (_ IN CASE WHEN summarySetThisWrite THEN [1] ELSE [] END |
|
|
489
|
-
SET c.summary = $summary
|
|
490
|
-
)
|
|
491
|
-
FOREACH (comp IN $components |
|
|
492
|
-
CREATE (m)-[:HAS_COMPONENT]->(:Component {
|
|
493
|
-
componentId: comp.componentId,
|
|
494
|
-
conversationId: $conversationId,
|
|
495
|
-
accountId: $accountId,
|
|
496
|
-
messageId: $messageId,
|
|
497
|
-
name: comp.name,
|
|
498
|
-
data: comp.data,
|
|
499
|
-
ordinal: comp.ordinal,
|
|
500
|
-
textOffset: comp.textOffset,
|
|
501
|
-
submitted: false,
|
|
502
|
-
createdAt: datetime(),
|
|
503
|
-
// Task 942 \u2014 store the artefact attachmentId on the :Component
|
|
504
|
-
// itself when this is a PERSISTENT_COMPONENTS write whose disk
|
|
505
|
-
// write succeeded. Field is null for non-persistent / mime-skip
|
|
506
|
-
// / disk-fail components. Lets the audit + backfill scripts
|
|
507
|
-
// read the attachmentId directly off the :Component row instead
|
|
508
|
-
// of re-deriving (the live + heal paths use different sources \u2014
|
|
509
|
-
// sha256(block.id) live, componentId-fallback backfill \u2014 and
|
|
510
|
-
// the audit must match either).
|
|
511
|
-
attachmentId: comp.artefactAttachmentId
|
|
512
|
-
})
|
|
513
|
-
)
|
|
514
|
-
// Task 942 \u2014 for PERSISTENT_COMPONENTS whose disk write succeeded
|
|
515
|
-
// (artefactAttachmentId is non-null), MERGE the sibling
|
|
516
|
-
// :KnowledgeDocument projection AND a :HAS_KNOWLEDGE_DOCUMENT edge
|
|
517
|
-
// from the parent :Message in the same Cypher tx. ON CREATE stamps
|
|
518
|
-
// name+encodingFormat from the live render; ON MATCH only bumps
|
|
519
|
-
// updatedAt so a heal-on-resume re-run cannot clobber an operator's
|
|
520
|
-
// subsequent rename via the knowledge-documents API (eng-review \xA72).
|
|
521
|
-
// The edge makes the projection graph-discoverable from the
|
|
522
|
-
// conversation timeline so file-delete-cascade and conversation
|
|
523
|
-
// cleanup can reach it. FOREACH on the filtered list collapses to a
|
|
524
|
-
// no-op when no component has an attachmentId, preserving the
|
|
525
|
-
// text-only-turn fast path.
|
|
526
|
-
FOREACH (kd IN $knowledgeDocs |
|
|
527
|
-
MERGE (k:KnowledgeDocument {accountId: $accountId, attachmentId: kd.attachmentId})
|
|
528
|
-
ON CREATE SET k.name = kd.title,
|
|
529
|
-
k.encodingFormat = kd.mimeType,
|
|
530
|
-
k.createdAt = datetime(),
|
|
531
|
-
k.updatedAt = datetime()
|
|
532
|
-
ON MATCH SET k.updatedAt = datetime()
|
|
533
|
-
MERGE (m)-[:HAS_KNOWLEDGE_DOCUMENT]->(k)
|
|
534
|
-
)
|
|
535
|
-
FOREACH (att IN $attachments |
|
|
536
|
-
CREATE (m)-[:HAS_ATTACHMENT]->(:Attachment {
|
|
537
|
-
attachmentId: att.attachmentId,
|
|
538
|
-
conversationId: $conversationId,
|
|
539
|
-
accountId: $accountId,
|
|
540
|
-
messageId: $messageId,
|
|
541
|
-
filename: att.filename,
|
|
542
|
-
mimeType: att.mimeType,
|
|
543
|
-
sizeBytes: att.sizeBytes,
|
|
544
|
-
storagePath: att.storagePath,
|
|
545
|
-
ordinal: att.ordinal,
|
|
546
|
-
createdAt: datetime()
|
|
547
|
-
})
|
|
548
|
-
)
|
|
549
|
-
RETURN tail.messageId AS prevMessageId,
|
|
550
|
-
summarySetThisWrite,
|
|
551
|
-
size([(m2:Message)-[:PART_OF]->(c) | m2]) AS chainLen`,
|
|
552
|
-
{
|
|
553
|
-
messageId,
|
|
554
|
-
conversationId,
|
|
555
|
-
accountId,
|
|
556
|
-
role,
|
|
557
|
-
content,
|
|
558
|
-
summary,
|
|
559
|
-
...createdAt ? { createdAt } : {},
|
|
560
|
-
...embedding ? { embedding } : {},
|
|
561
|
-
...sender ? { senderVisitorId: sender.visitorId, senderName: sender.displayName } : {},
|
|
562
|
-
...tokens?.inputTokens != null ? { inputTokens: neo4j.int(tokens.inputTokens) } : {},
|
|
563
|
-
...tokens?.outputTokens != null ? { outputTokens: neo4j.int(tokens.outputTokens) } : {},
|
|
564
|
-
...tokens?.cacheReadTokens != null ? { cacheReadTokens: neo4j.int(tokens.cacheReadTokens) } : {},
|
|
565
|
-
...tokens?.cacheCreationTokens != null ? { cacheCreationTokens: neo4j.int(tokens.cacheCreationTokens) } : {},
|
|
566
|
-
components: (components ?? []).map((comp) => ({
|
|
567
|
-
componentId: comp.componentId,
|
|
568
|
-
name: comp.name,
|
|
569
|
-
data: comp.data,
|
|
570
|
-
ordinal: neo4j.int(comp.ordinal),
|
|
571
|
-
textOffset: neo4j.int(comp.textOffset),
|
|
572
|
-
// Task 942 — null for non-persistent / mime-skip / disk-fail
|
|
573
|
-
// components; the FOREACH stamps this directly onto the
|
|
574
|
-
// :Component row so audit + backfill read attachmentId without
|
|
575
|
-
// re-deriving from componentId.
|
|
576
|
-
artefactAttachmentId: comp.artefactAttachmentId ?? null
|
|
577
|
-
})),
|
|
578
|
-
// Task 942 — :KnowledgeDocument MERGE list. One row per
|
|
579
|
-
// PERSISTENT_COMPONENTS component whose disk write succeeded;
|
|
580
|
-
// text-only / mime-skip / disk-fail components are absent so
|
|
581
|
-
// the FOREACH no-ops on them. attachmentId here matches the
|
|
582
|
-
// value the stream-parser stamped after writeAttachment.
|
|
583
|
-
knowledgeDocs: (components ?? []).filter((comp) => typeof comp.artefactAttachmentId === "string").map((comp) => ({
|
|
584
|
-
attachmentId: comp.artefactAttachmentId,
|
|
585
|
-
title: comp.artefactTitle ?? "",
|
|
586
|
-
mimeType: comp.artefactMimeType ?? ""
|
|
587
|
-
})),
|
|
588
|
-
attachments: (attachments ?? []).map((att) => ({
|
|
589
|
-
attachmentId: att.attachmentId,
|
|
590
|
-
filename: att.filename,
|
|
591
|
-
mimeType: att.mimeType,
|
|
592
|
-
sizeBytes: neo4j.int(att.sizeBytes),
|
|
593
|
-
storagePath: att.storagePath,
|
|
594
|
-
ordinal: neo4j.int(att.ordinal)
|
|
595
|
-
}))
|
|
596
|
-
}
|
|
597
|
-
);
|
|
598
|
-
if (result.records.length === 0) {
|
|
599
|
-
console.error(`[persist] Neo4j write skipped \u2014 conversation not found: ${conversationId.slice(0, 8)}\u2026`);
|
|
600
|
-
return null;
|
|
601
|
-
}
|
|
602
|
-
const record = result.records[0];
|
|
603
|
-
const prevMessageId = record.get("prevMessageId") ?? null;
|
|
604
|
-
const summarySetThisWrite = record.get("summarySetThisWrite") === true;
|
|
605
|
-
const chainLenRaw = record.get("chainLen");
|
|
606
|
-
const chainLen = typeof chainLenRaw === "bigint" ? Number(chainLenRaw) : typeof chainLenRaw?.toNumber === "function" ? chainLenRaw.toNumber() : Number(chainLenRaw ?? 0);
|
|
607
|
-
const messageSublabel = role === "user" ? "UserMessage" : "AssistantMessage";
|
|
608
|
-
console.error(`[neo4j-store] append-message conversationId=${conversationId.slice(0, 8)}\u2026 messageId=${messageId.slice(0, 8)}\u2026 prev=${prevMessageId ? prevMessageId.slice(0, 8) + "\u2026" : "null"} chainLen=${chainLen} waited=${waited} sublabel=${messageSublabel}`);
|
|
609
|
-
if (summarySetThisWrite) {
|
|
610
|
-
console.error(`[neo4j-store] conversation-summary-set conversationId=${conversationId.slice(0, 8)}\u2026 len=${summary.length}`);
|
|
611
|
-
}
|
|
612
|
-
const componentList = components ?? [];
|
|
613
|
-
if (componentList.length > 0) {
|
|
614
|
-
const relsCreated = result.summary.counters.updates().relationshipsCreated;
|
|
615
|
-
const expectedComponentEdges = componentList.length;
|
|
616
|
-
const baseEdges = relsCreated - expectedComponentEdges;
|
|
617
|
-
if (baseEdges < 1) {
|
|
618
|
-
console.error(`[neo4j-store] persist-component WARN conversationId=${conversationId.slice(0, 8)}\u2026 messageId=${messageId.slice(0, 8)}\u2026 relsCreated=${relsCreated} expected\u2265${1 + expectedComponentEdges} \u2014 component edges may not have been created`);
|
|
619
|
-
}
|
|
620
|
-
for (const comp of componentList) {
|
|
621
|
-
console.error(`[neo4j-store] persist-component conversationId=${conversationId.slice(0, 8)}\u2026 messageId=${messageId.slice(0, 8)}\u2026 componentId=${comp.componentId.slice(0, 8)}\u2026 name=${comp.name} dataLen=${comp.data.length} ordinal=${comp.ordinal} textOffset=${comp.textOffset}`);
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
const attachmentList = attachments ?? [];
|
|
625
|
-
if (attachmentList.length > 0) {
|
|
626
|
-
const relsCreated = result.summary.counters.updates().relationshipsCreated;
|
|
627
|
-
const expectedAttachmentEdges = attachmentList.length;
|
|
628
|
-
const expectedComponentEdges = (components ?? []).length;
|
|
629
|
-
const baseEdges = relsCreated - expectedComponentEdges - expectedAttachmentEdges;
|
|
630
|
-
if (baseEdges < 1) {
|
|
631
|
-
console.error(`[neo4j-store] persist-attachment WARN conversationId=${conversationId.slice(0, 8)}\u2026 messageId=${messageId.slice(0, 8)}\u2026 relsCreated=${relsCreated} expected\u2265${1 + expectedComponentEdges + expectedAttachmentEdges} \u2014 attachment edges may not have been created`);
|
|
632
|
-
}
|
|
633
|
-
for (const att of attachmentList) {
|
|
634
|
-
console.error(`[neo4j-store] persist-attachment conversationId=${conversationId.slice(0, 8)}\u2026 messageId=${messageId.slice(0, 8)}\u2026 attachmentId=${att.attachmentId.slice(0, 8)}\u2026 filename=${att.filename} mimeType=${att.mimeType} sizeBytes=${att.sizeBytes} ordinal=${att.ordinal}`);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
console.error(`[persist] ${(/* @__PURE__ */ new Date()).toISOString()} conversationId=${conversationId.slice(0, 8)}\u2026 role=${role} len=${content.length}${sender ? ` sender=${sender.displayName}` : ""}`);
|
|
638
|
-
return messageId;
|
|
639
|
-
} catch (err) {
|
|
640
|
-
console.error(`[persist] Neo4j write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
641
|
-
return null;
|
|
642
|
-
} finally {
|
|
643
|
-
release();
|
|
644
|
-
if (persistMessageLocks.get(conversationId) === chained) {
|
|
645
|
-
persistMessageLocks.delete(conversationId);
|
|
646
|
-
}
|
|
647
|
-
await session.close();
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
async function writeTurnFailure(args) {
|
|
651
|
-
const session = getSession();
|
|
652
|
-
try {
|
|
653
|
-
const result = await session.run(
|
|
654
|
-
`MATCH (c:Conversation {conversationId: $conversationId, accountId: $accountId})
|
|
655
|
-
MERGE (c)-[:HAS_FAILURE]->(f:TurnFailure {conversationId: $conversationId, at: datetime($at)})
|
|
656
|
-
ON CREATE SET
|
|
657
|
-
f.mode = $mode,
|
|
658
|
-
f.cacheKey = $cacheKey,
|
|
659
|
-
f.prior_event_count = $priorEventCount,
|
|
660
|
-
f.accountId = $accountId,
|
|
661
|
-
f.receivedAt = datetime()
|
|
662
|
-
RETURN toString(f.at) AS at`,
|
|
663
|
-
{
|
|
664
|
-
conversationId: args.conversationId,
|
|
665
|
-
accountId: args.accountId,
|
|
666
|
-
mode: args.mode,
|
|
667
|
-
at: args.at,
|
|
668
|
-
cacheKey: args.cacheKey,
|
|
669
|
-
priorEventCount: args.priorEventCount
|
|
670
|
-
}
|
|
671
|
-
);
|
|
672
|
-
if (result.records.length === 0) {
|
|
673
|
-
return { ok: false, reason: "cid-not-found" };
|
|
674
|
-
}
|
|
675
|
-
return { ok: true, at: result.records[0].get("at") };
|
|
676
|
-
} catch (err) {
|
|
677
|
-
console.error(`[persist] turn-failure convId=${args.conversationId.slice(0, 8)}\u2026 error=${err instanceof Error ? err.message : String(err)}`);
|
|
678
|
-
return { ok: false, reason: "neo4j" };
|
|
679
|
-
} finally {
|
|
680
|
-
await session.close();
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
async function getAgentSessionIdForConversation(conversationId) {
|
|
684
|
-
const session = getSession();
|
|
685
|
-
try {
|
|
686
|
-
const result = await session.run(
|
|
687
|
-
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
688
|
-
RETURN c.agentSessionId AS agentSessionId`,
|
|
689
|
-
{ conversationId }
|
|
690
|
-
);
|
|
691
|
-
const value = result.records[0]?.get("agentSessionId");
|
|
692
|
-
return typeof value === "string" && value.length > 0 ? value : null;
|
|
693
|
-
} catch (err) {
|
|
694
|
-
console.error(`[persist] agent-session-id read failed convId=${conversationId.slice(0, 8)}\u2026 error=${err instanceof Error ? err.message : String(err)}`);
|
|
695
|
-
return null;
|
|
696
|
-
} finally {
|
|
697
|
-
await session.close();
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
async function getRecentMessages(conversationId, limit = 50) {
|
|
701
|
-
const session = getSession();
|
|
702
|
-
try {
|
|
703
|
-
const result = await session.run(
|
|
704
|
-
`MATCH (tail:Message {conversationId: $conversationId})
|
|
705
|
-
WHERE NOT (tail)-[:NEXT]->(:Message)
|
|
706
|
-
WITH tail ORDER BY tail.createdAt DESC LIMIT 1
|
|
707
|
-
MATCH path = (m:Message)-[:NEXT*0..]->(tail)
|
|
708
|
-
WHERE m.conversationId = $conversationId
|
|
709
|
-
AND length(path) < $limit
|
|
710
|
-
WITH m, length(path) AS depthFromTail
|
|
711
|
-
OPTIONAL MATCH (m)-[:HAS_COMPONENT]->(c:Component)
|
|
712
|
-
WITH m, depthFromTail, c ORDER BY c.ordinal ASC
|
|
713
|
-
WITH m, depthFromTail,
|
|
714
|
-
[comp IN collect(c) WHERE comp IS NOT NULL | comp {.*}] AS components
|
|
715
|
-
OPTIONAL MATCH (m)-[:HAS_ATTACHMENT]->(a:Attachment)
|
|
716
|
-
WITH m, depthFromTail, components, a ORDER BY a.ordinal ASC
|
|
717
|
-
WITH m, depthFromTail, components,
|
|
718
|
-
[att IN collect(a) WHERE att IS NOT NULL | att {.*}] AS attachments
|
|
719
|
-
RETURN m.messageId AS messageId, m.role AS role, m.content AS content,
|
|
720
|
-
m.createdAt AS createdAt, components, attachments
|
|
721
|
-
ORDER BY depthFromTail DESC`,
|
|
722
|
-
{ conversationId, limit: neo4j.int(limit) }
|
|
723
|
-
);
|
|
724
|
-
return result.records.map((r) => {
|
|
725
|
-
const rawComponents = r.get("components") ?? [];
|
|
726
|
-
const components = rawComponents.map((c) => {
|
|
727
|
-
const rawAttachmentId = c.attachmentId;
|
|
728
|
-
const attachmentId = typeof rawAttachmentId === "string" && rawAttachmentId.length > 0 ? rawAttachmentId : void 0;
|
|
729
|
-
return {
|
|
730
|
-
componentId: String(c.componentId ?? ""),
|
|
731
|
-
name: String(c.name ?? ""),
|
|
732
|
-
data: String(c.data ?? ""),
|
|
733
|
-
ordinal: typeof c.ordinal?.toNumber === "function" ? c.ordinal.toNumber() : Number(c.ordinal ?? 0),
|
|
734
|
-
textOffset: typeof c.textOffset?.toNumber === "function" ? c.textOffset.toNumber() : Number(c.textOffset ?? 0),
|
|
735
|
-
submitted: c.submitted === true,
|
|
736
|
-
...attachmentId ? { attachmentId } : {}
|
|
737
|
-
};
|
|
738
|
-
});
|
|
739
|
-
const rawAttachments = r.get("attachments") ?? [];
|
|
740
|
-
const attachments = rawAttachments.map((a) => ({
|
|
741
|
-
attachmentId: String(a.attachmentId ?? ""),
|
|
742
|
-
filename: String(a.filename ?? ""),
|
|
743
|
-
mimeType: String(a.mimeType ?? ""),
|
|
744
|
-
sizeBytes: typeof a.sizeBytes?.toNumber === "function" ? a.sizeBytes.toNumber() : Number(a.sizeBytes ?? 0),
|
|
745
|
-
storagePath: String(a.storagePath ?? ""),
|
|
746
|
-
ordinal: typeof a.ordinal?.toNumber === "function" ? a.ordinal.toNumber() : Number(a.ordinal ?? 0),
|
|
747
|
-
accountId: String(a.accountId ?? "")
|
|
748
|
-
}));
|
|
749
|
-
return {
|
|
750
|
-
messageId: r.get("messageId"),
|
|
751
|
-
role: r.get("role"),
|
|
752
|
-
content: r.get("content"),
|
|
753
|
-
createdAt: String(r.get("createdAt")),
|
|
754
|
-
components,
|
|
755
|
-
attachments
|
|
756
|
-
};
|
|
757
|
-
});
|
|
758
|
-
} catch (err) {
|
|
759
|
-
console.error(`[persist] getRecentMessages failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
760
|
-
return [];
|
|
761
|
-
} finally {
|
|
762
|
-
await session.close();
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
async function verifyConversationOwnership(conversationId, accountId) {
|
|
766
|
-
const session = getSession();
|
|
767
|
-
try {
|
|
768
|
-
const result = await session.run(
|
|
769
|
-
`MATCH (c:Conversation {conversationId: $conversationId, accountId: $accountId})
|
|
770
|
-
RETURN c.conversationId AS id LIMIT 1`,
|
|
771
|
-
{ conversationId, accountId }
|
|
772
|
-
);
|
|
773
|
-
return result.records.length > 0;
|
|
774
|
-
} catch (err) {
|
|
775
|
-
console.error(`[persist] verifyConversationOwnership failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
776
|
-
return false;
|
|
777
|
-
} finally {
|
|
778
|
-
await session.close();
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
async function getConversationOwner(conversationId) {
|
|
782
|
-
const session = getSession();
|
|
783
|
-
try {
|
|
784
|
-
const result = await session.run(
|
|
785
|
-
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
786
|
-
RETURN c.accountId AS accountId, c.userId AS userId LIMIT 1`,
|
|
787
|
-
{ conversationId }
|
|
788
|
-
);
|
|
789
|
-
const record = result.records[0];
|
|
790
|
-
if (!record) return null;
|
|
791
|
-
const accountId = record.get("accountId");
|
|
792
|
-
const userId = record.get("userId");
|
|
793
|
-
if (!accountId) return null;
|
|
794
|
-
return { accountId, userId: userId ?? null };
|
|
795
|
-
} catch (err) {
|
|
796
|
-
console.error(`[persist] getConversationOwner failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
797
|
-
return null;
|
|
798
|
-
} finally {
|
|
799
|
-
await session.close();
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
async function verifyAndGetConversationUpdatedAt(conversationId, accountId) {
|
|
803
|
-
const session = getSession();
|
|
804
|
-
try {
|
|
805
|
-
const result = await session.run(
|
|
806
|
-
`MATCH (c:Conversation {conversationId: $conversationId, accountId: $accountId})
|
|
807
|
-
RETURN toString(c.updatedAt) AS updatedAt LIMIT 1`,
|
|
808
|
-
{ conversationId, accountId }
|
|
809
|
-
);
|
|
810
|
-
const record = result.records[0];
|
|
811
|
-
return record ? record.get("updatedAt") : null;
|
|
812
|
-
} catch (err) {
|
|
813
|
-
console.error(`[persist] verifyAndGetConversationUpdatedAt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
814
|
-
return null;
|
|
815
|
-
} finally {
|
|
816
|
-
await session.close();
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
var LIST_BACKFILL_CAP = 3;
|
|
820
|
-
async function listAdminSessions(accountId, userId, limit = 20) {
|
|
821
|
-
const session = getSession();
|
|
822
|
-
try {
|
|
823
|
-
const result = await session.run(
|
|
824
|
-
`MATCH (c:Conversation {accountId: $accountId, agentType: 'admin', userId: $userId})
|
|
825
|
-
WHERE NOT c:Trashed
|
|
826
|
-
WITH c
|
|
827
|
-
ORDER BY c.updatedAt DESC
|
|
828
|
-
LIMIT $limit
|
|
829
|
-
CALL {
|
|
830
|
-
WITH c
|
|
831
|
-
OPTIONAL MATCH (m:Message)-[:PART_OF]->(c)
|
|
832
|
-
WHERE m.role = 'user'
|
|
833
|
-
AND c.name IS NULL
|
|
834
|
-
AND NOT (m.content STARTS WITH '[New session.')
|
|
835
|
-
AND NOT (m.content STARTS WITH '{"')
|
|
836
|
-
AND size(m.content) >= 4
|
|
837
|
-
RETURN m.content AS content, m.createdAt AS createdAt
|
|
838
|
-
ORDER BY m.createdAt ASC
|
|
839
|
-
LIMIT 1
|
|
840
|
-
}
|
|
841
|
-
RETURN c.conversationId AS conversationId,
|
|
842
|
-
c.name AS name,
|
|
843
|
-
c.updatedAt AS updatedAt,
|
|
844
|
-
c.channel AS channel,
|
|
845
|
-
content AS firstSubstantiveUserMessage`,
|
|
846
|
-
{ accountId, userId, limit: neo4j.int(limit) }
|
|
847
|
-
);
|
|
848
|
-
const rows = result.records.map((r) => ({
|
|
849
|
-
conversationId: r.get("conversationId"),
|
|
850
|
-
name: r.get("name"),
|
|
851
|
-
updatedAt: String(r.get("updatedAt")),
|
|
852
|
-
channel: r.get("channel"),
|
|
853
|
-
firstSubstantiveUserMessage: r.get("firstSubstantiveUserMessage")
|
|
854
|
-
}));
|
|
855
|
-
let backfillsKicked = 0;
|
|
856
|
-
for (const row of rows) {
|
|
857
|
-
if (backfillsKicked >= LIST_BACKFILL_CAP) break;
|
|
858
|
-
if (row.name !== null) continue;
|
|
859
|
-
const seed = row.firstSubstantiveUserMessage;
|
|
860
|
-
if (!seed || !isMessageUseful(seed)) continue;
|
|
861
|
-
backfillsKicked++;
|
|
862
|
-
autoLabelSession(row.conversationId, seed).catch(() => {
|
|
863
|
-
});
|
|
864
|
-
}
|
|
865
|
-
return rows.map(({ conversationId, name, updatedAt, channel }) => ({ conversationId, name, updatedAt, channel }));
|
|
866
|
-
} catch (err) {
|
|
867
|
-
console.error(`[persist] listAdminSessions failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
868
|
-
return [];
|
|
869
|
-
} finally {
|
|
870
|
-
await session.close();
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
async function deleteConversation(conversationId) {
|
|
874
|
-
const session = getSession();
|
|
875
|
-
try {
|
|
876
|
-
const result = await session.run(
|
|
877
|
-
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
878
|
-
OPTIONAL MATCH (m:Message)-[:PART_OF]->(c)
|
|
879
|
-
OPTIONAL MATCH (m)-[:HAS_COMPONENT]->(comp:Component)
|
|
880
|
-
OPTIONAL MATCH (m)-[:HAS_ATTACHMENT]->(att:Attachment)
|
|
881
|
-
DETACH DELETE att, comp, m, c
|
|
882
|
-
RETURN count(c) AS deleted`,
|
|
883
|
-
{ conversationId }
|
|
884
|
-
);
|
|
885
|
-
const deleted = result.records[0]?.get("deleted");
|
|
886
|
-
const count = typeof deleted === "object" && deleted !== null ? Number(deleted) : Number(deleted ?? 0);
|
|
887
|
-
console.error(`[persist] deleteConversation ${conversationId.slice(0, 8)}\u2026: ${count > 0 ? "deleted" : "not found"}`);
|
|
888
|
-
return count > 0;
|
|
889
|
-
} catch (err) {
|
|
890
|
-
console.error(`[persist] deleteConversation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
891
|
-
return false;
|
|
892
|
-
} finally {
|
|
893
|
-
await session.close();
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
var GENERIC_MESSAGE = /^(h(i|ello|ey|owdy)|yo|sup|thanks|thank you|ok|okay|yes|no|good\s*(morning|afternoon|evening|night)|greetings|what'?s\s*up)[\s!?.,:;]*$/i;
|
|
897
|
-
var SESSION_LABEL_MSG_CAP = 500;
|
|
898
|
-
var SESSION_LABEL_TIMEOUT_MS = 15e3;
|
|
899
|
-
var SESSION_LABEL_MODEL = HAIKU_MODEL;
|
|
900
|
-
var SESSION_LABEL_MAX_WORDS = 6;
|
|
901
|
-
var SESSION_LABEL_MAX_ATTEMPTS = 3;
|
|
902
|
-
var SESSION_LABEL_MAX_STDERR = 2048;
|
|
903
|
-
function isMessageUseful(message) {
|
|
904
|
-
const trimmed = message.trim();
|
|
905
|
-
if (trimmed.length < 4) return false;
|
|
906
|
-
if (trimmed.startsWith('{"')) return false;
|
|
907
|
-
if (GENERIC_MESSAGE.test(trimmed)) return false;
|
|
908
|
-
if (trimmed.startsWith(DIRECTIVE_PREFIX)) return false;
|
|
909
|
-
return true;
|
|
910
|
-
}
|
|
911
|
-
function totalFailures(f) {
|
|
912
|
-
return f.skip + f.error;
|
|
913
|
-
}
|
|
914
|
-
function failureBreakdown(f) {
|
|
915
|
-
return `skip:${f.skip} error:${f.error}`;
|
|
916
|
-
}
|
|
917
|
-
var labelAccumulator = /* @__PURE__ */ new Map();
|
|
918
|
-
var _spawnOverride = null;
|
|
919
|
-
var SESSION_LABEL_SYSTEM = `You are a session labeler. Given the opening messages of a conversation with an AI assistant, produce a concise topic label.
|
|
920
|
-
|
|
921
|
-
Rules:
|
|
922
|
-
- Exactly 3 to 6 words
|
|
923
|
-
- Summarize the user's intent, do not copy verbatim
|
|
924
|
-
- Capitalize the first word only (sentence case)
|
|
925
|
-
- No punctuation, no quotes
|
|
926
|
-
- You MUST always produce a label. Vague, terse, or single-word messages still get a best-effort label (e.g. a one-word "hi" or an emoji becomes "Greeting"; an unintelligible fragment becomes "Unstructured note"). Never abstain.`;
|
|
927
|
-
async function generateSessionLabel(messages) {
|
|
928
|
-
const cappedMessages = messages.map((m) => m.slice(0, SESSION_LABEL_MSG_CAP));
|
|
929
|
-
const userContent = cappedMessages.map((m, i) => `Message ${i + 1}: ${m}`).join("\n");
|
|
930
|
-
const prompt = `${SESSION_LABEL_SYSTEM}
|
|
931
|
-
|
|
932
|
-
${userContent}`;
|
|
933
|
-
const args = [
|
|
934
|
-
"--print",
|
|
935
|
-
"--model",
|
|
936
|
-
SESSION_LABEL_MODEL,
|
|
937
|
-
"--max-turns",
|
|
938
|
-
"1",
|
|
939
|
-
"--permission-mode",
|
|
940
|
-
"dontAsk",
|
|
941
|
-
prompt
|
|
942
|
-
];
|
|
943
|
-
return new Promise((resolve2) => {
|
|
944
|
-
let stdout = "";
|
|
945
|
-
let stderr = "";
|
|
946
|
-
const spawnFn = _spawnOverride ?? spawn;
|
|
947
|
-
const proc = spawnFn("claude", args, {
|
|
948
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
949
|
-
});
|
|
950
|
-
proc.stdout?.on("data", (chunk) => {
|
|
951
|
-
stdout += chunk.toString("utf-8");
|
|
952
|
-
});
|
|
953
|
-
proc.stderr?.on("data", (chunk) => {
|
|
954
|
-
if (stderr.length < SESSION_LABEL_MAX_STDERR) {
|
|
955
|
-
stderr += chunk.toString("utf-8").slice(0, SESSION_LABEL_MAX_STDERR - stderr.length);
|
|
956
|
-
}
|
|
957
|
-
});
|
|
958
|
-
const timer = setTimeout(() => {
|
|
959
|
-
proc.kill("SIGTERM");
|
|
960
|
-
console.error("[persist] autoLabel: haiku subprocess timed out");
|
|
961
|
-
resolve2(null);
|
|
962
|
-
}, SESSION_LABEL_TIMEOUT_MS);
|
|
963
|
-
proc.on("error", (err) => {
|
|
964
|
-
clearTimeout(timer);
|
|
965
|
-
console.error(`[persist] autoLabel: subprocess error \u2014 ${err.message}`);
|
|
966
|
-
resolve2(null);
|
|
967
|
-
});
|
|
968
|
-
proc.on("close", (code) => {
|
|
969
|
-
clearTimeout(timer);
|
|
970
|
-
if (code !== 0) {
|
|
971
|
-
console.error(`[persist] autoLabel: subprocess exited code=${code}${stderr ? ` stderr=${stderr.trim().slice(0, 200)}` : ""}`);
|
|
972
|
-
resolve2(null);
|
|
973
|
-
return;
|
|
974
|
-
}
|
|
975
|
-
const text = stdout.trim();
|
|
976
|
-
if (!text) {
|
|
977
|
-
console.error("[persist] autoLabel: haiku returned empty response");
|
|
978
|
-
resolve2(null);
|
|
979
|
-
return;
|
|
980
|
-
}
|
|
981
|
-
const words = text.split(/\s+/).slice(0, SESSION_LABEL_MAX_WORDS);
|
|
982
|
-
const label = words.join(" ");
|
|
983
|
-
console.error(`[persist] autoLabel: haiku response="${label}"`);
|
|
984
|
-
resolve2(label);
|
|
985
|
-
});
|
|
986
|
-
});
|
|
987
|
-
}
|
|
988
|
-
async function autoLabelSession(conversationId, userMessage) {
|
|
989
|
-
if (!conversationId) return;
|
|
990
|
-
if (!isMessageUseful(userMessage)) {
|
|
991
|
-
const trimmed = userMessage.trim();
|
|
992
|
-
const reason = trimmed.startsWith('{"') ? "JSON envelope" : trimmed.startsWith(DIRECTIVE_PREFIX) ? "directive" : GENERIC_MESSAGE.test(trimmed) ? "greeting" : trimmed.length < 4 ? "too short" : "directive";
|
|
993
|
-
console.error(`[persist] autoLabel: skipped ${conversationId.slice(0, 8)}\u2026 \u2014 ${reason}`);
|
|
994
|
-
return;
|
|
995
|
-
}
|
|
996
|
-
try {
|
|
997
|
-
const preCheck = getSession();
|
|
998
|
-
try {
|
|
999
|
-
const res = await preCheck.run(
|
|
1000
|
-
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
1001
|
-
OPTIONAL MATCH (m:Message)-[:PART_OF]->(c)
|
|
1002
|
-
WHERE m.role = 'user'
|
|
1003
|
-
WITH c, count(m) AS userCount
|
|
1004
|
-
RETURN c.name AS name, userCount`,
|
|
1005
|
-
{ conversationId }
|
|
1006
|
-
);
|
|
1007
|
-
const firstRecord = res.records[0];
|
|
1008
|
-
const existingName = firstRecord?.get("name");
|
|
1009
|
-
const userCountRaw = firstRecord?.get("userCount");
|
|
1010
|
-
const userCount = typeof userCountRaw === "object" && userCountRaw !== null ? Number(userCountRaw) : Number(userCountRaw ?? 0);
|
|
1011
|
-
if (existingName) {
|
|
1012
|
-
console.error(`[persist] autoLabel: already named ${conversationId.slice(0, 8)}\u2026 \u2014 skipping`);
|
|
1013
|
-
labelAccumulator.delete(conversationId);
|
|
1014
|
-
return;
|
|
1015
|
-
}
|
|
1016
|
-
if (userCount > 3) {
|
|
1017
|
-
console.error(`[persist] autoLabel: past autolabel window ${conversationId.slice(0, 8)}\u2026 \u2014 userCount=${userCount}, skipping`);
|
|
1018
|
-
labelAccumulator.delete(conversationId);
|
|
1019
|
-
return;
|
|
1020
|
-
}
|
|
1021
|
-
} finally {
|
|
1022
|
-
await preCheck.close();
|
|
1023
|
-
}
|
|
1024
|
-
} catch (err) {
|
|
1025
|
-
console.error(`[persist] autoLabel: pre-check read failed for ${conversationId.slice(0, 8)}\u2026 \u2014 proceeding: ${err instanceof Error ? err.message : String(err)}`);
|
|
1026
|
-
}
|
|
1027
|
-
let entry = labelAccumulator.get(conversationId);
|
|
1028
|
-
if (!entry) {
|
|
1029
|
-
entry = {
|
|
1030
|
-
messages: [],
|
|
1031
|
-
pending: false,
|
|
1032
|
-
failures: { skip: 0, error: 0 }
|
|
1033
|
-
};
|
|
1034
|
-
labelAccumulator.set(conversationId, entry);
|
|
1035
|
-
}
|
|
1036
|
-
if (totalFailures(entry.failures) >= SESSION_LABEL_MAX_ATTEMPTS) {
|
|
1037
|
-
console.error(`[persist] autoLabel: evicted ${conversationId.slice(0, 8)}\u2026 after ${SESSION_LABEL_MAX_ATTEMPTS} failed-attempts (${failureBreakdown(entry.failures)})`);
|
|
1038
|
-
labelAccumulator.delete(conversationId);
|
|
1039
|
-
return;
|
|
1040
|
-
}
|
|
1041
|
-
entry.messages.push(userMessage.trim());
|
|
1042
|
-
if (entry.pending) {
|
|
1043
|
-
console.error(`[persist] autoLabel: accumulated for ${conversationId.slice(0, 8)}\u2026 (pending, ${entry.messages.length} msgs)`);
|
|
1044
|
-
return;
|
|
1045
|
-
}
|
|
1046
|
-
entry.pending = true;
|
|
1047
|
-
try {
|
|
1048
|
-
const label = await generateSessionLabel(entry.messages);
|
|
1049
|
-
if (!label) {
|
|
1050
|
-
entry.failures.skip++;
|
|
1051
|
-
console.error(`[persist] autoLabel: generateSessionLabel returned null for ${conversationId.slice(0, 8)}\u2026 (failures ${failureBreakdown(entry.failures)}, ${entry.messages.length} msgs)`);
|
|
1052
|
-
entry.pending = false;
|
|
1053
|
-
return;
|
|
1054
|
-
}
|
|
1055
|
-
const fullLabel = `${label} \xB7 ${conversationId.slice(0, 8)}`;
|
|
1056
|
-
let embedding = null;
|
|
1057
|
-
try {
|
|
1058
|
-
embedding = await embed(fullLabel);
|
|
1059
|
-
} catch (err) {
|
|
1060
|
-
console.error(`[persist] Conversation embedding failed, labelling without: ${err instanceof Error ? err.message : String(err)}`);
|
|
1061
|
-
}
|
|
1062
|
-
const session = getSession();
|
|
1063
|
-
try {
|
|
1064
|
-
const result = await session.run(
|
|
1065
|
-
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
1066
|
-
WHERE c.name IS NULL
|
|
1067
|
-
WITH c
|
|
1068
|
-
OPTIONAL MATCH (m:Message)-[:PART_OF]->(c)
|
|
1069
|
-
WHERE m.role = 'user'
|
|
1070
|
-
WITH c, count(m) AS userCount
|
|
1071
|
-
WHERE userCount <= 3
|
|
1072
|
-
SET c.name = $label, c.updatedAt = datetime()
|
|
1073
|
-
${embedding ? ", c.embedding = $embedding" : ""}
|
|
1074
|
-
RETURN c.name AS name`,
|
|
1075
|
-
{ conversationId, label: fullLabel, ...embedding ? { embedding } : {} }
|
|
1076
|
-
);
|
|
1077
|
-
if (result.records.length > 0) {
|
|
1078
|
-
console.error(`[persist] autoLabel: commit ${conversationId.slice(0, 8)}\u2026 name="${fullLabel}"${embedding ? " (embedded)" : ""}`);
|
|
1079
|
-
labelAccumulator.delete(conversationId);
|
|
1080
|
-
} else {
|
|
1081
|
-
console.error(`[persist] autoLabel: no-op commit ${conversationId.slice(0, 8)}\u2026 (name already set or userCount>3)`);
|
|
1082
|
-
labelAccumulator.delete(conversationId);
|
|
1083
|
-
}
|
|
1084
|
-
} catch (err) {
|
|
1085
|
-
entry.failures.error++;
|
|
1086
|
-
console.error(`[persist] autoLabelSession failed: ${err instanceof Error ? err.message : String(err)} (failures ${failureBreakdown(entry.failures)})`);
|
|
1087
|
-
} finally {
|
|
1088
|
-
await session.close();
|
|
1089
|
-
}
|
|
1090
|
-
} catch (err) {
|
|
1091
|
-
const currentEntry = labelAccumulator.get(conversationId);
|
|
1092
|
-
if (currentEntry) currentEntry.failures.error++;
|
|
1093
|
-
console.error(`[persist] autoLabel: unexpected error \u2014 ${err instanceof Error ? err.message : String(err)}${currentEntry ? ` (failures ${failureBreakdown(currentEntry.failures)})` : ""}`);
|
|
1094
|
-
} finally {
|
|
1095
|
-
const currentEntry = labelAccumulator.get(conversationId);
|
|
1096
|
-
if (currentEntry) currentEntry.pending = false;
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
async function renameConversation(conversationId, label) {
|
|
1100
|
-
let embedding = null;
|
|
1101
|
-
try {
|
|
1102
|
-
embedding = await embed(label);
|
|
1103
|
-
} catch (err) {
|
|
1104
|
-
console.error(`[persist] manual-label: embedding failed, persisting without: ${err instanceof Error ? err.message : String(err)}`);
|
|
1105
|
-
}
|
|
1106
|
-
const session = getSession();
|
|
1107
|
-
try {
|
|
1108
|
-
await session.run(
|
|
1109
|
-
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
1110
|
-
SET c.name = $label, c.updatedAt = datetime()
|
|
1111
|
-
${embedding ? ", c.embedding = $embedding" : ""}`,
|
|
1112
|
-
{ conversationId, label, ...embedding ? { embedding } : {} }
|
|
1113
|
-
);
|
|
1114
|
-
console.error(`[persist] manual-label: renamed ${conversationId} to "${label}"${embedding ? " (embedded)" : ""}`);
|
|
1115
|
-
} finally {
|
|
1116
|
-
await session.close();
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
async function getUserTimezone(accountId, userId) {
|
|
1120
|
-
const session = getSession();
|
|
1121
|
-
try {
|
|
1122
|
-
const query = userId ? `MATCH (up:UserProfile {accountId: $accountId, userId: $userId})
|
|
1123
|
-
RETURN up.timezone AS timezone` : `MATCH (up:UserProfile {accountId: $accountId})
|
|
1124
|
-
RETURN up.timezone AS timezone ORDER BY up.createdAt LIMIT 1`;
|
|
1125
|
-
const result = await session.run(query, { accountId, userId: userId ?? "" });
|
|
1126
|
-
if (result.records.length === 0) return null;
|
|
1127
|
-
const tz = result.records[0].get("timezone");
|
|
1128
|
-
return tz && tz.trim().length > 0 ? tz : null;
|
|
1129
|
-
} catch (err) {
|
|
1130
|
-
console.error(`[datetime] getUserTimezone failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1131
|
-
return null;
|
|
1132
|
-
} finally {
|
|
1133
|
-
await session.close();
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
async function loadAdminUserName(accountId, userId) {
|
|
1137
|
-
const session = getSession();
|
|
1138
|
-
try {
|
|
1139
|
-
const result = await session.run(
|
|
1140
|
-
`MATCH (au:AdminUser {userId: $userId})-[:OWNS]->(p:Person {accountId: $accountId})
|
|
1141
|
-
RETURN p.givenName AS givenName, p.familyName AS familyName, p.avatar AS avatar
|
|
1142
|
-
LIMIT 1`,
|
|
1143
|
-
{ accountId, userId }
|
|
1144
|
-
);
|
|
1145
|
-
if (result.records.length === 0) {
|
|
1146
|
-
return { source: "fallback", reason: "no-person-node" };
|
|
1147
|
-
}
|
|
1148
|
-
const givenName = result.records[0].get("givenName");
|
|
1149
|
-
const familyName = result.records[0].get("familyName");
|
|
1150
|
-
const avatar = result.records[0].get("avatar");
|
|
1151
|
-
if (!givenName || givenName.trim().length === 0) {
|
|
1152
|
-
return { source: "fallback", reason: "no-givenName" };
|
|
1153
|
-
}
|
|
1154
|
-
const trimmedFamily = familyName && familyName.trim().length > 0 ? familyName.trim() : null;
|
|
1155
|
-
const trimmedAvatar = avatar && avatar.trim().length > 0 ? avatar.trim() : null;
|
|
1156
|
-
const joined = trimmedFamily ? `${givenName.trim()} ${trimmedFamily}` : givenName.trim();
|
|
1157
|
-
return { source: "neo4j", joined, givenName: givenName.trim(), familyName: trimmedFamily, avatar: trimmedAvatar };
|
|
1158
|
-
} catch (err) {
|
|
1159
|
-
console.error(`[admin-identity] loadAdminUserName failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1160
|
-
return { source: "fallback", reason: "neo4j-unreachable" };
|
|
1161
|
-
} finally {
|
|
1162
|
-
await session.close();
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
async function writeAdminUserAndPerson(params) {
|
|
1166
|
-
const { userId, fullName, accountId } = params;
|
|
1167
|
-
const trimmed = fullName.trim();
|
|
1168
|
-
if (!trimmed) {
|
|
1169
|
-
throw new Error("writeAdminUserAndPerson: fullName cannot be empty");
|
|
1170
|
-
}
|
|
1171
|
-
const firstSpace = trimmed.search(/\s/);
|
|
1172
|
-
const givenName = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace).trim();
|
|
1173
|
-
const familyName = firstSpace === -1 ? null : trimmed.slice(firstSpace + 1).trim() || null;
|
|
1174
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1175
|
-
const session = getSession();
|
|
1176
|
-
try {
|
|
1177
|
-
const result = await session.run(
|
|
1178
|
-
`MERGE (au:AdminUser {userId: $userId})
|
|
1179
|
-
ON CREATE SET au.name = $fullName, au.createdAt = $now
|
|
1180
|
-
ON MATCH SET au.name = $fullName, au.updatedAt = $now
|
|
1181
|
-
WITH au
|
|
1182
|
-
|
|
1183
|
-
// Case-insensitive Person reuse: same accountId, same givenName, same familyName
|
|
1184
|
-
// (null/empty familyName collapsed to '' so 'Joel' does NOT match 'Joel Smalley').
|
|
1185
|
-
OPTIONAL MATCH (existingPerson:Person {accountId: $accountId})
|
|
1186
|
-
WHERE toLower(existingPerson.givenName) = toLower($givenName)
|
|
1187
|
-
AND coalesce(toLower(existingPerson.familyName), '') = coalesce(toLower($familyName), '')
|
|
1188
|
-
WITH au, existingPerson
|
|
1189
|
-
|
|
1190
|
-
CALL {
|
|
1191
|
-
WITH au, existingPerson
|
|
1192
|
-
WITH au, existingPerson WHERE existingPerson IS NOT NULL
|
|
1193
|
-
MERGE (au)-[:OWNS]->(existingPerson)
|
|
1194
|
-
RETURN existingPerson AS p, true AS reused
|
|
1195
|
-
UNION
|
|
1196
|
-
WITH au, existingPerson
|
|
1197
|
-
WITH au WHERE existingPerson IS NULL
|
|
1198
|
-
CREATE (newPerson:Person {
|
|
1199
|
-
accountId: $accountId,
|
|
1200
|
-
givenName: $givenName,
|
|
1201
|
-
familyName: $familyName,
|
|
1202
|
-
role: 'admin-personal',
|
|
1203
|
-
scope: 'admin',
|
|
1204
|
-
createdAt: $now
|
|
1205
|
-
})
|
|
1206
|
-
MERGE (au)-[:OWNS]->(newPerson)
|
|
1207
|
-
RETURN newPerson AS p, false AS reused
|
|
1208
|
-
}
|
|
1209
|
-
RETURN reused, elementId(p) AS personElementId,
|
|
1210
|
-
p.givenName AS givenName, p.familyName AS familyName`,
|
|
1211
|
-
{ userId, fullName: trimmed, accountId, givenName, familyName, now }
|
|
1212
|
-
);
|
|
1213
|
-
if (result.records.length === 0) {
|
|
1214
|
-
throw new Error("writeAdminUserAndPerson: no record returned");
|
|
1215
|
-
}
|
|
1216
|
-
const record = result.records[0];
|
|
1217
|
-
return {
|
|
1218
|
-
personReused: record.get("reused"),
|
|
1219
|
-
personElementId: record.get("personElementId"),
|
|
1220
|
-
givenName: record.get("givenName"),
|
|
1221
|
-
familyName: record.get("familyName")
|
|
1222
|
-
};
|
|
1223
|
-
} finally {
|
|
1224
|
-
await session.close();
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
var PROFILE_FIELD_TEMPLATES = {
|
|
1228
|
-
communication: [
|
|
1229
|
-
"preferredChannel",
|
|
1230
|
-
"quietHours",
|
|
1231
|
-
"responseLatencyExpectation",
|
|
1232
|
-
"formalityLevel",
|
|
1233
|
-
"escalationPath"
|
|
1234
|
-
],
|
|
1235
|
-
scheduling: [
|
|
1236
|
-
"workdayStartTime",
|
|
1237
|
-
"workdayEndTime",
|
|
1238
|
-
"weekendAvailability",
|
|
1239
|
-
"meetingBlockPreference",
|
|
1240
|
-
"focusBlockTiming"
|
|
1241
|
-
],
|
|
1242
|
-
decision: [
|
|
1243
|
-
"riskTolerance",
|
|
1244
|
-
"autonomyBoundary",
|
|
1245
|
-
"evidenceThreshold",
|
|
1246
|
-
"reversibilityPreference",
|
|
1247
|
-
"stakeholderConsultation"
|
|
1248
|
-
],
|
|
1249
|
-
workflow: [
|
|
1250
|
-
"batchingPreference",
|
|
1251
|
-
"taskGranularity",
|
|
1252
|
-
"deepWorkBlocks",
|
|
1253
|
-
"statusUpdateCadence",
|
|
1254
|
-
"toolingChoices"
|
|
1255
|
-
],
|
|
1256
|
-
content: [
|
|
1257
|
-
"outputFormat",
|
|
1258
|
-
"lengthPreference",
|
|
1259
|
-
"citationStyle",
|
|
1260
|
-
"tonality"
|
|
1261
|
-
],
|
|
1262
|
-
interaction: [
|
|
1263
|
-
"addressForm",
|
|
1264
|
-
"humourTolerance",
|
|
1265
|
-
"directnessLevel",
|
|
1266
|
-
"emotionalRegisterPreference"
|
|
1267
|
-
]
|
|
1268
|
-
};
|
|
1269
|
-
var TOTAL_PROFILE_FIELDS = Object.values(PROFILE_FIELD_TEMPLATES).reduce(
|
|
1270
|
-
(n, fs) => n + fs.length,
|
|
1271
|
-
0
|
|
1272
|
-
);
|
|
1273
|
-
var RECENT_FAILURES_TAIL_BYTES = 10 * 1024;
|
|
1274
|
-
async function consumeStep7FlagUI(session, accountId) {
|
|
1275
|
-
const accountDir = resolve(PLATFORM_ROOT, "..", "data/accounts", accountId);
|
|
1276
|
-
const flagPath = resolve(accountDir, "onboarding", "step7-complete");
|
|
1277
|
-
if (!existsSync(flagPath)) return false;
|
|
1278
|
-
let completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1279
|
-
try {
|
|
1280
|
-
const raw = readFileSync(flagPath, "utf-8").trim();
|
|
1281
|
-
if (raw) {
|
|
1282
|
-
const parsed = JSON.parse(raw);
|
|
1283
|
-
if (typeof parsed.completedAt === "string") {
|
|
1284
|
-
completedAt = parsed.completedAt;
|
|
1285
|
-
}
|
|
1286
|
-
}
|
|
1287
|
-
} catch {
|
|
1288
|
-
}
|
|
1289
|
-
const result = await session.run(
|
|
1290
|
-
`MATCH (o:OnboardingState {accountId: $accountId})
|
|
1291
|
-
SET o.step7CompletedAt = CASE WHEN o.step7CompletedAt IS NULL THEN $completedAt ELSE o.step7CompletedAt END,
|
|
1292
|
-
o.currentStep = CASE WHEN 7 > o.currentStep THEN 7 ELSE o.currentStep END,
|
|
1293
|
-
o.updatedAt = $completedAt
|
|
1294
|
-
RETURN count(o) AS updated`,
|
|
1295
|
-
{ accountId, completedAt }
|
|
1296
|
-
);
|
|
1297
|
-
const updatedRaw = result.records[0]?.get("updated");
|
|
1298
|
-
const updated = typeof updatedRaw === "number" ? updatedRaw : updatedRaw && typeof updatedRaw === "object" && "toNumber" in updatedRaw ? updatedRaw.toNumber() : 0;
|
|
1299
|
-
if (updated === 0) {
|
|
1300
|
-
console.log(
|
|
1301
|
-
`[onboarding-flag-consumed] accountId=${accountId.slice(0, 8)}\u2026 step=7 source=filesystem flagPath=${flagPath} skipped=no-node`
|
|
1302
|
-
);
|
|
1303
|
-
return false;
|
|
1304
|
-
}
|
|
1305
|
-
console.log(
|
|
1306
|
-
`[onboarding-flag-consumed] accountId=${accountId.slice(0, 8)}\u2026 step=7 source=filesystem flagPath=${flagPath} completedAt=${completedAt}`
|
|
1307
|
-
);
|
|
1308
|
-
try {
|
|
1309
|
-
rmSync(flagPath);
|
|
1310
|
-
} catch (err) {
|
|
1311
|
-
console.error(
|
|
1312
|
-
`[onboarding-flag-consumed] warn: failed to delete ${flagPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
1313
|
-
);
|
|
1314
|
-
}
|
|
1315
|
-
return true;
|
|
1316
|
-
}
|
|
1317
|
-
async function loadOnboardingStep(accountId) {
|
|
1318
|
-
const session = getSession();
|
|
1319
|
-
try {
|
|
1320
|
-
await consumeStep7FlagUI(session, accountId);
|
|
1321
|
-
const result = await session.run(
|
|
1322
|
-
`MATCH (o:OnboardingState {accountId: $accountId})
|
|
1323
|
-
RETURN o.currentStep AS currentStep`,
|
|
1324
|
-
{ accountId }
|
|
1325
|
-
);
|
|
1326
|
-
if (result.records.length === 0) {
|
|
1327
|
-
return -1;
|
|
1328
|
-
}
|
|
1329
|
-
const raw = result.records[0].get("currentStep");
|
|
1330
|
-
if (typeof raw === "number") return raw;
|
|
1331
|
-
if (raw && typeof raw === "object" && "toNumber" in raw) {
|
|
1332
|
-
return raw.toNumber();
|
|
1333
|
-
}
|
|
1334
|
-
return 0;
|
|
1335
|
-
} catch (err) {
|
|
1336
|
-
console.error(`[onboarding-inject] loadOnboardingStep failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1337
|
-
return null;
|
|
1338
|
-
} finally {
|
|
1339
|
-
await session.close();
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
|
-
var AGENT_FILE_ROLES = [
|
|
1343
|
-
{ filename: "IDENTITY.md", role: "identity" },
|
|
1344
|
-
{ filename: "SOUL.md", role: "soul" },
|
|
1345
|
-
{ filename: "KNOWLEDGE.md", role: "knowledge" },
|
|
1346
|
-
{ filename: "KNOWLEDGE-SUMMARY.md", role: "knowledge-summary" }
|
|
1347
|
-
];
|
|
1348
|
-
var ROLE_TO_EDGE = {
|
|
1349
|
-
identity: "HAS_IDENTITY",
|
|
1350
|
-
soul: "HAS_SOUL",
|
|
1351
|
-
knowledge: "HAS_KNOWLEDGE",
|
|
1352
|
-
"knowledge-summary": "HAS_KNOWLEDGE"
|
|
1353
|
-
};
|
|
1354
|
-
function assertSafeAgentSlug(slug) {
|
|
1355
|
-
if (!slug || slug.includes("/") || slug.includes("\\") || slug.includes("..")) {
|
|
1356
|
-
throw new Error(`[agent-graph] refusing unsafe slug=${JSON.stringify(slug)}`);
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
function agentAttachmentId(accountId, slug, role) {
|
|
1360
|
-
return `agent:${accountId}:${slug}:${role}`;
|
|
1361
|
-
}
|
|
1362
|
-
async function projectAgent(accountId, accountDir, slug) {
|
|
1363
|
-
const start = Date.now();
|
|
1364
|
-
const account8 = accountId.slice(0, 8);
|
|
1365
|
-
try {
|
|
1366
|
-
assertSafeAgentSlug(slug);
|
|
1367
|
-
} catch (err) {
|
|
1368
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1369
|
-
console.error(
|
|
1370
|
-
`[agent-graph] project FAILED slug=${slug} account=${account8} error="${msg}"`
|
|
1371
|
-
);
|
|
1372
|
-
return;
|
|
1373
|
-
}
|
|
1374
|
-
const agentDir = resolve(accountDir, "agents", slug);
|
|
1375
|
-
const configPath = resolve(agentDir, "config.json");
|
|
1376
|
-
let config;
|
|
1377
|
-
let presentRoles = [];
|
|
1378
|
-
try {
|
|
1379
|
-
if (!existsSync(configPath)) {
|
|
1380
|
-
console.error(
|
|
1381
|
-
`[agent-graph] project FAILED slug=${slug} account=${account8} error="config.json missing at ${configPath}"`
|
|
1382
|
-
);
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
1386
|
-
for (const { filename, role } of AGENT_FILE_ROLES) {
|
|
1387
|
-
const filePath = resolve(agentDir, filename);
|
|
1388
|
-
if (!existsSync(filePath)) continue;
|
|
1389
|
-
const body = readFileSync(filePath, "utf-8");
|
|
1390
|
-
presentRoles.push({ role, body, edge: ROLE_TO_EDGE[role] });
|
|
1391
|
-
}
|
|
1392
|
-
} catch (err) {
|
|
1393
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1394
|
-
console.error(
|
|
1395
|
-
`[agent-graph] project FAILED slug=${slug} account=${account8} error="${msg}"`
|
|
1396
|
-
);
|
|
1397
|
-
return;
|
|
1398
|
-
}
|
|
1399
|
-
const session = getSession();
|
|
1400
|
-
try {
|
|
1401
|
-
await session.run(
|
|
1402
|
-
`MERGE (a:Agent {accountId: $accountId, slug: $slug})
|
|
1403
|
-
ON CREATE SET a.createdAt = datetime()
|
|
1404
|
-
SET a.displayName = $displayName,
|
|
1405
|
-
a.status = $status,
|
|
1406
|
-
a.model = $model,
|
|
1407
|
-
a.liveMemory = $liveMemory,
|
|
1408
|
-
a.knowledgeKeywords = $knowledgeKeywords,
|
|
1409
|
-
a.role = 'agent',
|
|
1410
|
-
a.updatedAt = datetime()
|
|
1411
|
-
RETURN a.slug AS slug`,
|
|
1412
|
-
{
|
|
1413
|
-
accountId,
|
|
1414
|
-
slug,
|
|
1415
|
-
displayName: config.displayName ?? slug,
|
|
1416
|
-
status: config.status ?? "unknown",
|
|
1417
|
-
model: config.model ?? "",
|
|
1418
|
-
liveMemory: config.liveMemory ?? false,
|
|
1419
|
-
knowledgeKeywords: config.knowledgeKeywords ?? []
|
|
1420
|
-
}
|
|
1421
|
-
);
|
|
1422
|
-
for (const { role, body, edge } of presentRoles) {
|
|
1423
|
-
const attachmentId = agentAttachmentId(accountId, slug, role);
|
|
1424
|
-
await session.run(
|
|
1425
|
-
`MATCH (a:Agent {accountId: $accountId, slug: $slug})
|
|
1426
|
-
MERGE (k:KnowledgeDocument {attachmentId: $attachmentId})
|
|
1427
|
-
ON CREATE SET k.createdAt = datetime()
|
|
1428
|
-
SET k.accountId = $accountId,
|
|
1429
|
-
k.role = $role,
|
|
1430
|
-
k.name = $name,
|
|
1431
|
-
k.text = $text,
|
|
1432
|
-
k.scope = 'admin',
|
|
1433
|
-
k.updatedAt = datetime()
|
|
1434
|
-
MERGE (a)-[:${edge}]->(k)
|
|
1435
|
-
RETURN k.attachmentId AS attachmentId`,
|
|
1436
|
-
{
|
|
1437
|
-
accountId,
|
|
1438
|
-
slug,
|
|
1439
|
-
attachmentId,
|
|
1440
|
-
role,
|
|
1441
|
-
name: `${slug} ${role}`,
|
|
1442
|
-
text: body
|
|
1443
|
-
}
|
|
1444
|
-
);
|
|
1445
|
-
}
|
|
1446
|
-
const usesResult = await session.run(
|
|
1447
|
-
`MATCH (a:Agent {accountId: $accountId, slug: $slug})
|
|
1448
|
-
MATCH (k:KnowledgeDocument {accountId: $accountId})
|
|
1449
|
-
WHERE $slug IN coalesce(k.agents, [])
|
|
1450
|
-
AND NOT k.attachmentId STARTS WITH $namespacePrefix
|
|
1451
|
-
MERGE (a)-[:USES_KNOWLEDGE]->(k)
|
|
1452
|
-
RETURN count(k) AS uses`,
|
|
1453
|
-
{
|
|
1454
|
-
accountId,
|
|
1455
|
-
slug,
|
|
1456
|
-
namespacePrefix: `agent:${accountId}:${slug}:`
|
|
1457
|
-
}
|
|
1458
|
-
);
|
|
1459
|
-
const uses = usesResult.records[0]?.get("uses");
|
|
1460
|
-
const usesCount = typeof uses === "number" ? uses : uses && typeof uses.toNumber === "function" ? uses.toNumber() : 0;
|
|
1461
|
-
const ms = Date.now() - start;
|
|
1462
|
-
console.error(
|
|
1463
|
-
`[agent-graph] project slug=${slug} account=${account8} docs=${presentRoles.length} uses=${usesCount} ms=${ms}`
|
|
1464
|
-
);
|
|
1465
|
-
} catch (err) {
|
|
1466
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1467
|
-
console.error(
|
|
1468
|
-
`[agent-graph] project FAILED slug=${slug} account=${account8} error="${msg}"`
|
|
1469
|
-
);
|
|
1470
|
-
throw err;
|
|
1471
|
-
} finally {
|
|
1472
|
-
await session.close();
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
async function deleteAgentProjection(accountId, slug) {
|
|
1476
|
-
const start = Date.now();
|
|
1477
|
-
const account8 = accountId.slice(0, 8);
|
|
1478
|
-
assertSafeAgentSlug(slug);
|
|
1479
|
-
const session = getSession();
|
|
1480
|
-
try {
|
|
1481
|
-
await session.run(
|
|
1482
|
-
`MATCH (a:Agent {accountId: $accountId, slug: $slug})
|
|
1483
|
-
DETACH DELETE a
|
|
1484
|
-
WITH 1 AS _
|
|
1485
|
-
UNWIND $attachmentIds AS aid
|
|
1486
|
-
OPTIONAL MATCH (k:KnowledgeDocument {accountId: $accountId, attachmentId: aid})
|
|
1487
|
-
WHERE k.attachmentId STARTS WITH $namespacePrefix
|
|
1488
|
-
DETACH DELETE k`,
|
|
1489
|
-
{
|
|
1490
|
-
accountId,
|
|
1491
|
-
slug,
|
|
1492
|
-
namespacePrefix: `agent:${accountId}:${slug}:`,
|
|
1493
|
-
attachmentIds: ["identity", "soul", "knowledge", "knowledge-summary"].map((role) => agentAttachmentId(accountId, slug, role))
|
|
1494
|
-
}
|
|
1495
|
-
);
|
|
1496
|
-
const ms = Date.now() - start;
|
|
1497
|
-
console.error(
|
|
1498
|
-
`[agent-graph] delete slug=${slug} account=${account8} ms=${ms}`
|
|
1499
|
-
);
|
|
1500
|
-
} catch (err) {
|
|
1501
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1502
|
-
console.error(
|
|
1503
|
-
`[agent-graph] delete FAILED slug=${slug} account=${account8} error="${msg}"`
|
|
1504
|
-
);
|
|
1505
|
-
throw err;
|
|
1506
|
-
} finally {
|
|
1507
|
-
await session.close();
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
export {
|
|
1512
|
-
HAIKU_MODEL,
|
|
1513
|
-
getSession,
|
|
1514
|
-
runAdminUserSelfHeal,
|
|
1515
|
-
embed,
|
|
1516
|
-
GREETING_DIRECTIVE,
|
|
1517
|
-
ensureConversation,
|
|
1518
|
-
findRecentConversation,
|
|
1519
|
-
findGroupBySlug,
|
|
1520
|
-
getGroupParticipants,
|
|
1521
|
-
checkGroupMembership,
|
|
1522
|
-
bindVisitorToGroup,
|
|
1523
|
-
getMessagesSince,
|
|
1524
|
-
backfillConversationChannelAddress,
|
|
1525
|
-
backfillNullUserIdConversations,
|
|
1526
|
-
fetchBranding,
|
|
1527
|
-
persistMessage,
|
|
1528
|
-
writeTurnFailure,
|
|
1529
|
-
getAgentSessionIdForConversation,
|
|
1530
|
-
getRecentMessages,
|
|
1531
|
-
verifyConversationOwnership,
|
|
1532
|
-
getConversationOwner,
|
|
1533
|
-
verifyAndGetConversationUpdatedAt,
|
|
1534
|
-
listAdminSessions,
|
|
1535
|
-
deleteConversation,
|
|
1536
|
-
generateSessionLabel,
|
|
1537
|
-
renameConversation,
|
|
1538
|
-
getUserTimezone,
|
|
1539
|
-
loadAdminUserName,
|
|
1540
|
-
writeAdminUserAndPerson,
|
|
1541
|
-
loadOnboardingStep,
|
|
1542
|
-
projectAgent,
|
|
1543
|
-
deleteAgentProjection
|
|
1544
|
-
};
|