@rubytech/create-maxy 1.0.805 → 1.0.807
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/neo4j/migrations/004-project-admin-agent.ts +247 -0
- package/payload/platform/neo4j/migrations/004-prune-alien-accounts.ts +134 -0
- package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
- package/payload/platform/plugins/docs/references/graph.md +42 -0
- package/payload/platform/plugins/docs/references/internals.md +11 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/whatsapp-import/PLUGIN.md +18 -5
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import-enrich/SKILL.md +314 -0
- package/payload/platform/templates/agents/admin/IDENTITY.md +3 -1
- package/payload/platform/templates/specialists/agents/database-operator.md +5 -2
- package/payload/server/chunk-LSUMH6OF.js +9993 -0
- package/payload/server/chunk-LTIWPCUF.js +3477 -0
- package/payload/server/chunk-SC3ZSD7N.js +9993 -0
- package/payload/server/chunk-YULDSPAC.js +3484 -0
- package/payload/server/client-pool-CD7WHZIK.js +31 -0
- package/payload/server/client-pool-LXE7RIRT.js +31 -0
- package/payload/server/maxy-edge.js +2 -2
- package/payload/server/neo4j-migrations-HEECOAGK.js +128 -0
- package/payload/server/public/assets/admin-CTM9Vb-j.js +352 -0
- package/payload/server/public/assets/{graph-CBu0rtrP.js → graph-CDwy6Qw1.js} +1 -1
- package/payload/server/public/assets/page-DEyK-lSN.js +50 -0
- package/payload/server/public/graph.html +2 -2
- package/payload/server/public/index.html +2 -2
- package/payload/server/server.js +348 -202
- package/payload/server/public/assets/admin-BYsaXlDv.js +0 -352
- package/payload/server/public/assets/page-BNM63zsb.js +0 -50
|
@@ -0,0 +1,3477 @@
|
|
|
1
|
+
// app/lib/claude-agent/client-pool.ts
|
|
2
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
+
import { appendFileSync as appendFileSync2 } from "fs";
|
|
4
|
+
import { resolve as resolvePath } from "path";
|
|
5
|
+
|
|
6
|
+
// app/lib/claude-agent/logging.ts
|
|
7
|
+
import { spawnSync } from "child_process";
|
|
8
|
+
import { resolve } from "path";
|
|
9
|
+
import { platform as osPlatform } from "os";
|
|
10
|
+
import { readFileSync, readdirSync, mkdirSync, createWriteStream, statSync, unlinkSync, renameSync, appendFileSync, existsSync } from "fs";
|
|
11
|
+
import { lookup as dnsLookup } from "dns/promises";
|
|
12
|
+
import { createConnection as netConnect } from "net";
|
|
13
|
+
import { StringDecoder } from "string_decoder";
|
|
14
|
+
var LOG_RETENTION_DAYS = 7;
|
|
15
|
+
var isoTs = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
16
|
+
var BROWSER_TOOL_PREFIXES = [
|
|
17
|
+
"mcp__plugin_playwright_playwright__",
|
|
18
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__"
|
|
19
|
+
];
|
|
20
|
+
function isBrowserTool(name) {
|
|
21
|
+
return BROWSER_TOOL_PREFIXES.some((p) => name.startsWith(p));
|
|
22
|
+
}
|
|
23
|
+
var DIAG_HARD_CAP_MS = 5e3;
|
|
24
|
+
var DIAG_DNS_TIMEOUT_MS = 2e3;
|
|
25
|
+
var DIAG_TCP_TIMEOUT_MS = 3e3;
|
|
26
|
+
var DIAG_HTTP_TIMEOUT_MS = 4e3;
|
|
27
|
+
function quoteDiag(value) {
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
}
|
|
30
|
+
function extractUrl(toolName, input) {
|
|
31
|
+
if (input === null || typeof input !== "object") return void 0;
|
|
32
|
+
const obj = input;
|
|
33
|
+
if (toolName === "WebFetch" && typeof obj.url === "string") return obj.url;
|
|
34
|
+
if (isBrowserTool(toolName) && typeof obj.url === "string") return obj.url;
|
|
35
|
+
if (typeof obj.url === "string" && /^https?:\/\//.test(obj.url)) return obj.url;
|
|
36
|
+
return void 0;
|
|
37
|
+
}
|
|
38
|
+
var FULL_REDACT_ENV_VARS = /* @__PURE__ */ new Set(["HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY"]);
|
|
39
|
+
function redactEnvField(name) {
|
|
40
|
+
const value = process.env[name];
|
|
41
|
+
if (!value) return `${name.toLowerCase()}=absent`;
|
|
42
|
+
if (FULL_REDACT_ENV_VARS.has(name)) {
|
|
43
|
+
return `${name.toLowerCase()}=present`;
|
|
44
|
+
}
|
|
45
|
+
const suffix = value.length > 40 ? value.slice(-40) : value;
|
|
46
|
+
return `${name.toLowerCase()}=present suffix=${quoteDiag(suffix)}`;
|
|
47
|
+
}
|
|
48
|
+
async function probeDns(host, family) {
|
|
49
|
+
const label = family === 4 ? "dns_a" : "dns_aaaa";
|
|
50
|
+
const start = Date.now();
|
|
51
|
+
let timer;
|
|
52
|
+
try {
|
|
53
|
+
const result = await Promise.race([
|
|
54
|
+
dnsLookup(host, { family, verbatim: true }),
|
|
55
|
+
new Promise((_, reject) => {
|
|
56
|
+
timer = setTimeout(() => reject(new Error("timeout")), DIAG_DNS_TIMEOUT_MS);
|
|
57
|
+
})
|
|
58
|
+
]);
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
const ms = Date.now() - start;
|
|
61
|
+
return `${label}=${result.address} ${label}_ms=${ms}`;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (timer) clearTimeout(timer);
|
|
64
|
+
const ms = Date.now() - start;
|
|
65
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
66
|
+
return `${label}=err ${label}_err=${quoteDiag(msg.slice(0, 60))} ${label}_ms=${ms}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function probeTcp(host, port) {
|
|
70
|
+
const start = Date.now();
|
|
71
|
+
return new Promise((resolvePromise) => {
|
|
72
|
+
let settled = false;
|
|
73
|
+
const sock = netConnect({ host, port, family: 0 });
|
|
74
|
+
const finish = (result) => {
|
|
75
|
+
if (settled) return;
|
|
76
|
+
settled = true;
|
|
77
|
+
try {
|
|
78
|
+
sock.destroy();
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
resolvePromise(result);
|
|
82
|
+
};
|
|
83
|
+
const timer = setTimeout(() => finish(`tcp=timeout tcp_ms=${Date.now() - start}`), DIAG_TCP_TIMEOUT_MS);
|
|
84
|
+
sock.once("connect", () => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
finish(`tcp=ok tcp_ms=${Date.now() - start}`);
|
|
87
|
+
});
|
|
88
|
+
sock.once("error", (err) => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
91
|
+
finish(`tcp=err tcp_err=${quoteDiag(msg.slice(0, 60))} tcp_ms=${Date.now() - start}`);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
async function probeHttp(url) {
|
|
96
|
+
const start = Date.now();
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timer = setTimeout(() => controller.abort(), DIAG_HTTP_TIMEOUT_MS);
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch(url, { method: "HEAD", redirect: "manual", signal: controller.signal });
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
return `http_status=${res.status} http_ms=${Date.now() - start}`;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
106
|
+
return `http_status=err http_err=${quoteDiag(msg.slice(0, 60))} http_ms=${Date.now() - start}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function runFailureDiagnostic(toolName, toolInput) {
|
|
110
|
+
const inputKeys = toolInput !== null && typeof toolInput === "object" ? Object.keys(toolInput).join(",") : "";
|
|
111
|
+
const envFields = [
|
|
112
|
+
redactEnvField("HTTPS_PROXY"),
|
|
113
|
+
redactEnvField("HTTP_PROXY"),
|
|
114
|
+
redactEnvField("NO_PROXY"),
|
|
115
|
+
redactEnvField("NODE_OPTIONS")
|
|
116
|
+
].join(" ");
|
|
117
|
+
const url = extractUrl(toolName, toolInput);
|
|
118
|
+
if (!url) {
|
|
119
|
+
return `diag_url=none input_keys=[${inputKeys}] ${envFields}`;
|
|
120
|
+
}
|
|
121
|
+
let host;
|
|
122
|
+
let port;
|
|
123
|
+
try {
|
|
124
|
+
const parsed = new URL(url);
|
|
125
|
+
host = parsed.hostname;
|
|
126
|
+
port = parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
|
|
127
|
+
} catch {
|
|
128
|
+
return `diag_url=unparseable input_keys=[${inputKeys}] ${envFields}`;
|
|
129
|
+
}
|
|
130
|
+
const probes = Promise.allSettled([
|
|
131
|
+
probeDns(host, 4),
|
|
132
|
+
probeDns(host, 6),
|
|
133
|
+
probeTcp(host, port),
|
|
134
|
+
probeHttp(url)
|
|
135
|
+
]);
|
|
136
|
+
let capTimer;
|
|
137
|
+
const capped = await Promise.race([
|
|
138
|
+
probes,
|
|
139
|
+
new Promise((resolvePromise) => {
|
|
140
|
+
capTimer = setTimeout(() => resolvePromise("__diag_timeout__"), DIAG_HARD_CAP_MS);
|
|
141
|
+
})
|
|
142
|
+
]);
|
|
143
|
+
if (capTimer) clearTimeout(capTimer);
|
|
144
|
+
if (capped === "__diag_timeout__") {
|
|
145
|
+
return `diag_host=${host} diag_port=${port} diag_timeout=true input_keys=[${inputKeys}] ${envFields}`;
|
|
146
|
+
}
|
|
147
|
+
const fields = capped.map((r) => r.status === "fulfilled" ? r.value : `probe_err=${quoteDiag(String(r.reason).slice(0, 40))}`).join(" ");
|
|
148
|
+
return `diag_host=${host} diag_port=${port} ${fields} input_keys=[${inputKeys}] ${envFields}`;
|
|
149
|
+
}
|
|
150
|
+
function agentLogStream(name, accountDir, conversationId) {
|
|
151
|
+
if (!conversationId) {
|
|
152
|
+
throw new Error(`agentLogStream: conversationId is required (name=${name}) \u2014 use preConversationLogStream for pre-session events`);
|
|
153
|
+
}
|
|
154
|
+
const logDir = resolve(accountDir, "logs");
|
|
155
|
+
mkdirSync(logDir, { recursive: true });
|
|
156
|
+
purgeOldLogs(logDir, `${name}-`);
|
|
157
|
+
const logPath = resolve(logDir, `${name}-${conversationId}.log`);
|
|
158
|
+
const stream = createWriteStream(logPath, { flags: "a" });
|
|
159
|
+
registerStreamLog(stream, { path: logPath, conversationId, name });
|
|
160
|
+
return stream;
|
|
161
|
+
}
|
|
162
|
+
function preConversationLogStream(name, accountDir) {
|
|
163
|
+
const logDir = resolve(accountDir, "logs");
|
|
164
|
+
mkdirSync(logDir, { recursive: true });
|
|
165
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
166
|
+
purgeOldLogs(logDir, `preconversation-${name}-`);
|
|
167
|
+
const logPath = resolve(logDir, `preconversation-${name}-${date}.log`);
|
|
168
|
+
const stream = createWriteStream(logPath, { flags: "a" });
|
|
169
|
+
registerStreamLog(stream, { path: logPath, conversationId: null, name: `preconversation-${name}` });
|
|
170
|
+
return stream;
|
|
171
|
+
}
|
|
172
|
+
var openStreamLogs = /* @__PURE__ */ new Map();
|
|
173
|
+
function registerStreamLog(stream, entry) {
|
|
174
|
+
openStreamLogs.set(stream, entry);
|
|
175
|
+
stream.once("close", () => {
|
|
176
|
+
openStreamLogs.delete(stream);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function sigtermFlushStreamLogs(reason, source) {
|
|
180
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
181
|
+
for (const entry of openStreamLogs.values()) {
|
|
182
|
+
const convPart = entry.conversationId ? ` conversationId=${entry.conversationId}` : "";
|
|
183
|
+
const line = `[${ts}] [server-sigterm] reason=${reason}${convPart} name=${entry.name} source=${source}
|
|
184
|
+
`;
|
|
185
|
+
try {
|
|
186
|
+
appendFileSync(entry.path, line);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
189
|
+
console.error(`[server-sigterm-flush-err] path=${entry.path} reason=${msg}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function purgeOldLogs(logDir, prefix) {
|
|
194
|
+
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1e3;
|
|
195
|
+
let entries;
|
|
196
|
+
try {
|
|
197
|
+
entries = readdirSync(logDir);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
200
|
+
console.error(`[log-purge-err] readdir dir=${logDir} prefix=${prefix} reason=${msg}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
for (const file of entries) {
|
|
204
|
+
if (!file.startsWith(prefix)) continue;
|
|
205
|
+
const filePath = resolve(logDir, file);
|
|
206
|
+
try {
|
|
207
|
+
if (statSync(filePath).mtimeMs < cutoff) unlinkSync(filePath);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
210
|
+
console.error(`[log-purge-err] file=${file} reason=${msg}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function preflushStreamLogKey(sessionKey) {
|
|
215
|
+
return `preflush-${sessionKey.slice(0, 12)}`;
|
|
216
|
+
}
|
|
217
|
+
function renameStreamLogsOnFlush(accountDir, sessionKey, conversationId) {
|
|
218
|
+
const STREAM_LOG_NAMES = ["claude-agent-stream", "claude-agent-stderr", "public-agent-stream"];
|
|
219
|
+
const logDir = resolve(accountDir, "logs");
|
|
220
|
+
const preflushSuffix = preflushStreamLogKey(sessionKey);
|
|
221
|
+
const sk8 = sessionKey.slice(0, 8);
|
|
222
|
+
const cid8 = conversationId.slice(0, 8);
|
|
223
|
+
for (const name of STREAM_LOG_NAMES) {
|
|
224
|
+
const fromPath = resolve(logDir, `${name}-${preflushSuffix}.log`);
|
|
225
|
+
const toPath = resolve(logDir, `${name}-${conversationId}.log`);
|
|
226
|
+
let result;
|
|
227
|
+
let reason = "";
|
|
228
|
+
try {
|
|
229
|
+
if (!existsSync(fromPath)) {
|
|
230
|
+
result = "skipped";
|
|
231
|
+
reason = "no-preflush-file";
|
|
232
|
+
} else {
|
|
233
|
+
const targetExisted = existsSync(toPath);
|
|
234
|
+
renameSync(fromPath, toPath);
|
|
235
|
+
for (const entry of openStreamLogs.values()) {
|
|
236
|
+
if (entry.path === fromPath) {
|
|
237
|
+
entry.path = toPath;
|
|
238
|
+
entry.conversationId = conversationId;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
result = "renamed";
|
|
242
|
+
if (targetExisted) reason = "target-existed";
|
|
243
|
+
}
|
|
244
|
+
} catch (err) {
|
|
245
|
+
result = "error";
|
|
246
|
+
reason = err instanceof Error ? err.message : String(err);
|
|
247
|
+
}
|
|
248
|
+
const reasonPart = reason ? ` reason=${JSON.stringify(reason)}` : "";
|
|
249
|
+
console.log(`[stream-log-rename] ${(/* @__PURE__ */ new Date()).toISOString()} sessionKey=${sk8} conversationId=${cid8} name=${name} result=${result}${reasonPart}`);
|
|
250
|
+
if (result === "renamed" && existsSync(fromPath)) {
|
|
251
|
+
console.error(`[stream-log-rename] ${(/* @__PURE__ */ new Date()).toISOString()} outcome=stale-sibling preflush=${JSON.stringify(fromPath)} full=${JSON.stringify(toPath)} reason=${JSON.stringify("rename succeeded but preflush still present")} sessionKey=${sk8} conversationId=${cid8} name=${name}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// app/lib/claude-agent/session-store.ts
|
|
257
|
+
import { resolve as resolve4 } from "path";
|
|
258
|
+
|
|
259
|
+
// app/lib/neo4j-store.ts
|
|
260
|
+
import neo4j from "neo4j-driver";
|
|
261
|
+
import { randomUUID } from "crypto";
|
|
262
|
+
import { spawn as spawn2 } from "child_process";
|
|
263
|
+
import { readFileSync as readFileSync2, readdirSync as readdirSync2, existsSync as existsSync2, openSync, readSync, closeSync, statSync as statSync2, rmSync } from "fs";
|
|
264
|
+
import { resolve as resolve2 } from "path";
|
|
265
|
+
|
|
266
|
+
// ../lib/models/src/index.ts
|
|
267
|
+
var OPUS_MODEL = "claude-opus-4-7";
|
|
268
|
+
var SONNET_MODEL = "claude-sonnet-4-6";
|
|
269
|
+
var HAIKU_MODEL = "claude-haiku-4-5";
|
|
270
|
+
var MODEL_CONTEXT_WINDOW = {
|
|
271
|
+
[OPUS_MODEL]: 2e5,
|
|
272
|
+
[SONNET_MODEL]: 2e5,
|
|
273
|
+
[HAIKU_MODEL]: 2e5
|
|
274
|
+
};
|
|
275
|
+
function contextWindow(model) {
|
|
276
|
+
return MODEL_CONTEXT_WINDOW[model] ?? 2e5;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// app/lib/neo4j-store.ts
|
|
280
|
+
var PLATFORM_ROOT = process.env.MAXY_PLATFORM_ROOT ?? resolve2(process.cwd(), "..");
|
|
281
|
+
var driver = null;
|
|
282
|
+
function readPassword() {
|
|
283
|
+
if (process.env.NEO4J_PASSWORD) return process.env.NEO4J_PASSWORD;
|
|
284
|
+
const passwordFile = resolve2(PLATFORM_ROOT, "config/.neo4j-password");
|
|
285
|
+
try {
|
|
286
|
+
return readFileSync2(passwordFile, "utf-8").trim();
|
|
287
|
+
} catch {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`Neo4j password not found. Expected at ${passwordFile} or in NEO4J_PASSWORD env var.`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function getDriver() {
|
|
294
|
+
if (!driver) {
|
|
295
|
+
const uri = process.env.NEO4J_URI;
|
|
296
|
+
if (!uri) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"[ui/neo4j-store] NEO4J_URI unset \u2014 refusing to default to bolt://localhost:7687 (Task 580)"
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const user = process.env.NEO4J_USER ?? "neo4j";
|
|
302
|
+
const password = readPassword();
|
|
303
|
+
console.error(`[ui/neo4j-store] resolved neo4j_uri=${uri}`);
|
|
304
|
+
driver = neo4j.driver(uri, neo4j.auth.basic(user, password), {
|
|
305
|
+
maxConnectionPoolSize: 5
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return driver;
|
|
309
|
+
}
|
|
310
|
+
function getSession() {
|
|
311
|
+
return getDriver().session();
|
|
312
|
+
}
|
|
313
|
+
async function runBootMigrations() {
|
|
314
|
+
const { applyBootMigrations } = await import("./neo4j-migrations-IUSBODOP.js");
|
|
315
|
+
await applyBootMigrations(getDriver(), PLATFORM_ROOT);
|
|
316
|
+
}
|
|
317
|
+
process.on("SIGINT", async () => {
|
|
318
|
+
if (driver) {
|
|
319
|
+
await driver.close();
|
|
320
|
+
driver = null;
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
var OLLAMA_URL = process.env.OLLAMA_URL ?? "http://localhost:11434";
|
|
324
|
+
var EMBED_MODEL = process.env.EMBED_MODEL ?? "nomic-embed-text";
|
|
325
|
+
async function embed(text) {
|
|
326
|
+
const res = await fetch(`${OLLAMA_URL}/api/embed`, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: { "Content-Type": "application/json" },
|
|
329
|
+
body: JSON.stringify({ model: EMBED_MODEL, input: text }),
|
|
330
|
+
signal: AbortSignal.timeout(5e3)
|
|
331
|
+
});
|
|
332
|
+
if (!res.ok) {
|
|
333
|
+
const body = await res.text();
|
|
334
|
+
throw new Error(`Ollama embedding failed (${res.status}): ${body}`);
|
|
335
|
+
}
|
|
336
|
+
const data = await res.json();
|
|
337
|
+
return data.embeddings[0];
|
|
338
|
+
}
|
|
339
|
+
var sessionStoreRef = null;
|
|
340
|
+
function setSessionStoreRef(ref) {
|
|
341
|
+
sessionStoreRef = ref;
|
|
342
|
+
}
|
|
343
|
+
function getCachedConversationId(sessionKey) {
|
|
344
|
+
return sessionStoreRef?.get(sessionKey)?.conversationId;
|
|
345
|
+
}
|
|
346
|
+
function cacheConversationId(sessionKey, conversationId) {
|
|
347
|
+
const session = sessionStoreRef?.get(sessionKey);
|
|
348
|
+
if (session) {
|
|
349
|
+
session.conversationId = conversationId;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
var GREETING_DIRECTIVE = "[New session. Greet the visitor.]";
|
|
353
|
+
var DIRECTIVE_PREFIX = "[New session.";
|
|
354
|
+
async function ensureConversation(accountId, agentType, sessionKey, visitorId, agentSlug, userId) {
|
|
355
|
+
const cached = getCachedConversationId(sessionKey);
|
|
356
|
+
if (cached) return cached;
|
|
357
|
+
const conversationId = randomUUID();
|
|
358
|
+
const channel = sessionKey.startsWith("whatsapp:") ? "whatsapp" : sessionKey.startsWith("telegram:") ? "telegram" : "webchat";
|
|
359
|
+
const conversationSublabel = agentType === "admin" ? "AdminConversation" : "PublicConversation";
|
|
360
|
+
const wantsHandledByEdge = agentType === "public" && !!agentSlug;
|
|
361
|
+
const handledByClause = wantsHandledByEdge ? `WITH c
|
|
362
|
+
OPTIONAL MATCH (a:Agent {accountId: $accountId, slug: $agentSlug})
|
|
363
|
+
FOREACH (_ IN CASE WHEN a IS NULL THEN [] ELSE [1] END | MERGE (c)-[:HANDLED_BY]->(a))
|
|
364
|
+
RETURN c.conversationId AS conversationId, a IS NOT NULL AS handledBy` : `RETURN c.conversationId AS conversationId, false AS handledBy`;
|
|
365
|
+
const session = getSession();
|
|
366
|
+
try {
|
|
367
|
+
const result = await session.run(
|
|
368
|
+
`MERGE (c:Conversation {sessionKey: $sessionKey})
|
|
369
|
+
ON CREATE SET
|
|
370
|
+
c:${conversationSublabel},
|
|
371
|
+
c.conversationId = $conversationId,
|
|
372
|
+
c.accountId = $accountId,
|
|
373
|
+
c.agentType = $agentType,
|
|
374
|
+
c.channel = $channel,
|
|
375
|
+
${visitorId ? "c.visitorId = $visitorId," : ""}
|
|
376
|
+
${agentSlug ? "c.agentSlug = $agentSlug," : ""}
|
|
377
|
+
${userId ? "c.userId = $userId," : ""}
|
|
378
|
+
c.createdAt = datetime(),
|
|
379
|
+
c.updatedAt = datetime()
|
|
380
|
+
ON MATCH SET
|
|
381
|
+
c.updatedAt = datetime()
|
|
382
|
+
${handledByClause}`,
|
|
383
|
+
{
|
|
384
|
+
sessionKey,
|
|
385
|
+
conversationId,
|
|
386
|
+
accountId,
|
|
387
|
+
agentType,
|
|
388
|
+
channel,
|
|
389
|
+
...visitorId ? { visitorId } : {},
|
|
390
|
+
...agentSlug ? { agentSlug } : {},
|
|
391
|
+
...userId ? { userId } : {}
|
|
392
|
+
}
|
|
393
|
+
);
|
|
394
|
+
const id = result.records[0]?.get("conversationId");
|
|
395
|
+
if (id) {
|
|
396
|
+
cacheConversationId(sessionKey, id);
|
|
397
|
+
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}`);
|
|
398
|
+
if (wantsHandledByEdge) {
|
|
399
|
+
const handled = result.records[0]?.get("handledBy");
|
|
400
|
+
const id8 = id.slice(0, 8);
|
|
401
|
+
if (handled === true) {
|
|
402
|
+
console.error(`[agent-graph] handled-by-write conversationId=${id8} slug=${agentSlug}`);
|
|
403
|
+
} else {
|
|
404
|
+
console.error(`[agent-graph] handled-by-skip reason=no-agent-node conversationId=${id8} slug=${agentSlug}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return id ?? null;
|
|
409
|
+
} catch (err) {
|
|
410
|
+
console.error(`[persist] ensureConversation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
411
|
+
return null;
|
|
412
|
+
} finally {
|
|
413
|
+
await session.close();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
async function findRecentConversation(visitorId, accountId, agentSlug, maxAgeHours = 24) {
|
|
417
|
+
const session = getSession();
|
|
418
|
+
try {
|
|
419
|
+
const result = await session.run(
|
|
420
|
+
`MATCH (c:Conversation {visitorId: $visitorId, accountId: $accountId, agentType: 'public'})
|
|
421
|
+
WHERE c.agentSlug = $agentSlug
|
|
422
|
+
AND c.updatedAt > datetime() - duration({hours: $maxAgeHours})
|
|
423
|
+
RETURN c.conversationId AS conversationId, c.sessionKey AS sessionKey
|
|
424
|
+
ORDER BY c.updatedAt DESC
|
|
425
|
+
LIMIT 1`,
|
|
426
|
+
{ visitorId, accountId, agentSlug, maxAgeHours: neo4j.int(maxAgeHours) },
|
|
427
|
+
{ timeout: 2e3 }
|
|
428
|
+
);
|
|
429
|
+
const record = result.records[0];
|
|
430
|
+
if (!record) return null;
|
|
431
|
+
const conversationId = record.get("conversationId");
|
|
432
|
+
const sessionKey = record.get("sessionKey");
|
|
433
|
+
if (!conversationId) return null;
|
|
434
|
+
console.log(`[persist] found recent conversation ${conversationId.slice(0, 8)}\u2026 for visitor ${visitorId.slice(0, 8)}\u2026`);
|
|
435
|
+
return { conversationId, sessionKey };
|
|
436
|
+
} catch (err) {
|
|
437
|
+
console.error(`[persist] findRecentConversation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
438
|
+
return null;
|
|
439
|
+
} finally {
|
|
440
|
+
await session.close();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function findGroupBySlug(groupSlug, accountId) {
|
|
444
|
+
const session = getSession();
|
|
445
|
+
try {
|
|
446
|
+
const result = await session.run(
|
|
447
|
+
`MATCH (c:Conversation {groupSlug: $groupSlug, accountId: $accountId, type: 'group'})
|
|
448
|
+
RETURN c.conversationId AS conversationId, c.groupName AS groupName, c.agentSlug AS agentSlug`,
|
|
449
|
+
{ groupSlug, accountId },
|
|
450
|
+
{ timeout: 2e3 }
|
|
451
|
+
);
|
|
452
|
+
const record = result.records[0];
|
|
453
|
+
if (!record) return null;
|
|
454
|
+
return {
|
|
455
|
+
conversationId: record.get("conversationId"),
|
|
456
|
+
groupName: record.get("groupName"),
|
|
457
|
+
agentSlug: record.get("agentSlug")
|
|
458
|
+
};
|
|
459
|
+
} catch (err) {
|
|
460
|
+
console.error(`[group] findGroupBySlug failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
461
|
+
return null;
|
|
462
|
+
} finally {
|
|
463
|
+
await session.close();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
async function getGroupParticipants(conversationId) {
|
|
467
|
+
const session = getSession();
|
|
468
|
+
try {
|
|
469
|
+
const result = await session.run(
|
|
470
|
+
`MATCH (p:Person)-[r:PARTICIPATES_IN]->(c:Conversation {conversationId: $conversationId})
|
|
471
|
+
RETURN p.givenName AS givenName, p.familyName AS familyName,
|
|
472
|
+
r.displayName AS displayName, r.joinedAt AS joinedAt, r.visitorId AS visitorId`,
|
|
473
|
+
{ conversationId }
|
|
474
|
+
);
|
|
475
|
+
return result.records.map((r) => ({
|
|
476
|
+
displayName: r.get("displayName") || r.get("givenName"),
|
|
477
|
+
givenName: r.get("givenName"),
|
|
478
|
+
familyName: r.get("familyName"),
|
|
479
|
+
joinedAt: String(r.get("joinedAt")),
|
|
480
|
+
visitorId: r.get("visitorId")
|
|
481
|
+
}));
|
|
482
|
+
} catch (err) {
|
|
483
|
+
console.error(`[group] getGroupParticipants failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
484
|
+
return [];
|
|
485
|
+
} finally {
|
|
486
|
+
await session.close();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
async function checkGroupMembership(conversationId, visitorId) {
|
|
490
|
+
const session = getSession();
|
|
491
|
+
try {
|
|
492
|
+
const result = await session.run(
|
|
493
|
+
`MATCH (p:Person)-[r:PARTICIPATES_IN]->(c:Conversation {conversationId: $conversationId})
|
|
494
|
+
WHERE r.visitorId = $visitorId
|
|
495
|
+
RETURN r.displayName AS displayName
|
|
496
|
+
LIMIT 1`,
|
|
497
|
+
{ conversationId, visitorId }
|
|
498
|
+
);
|
|
499
|
+
return result.records[0]?.get("displayName") ?? null;
|
|
500
|
+
} catch (err) {
|
|
501
|
+
console.error(`[group] checkGroupMembership failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
502
|
+
return null;
|
|
503
|
+
} finally {
|
|
504
|
+
await session.close();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async function bindVisitorToGroup(conversationId, visitorId, personEmail, personPhone) {
|
|
508
|
+
const session = getSession();
|
|
509
|
+
try {
|
|
510
|
+
const result = await session.run(
|
|
511
|
+
`MATCH (p:Person)-[r:PARTICIPATES_IN]->(c:Conversation {conversationId: $conversationId})
|
|
512
|
+
WHERE ($email IS NOT NULL AND p.email = $email)
|
|
513
|
+
OR ($phone IS NOT NULL AND p.telephone = $phone)
|
|
514
|
+
SET r.visitorId = $visitorId
|
|
515
|
+
RETURN r.displayName AS displayName
|
|
516
|
+
LIMIT 1`,
|
|
517
|
+
{
|
|
518
|
+
conversationId,
|
|
519
|
+
visitorId,
|
|
520
|
+
email: personEmail ?? null,
|
|
521
|
+
phone: personPhone ?? null
|
|
522
|
+
}
|
|
523
|
+
);
|
|
524
|
+
const name = result.records[0]?.get("displayName");
|
|
525
|
+
if (name) {
|
|
526
|
+
console.error(`[group] joined id=${conversationId.slice(0, 8)}\u2026 visitor=${visitorId.slice(0, 8)}\u2026`);
|
|
527
|
+
} else {
|
|
528
|
+
console.error(`[group] auth-denied id=${conversationId.slice(0, 8)}\u2026 visitor=${visitorId.slice(0, 8)}\u2026`);
|
|
529
|
+
}
|
|
530
|
+
return name ? { displayName: name } : null;
|
|
531
|
+
} catch (err) {
|
|
532
|
+
console.error(`[group] bindVisitorToGroup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
533
|
+
return null;
|
|
534
|
+
} finally {
|
|
535
|
+
await session.close();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async function getMessagesSince(conversationId, since, limit = 100) {
|
|
539
|
+
const session = getSession();
|
|
540
|
+
try {
|
|
541
|
+
const result = await session.run(
|
|
542
|
+
`MATCH (m:Message)-[:PART_OF]->(c:Conversation {conversationId: $conversationId})
|
|
543
|
+
WHERE m.createdAt > datetime($since)
|
|
544
|
+
RETURN m.messageId AS messageId, m.role AS role, m.content AS content,
|
|
545
|
+
m.senderName AS senderName, m.senderVisitorId AS senderVisitorId,
|
|
546
|
+
m.createdAt AS createdAt
|
|
547
|
+
ORDER BY m.createdAt ASC
|
|
548
|
+
LIMIT $limit`,
|
|
549
|
+
{ conversationId, since, limit: neo4j.int(limit) },
|
|
550
|
+
{ timeout: 3e3 }
|
|
551
|
+
);
|
|
552
|
+
return result.records.map((r) => ({
|
|
553
|
+
messageId: r.get("messageId"),
|
|
554
|
+
role: r.get("role"),
|
|
555
|
+
content: r.get("content"),
|
|
556
|
+
senderName: r.get("senderName"),
|
|
557
|
+
senderVisitorId: r.get("senderVisitorId"),
|
|
558
|
+
createdAt: String(r.get("createdAt"))
|
|
559
|
+
}));
|
|
560
|
+
} catch (err) {
|
|
561
|
+
console.error(`[group] getMessagesSince failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
562
|
+
return [];
|
|
563
|
+
} finally {
|
|
564
|
+
await session.close();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async function getGroupMessagesForContext(conversationId, limit = 50) {
|
|
568
|
+
const session = getSession();
|
|
569
|
+
try {
|
|
570
|
+
const result = await session.run(
|
|
571
|
+
`MATCH (m:Message)-[:PART_OF]->(c:Conversation {conversationId: $conversationId})
|
|
572
|
+
WITH m ORDER BY m.createdAt DESC LIMIT $limit
|
|
573
|
+
RETURN m.role AS role, m.content AS content, m.senderName AS senderName
|
|
574
|
+
ORDER BY m.createdAt ASC`,
|
|
575
|
+
{ conversationId, limit: neo4j.int(limit) }
|
|
576
|
+
);
|
|
577
|
+
return result.records.map((r) => ({
|
|
578
|
+
role: r.get("role"),
|
|
579
|
+
content: r.get("content"),
|
|
580
|
+
senderName: r.get("senderName")
|
|
581
|
+
}));
|
|
582
|
+
} catch (err) {
|
|
583
|
+
console.error(`[group] getGroupMessagesForContext failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
584
|
+
return [];
|
|
585
|
+
} finally {
|
|
586
|
+
await session.close();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async function backfillNullUserIdConversations(userId) {
|
|
590
|
+
if (!userId) {
|
|
591
|
+
console.warn(`[session] ${(/* @__PURE__ */ new Date()).toISOString()} backfill: skipped \u2014 no userId provided (no Owner in users.json?)`);
|
|
592
|
+
return 0;
|
|
593
|
+
}
|
|
594
|
+
const session = getSession();
|
|
595
|
+
try {
|
|
596
|
+
const result = await session.run(
|
|
597
|
+
`MATCH (c:Conversation {agentType: 'admin'})
|
|
598
|
+
WHERE c.userId IS NULL
|
|
599
|
+
SET c.userId = $userId
|
|
600
|
+
RETURN count(c) AS updated`,
|
|
601
|
+
{ userId }
|
|
602
|
+
);
|
|
603
|
+
const updated = result.records[0]?.get("updated")?.toNumber?.() ?? result.records[0]?.get("updated") ?? 0;
|
|
604
|
+
if (updated > 0) {
|
|
605
|
+
console.log(`[session] ${(/* @__PURE__ */ new Date()).toISOString()} backfill: set userId on ${updated} admin conversations`);
|
|
606
|
+
} else {
|
|
607
|
+
console.log(`[session] ${(/* @__PURE__ */ new Date()).toISOString()} backfill: no orphaned admin conversations found`);
|
|
608
|
+
}
|
|
609
|
+
return typeof updated === "number" ? updated : 0;
|
|
610
|
+
} catch (err) {
|
|
611
|
+
console.error(`[session] backfillNullUserIdConversations failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
612
|
+
return 0;
|
|
613
|
+
} finally {
|
|
614
|
+
await session.close();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
async function createNewAdminConversation(userId, accountId, sessionKey, adminName) {
|
|
618
|
+
if (!userId || !sessionKey || !accountId) {
|
|
619
|
+
console.error(`[admin/conversation-write] schema-violation userId=${userId ? "set" : "EMPTY"} sessionKey=${sessionKey ? "set" : "EMPTY"} accountId=${accountId ? "set" : "EMPTY"}`);
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
const conversationId = randomUUID();
|
|
623
|
+
const session = getSession();
|
|
624
|
+
try {
|
|
625
|
+
const result = await session.run(
|
|
626
|
+
`OPTIONAL MATCH (existing:AdminUser {userId: $userId})
|
|
627
|
+
WITH existing IS NULL AS adminUserCreated
|
|
628
|
+
MERGE (au:AdminUser {userId: $userId})
|
|
629
|
+
ON CREATE SET au.accountId = $accountId,
|
|
630
|
+
au.name = COALESCE($adminName, 'Admin'),
|
|
631
|
+
au.createdAt = datetime(),
|
|
632
|
+
au.scope = 'admin'
|
|
633
|
+
CREATE (c:Conversation:AdminConversation {
|
|
634
|
+
conversationId: $conversationId,
|
|
635
|
+
sessionKey: $sessionKey,
|
|
636
|
+
accountId: $accountId,
|
|
637
|
+
agentType: 'admin',
|
|
638
|
+
userId: $userId,
|
|
639
|
+
createdBySource: 'admin-conversation',
|
|
640
|
+
createdAt: datetime(),
|
|
641
|
+
updatedAt: datetime()
|
|
642
|
+
})-[:STARTED_BY]->(au)
|
|
643
|
+
RETURN adminUserCreated`,
|
|
644
|
+
{ conversationId, sessionKey, accountId, userId, adminName: adminName ?? null }
|
|
645
|
+
);
|
|
646
|
+
const adminUserCreated = result.records[0]?.get("adminUserCreated") === true;
|
|
647
|
+
cacheConversationId(sessionKey, conversationId);
|
|
648
|
+
console.log(`[session] ${(/* @__PURE__ */ new Date()).toISOString()} conversation created: conversationId=${conversationId.slice(0, 8)}\u2026 sessionKey=${sessionKey.slice(0, 8)}\u2026 accountId=${accountId.slice(0, 8)}\u2026 userId=${userId} agentType=admin createdBySource=admin-conversation createdAt=set updatedAt=set sublabel=AdminConversation adminUserCreated=${adminUserCreated}`);
|
|
649
|
+
return conversationId;
|
|
650
|
+
} catch (err) {
|
|
651
|
+
console.error(`[session] createNewAdminConversation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
652
|
+
return null;
|
|
653
|
+
} finally {
|
|
654
|
+
await session.close();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
var HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/;
|
|
658
|
+
async function fetchBranding(accountId) {
|
|
659
|
+
const session = getSession();
|
|
660
|
+
try {
|
|
661
|
+
const result = await session.run(
|
|
662
|
+
`MATCH (b:LocalBusiness {accountId: $accountId})
|
|
663
|
+
OPTIONAL MATCH (b)-[:HAS_BRAND_ASSET]->(logo:ImageObject {purpose: "logo"})
|
|
664
|
+
OPTIONAL MATCH (b)-[:HAS_BRAND_ASSET]->(icon:ImageObject {purpose: "icon"})
|
|
665
|
+
RETURN b.name AS name,
|
|
666
|
+
b.primaryColor AS primaryColor,
|
|
667
|
+
b.accentColor AS accentColor,
|
|
668
|
+
b.backgroundColor AS backgroundColor,
|
|
669
|
+
b.tagline AS tagline,
|
|
670
|
+
logo.contentUrl AS logoUrl,
|
|
671
|
+
icon.contentUrl AS faviconUrl`,
|
|
672
|
+
{ accountId },
|
|
673
|
+
{ timeout: 2e3 }
|
|
674
|
+
);
|
|
675
|
+
const record = result.records[0];
|
|
676
|
+
if (!record) return null;
|
|
677
|
+
const name = record.get("name");
|
|
678
|
+
if (!name) return null;
|
|
679
|
+
const primaryColor = record.get("primaryColor");
|
|
680
|
+
const accentColor = record.get("accentColor");
|
|
681
|
+
const backgroundColor = record.get("backgroundColor");
|
|
682
|
+
const tagline = record.get("tagline");
|
|
683
|
+
const logoUrl = record.get("logoUrl");
|
|
684
|
+
const faviconUrl = record.get("faviconUrl");
|
|
685
|
+
const hasBranding = primaryColor || accentColor || backgroundColor || tagline || logoUrl || faviconUrl;
|
|
686
|
+
if (!hasBranding) return null;
|
|
687
|
+
const branding = { name };
|
|
688
|
+
if (primaryColor && HEX_COLOR_RE.test(primaryColor)) branding.primaryColor = primaryColor;
|
|
689
|
+
if (accentColor && HEX_COLOR_RE.test(accentColor)) branding.accentColor = accentColor;
|
|
690
|
+
if (backgroundColor && HEX_COLOR_RE.test(backgroundColor)) branding.backgroundColor = backgroundColor;
|
|
691
|
+
if (tagline) branding.tagline = tagline;
|
|
692
|
+
if (logoUrl) branding.logoUrl = logoUrl;
|
|
693
|
+
if (faviconUrl) branding.faviconUrl = faviconUrl;
|
|
694
|
+
console.error(`[branding] resolved for accountId=${accountId.slice(0, 8)}\u2026: primary=${branding.primaryColor ?? "\u2013"} logo=${branding.logoUrl ? "yes" : "no"}`);
|
|
695
|
+
return branding;
|
|
696
|
+
} catch (err) {
|
|
697
|
+
console.error(`[branding] fetchBranding failed for accountId=${accountId.slice(0, 8)}\u2026: ${err instanceof Error ? err.message : String(err)}`);
|
|
698
|
+
return null;
|
|
699
|
+
} finally {
|
|
700
|
+
await session.close();
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async function persistToolCall(record) {
|
|
704
|
+
const session = getSession();
|
|
705
|
+
try {
|
|
706
|
+
const optionalFields = [
|
|
707
|
+
record.pluginName != null ? ", pluginName: $pluginName" : "",
|
|
708
|
+
record.error != null ? ", error: $error" : "",
|
|
709
|
+
record.subagentType != null ? ", subagentType: $subagentType" : "",
|
|
710
|
+
record.subagentDescription != null ? ", subagentDescription: $subagentDescription" : "",
|
|
711
|
+
record.approvalState != null ? ", approvalState: $approvalState" : "",
|
|
712
|
+
record.originalInput != null ? ", originalInput: $originalInput" : ""
|
|
713
|
+
].join("");
|
|
714
|
+
const res = await session.run(
|
|
715
|
+
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
716
|
+
CREATE (c)-[:HAS_TOOL_CALL]->(tc:ToolCall {
|
|
717
|
+
callId: $callId,
|
|
718
|
+
toolName: $toolName,
|
|
719
|
+
input: $input,
|
|
720
|
+
output: $output,
|
|
721
|
+
isError: $isError,
|
|
722
|
+
agentType: $agentType,
|
|
723
|
+
accountId: $accountId,
|
|
724
|
+
conversationId: $conversationId,
|
|
725
|
+
createdBySource: 'persist-tool-call',
|
|
726
|
+
createdBySession: $conversationId,
|
|
727
|
+
startedAt: datetime($startedAt),
|
|
728
|
+
completedAt: datetime($completedAt)
|
|
729
|
+
${optionalFields}
|
|
730
|
+
})`,
|
|
731
|
+
{
|
|
732
|
+
callId: record.callId,
|
|
733
|
+
toolName: record.toolName,
|
|
734
|
+
input: record.input,
|
|
735
|
+
output: record.output,
|
|
736
|
+
isError: record.isError,
|
|
737
|
+
agentType: record.agentType,
|
|
738
|
+
accountId: record.accountId,
|
|
739
|
+
conversationId: record.conversationId,
|
|
740
|
+
startedAt: record.startedAt,
|
|
741
|
+
completedAt: record.completedAt,
|
|
742
|
+
...record.pluginName != null ? { pluginName: record.pluginName } : {},
|
|
743
|
+
...record.error != null ? { error: record.error } : {},
|
|
744
|
+
...record.subagentType != null ? { subagentType: record.subagentType } : {},
|
|
745
|
+
...record.subagentDescription != null ? { subagentDescription: record.subagentDescription } : {},
|
|
746
|
+
...record.approvalState != null ? { approvalState: record.approvalState } : {},
|
|
747
|
+
...record.originalInput != null ? { originalInput: record.originalInput } : {}
|
|
748
|
+
}
|
|
749
|
+
);
|
|
750
|
+
if (res.summary.counters.updates().nodesCreated === 0) {
|
|
751
|
+
console.error(
|
|
752
|
+
`[persist] tool-call skipped: conversation ${record.conversationId.slice(0, 8)}\u2026 not found for tool=${record.toolName}`
|
|
753
|
+
);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
console.error(`[persist] tool-call persisted: name=${record.toolName} conversation=${record.conversationId.slice(0, 8)}\u2026${record.approvalState ? ` approval=${record.approvalState}` : ""}`);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
console.error(`[persist] tool-call write failed: name=${record.toolName} error=${err instanceof Error ? err.message : String(err)}`);
|
|
759
|
+
} finally {
|
|
760
|
+
await session.close();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
var SUMMARY_MAX_LEN = 200;
|
|
764
|
+
var persistMessageLocks = /* @__PURE__ */ new Map();
|
|
765
|
+
async function persistMessage(conversationId, role, content, accountId, tokens, createdAt, sender, components, attachments) {
|
|
766
|
+
if (!content) return null;
|
|
767
|
+
const messageId = randomUUID();
|
|
768
|
+
const summary = role === "user" ? content.slice(0, SUMMARY_MAX_LEN).trim() : "";
|
|
769
|
+
let embedding = null;
|
|
770
|
+
try {
|
|
771
|
+
embedding = await embed(content);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
console.error(`[persist] Embedding failed, storing without: ${err instanceof Error ? err.message : String(err)}`);
|
|
774
|
+
}
|
|
775
|
+
const prev = persistMessageLocks.get(conversationId);
|
|
776
|
+
const waited = prev !== void 0;
|
|
777
|
+
let release;
|
|
778
|
+
const mine = new Promise((resolve5) => {
|
|
779
|
+
release = resolve5;
|
|
780
|
+
});
|
|
781
|
+
const chained = (prev ?? Promise.resolve()).then(() => mine);
|
|
782
|
+
persistMessageLocks.set(conversationId, chained);
|
|
783
|
+
await prev;
|
|
784
|
+
const session = getSession();
|
|
785
|
+
try {
|
|
786
|
+
const result = await session.run(
|
|
787
|
+
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
788
|
+
OPTIONAL MATCH (tail:Message)-[:PART_OF]->(c)
|
|
789
|
+
WHERE NOT (tail)-[:NEXT]->(:Message)
|
|
790
|
+
AND (tail:UserMessage OR tail:AssistantMessage)
|
|
791
|
+
// Task 857 \u2014 sublabel-scope the tail to the agent-path's own sublabels.
|
|
792
|
+
// Without this, a Task-857 :WhatsAppMessage row (no outgoing :NEXT)
|
|
793
|
+
// would be picked as tail and the agent-path would chain
|
|
794
|
+
// :WhatsAppMessage\u2192:UserMessage, crossing the live-channel chain.
|
|
795
|
+
// The live writer's tail predicate is sublabel-scoped to
|
|
796
|
+
// :WhatsAppMessage; this clause is the matching defence on the
|
|
797
|
+
// agent side. Backward-compatible \u2014 pre-Task-857 graphs only carry
|
|
798
|
+
// UserMessage/AssistantMessage sublabels under :Message.
|
|
799
|
+
// Capture whether THIS write's guard will fire. Read c.summary
|
|
800
|
+
// before the SET so the boolean reflects the pre-state, not the
|
|
801
|
+
// post-state \u2014 a false positive otherwise when a new user message
|
|
802
|
+
// happens to equal an already-set summary (verbatim retry, short
|
|
803
|
+
// catchphrase) and fooled a post-state equality check.
|
|
804
|
+
WITH c, tail, (c.summary IS NULL AND $role = 'user' AND $summary <> '') AS summarySetThisWrite
|
|
805
|
+
CREATE (m:Message:${role === "user" ? "UserMessage" : "AssistantMessage"} {
|
|
806
|
+
messageId: $messageId,
|
|
807
|
+
conversationId: $conversationId,
|
|
808
|
+
accountId: $accountId,
|
|
809
|
+
role: $role,
|
|
810
|
+
content: $content,
|
|
811
|
+
createdAt: ${createdAt ? "datetime($createdAt)" : "datetime()"}
|
|
812
|
+
${embedding ? ", embedding: $embedding" : ""}
|
|
813
|
+
${sender ? ", senderVisitorId: $senderVisitorId, senderName: $senderName" : ""}
|
|
814
|
+
${tokens?.inputTokens != null ? ", inputTokens: $inputTokens" : ""}
|
|
815
|
+
${tokens?.outputTokens != null ? ", outputTokens: $outputTokens" : ""}
|
|
816
|
+
${tokens?.cacheReadTokens != null ? ", cacheReadTokens: $cacheReadTokens" : ""}
|
|
817
|
+
${tokens?.cacheCreationTokens != null ? ", cacheCreationTokens: $cacheCreationTokens" : ""}
|
|
818
|
+
})
|
|
819
|
+
SET c.updatedAt = datetime()
|
|
820
|
+
CREATE (m)-[:PART_OF]->(c)
|
|
821
|
+
FOREACH (prev IN CASE WHEN tail IS NULL THEN [] ELSE [tail] END |
|
|
822
|
+
CREATE (prev)-[:NEXT]->(m)
|
|
823
|
+
)
|
|
824
|
+
FOREACH (_ IN CASE WHEN summarySetThisWrite THEN [1] ELSE [] END |
|
|
825
|
+
SET c.summary = $summary
|
|
826
|
+
)
|
|
827
|
+
FOREACH (comp IN $components |
|
|
828
|
+
CREATE (m)-[:HAS_COMPONENT]->(:Component {
|
|
829
|
+
componentId: comp.componentId,
|
|
830
|
+
conversationId: $conversationId,
|
|
831
|
+
accountId: $accountId,
|
|
832
|
+
messageId: $messageId,
|
|
833
|
+
name: comp.name,
|
|
834
|
+
data: comp.data,
|
|
835
|
+
ordinal: comp.ordinal,
|
|
836
|
+
textOffset: comp.textOffset,
|
|
837
|
+
submitted: false,
|
|
838
|
+
createdAt: datetime()
|
|
839
|
+
})
|
|
840
|
+
)
|
|
841
|
+
FOREACH (att IN $attachments |
|
|
842
|
+
CREATE (m)-[:HAS_ATTACHMENT]->(:Attachment {
|
|
843
|
+
attachmentId: att.attachmentId,
|
|
844
|
+
conversationId: $conversationId,
|
|
845
|
+
accountId: $accountId,
|
|
846
|
+
messageId: $messageId,
|
|
847
|
+
filename: att.filename,
|
|
848
|
+
mimeType: att.mimeType,
|
|
849
|
+
sizeBytes: att.sizeBytes,
|
|
850
|
+
storagePath: att.storagePath,
|
|
851
|
+
ordinal: att.ordinal,
|
|
852
|
+
createdAt: datetime()
|
|
853
|
+
})
|
|
854
|
+
)
|
|
855
|
+
RETURN tail.messageId AS prevMessageId,
|
|
856
|
+
summarySetThisWrite,
|
|
857
|
+
size([(m2:Message)-[:PART_OF]->(c) | m2]) AS chainLen`,
|
|
858
|
+
{
|
|
859
|
+
messageId,
|
|
860
|
+
conversationId,
|
|
861
|
+
accountId,
|
|
862
|
+
role,
|
|
863
|
+
content,
|
|
864
|
+
summary,
|
|
865
|
+
...createdAt ? { createdAt } : {},
|
|
866
|
+
...embedding ? { embedding } : {},
|
|
867
|
+
...sender ? { senderVisitorId: sender.visitorId, senderName: sender.displayName } : {},
|
|
868
|
+
...tokens?.inputTokens != null ? { inputTokens: neo4j.int(tokens.inputTokens) } : {},
|
|
869
|
+
...tokens?.outputTokens != null ? { outputTokens: neo4j.int(tokens.outputTokens) } : {},
|
|
870
|
+
...tokens?.cacheReadTokens != null ? { cacheReadTokens: neo4j.int(tokens.cacheReadTokens) } : {},
|
|
871
|
+
...tokens?.cacheCreationTokens != null ? { cacheCreationTokens: neo4j.int(tokens.cacheCreationTokens) } : {},
|
|
872
|
+
components: (components ?? []).map((comp) => ({
|
|
873
|
+
componentId: comp.componentId,
|
|
874
|
+
name: comp.name,
|
|
875
|
+
data: comp.data,
|
|
876
|
+
ordinal: neo4j.int(comp.ordinal),
|
|
877
|
+
textOffset: neo4j.int(comp.textOffset)
|
|
878
|
+
})),
|
|
879
|
+
attachments: (attachments ?? []).map((att) => ({
|
|
880
|
+
attachmentId: att.attachmentId,
|
|
881
|
+
filename: att.filename,
|
|
882
|
+
mimeType: att.mimeType,
|
|
883
|
+
sizeBytes: neo4j.int(att.sizeBytes),
|
|
884
|
+
storagePath: att.storagePath,
|
|
885
|
+
ordinal: neo4j.int(att.ordinal)
|
|
886
|
+
}))
|
|
887
|
+
}
|
|
888
|
+
);
|
|
889
|
+
if (result.records.length === 0) {
|
|
890
|
+
console.error(`[persist] Neo4j write skipped \u2014 conversation not found: ${conversationId.slice(0, 8)}\u2026`);
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
const record = result.records[0];
|
|
894
|
+
const prevMessageId = record.get("prevMessageId") ?? null;
|
|
895
|
+
const summarySetThisWrite = record.get("summarySetThisWrite") === true;
|
|
896
|
+
const chainLenRaw = record.get("chainLen");
|
|
897
|
+
const chainLen = typeof chainLenRaw === "bigint" ? Number(chainLenRaw) : typeof chainLenRaw?.toNumber === "function" ? chainLenRaw.toNumber() : Number(chainLenRaw ?? 0);
|
|
898
|
+
const messageSublabel = role === "user" ? "UserMessage" : "AssistantMessage";
|
|
899
|
+
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}`);
|
|
900
|
+
if (summarySetThisWrite) {
|
|
901
|
+
console.error(`[neo4j-store] conversation-summary-set conversationId=${conversationId.slice(0, 8)}\u2026 len=${summary.length}`);
|
|
902
|
+
}
|
|
903
|
+
const componentList = components ?? [];
|
|
904
|
+
if (componentList.length > 0) {
|
|
905
|
+
const relsCreated = result.summary.counters.updates().relationshipsCreated;
|
|
906
|
+
const expectedComponentEdges = componentList.length;
|
|
907
|
+
const baseEdges = relsCreated - expectedComponentEdges;
|
|
908
|
+
if (baseEdges < 1) {
|
|
909
|
+
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`);
|
|
910
|
+
}
|
|
911
|
+
for (const comp of componentList) {
|
|
912
|
+
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}`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const attachmentList = attachments ?? [];
|
|
916
|
+
if (attachmentList.length > 0) {
|
|
917
|
+
const relsCreated = result.summary.counters.updates().relationshipsCreated;
|
|
918
|
+
const expectedAttachmentEdges = attachmentList.length;
|
|
919
|
+
const expectedComponentEdges = (components ?? []).length;
|
|
920
|
+
const baseEdges = relsCreated - expectedComponentEdges - expectedAttachmentEdges;
|
|
921
|
+
if (baseEdges < 1) {
|
|
922
|
+
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`);
|
|
923
|
+
}
|
|
924
|
+
for (const att of attachmentList) {
|
|
925
|
+
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}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
console.error(`[persist] ${(/* @__PURE__ */ new Date()).toISOString()} conversationId=${conversationId.slice(0, 8)}\u2026 role=${role} len=${content.length}${sender ? ` sender=${sender.displayName}` : ""}`);
|
|
929
|
+
return messageId;
|
|
930
|
+
} catch (err) {
|
|
931
|
+
console.error(`[persist] Neo4j write failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
932
|
+
return null;
|
|
933
|
+
} finally {
|
|
934
|
+
release();
|
|
935
|
+
if (persistMessageLocks.get(conversationId) === chained) {
|
|
936
|
+
persistMessageLocks.delete(conversationId);
|
|
937
|
+
}
|
|
938
|
+
await session.close();
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
async function setConversationAgentSessionId(conversationId, agentSessionId) {
|
|
942
|
+
const prev = persistMessageLocks.get(conversationId);
|
|
943
|
+
let release;
|
|
944
|
+
const mine = new Promise((resolve5) => {
|
|
945
|
+
release = resolve5;
|
|
946
|
+
});
|
|
947
|
+
const chained = (prev ?? Promise.resolve()).then(() => mine);
|
|
948
|
+
persistMessageLocks.set(conversationId, chained);
|
|
949
|
+
await prev;
|
|
950
|
+
const session = getSession();
|
|
951
|
+
try {
|
|
952
|
+
const result = await session.run(
|
|
953
|
+
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
954
|
+
SET c.agentSessionId = $agentSessionId
|
|
955
|
+
RETURN c.conversationId AS conversationId`,
|
|
956
|
+
{ conversationId, agentSessionId }
|
|
957
|
+
);
|
|
958
|
+
if (result.records.length === 0) {
|
|
959
|
+
console.error(`[persist] agent-session-id convId=${conversationId.slice(0, 8)}\u2026 status=skipped reason=conversation-not-found`);
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
console.log(`[persist] agent-session-id convId=${conversationId.slice(0, 8)}\u2026 status=ok agentSessionId=${agentSessionId ? agentSessionId.slice(0, 8) + "\u2026" : "null"}`);
|
|
963
|
+
return true;
|
|
964
|
+
} catch (err) {
|
|
965
|
+
console.error(`[persist] agent-session-id convId=${conversationId.slice(0, 8)}\u2026 status=failed error=${err instanceof Error ? err.message : String(err)}`);
|
|
966
|
+
return false;
|
|
967
|
+
} finally {
|
|
968
|
+
release();
|
|
969
|
+
if (persistMessageLocks.get(conversationId) === chained) {
|
|
970
|
+
persistMessageLocks.delete(conversationId);
|
|
971
|
+
}
|
|
972
|
+
await session.close();
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
async function getAgentSessionIdForConversation(conversationId) {
|
|
976
|
+
const session = getSession();
|
|
977
|
+
try {
|
|
978
|
+
const result = await session.run(
|
|
979
|
+
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
980
|
+
RETURN c.agentSessionId AS agentSessionId`,
|
|
981
|
+
{ conversationId }
|
|
982
|
+
);
|
|
983
|
+
const value = result.records[0]?.get("agentSessionId");
|
|
984
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
985
|
+
} catch (err) {
|
|
986
|
+
console.error(`[persist] agent-session-id read failed convId=${conversationId.slice(0, 8)}\u2026 error=${err instanceof Error ? err.message : String(err)}`);
|
|
987
|
+
return null;
|
|
988
|
+
} finally {
|
|
989
|
+
await session.close();
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
async function getRecentMessages(conversationId, limit = 50) {
|
|
993
|
+
const session = getSession();
|
|
994
|
+
try {
|
|
995
|
+
const result = await session.run(
|
|
996
|
+
`MATCH (tail:Message {conversationId: $conversationId})
|
|
997
|
+
WHERE NOT (tail)-[:NEXT]->(:Message)
|
|
998
|
+
WITH tail ORDER BY tail.createdAt DESC LIMIT 1
|
|
999
|
+
MATCH path = (m:Message)-[:NEXT*0..]->(tail)
|
|
1000
|
+
WHERE m.conversationId = $conversationId
|
|
1001
|
+
AND length(path) < $limit
|
|
1002
|
+
WITH m, length(path) AS depthFromTail
|
|
1003
|
+
OPTIONAL MATCH (m)-[:HAS_COMPONENT]->(c:Component)
|
|
1004
|
+
WITH m, depthFromTail, c ORDER BY c.ordinal ASC
|
|
1005
|
+
WITH m, depthFromTail,
|
|
1006
|
+
[comp IN collect(c) WHERE comp IS NOT NULL | comp {.*}] AS components
|
|
1007
|
+
OPTIONAL MATCH (m)-[:HAS_ATTACHMENT]->(a:Attachment)
|
|
1008
|
+
WITH m, depthFromTail, components, a ORDER BY a.ordinal ASC
|
|
1009
|
+
WITH m, depthFromTail, components,
|
|
1010
|
+
[att IN collect(a) WHERE att IS NOT NULL | att {.*}] AS attachments
|
|
1011
|
+
RETURN m.messageId AS messageId, m.role AS role, m.content AS content,
|
|
1012
|
+
m.createdAt AS createdAt, components, attachments
|
|
1013
|
+
ORDER BY depthFromTail DESC`,
|
|
1014
|
+
{ conversationId, limit: neo4j.int(limit) }
|
|
1015
|
+
);
|
|
1016
|
+
return result.records.map((r) => {
|
|
1017
|
+
const rawComponents = r.get("components") ?? [];
|
|
1018
|
+
const components = rawComponents.map((c) => ({
|
|
1019
|
+
componentId: String(c.componentId ?? ""),
|
|
1020
|
+
name: String(c.name ?? ""),
|
|
1021
|
+
data: String(c.data ?? ""),
|
|
1022
|
+
ordinal: typeof c.ordinal?.toNumber === "function" ? c.ordinal.toNumber() : Number(c.ordinal ?? 0),
|
|
1023
|
+
textOffset: typeof c.textOffset?.toNumber === "function" ? c.textOffset.toNumber() : Number(c.textOffset ?? 0),
|
|
1024
|
+
submitted: c.submitted === true
|
|
1025
|
+
}));
|
|
1026
|
+
const rawAttachments = r.get("attachments") ?? [];
|
|
1027
|
+
const attachments = rawAttachments.map((a) => ({
|
|
1028
|
+
attachmentId: String(a.attachmentId ?? ""),
|
|
1029
|
+
filename: String(a.filename ?? ""),
|
|
1030
|
+
mimeType: String(a.mimeType ?? ""),
|
|
1031
|
+
sizeBytes: typeof a.sizeBytes?.toNumber === "function" ? a.sizeBytes.toNumber() : Number(a.sizeBytes ?? 0),
|
|
1032
|
+
storagePath: String(a.storagePath ?? ""),
|
|
1033
|
+
ordinal: typeof a.ordinal?.toNumber === "function" ? a.ordinal.toNumber() : Number(a.ordinal ?? 0),
|
|
1034
|
+
accountId: String(a.accountId ?? "")
|
|
1035
|
+
}));
|
|
1036
|
+
return {
|
|
1037
|
+
messageId: r.get("messageId"),
|
|
1038
|
+
role: r.get("role"),
|
|
1039
|
+
content: r.get("content"),
|
|
1040
|
+
createdAt: String(r.get("createdAt")),
|
|
1041
|
+
components,
|
|
1042
|
+
attachments
|
|
1043
|
+
};
|
|
1044
|
+
});
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
console.error(`[persist] getRecentMessages failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1047
|
+
return [];
|
|
1048
|
+
} finally {
|
|
1049
|
+
await session.close();
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
async function markComponentSubmitted(conversationId, componentName, accountId) {
|
|
1053
|
+
const session = getSession();
|
|
1054
|
+
try {
|
|
1055
|
+
const result = await session.run(
|
|
1056
|
+
`MATCH (conv:Conversation {conversationId: $conversationId, accountId: $accountId})
|
|
1057
|
+
MATCH (m:Message)-[:PART_OF]->(conv)
|
|
1058
|
+
MATCH (m)-[:HAS_COMPONENT]->(c:Component {name: $componentName, accountId: $accountId})
|
|
1059
|
+
WHERE c.submitted = false OR c.submitted IS NULL
|
|
1060
|
+
WITH c, m ORDER BY m.createdAt DESC, c.ordinal DESC
|
|
1061
|
+
LIMIT 1
|
|
1062
|
+
SET c.submitted = true, c.submittedAt = datetime()
|
|
1063
|
+
RETURN c.componentId AS componentId`,
|
|
1064
|
+
{ conversationId, componentName, accountId }
|
|
1065
|
+
);
|
|
1066
|
+
const componentId = result.records[0]?.get("componentId");
|
|
1067
|
+
if (componentId) {
|
|
1068
|
+
console.error(`[neo4j-store] component-submitted conversationId=${conversationId.slice(0, 8)}\u2026 name=${componentName} componentId=${componentId.slice(0, 8)}\u2026`);
|
|
1069
|
+
return componentId;
|
|
1070
|
+
}
|
|
1071
|
+
console.error(`[neo4j-store] component-submit-skipped conversationId=${conversationId.slice(0, 8)}\u2026 name=${componentName} reason=no-unsubmitted-match`);
|
|
1072
|
+
return null;
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
console.error(`[neo4j-store] component-submit-failed conversationId=${conversationId.slice(0, 8)}\u2026 name=${componentName} error=${err instanceof Error ? err.message : String(err)}`);
|
|
1075
|
+
return null;
|
|
1076
|
+
} finally {
|
|
1077
|
+
await session.close();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async function verifyConversationOwnership(conversationId, accountId) {
|
|
1081
|
+
const session = getSession();
|
|
1082
|
+
try {
|
|
1083
|
+
const result = await session.run(
|
|
1084
|
+
`MATCH (c:Conversation {conversationId: $conversationId, accountId: $accountId})
|
|
1085
|
+
RETURN c.conversationId AS id LIMIT 1`,
|
|
1086
|
+
{ conversationId, accountId }
|
|
1087
|
+
);
|
|
1088
|
+
return result.records.length > 0;
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
console.error(`[persist] verifyConversationOwnership failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1091
|
+
return false;
|
|
1092
|
+
} finally {
|
|
1093
|
+
await session.close();
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
async function verifyAndGetConversationUpdatedAt(conversationId, accountId) {
|
|
1097
|
+
const session = getSession();
|
|
1098
|
+
try {
|
|
1099
|
+
const result = await session.run(
|
|
1100
|
+
`MATCH (c:Conversation {conversationId: $conversationId, accountId: $accountId})
|
|
1101
|
+
RETURN toString(c.updatedAt) AS updatedAt LIMIT 1`,
|
|
1102
|
+
{ conversationId, accountId }
|
|
1103
|
+
);
|
|
1104
|
+
const record = result.records[0];
|
|
1105
|
+
return record ? record.get("updatedAt") : null;
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
console.error(`[persist] verifyAndGetConversationUpdatedAt failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1108
|
+
return null;
|
|
1109
|
+
} finally {
|
|
1110
|
+
await session.close();
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
async function searchMessages(accountId, queryEmbedding, limit = 10) {
|
|
1114
|
+
const session = getSession();
|
|
1115
|
+
try {
|
|
1116
|
+
const result = await session.run(
|
|
1117
|
+
`CALL db.index.vector.queryNodes('message_embedding', $limit, $embedding)
|
|
1118
|
+
YIELD node, score
|
|
1119
|
+
WHERE node.accountId = $accountId
|
|
1120
|
+
RETURN node.messageId AS messageId,
|
|
1121
|
+
node.role AS role,
|
|
1122
|
+
node.content AS content,
|
|
1123
|
+
node.conversationId AS conversationId,
|
|
1124
|
+
node.createdAt AS createdAt,
|
|
1125
|
+
score
|
|
1126
|
+
ORDER BY score DESC
|
|
1127
|
+
LIMIT $limit`,
|
|
1128
|
+
{ embedding: queryEmbedding, limit: neo4j.int(limit), accountId }
|
|
1129
|
+
);
|
|
1130
|
+
return result.records.map((r) => ({
|
|
1131
|
+
messageId: r.get("messageId"),
|
|
1132
|
+
role: r.get("role"),
|
|
1133
|
+
content: r.get("content"),
|
|
1134
|
+
conversationId: r.get("conversationId"),
|
|
1135
|
+
createdAt: String(r.get("createdAt")),
|
|
1136
|
+
score: typeof r.get("score") === "number" ? r.get("score") : Number(r.get("score"))
|
|
1137
|
+
}));
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
console.error(`[persist] searchMessages failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1140
|
+
return [];
|
|
1141
|
+
} finally {
|
|
1142
|
+
await session.close();
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
var LIST_BACKFILL_CAP = 3;
|
|
1146
|
+
async function listAdminSessions(accountId, userId, limit = 20) {
|
|
1147
|
+
const session = getSession();
|
|
1148
|
+
try {
|
|
1149
|
+
const result = await session.run(
|
|
1150
|
+
`MATCH (c:Conversation {accountId: $accountId, agentType: 'admin', userId: $userId})
|
|
1151
|
+
WITH c
|
|
1152
|
+
ORDER BY c.updatedAt DESC
|
|
1153
|
+
LIMIT $limit
|
|
1154
|
+
CALL {
|
|
1155
|
+
WITH c
|
|
1156
|
+
OPTIONAL MATCH (m:Message)-[:PART_OF]->(c)
|
|
1157
|
+
WHERE m.role = 'user'
|
|
1158
|
+
AND c.name IS NULL
|
|
1159
|
+
AND NOT (m.content STARTS WITH '[New session.')
|
|
1160
|
+
AND NOT (m.content STARTS WITH '{"')
|
|
1161
|
+
AND size(m.content) >= 4
|
|
1162
|
+
RETURN m.content AS content, m.createdAt AS createdAt
|
|
1163
|
+
ORDER BY m.createdAt ASC
|
|
1164
|
+
LIMIT 1
|
|
1165
|
+
}
|
|
1166
|
+
RETURN c.conversationId AS conversationId,
|
|
1167
|
+
c.name AS name,
|
|
1168
|
+
c.updatedAt AS updatedAt,
|
|
1169
|
+
content AS firstSubstantiveUserMessage`,
|
|
1170
|
+
{ accountId, userId, limit: neo4j.int(limit) }
|
|
1171
|
+
);
|
|
1172
|
+
const rows = result.records.map((r) => ({
|
|
1173
|
+
conversationId: r.get("conversationId"),
|
|
1174
|
+
name: r.get("name"),
|
|
1175
|
+
updatedAt: String(r.get("updatedAt")),
|
|
1176
|
+
firstSubstantiveUserMessage: r.get("firstSubstantiveUserMessage")
|
|
1177
|
+
}));
|
|
1178
|
+
let backfillsKicked = 0;
|
|
1179
|
+
for (const row of rows) {
|
|
1180
|
+
if (backfillsKicked >= LIST_BACKFILL_CAP) break;
|
|
1181
|
+
if (row.name !== null) continue;
|
|
1182
|
+
const seed = row.firstSubstantiveUserMessage;
|
|
1183
|
+
if (!seed || !isMessageUseful(seed)) continue;
|
|
1184
|
+
backfillsKicked++;
|
|
1185
|
+
autoLabelSession(row.conversationId, seed).catch(() => {
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
return rows.map(({ conversationId, name, updatedAt }) => ({ conversationId, name, updatedAt }));
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
console.error(`[persist] listAdminSessions failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1191
|
+
return [];
|
|
1192
|
+
} finally {
|
|
1193
|
+
await session.close();
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
async function deleteConversation(conversationId) {
|
|
1197
|
+
const session = getSession();
|
|
1198
|
+
try {
|
|
1199
|
+
const result = await session.run(
|
|
1200
|
+
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
1201
|
+
OPTIONAL MATCH (m:Message)-[:PART_OF]->(c)
|
|
1202
|
+
OPTIONAL MATCH (m)-[:HAS_COMPONENT]->(comp:Component)
|
|
1203
|
+
OPTIONAL MATCH (m)-[:HAS_ATTACHMENT]->(att:Attachment)
|
|
1204
|
+
DETACH DELETE att, comp, m, c
|
|
1205
|
+
RETURN count(c) AS deleted`,
|
|
1206
|
+
{ conversationId }
|
|
1207
|
+
);
|
|
1208
|
+
const deleted = result.records[0]?.get("deleted");
|
|
1209
|
+
const count = typeof deleted === "object" && deleted !== null ? Number(deleted) : Number(deleted ?? 0);
|
|
1210
|
+
console.error(`[persist] deleteConversation ${conversationId.slice(0, 8)}\u2026: ${count > 0 ? "deleted" : "not found"}`);
|
|
1211
|
+
return count > 0;
|
|
1212
|
+
} catch (err) {
|
|
1213
|
+
console.error(`[persist] deleteConversation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1214
|
+
return false;
|
|
1215
|
+
} finally {
|
|
1216
|
+
await session.close();
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
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;
|
|
1220
|
+
var SESSION_LABEL_MSG_CAP = 500;
|
|
1221
|
+
var SESSION_LABEL_TIMEOUT_MS = 15e3;
|
|
1222
|
+
var SESSION_LABEL_MODEL = HAIKU_MODEL;
|
|
1223
|
+
var SESSION_LABEL_MAX_WORDS = 6;
|
|
1224
|
+
var SESSION_LABEL_MAX_ATTEMPTS = 3;
|
|
1225
|
+
var SESSION_LABEL_MAX_STDERR = 2048;
|
|
1226
|
+
function isMessageUseful(message) {
|
|
1227
|
+
const trimmed = message.trim();
|
|
1228
|
+
if (trimmed.length < 4) return false;
|
|
1229
|
+
if (trimmed.startsWith('{"')) return false;
|
|
1230
|
+
if (GENERIC_MESSAGE.test(trimmed)) return false;
|
|
1231
|
+
if (trimmed.startsWith(DIRECTIVE_PREFIX)) return false;
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
function totalFailures(f) {
|
|
1235
|
+
return f.skip + f.error;
|
|
1236
|
+
}
|
|
1237
|
+
function failureBreakdown(f) {
|
|
1238
|
+
return `skip:${f.skip} error:${f.error}`;
|
|
1239
|
+
}
|
|
1240
|
+
var labelAccumulator = /* @__PURE__ */ new Map();
|
|
1241
|
+
var _spawnOverride = null;
|
|
1242
|
+
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.
|
|
1243
|
+
|
|
1244
|
+
Rules:
|
|
1245
|
+
- Exactly 3 to 6 words
|
|
1246
|
+
- Summarize the user's intent, do not copy verbatim
|
|
1247
|
+
- Capitalize the first word only (sentence case)
|
|
1248
|
+
- No punctuation, no quotes
|
|
1249
|
+
- If the messages are too vague or meaningless to summarize, respond with exactly: SKIP`;
|
|
1250
|
+
async function generateSessionLabel(messages) {
|
|
1251
|
+
const cappedMessages = messages.map((m) => m.slice(0, SESSION_LABEL_MSG_CAP));
|
|
1252
|
+
const userContent = cappedMessages.map((m, i) => `Message ${i + 1}: ${m}`).join("\n");
|
|
1253
|
+
const prompt = `${SESSION_LABEL_SYSTEM}
|
|
1254
|
+
|
|
1255
|
+
${userContent}`;
|
|
1256
|
+
const args = [
|
|
1257
|
+
"--print",
|
|
1258
|
+
"--model",
|
|
1259
|
+
SESSION_LABEL_MODEL,
|
|
1260
|
+
"--max-turns",
|
|
1261
|
+
"1",
|
|
1262
|
+
"--permission-mode",
|
|
1263
|
+
"dontAsk",
|
|
1264
|
+
prompt
|
|
1265
|
+
];
|
|
1266
|
+
return new Promise((resolve5) => {
|
|
1267
|
+
let stdout = "";
|
|
1268
|
+
let stderr = "";
|
|
1269
|
+
const spawnFn = _spawnOverride ?? spawn2;
|
|
1270
|
+
const proc = spawnFn("claude", args, {
|
|
1271
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1272
|
+
});
|
|
1273
|
+
proc.stdout?.on("data", (chunk) => {
|
|
1274
|
+
stdout += chunk.toString("utf-8");
|
|
1275
|
+
});
|
|
1276
|
+
proc.stderr?.on("data", (chunk) => {
|
|
1277
|
+
if (stderr.length < SESSION_LABEL_MAX_STDERR) {
|
|
1278
|
+
stderr += chunk.toString("utf-8").slice(0, SESSION_LABEL_MAX_STDERR - stderr.length);
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
const timer = setTimeout(() => {
|
|
1282
|
+
proc.kill("SIGTERM");
|
|
1283
|
+
console.error("[persist] autoLabel: haiku subprocess timed out");
|
|
1284
|
+
resolve5(null);
|
|
1285
|
+
}, SESSION_LABEL_TIMEOUT_MS);
|
|
1286
|
+
proc.on("error", (err) => {
|
|
1287
|
+
clearTimeout(timer);
|
|
1288
|
+
console.error(`[persist] autoLabel: subprocess error \u2014 ${err.message}`);
|
|
1289
|
+
resolve5(null);
|
|
1290
|
+
});
|
|
1291
|
+
proc.on("close", (code) => {
|
|
1292
|
+
clearTimeout(timer);
|
|
1293
|
+
if (code !== 0) {
|
|
1294
|
+
console.error(`[persist] autoLabel: subprocess exited code=${code}${stderr ? ` stderr=${stderr.trim().slice(0, 200)}` : ""}`);
|
|
1295
|
+
resolve5(null);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
const text = stdout.trim();
|
|
1299
|
+
if (!text) {
|
|
1300
|
+
console.error("[persist] autoLabel: haiku returned empty response");
|
|
1301
|
+
resolve5(null);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (text === "SKIP") {
|
|
1305
|
+
console.error("[persist] autoLabel: haiku returned SKIP \u2014 messages too vague");
|
|
1306
|
+
resolve5(null);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
const words = text.split(/\s+/).slice(0, SESSION_LABEL_MAX_WORDS);
|
|
1310
|
+
const label = words.join(" ");
|
|
1311
|
+
console.error(`[persist] autoLabel: haiku response="${label}"`);
|
|
1312
|
+
resolve5(label);
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
async function autoLabelSession(conversationId, userMessage) {
|
|
1317
|
+
if (!conversationId) return;
|
|
1318
|
+
if (!isMessageUseful(userMessage)) {
|
|
1319
|
+
const trimmed = userMessage.trim();
|
|
1320
|
+
const reason = trimmed.startsWith('{"') ? "JSON envelope" : trimmed.startsWith(DIRECTIVE_PREFIX) ? "directive" : GENERIC_MESSAGE.test(trimmed) ? "greeting" : trimmed.length < 4 ? "too short" : "directive";
|
|
1321
|
+
console.error(`[persist] autoLabel: skipped ${conversationId.slice(0, 8)}\u2026 \u2014 ${reason}`);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
try {
|
|
1325
|
+
const preCheck = getSession();
|
|
1326
|
+
try {
|
|
1327
|
+
const res = await preCheck.run(
|
|
1328
|
+
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
1329
|
+
OPTIONAL MATCH (m:Message)-[:PART_OF]->(c)
|
|
1330
|
+
WHERE m.role = 'user'
|
|
1331
|
+
WITH c, count(m) AS userCount
|
|
1332
|
+
RETURN c.name AS name, userCount`,
|
|
1333
|
+
{ conversationId }
|
|
1334
|
+
);
|
|
1335
|
+
const firstRecord = res.records[0];
|
|
1336
|
+
const existingName = firstRecord?.get("name");
|
|
1337
|
+
const userCountRaw = firstRecord?.get("userCount");
|
|
1338
|
+
const userCount = typeof userCountRaw === "object" && userCountRaw !== null ? Number(userCountRaw) : Number(userCountRaw ?? 0);
|
|
1339
|
+
if (existingName) {
|
|
1340
|
+
console.error(`[persist] autoLabel: already named ${conversationId.slice(0, 8)}\u2026 \u2014 skipping`);
|
|
1341
|
+
labelAccumulator.delete(conversationId);
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (userCount > 3) {
|
|
1345
|
+
console.error(`[persist] autoLabel: past autolabel window ${conversationId.slice(0, 8)}\u2026 \u2014 userCount=${userCount}, skipping`);
|
|
1346
|
+
labelAccumulator.delete(conversationId);
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
} finally {
|
|
1350
|
+
await preCheck.close();
|
|
1351
|
+
}
|
|
1352
|
+
} catch (err) {
|
|
1353
|
+
console.error(`[persist] autoLabel: pre-check read failed for ${conversationId.slice(0, 8)}\u2026 \u2014 proceeding: ${err instanceof Error ? err.message : String(err)}`);
|
|
1354
|
+
}
|
|
1355
|
+
let entry = labelAccumulator.get(conversationId);
|
|
1356
|
+
if (!entry) {
|
|
1357
|
+
entry = {
|
|
1358
|
+
messages: [],
|
|
1359
|
+
pending: false,
|
|
1360
|
+
failures: { skip: 0, error: 0 }
|
|
1361
|
+
};
|
|
1362
|
+
labelAccumulator.set(conversationId, entry);
|
|
1363
|
+
}
|
|
1364
|
+
if (totalFailures(entry.failures) >= SESSION_LABEL_MAX_ATTEMPTS) {
|
|
1365
|
+
console.error(`[persist] autoLabel: evicted ${conversationId.slice(0, 8)}\u2026 after ${SESSION_LABEL_MAX_ATTEMPTS} failed-attempts (${failureBreakdown(entry.failures)})`);
|
|
1366
|
+
labelAccumulator.delete(conversationId);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
entry.messages.push(userMessage.trim());
|
|
1370
|
+
if (entry.pending) {
|
|
1371
|
+
console.error(`[persist] autoLabel: accumulated for ${conversationId.slice(0, 8)}\u2026 (pending, ${entry.messages.length} msgs)`);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
entry.pending = true;
|
|
1375
|
+
try {
|
|
1376
|
+
const label = await generateSessionLabel(entry.messages);
|
|
1377
|
+
if (!label) {
|
|
1378
|
+
entry.failures.skip++;
|
|
1379
|
+
console.error(`[persist] autoLabel: generateSessionLabel returned null for ${conversationId.slice(0, 8)}\u2026 (failures ${failureBreakdown(entry.failures)}, ${entry.messages.length} msgs)`);
|
|
1380
|
+
entry.pending = false;
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const fullLabel = `${label} \xB7 ${conversationId.slice(0, 8)}`;
|
|
1384
|
+
let embedding = null;
|
|
1385
|
+
try {
|
|
1386
|
+
embedding = await embed(fullLabel);
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
console.error(`[persist] Conversation embedding failed, labelling without: ${err instanceof Error ? err.message : String(err)}`);
|
|
1389
|
+
}
|
|
1390
|
+
const session = getSession();
|
|
1391
|
+
try {
|
|
1392
|
+
const result = await session.run(
|
|
1393
|
+
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
1394
|
+
WHERE c.name IS NULL
|
|
1395
|
+
WITH c
|
|
1396
|
+
OPTIONAL MATCH (m:Message)-[:PART_OF]->(c)
|
|
1397
|
+
WHERE m.role = 'user'
|
|
1398
|
+
WITH c, count(m) AS userCount
|
|
1399
|
+
WHERE userCount <= 3
|
|
1400
|
+
SET c.name = $label, c.updatedAt = datetime()
|
|
1401
|
+
${embedding ? ", c.embedding = $embedding" : ""}
|
|
1402
|
+
RETURN c.name AS name`,
|
|
1403
|
+
{ conversationId, label: fullLabel, ...embedding ? { embedding } : {} }
|
|
1404
|
+
);
|
|
1405
|
+
if (result.records.length > 0) {
|
|
1406
|
+
console.error(`[persist] autoLabel: commit ${conversationId.slice(0, 8)}\u2026 name="${fullLabel}"${embedding ? " (embedded)" : ""}`);
|
|
1407
|
+
labelAccumulator.delete(conversationId);
|
|
1408
|
+
} else {
|
|
1409
|
+
console.error(`[persist] autoLabel: no-op commit ${conversationId.slice(0, 8)}\u2026 (name already set or userCount>3)`);
|
|
1410
|
+
labelAccumulator.delete(conversationId);
|
|
1411
|
+
}
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
entry.failures.error++;
|
|
1414
|
+
console.error(`[persist] autoLabelSession failed: ${err instanceof Error ? err.message : String(err)} (failures ${failureBreakdown(entry.failures)})`);
|
|
1415
|
+
} finally {
|
|
1416
|
+
await session.close();
|
|
1417
|
+
}
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
const currentEntry = labelAccumulator.get(conversationId);
|
|
1420
|
+
if (currentEntry) currentEntry.failures.error++;
|
|
1421
|
+
console.error(`[persist] autoLabel: unexpected error \u2014 ${err instanceof Error ? err.message : String(err)}${currentEntry ? ` (failures ${failureBreakdown(currentEntry.failures)})` : ""}`);
|
|
1422
|
+
} finally {
|
|
1423
|
+
const currentEntry = labelAccumulator.get(conversationId);
|
|
1424
|
+
if (currentEntry) currentEntry.pending = false;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
async function renameConversation(conversationId, label) {
|
|
1428
|
+
let embedding = null;
|
|
1429
|
+
try {
|
|
1430
|
+
embedding = await embed(label);
|
|
1431
|
+
} catch (err) {
|
|
1432
|
+
console.error(`[persist] manual-label: embedding failed, persisting without: ${err instanceof Error ? err.message : String(err)}`);
|
|
1433
|
+
}
|
|
1434
|
+
const session = getSession();
|
|
1435
|
+
try {
|
|
1436
|
+
await session.run(
|
|
1437
|
+
`MATCH (c:Conversation {conversationId: $conversationId})
|
|
1438
|
+
SET c.name = $label, c.updatedAt = datetime()
|
|
1439
|
+
${embedding ? ", c.embedding = $embedding" : ""}`,
|
|
1440
|
+
{ conversationId, label, ...embedding ? { embedding } : {} }
|
|
1441
|
+
);
|
|
1442
|
+
console.error(`[persist] manual-label: renamed ${conversationId} to "${label}"${embedding ? " (embedded)" : ""}`);
|
|
1443
|
+
} finally {
|
|
1444
|
+
await session.close();
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
var INITIAL_CONFIDENCE = 0.5;
|
|
1448
|
+
var REINFORCEMENT_INCREMENT = 0.15;
|
|
1449
|
+
var DECAY_THRESHOLD_DAYS = 14;
|
|
1450
|
+
var DECAY_RATE_PER_DAY = 0.05;
|
|
1451
|
+
var INJECTION_THRESHOLD = 0.4;
|
|
1452
|
+
var MAX_SUMMARY_PREFERENCES = 30;
|
|
1453
|
+
var VALID_CATEGORIES = /* @__PURE__ */ new Set([
|
|
1454
|
+
"communication",
|
|
1455
|
+
"scheduling",
|
|
1456
|
+
"decision",
|
|
1457
|
+
"workflow",
|
|
1458
|
+
"content",
|
|
1459
|
+
"interaction"
|
|
1460
|
+
]);
|
|
1461
|
+
async function getUserTimezone(accountId, userId) {
|
|
1462
|
+
const session = getSession();
|
|
1463
|
+
try {
|
|
1464
|
+
const query2 = userId ? `MATCH (up:UserProfile {accountId: $accountId, userId: $userId})
|
|
1465
|
+
RETURN up.timezone AS timezone` : `MATCH (up:UserProfile {accountId: $accountId})
|
|
1466
|
+
RETURN up.timezone AS timezone ORDER BY up.createdAt LIMIT 1`;
|
|
1467
|
+
const result = await session.run(query2, { accountId, userId: userId ?? "" });
|
|
1468
|
+
if (result.records.length === 0) return null;
|
|
1469
|
+
const tz = result.records[0].get("timezone");
|
|
1470
|
+
return tz && tz.trim().length > 0 ? tz : null;
|
|
1471
|
+
} catch (err) {
|
|
1472
|
+
console.error(`[datetime] getUserTimezone failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1473
|
+
return null;
|
|
1474
|
+
} finally {
|
|
1475
|
+
await session.close();
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
async function loadAdminUserName(accountId, userId) {
|
|
1479
|
+
const session = getSession();
|
|
1480
|
+
try {
|
|
1481
|
+
const result = await session.run(
|
|
1482
|
+
`MATCH (au:AdminUser {userId: $userId})-[:OWNS]->(p:Person {accountId: $accountId})
|
|
1483
|
+
RETURN p.givenName AS givenName, p.familyName AS familyName, p.avatar AS avatar
|
|
1484
|
+
LIMIT 1`,
|
|
1485
|
+
{ accountId, userId }
|
|
1486
|
+
);
|
|
1487
|
+
if (result.records.length === 0) {
|
|
1488
|
+
return { source: "fallback", reason: "no-person-node" };
|
|
1489
|
+
}
|
|
1490
|
+
const givenName = result.records[0].get("givenName");
|
|
1491
|
+
const familyName = result.records[0].get("familyName");
|
|
1492
|
+
const avatar = result.records[0].get("avatar");
|
|
1493
|
+
if (!givenName || givenName.trim().length === 0) {
|
|
1494
|
+
return { source: "fallback", reason: "no-givenName" };
|
|
1495
|
+
}
|
|
1496
|
+
const trimmedFamily = familyName && familyName.trim().length > 0 ? familyName.trim() : null;
|
|
1497
|
+
const trimmedAvatar = avatar && avatar.trim().length > 0 ? avatar.trim() : null;
|
|
1498
|
+
const joined = trimmedFamily ? `${givenName.trim()} ${trimmedFamily}` : givenName.trim();
|
|
1499
|
+
return { source: "neo4j", joined, givenName: givenName.trim(), familyName: trimmedFamily, avatar: trimmedAvatar };
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
console.error(`[admin-identity] loadAdminUserName failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1502
|
+
return { source: "fallback", reason: "neo4j-unreachable" };
|
|
1503
|
+
} finally {
|
|
1504
|
+
await session.close();
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
async function writeAdminUserAndPerson(params) {
|
|
1508
|
+
const { userId, fullName, accountId } = params;
|
|
1509
|
+
const trimmed = fullName.trim();
|
|
1510
|
+
if (!trimmed) {
|
|
1511
|
+
throw new Error("writeAdminUserAndPerson: fullName cannot be empty");
|
|
1512
|
+
}
|
|
1513
|
+
const firstSpace = trimmed.search(/\s/);
|
|
1514
|
+
const givenName = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace).trim();
|
|
1515
|
+
const familyName = firstSpace === -1 ? null : trimmed.slice(firstSpace + 1).trim() || null;
|
|
1516
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1517
|
+
const session = getSession();
|
|
1518
|
+
try {
|
|
1519
|
+
const result = await session.run(
|
|
1520
|
+
`MERGE (au:AdminUser {userId: $userId})
|
|
1521
|
+
ON CREATE SET au.name = $fullName, au.createdAt = $now
|
|
1522
|
+
ON MATCH SET au.name = $fullName, au.updatedAt = $now
|
|
1523
|
+
WITH au
|
|
1524
|
+
|
|
1525
|
+
// Case-insensitive Person reuse: same accountId, same givenName, same familyName
|
|
1526
|
+
// (null/empty familyName collapsed to '' so 'Joel' does NOT match 'Joel Smalley').
|
|
1527
|
+
OPTIONAL MATCH (existingPerson:Person {accountId: $accountId})
|
|
1528
|
+
WHERE toLower(existingPerson.givenName) = toLower($givenName)
|
|
1529
|
+
AND coalesce(toLower(existingPerson.familyName), '') = coalesce(toLower($familyName), '')
|
|
1530
|
+
WITH au, existingPerson
|
|
1531
|
+
|
|
1532
|
+
CALL {
|
|
1533
|
+
WITH au, existingPerson
|
|
1534
|
+
WITH au, existingPerson WHERE existingPerson IS NOT NULL
|
|
1535
|
+
MERGE (au)-[:OWNS]->(existingPerson)
|
|
1536
|
+
RETURN existingPerson AS p, true AS reused
|
|
1537
|
+
UNION
|
|
1538
|
+
WITH au, existingPerson
|
|
1539
|
+
WITH au WHERE existingPerson IS NULL
|
|
1540
|
+
CREATE (newPerson:Person {
|
|
1541
|
+
accountId: $accountId,
|
|
1542
|
+
givenName: $givenName,
|
|
1543
|
+
familyName: $familyName,
|
|
1544
|
+
role: 'admin-personal',
|
|
1545
|
+
scope: 'admin',
|
|
1546
|
+
createdAt: $now
|
|
1547
|
+
})
|
|
1548
|
+
MERGE (au)-[:OWNS]->(newPerson)
|
|
1549
|
+
RETURN newPerson AS p, false AS reused
|
|
1550
|
+
}
|
|
1551
|
+
RETURN reused, elementId(p) AS personElementId,
|
|
1552
|
+
p.givenName AS givenName, p.familyName AS familyName`,
|
|
1553
|
+
{ userId, fullName: trimmed, accountId, givenName, familyName, now }
|
|
1554
|
+
);
|
|
1555
|
+
if (result.records.length === 0) {
|
|
1556
|
+
throw new Error("writeAdminUserAndPerson: no record returned");
|
|
1557
|
+
}
|
|
1558
|
+
const record = result.records[0];
|
|
1559
|
+
return {
|
|
1560
|
+
personReused: record.get("reused"),
|
|
1561
|
+
personElementId: record.get("personElementId"),
|
|
1562
|
+
givenName: record.get("givenName"),
|
|
1563
|
+
familyName: record.get("familyName")
|
|
1564
|
+
};
|
|
1565
|
+
} finally {
|
|
1566
|
+
await session.close();
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
async function loadUserProfile(accountId, userId) {
|
|
1570
|
+
const session = getSession();
|
|
1571
|
+
try {
|
|
1572
|
+
const serverTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1573
|
+
const mergeResult = await session.run(
|
|
1574
|
+
`MATCH (au:AdminUser {userId: $userId})
|
|
1575
|
+
MERGE (au)-[:HAS_PROFILE]->(up:UserProfile {accountId: $accountId, userId: $userId})
|
|
1576
|
+
ON CREATE SET
|
|
1577
|
+
up.createdAt = $now,
|
|
1578
|
+
up.updatedAt = $now,
|
|
1579
|
+
up.profileVersion = 0,
|
|
1580
|
+
up.scope = 'admin',
|
|
1581
|
+
up.timezone = $serverTimezone
|
|
1582
|
+
ON MATCH SET
|
|
1583
|
+
up.timezone = CASE WHEN up.timezone IS NULL OR trim(up.timezone) = '' THEN $serverTimezone ELSE up.timezone END
|
|
1584
|
+
RETURN up.createdAt = $now AS isNew`,
|
|
1585
|
+
{ accountId, userId, now: (/* @__PURE__ */ new Date()).toISOString(), serverTimezone }
|
|
1586
|
+
);
|
|
1587
|
+
if (mergeResult.records.length === 0) {
|
|
1588
|
+
console.error(`[profile] loadUserProfile: AdminUser not found for userId=${userId} accountId=${accountId.slice(0, 8)}\u2026 \u2014 profile not created, invariant violated`);
|
|
1589
|
+
return null;
|
|
1590
|
+
}
|
|
1591
|
+
const isNew = mergeResult.records[0].get("isNew");
|
|
1592
|
+
if (isNew) {
|
|
1593
|
+
console.error(`[profile] created new profile: userId=${userId} accountId=${accountId.slice(0, 8)}\u2026 timezone=${serverTimezone}`);
|
|
1594
|
+
}
|
|
1595
|
+
const nowMs = Date.now();
|
|
1596
|
+
const thresholdMs = DECAY_THRESHOLD_DAYS * 24 * 60 * 60 * 1e3;
|
|
1597
|
+
const staleResult = await session.run(
|
|
1598
|
+
`MATCH (up:UserProfile {accountId: $accountId, userId: $userId})-[:HAS_PREFERENCE]->(pref:Preference)
|
|
1599
|
+
WHERE pref.observedAt IS NOT NULL
|
|
1600
|
+
RETURN pref.preferenceId AS preferenceId,
|
|
1601
|
+
pref.observedAt AS observedAt,
|
|
1602
|
+
pref.confidence AS confidence`,
|
|
1603
|
+
{ accountId, userId }
|
|
1604
|
+
);
|
|
1605
|
+
let decayCount = 0;
|
|
1606
|
+
for (const record of staleResult.records) {
|
|
1607
|
+
const observedAt = record.get("observedAt");
|
|
1608
|
+
const confidence = record.get("confidence");
|
|
1609
|
+
const observedMs = new Date(observedAt).getTime();
|
|
1610
|
+
const ageMs = nowMs - observedMs;
|
|
1611
|
+
if (ageMs > thresholdMs && confidence > 0) {
|
|
1612
|
+
const daysPastThreshold = (ageMs - thresholdMs) / (24 * 60 * 60 * 1e3);
|
|
1613
|
+
const decayedConfidence = Math.max(0, confidence - DECAY_RATE_PER_DAY * daysPastThreshold);
|
|
1614
|
+
if (decayedConfidence !== confidence) {
|
|
1615
|
+
await session.run(
|
|
1616
|
+
`MATCH (pref:Preference {preferenceId: $preferenceId, accountId: $accountId})
|
|
1617
|
+
SET pref.confidence = $confidence`,
|
|
1618
|
+
{
|
|
1619
|
+
preferenceId: record.get("preferenceId"),
|
|
1620
|
+
accountId,
|
|
1621
|
+
confidence: decayedConfidence
|
|
1622
|
+
}
|
|
1623
|
+
);
|
|
1624
|
+
decayCount++;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const result = await session.run(
|
|
1629
|
+
`MATCH (up:UserProfile {accountId: $accountId, userId: $userId})
|
|
1630
|
+
OPTIONAL MATCH (au:AdminUser {userId: $userId})-[:OWNS]->(p:Person {accountId: $accountId})
|
|
1631
|
+
OPTIONAL MATCH (up)-[:HAS_PREFERENCE]->(pref:Preference)
|
|
1632
|
+
WHERE pref.confidence >= $threshold
|
|
1633
|
+
WITH up, p, pref
|
|
1634
|
+
ORDER BY pref.confidence DESC
|
|
1635
|
+
WITH up, p, collect(pref) AS allPrefs
|
|
1636
|
+
WITH up, p, allPrefs[0..$limit] AS prefs
|
|
1637
|
+
RETURN up, p AS person, prefs`,
|
|
1638
|
+
{ accountId, userId, threshold: INJECTION_THRESHOLD, limit: MAX_SUMMARY_PREFERENCES }
|
|
1639
|
+
);
|
|
1640
|
+
if (result.records.length === 0 || !result.records[0].get("up")) {
|
|
1641
|
+
return null;
|
|
1642
|
+
}
|
|
1643
|
+
const upNode = result.records[0].get("up");
|
|
1644
|
+
const profileProps = { ...upNode.properties };
|
|
1645
|
+
const personNode = result.records[0].get("person");
|
|
1646
|
+
if (personNode?.properties) {
|
|
1647
|
+
if (personNode.properties.givenName !== void 0) profileProps.givenName = personNode.properties.givenName;
|
|
1648
|
+
if (personNode.properties.familyName !== void 0) profileProps.familyName = personNode.properties.familyName;
|
|
1649
|
+
}
|
|
1650
|
+
const prefNodes = result.records[0].get("prefs");
|
|
1651
|
+
const preferences = prefNodes.map((p) => ({
|
|
1652
|
+
preferenceId: p.properties.preferenceId,
|
|
1653
|
+
category: p.properties.category,
|
|
1654
|
+
key: p.properties.key,
|
|
1655
|
+
value: p.properties.value,
|
|
1656
|
+
confidence: p.properties.confidence,
|
|
1657
|
+
source: p.properties.source,
|
|
1658
|
+
observedAt: p.properties.observedAt
|
|
1659
|
+
}));
|
|
1660
|
+
const summary = formatProfileSummary(profileProps, preferences);
|
|
1661
|
+
console.error(
|
|
1662
|
+
`[profile] loaded for userId=${userId} accountId=${accountId.slice(0, 8)}\u2026 preferences=${preferences.length} (decay: ${decayCount} updated)`
|
|
1663
|
+
);
|
|
1664
|
+
return summary;
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
console.error(`[profile] loadUserProfile failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1667
|
+
return null;
|
|
1668
|
+
} finally {
|
|
1669
|
+
await session.close();
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
function formatProfileSummary(profile, preferences) {
|
|
1673
|
+
const givenName = typeof profile.givenName === "string" ? profile.givenName.trim() : "";
|
|
1674
|
+
const familyName = typeof profile.familyName === "string" ? profile.familyName.trim() : "";
|
|
1675
|
+
const joined = familyName ? `${givenName} ${familyName}` : givenName;
|
|
1676
|
+
const name = joined || "Owner";
|
|
1677
|
+
const role = profile.role ? ` (${profile.role})` : "";
|
|
1678
|
+
const tz = profile.timezone ? ` \u2014 ${profile.timezone}` : "";
|
|
1679
|
+
const locale = profile.locale ? `, ${profile.locale}` : "";
|
|
1680
|
+
const expertise = profile.expertise ? `
|
|
1681
|
+
Expertise: ${typeof profile.expertise === "string" ? profile.expertise : JSON.stringify(profile.expertise)}` : "";
|
|
1682
|
+
const lines = [`## About the Owner
|
|
1683
|
+
`];
|
|
1684
|
+
lines.push(`${name}${role}${tz}${locale}${expertise}
|
|
1685
|
+
`);
|
|
1686
|
+
const categories = {};
|
|
1687
|
+
for (const pref of preferences) {
|
|
1688
|
+
if (!categories[pref.category]) categories[pref.category] = [];
|
|
1689
|
+
categories[pref.category].push(pref);
|
|
1690
|
+
}
|
|
1691
|
+
const categoryLabels = {
|
|
1692
|
+
communication: "Communication",
|
|
1693
|
+
scheduling: "Scheduling",
|
|
1694
|
+
decision: "Decisions",
|
|
1695
|
+
workflow: "Workflow",
|
|
1696
|
+
content: "Content",
|
|
1697
|
+
interaction: "Interaction patterns"
|
|
1698
|
+
};
|
|
1699
|
+
const workCategories = ["communication", "scheduling", "decision", "workflow", "content"];
|
|
1700
|
+
const workPrefs = workCategories.filter((c) => categories[c]);
|
|
1701
|
+
if (workPrefs.length > 0) {
|
|
1702
|
+
lines.push(`### How they work`);
|
|
1703
|
+
for (const cat of workPrefs) {
|
|
1704
|
+
const label = categoryLabels[cat] || cat;
|
|
1705
|
+
const items = categories[cat].map((p) => p.value).join("; ");
|
|
1706
|
+
lines.push(`- ${label}: ${items}`);
|
|
1707
|
+
}
|
|
1708
|
+
lines.push("");
|
|
1709
|
+
}
|
|
1710
|
+
if (categories.interaction) {
|
|
1711
|
+
lines.push(`### Interaction patterns`);
|
|
1712
|
+
for (const pref of categories.interaction) {
|
|
1713
|
+
lines.push(`- ${pref.value}`);
|
|
1714
|
+
}
|
|
1715
|
+
lines.push("");
|
|
1716
|
+
}
|
|
1717
|
+
return lines.join("\n");
|
|
1718
|
+
}
|
|
1719
|
+
var MAX_SESSION_TASKS = 20;
|
|
1720
|
+
var MAX_SESSION_REVIEW_ALERTS = 5;
|
|
1721
|
+
var MAX_SESSION_PROJECTS = 5;
|
|
1722
|
+
var MAX_RECENT_TOOL_FAILURES = 3;
|
|
1723
|
+
var RECENT_FAILURES_TAIL_BYTES = 10 * 1024;
|
|
1724
|
+
function readRecentToolFailures(accountId, conversationId) {
|
|
1725
|
+
try {
|
|
1726
|
+
const platformRoot = process.env.MAXY_PLATFORM_ROOT ?? resolve2(process.cwd(), "..");
|
|
1727
|
+
const logDir = resolve2(platformRoot, "..", "data/accounts", accountId, "logs");
|
|
1728
|
+
const logPath = resolve2(logDir, `claude-agent-stream-${conversationId}.log`);
|
|
1729
|
+
if (!existsSync2(logPath)) {
|
|
1730
|
+
console.error(`[review-tail-skip] path=${logPath} reason=file-missing \u2014 first turn of conversation, subprocess not yet spawned, or log rotated`);
|
|
1731
|
+
return [];
|
|
1732
|
+
}
|
|
1733
|
+
const st = statSync2(logPath);
|
|
1734
|
+
const size = st.size;
|
|
1735
|
+
const readBytes = Math.min(size, RECENT_FAILURES_TAIL_BYTES);
|
|
1736
|
+
const position = size - readBytes;
|
|
1737
|
+
const fd = openSync(logPath, "r");
|
|
1738
|
+
try {
|
|
1739
|
+
const buf = Buffer.alloc(readBytes);
|
|
1740
|
+
readSync(fd, buf, 0, readBytes, position);
|
|
1741
|
+
const text = buf.toString("utf-8");
|
|
1742
|
+
const lines = text.split("\n");
|
|
1743
|
+
const matches = [];
|
|
1744
|
+
for (let i = lines.length - 1; i >= 0 && matches.length < MAX_RECENT_TOOL_FAILURES; i--) {
|
|
1745
|
+
const line = lines[i];
|
|
1746
|
+
if (line.includes("[tool-failure-diag]")) {
|
|
1747
|
+
matches.push(line);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
return matches.reverse();
|
|
1751
|
+
} finally {
|
|
1752
|
+
closeSync(fd);
|
|
1753
|
+
}
|
|
1754
|
+
} catch (err) {
|
|
1755
|
+
console.error(
|
|
1756
|
+
`[session-context] recent-tool-failures read failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1757
|
+
);
|
|
1758
|
+
return [];
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
async function loadSessionContext(accountId, conversationId) {
|
|
1762
|
+
const session = getSession();
|
|
1763
|
+
try {
|
|
1764
|
+
const digestResult = await session.run(
|
|
1765
|
+
`MATCH (d:CreativeWork {accountId: $accountId})
|
|
1766
|
+
WHERE d.title STARTS WITH 'Chat Review' OR d.title STARTS WITH 'Review Digest'
|
|
1767
|
+
RETURN d.title AS title, d.createdAt AS createdAt, d.abstract AS abstract
|
|
1768
|
+
ORDER BY d.createdAt DESC LIMIT 1`,
|
|
1769
|
+
{ accountId }
|
|
1770
|
+
);
|
|
1771
|
+
const tasksResult = await session.run(
|
|
1772
|
+
`MATCH (t:Task {accountId: $accountId})
|
|
1773
|
+
WHERE t.status IN ['pending', 'active']
|
|
1774
|
+
RETURN t.taskId AS taskId, t.name AS name, t.status AS status,
|
|
1775
|
+
t.priority AS priority, t.dueDate AS dueDate
|
|
1776
|
+
ORDER BY
|
|
1777
|
+
CASE t.priority
|
|
1778
|
+
WHEN 'urgent' THEN 0
|
|
1779
|
+
WHEN 'high' THEN 1
|
|
1780
|
+
WHEN 'normal' THEN 2
|
|
1781
|
+
WHEN 'low' THEN 3
|
|
1782
|
+
ELSE 4
|
|
1783
|
+
END,
|
|
1784
|
+
t.createdAt DESC
|
|
1785
|
+
LIMIT $limit`,
|
|
1786
|
+
{ accountId, limit: neo4j.int(MAX_SESSION_TASKS) }
|
|
1787
|
+
);
|
|
1788
|
+
const alertsResult = await session.run(
|
|
1789
|
+
`MATCH (a:ReviewAlert {accountId: $accountId})
|
|
1790
|
+
WHERE a.resolvedAt IS NULL
|
|
1791
|
+
AND (a.suppressedUntil IS NULL OR a.suppressedUntil < datetime())
|
|
1792
|
+
AND a.lastMatchAt > datetime() - duration('P1D')
|
|
1793
|
+
RETURN a.ruleId AS ruleId, a.ruleName AS ruleName, a.lastMatchAt AS lastMatchAt,
|
|
1794
|
+
a.cumulativeMatchCount AS cumulativeMatchCount, a.sampleEvidence AS sampleEvidence,
|
|
1795
|
+
a.suggestedAction AS suggestedAction
|
|
1796
|
+
ORDER BY a.lastMatchAt DESC
|
|
1797
|
+
LIMIT $limit`,
|
|
1798
|
+
{ accountId, limit: neo4j.int(MAX_SESSION_REVIEW_ALERTS) }
|
|
1799
|
+
);
|
|
1800
|
+
let projectsCount = 0;
|
|
1801
|
+
let projectLines = [];
|
|
1802
|
+
try {
|
|
1803
|
+
const projectsResult = await session.run(
|
|
1804
|
+
`MATCH (p:Project:Task {accountId: $accountId})
|
|
1805
|
+
WHERE p.status IN ['pending', 'active']
|
|
1806
|
+
OPTIONAL MATCH (p)-[:HAS_TASK]->(child:Task)
|
|
1807
|
+
WITH p,
|
|
1808
|
+
count(child) AS totalTasks,
|
|
1809
|
+
count(CASE WHEN child.status = 'completed' THEN 1 END) AS completedTasks,
|
|
1810
|
+
count(CASE WHEN child.dueDate IS NOT NULL
|
|
1811
|
+
AND date(child.dueDate) < date()
|
|
1812
|
+
AND child.status <> 'completed' THEN 1 END) AS overdueTasks,
|
|
1813
|
+
count(CASE WHEN child.status IN ['pending', 'active'] AND EXISTS {
|
|
1814
|
+
MATCH (blocker:Task)-[:BLOCKS]->(child)
|
|
1815
|
+
WHERE blocker.status IN ['pending', 'active']
|
|
1816
|
+
} THEN 1 END) AS blockedTasks
|
|
1817
|
+
RETURN p.name AS name, p.phase AS phase, p.tier AS tier,
|
|
1818
|
+
p.clientRef AS clientRef, p.status AS status,
|
|
1819
|
+
totalTasks, completedTasks, overdueTasks, blockedTasks
|
|
1820
|
+
ORDER BY p.updatedAt DESC
|
|
1821
|
+
LIMIT $limit`,
|
|
1822
|
+
{ accountId, limit: neo4j.int(MAX_SESSION_PROJECTS) }
|
|
1823
|
+
);
|
|
1824
|
+
projectsCount = projectsResult.records.length;
|
|
1825
|
+
if (projectsCount > 0) {
|
|
1826
|
+
projectLines = projectsResult.records.map((r) => {
|
|
1827
|
+
const name = r.get("name") || "Unnamed project";
|
|
1828
|
+
const phase = r.get("phase") || "planning";
|
|
1829
|
+
const tier = r.get("tier") || "standard";
|
|
1830
|
+
const clientRef = r.get("clientRef");
|
|
1831
|
+
const total = typeof r.get("totalTasks") === "number" ? r.get("totalTasks") : r.get("totalTasks")?.toNumber?.() ?? 0;
|
|
1832
|
+
const completed = typeof r.get("completedTasks") === "number" ? r.get("completedTasks") : r.get("completedTasks")?.toNumber?.() ?? 0;
|
|
1833
|
+
const overdue = typeof r.get("overdueTasks") === "number" ? r.get("overdueTasks") : r.get("overdueTasks")?.toNumber?.() ?? 0;
|
|
1834
|
+
const blocked = typeof r.get("blockedTasks") === "number" ? r.get("blockedTasks") : r.get("blockedTasks")?.toNumber?.() ?? 0;
|
|
1835
|
+
let signal;
|
|
1836
|
+
const hasDueDates = overdue > 0 || total > 0;
|
|
1837
|
+
if (!hasDueDates && total === 0) {
|
|
1838
|
+
signal = "grey";
|
|
1839
|
+
} else if (overdue > 1 || blocked > 1) {
|
|
1840
|
+
signal = "red";
|
|
1841
|
+
} else if (overdue > 0 || blocked > 0) {
|
|
1842
|
+
signal = "amber";
|
|
1843
|
+
} else {
|
|
1844
|
+
signal = "green";
|
|
1845
|
+
}
|
|
1846
|
+
const client = clientRef ? ` (${clientRef})` : "";
|
|
1847
|
+
const health = signal !== "grey" ? ` [${signal.toUpperCase()}]` : "";
|
|
1848
|
+
return `- **${name}**${client} \u2014 ${tier}, ${phase}${health}, ${completed}/${total} done${overdue > 0 ? `, ${overdue} overdue` : ""}${blocked > 0 ? `, ${blocked} blocked` : ""}`;
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
} catch (projectErr) {
|
|
1852
|
+
console.error(
|
|
1853
|
+
`[session-context] project query failed: ${projectErr instanceof Error ? projectErr.message : String(projectErr)}`
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
const sections = [];
|
|
1857
|
+
if (digestResult.records.length > 0) {
|
|
1858
|
+
const rec = digestResult.records[0];
|
|
1859
|
+
const title = rec.get("title");
|
|
1860
|
+
const createdAt = rec.get("createdAt");
|
|
1861
|
+
const abstract = rec.get("abstract");
|
|
1862
|
+
if (abstract) {
|
|
1863
|
+
const dateSuffix = createdAt ? ` (${createdAt.slice(0, 10)})` : "";
|
|
1864
|
+
const heading = title?.startsWith("Review Digest") ? `## Recent Review Digest${dateSuffix}` : `## Recent Public Chat Digest${dateSuffix}`;
|
|
1865
|
+
sections.push(`${heading}
|
|
1866
|
+
${abstract}`);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
if (tasksResult.records.length > 0) {
|
|
1870
|
+
const taskLines = tasksResult.records.map((r) => {
|
|
1871
|
+
const priority = (r.get("priority") || "normal").toUpperCase();
|
|
1872
|
+
const status = r.get("status");
|
|
1873
|
+
const name = r.get("name");
|
|
1874
|
+
const dueDate = r.get("dueDate");
|
|
1875
|
+
const due = dueDate ? ` due:${dueDate.slice(0, 10)}` : "";
|
|
1876
|
+
const taskId = r.get("taskId");
|
|
1877
|
+
return `- [${priority}] ${status} \u2014 ${name}${due} (${taskId})`;
|
|
1878
|
+
});
|
|
1879
|
+
sections.push(`## Open Tasks (${tasksResult.records.length})
|
|
1880
|
+
${taskLines.join("\n")}`);
|
|
1881
|
+
}
|
|
1882
|
+
let pendingCount = 0;
|
|
1883
|
+
let pendingLines = [];
|
|
1884
|
+
try {
|
|
1885
|
+
const platformRoot = process.env.MAXY_PLATFORM_ROOT ?? resolve2(process.cwd(), "..");
|
|
1886
|
+
const pendingDir = resolve2(platformRoot, "..", "data/accounts", accountId, "pending-actions");
|
|
1887
|
+
if (existsSync2(pendingDir)) {
|
|
1888
|
+
const files = readdirSync2(pendingDir).filter((f) => f.endsWith(".json") && !f.startsWith("."));
|
|
1889
|
+
for (const file of files) {
|
|
1890
|
+
try {
|
|
1891
|
+
const raw = readFileSync2(resolve2(pendingDir, file), "utf-8");
|
|
1892
|
+
const action = JSON.parse(raw);
|
|
1893
|
+
if (action.state === "pending") {
|
|
1894
|
+
const inputSummary = JSON.stringify(action.hookPayload?.tool_input ?? {}).slice(0, 150);
|
|
1895
|
+
pendingLines.push(`- **${action.toolName}** (${action.actionId.slice(0, 8)}\u2026) queued ${action.createdAt?.slice(0, 16) ?? "unknown"}
|
|
1896
|
+
Input: ${inputSummary}`);
|
|
1897
|
+
pendingCount++;
|
|
1898
|
+
}
|
|
1899
|
+
} catch {
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
} catch (pendingErr) {
|
|
1904
|
+
console.error(
|
|
1905
|
+
`[session-context] pending actions read failed: ${pendingErr instanceof Error ? pendingErr.message : String(pendingErr)}`
|
|
1906
|
+
);
|
|
1907
|
+
}
|
|
1908
|
+
if (pendingCount > 0) {
|
|
1909
|
+
sections.push(`## Pending Actions (${pendingCount})
|
|
1910
|
+
Actions queued for your approval. Use action-approve, action-reject, or action-edit.
|
|
1911
|
+
${pendingLines.join("\n")}`);
|
|
1912
|
+
}
|
|
1913
|
+
if (projectLines.length > 0) {
|
|
1914
|
+
sections.push(`## Active Projects (${projectLines.length})
|
|
1915
|
+
${projectLines.join("\n")}`);
|
|
1916
|
+
}
|
|
1917
|
+
if (alertsResult.records.length > 0) {
|
|
1918
|
+
const alertLines = alertsResult.records.map((r) => {
|
|
1919
|
+
const ruleName = r.get("ruleName");
|
|
1920
|
+
const count = r.get("cumulativeMatchCount");
|
|
1921
|
+
const countValue = typeof count === "number" ? count : count?.toNumber?.() ?? 0;
|
|
1922
|
+
const sample = r.get("sampleEvidence") ?? "";
|
|
1923
|
+
const action = r.get("suggestedAction");
|
|
1924
|
+
const shortSample = sample.length > 160 ? sample.slice(0, 160) + "\u2026" : sample;
|
|
1925
|
+
return `- **${ruleName}** (fired ${countValue}\xD7): ${shortSample}
|
|
1926
|
+
\u2192 ${action}`;
|
|
1927
|
+
});
|
|
1928
|
+
sections.push(`## Active Review Alerts (${alertsResult.records.length})
|
|
1929
|
+
${alertLines.join("\n")}`);
|
|
1930
|
+
}
|
|
1931
|
+
let recentFailuresCount = 0;
|
|
1932
|
+
if (conversationId) {
|
|
1933
|
+
const failureLines = readRecentToolFailures(accountId, conversationId);
|
|
1934
|
+
if (failureLines.length > 0) {
|
|
1935
|
+
recentFailuresCount = failureLines.length;
|
|
1936
|
+
const guidance = "These tool calls failed in the current conversation. Inspect the diagnostic fields before retrying the same tool against the same target \u2014 if you do retry, narrate explicitly why the diagnostic suggests a retry will now succeed. A second identical failure is evidence the path is broken, not evidence another attempt is warranted.";
|
|
1937
|
+
sections.push(`## Recent Tool Failures (${failureLines.length})
|
|
1938
|
+
${guidance}
|
|
1939
|
+
|
|
1940
|
+
${failureLines.map((l) => `- ${l.trim()}`).join("\n")}`);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
if (sections.length === 0) return null;
|
|
1944
|
+
console.error(
|
|
1945
|
+
`[session-context] Loaded for ${accountId.slice(0, 8)}\u2026: digest=${digestResult.records.length > 0 ? "yes" : "no"}, tasks=${tasksResult.records.length}, projects=${projectsCount}, reviewAlerts=${alertsResult.records.length}, pendingActions=${pendingCount}, recentFailures=${recentFailuresCount}`
|
|
1946
|
+
);
|
|
1947
|
+
return `<previous-context>
|
|
1948
|
+
${sections.join("\n\n")}
|
|
1949
|
+
</previous-context>`;
|
|
1950
|
+
} catch (err) {
|
|
1951
|
+
console.error(`[session-context] loadSessionContext failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1952
|
+
return null;
|
|
1953
|
+
} finally {
|
|
1954
|
+
await session.close();
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
async function consumeStep7FlagUI(session, accountId) {
|
|
1958
|
+
const accountDir = resolve2(PLATFORM_ROOT, "..", "data/accounts", accountId);
|
|
1959
|
+
const flagPath = resolve2(accountDir, "onboarding", "step7-complete");
|
|
1960
|
+
if (!existsSync2(flagPath)) return false;
|
|
1961
|
+
let completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1962
|
+
try {
|
|
1963
|
+
const raw = readFileSync2(flagPath, "utf-8").trim();
|
|
1964
|
+
if (raw) {
|
|
1965
|
+
const parsed = JSON.parse(raw);
|
|
1966
|
+
if (typeof parsed.completedAt === "string") {
|
|
1967
|
+
completedAt = parsed.completedAt;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
} catch {
|
|
1971
|
+
}
|
|
1972
|
+
const result = await session.run(
|
|
1973
|
+
`MATCH (o:OnboardingState {accountId: $accountId})
|
|
1974
|
+
SET o.step7CompletedAt = CASE WHEN o.step7CompletedAt IS NULL THEN $completedAt ELSE o.step7CompletedAt END,
|
|
1975
|
+
o.currentStep = CASE WHEN 7 > o.currentStep THEN 7 ELSE o.currentStep END,
|
|
1976
|
+
o.updatedAt = $completedAt
|
|
1977
|
+
RETURN count(o) AS updated`,
|
|
1978
|
+
{ accountId, completedAt }
|
|
1979
|
+
);
|
|
1980
|
+
const updatedRaw = result.records[0]?.get("updated");
|
|
1981
|
+
const updated = typeof updatedRaw === "number" ? updatedRaw : updatedRaw && typeof updatedRaw === "object" && "toNumber" in updatedRaw ? updatedRaw.toNumber() : 0;
|
|
1982
|
+
if (updated === 0) {
|
|
1983
|
+
console.log(
|
|
1984
|
+
`[onboarding-flag-consumed] accountId=${accountId.slice(0, 8)}\u2026 step=7 source=filesystem flagPath=${flagPath} skipped=no-node`
|
|
1985
|
+
);
|
|
1986
|
+
return false;
|
|
1987
|
+
}
|
|
1988
|
+
console.log(
|
|
1989
|
+
`[onboarding-flag-consumed] accountId=${accountId.slice(0, 8)}\u2026 step=7 source=filesystem flagPath=${flagPath} completedAt=${completedAt}`
|
|
1990
|
+
);
|
|
1991
|
+
try {
|
|
1992
|
+
rmSync(flagPath);
|
|
1993
|
+
} catch (err) {
|
|
1994
|
+
console.error(
|
|
1995
|
+
`[onboarding-flag-consumed] warn: failed to delete ${flagPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
1996
|
+
);
|
|
1997
|
+
}
|
|
1998
|
+
return true;
|
|
1999
|
+
}
|
|
2000
|
+
async function loadOnboardingStep(accountId) {
|
|
2001
|
+
const session = getSession();
|
|
2002
|
+
try {
|
|
2003
|
+
await consumeStep7FlagUI(session, accountId);
|
|
2004
|
+
const result = await session.run(
|
|
2005
|
+
`MATCH (o:OnboardingState {accountId: $accountId})
|
|
2006
|
+
RETURN o.currentStep AS currentStep`,
|
|
2007
|
+
{ accountId }
|
|
2008
|
+
);
|
|
2009
|
+
if (result.records.length === 0) {
|
|
2010
|
+
return -1;
|
|
2011
|
+
}
|
|
2012
|
+
const raw = result.records[0].get("currentStep");
|
|
2013
|
+
if (typeof raw === "number") return raw;
|
|
2014
|
+
if (raw && typeof raw === "object" && "toNumber" in raw) {
|
|
2015
|
+
return raw.toNumber();
|
|
2016
|
+
}
|
|
2017
|
+
return 0;
|
|
2018
|
+
} catch (err) {
|
|
2019
|
+
console.error(`[onboarding-inject] loadOnboardingStep failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2020
|
+
return null;
|
|
2021
|
+
} finally {
|
|
2022
|
+
await session.close();
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
async function writeReflectionPreferences(accountId, userId, conversationId, updates) {
|
|
2026
|
+
if (updates.length === 0) return 0;
|
|
2027
|
+
const session = getSession();
|
|
2028
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2029
|
+
let written = 0;
|
|
2030
|
+
try {
|
|
2031
|
+
const VALID_MODES = /* @__PURE__ */ new Set(["reinforce", "update", "contradict", "merge"]);
|
|
2032
|
+
for (const update of updates) {
|
|
2033
|
+
if (!VALID_CATEGORIES.has(update.category)) {
|
|
2034
|
+
console.error(`[profile-reflection] Skipping invalid category "${update.category}"`);
|
|
2035
|
+
continue;
|
|
2036
|
+
}
|
|
2037
|
+
if (update.mode && !VALID_MODES.has(update.mode)) {
|
|
2038
|
+
console.error(`[profile-reflection] Skipping invalid mode "${update.mode}" for ${update.category}/${update.key}`);
|
|
2039
|
+
continue;
|
|
2040
|
+
}
|
|
2041
|
+
if (!update.value || update.value.trim().length === 0) {
|
|
2042
|
+
console.error(`[profile-reflection] Skipping empty value for ${update.category}/${update.key}`);
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
try {
|
|
2046
|
+
if (update.mode === "merge" && update.mergeSourceIds?.length) {
|
|
2047
|
+
const txc = session.beginTransaction();
|
|
2048
|
+
try {
|
|
2049
|
+
const sourcesResult = await txc.run(
|
|
2050
|
+
`MATCH (pref:Preference)
|
|
2051
|
+
WHERE pref.preferenceId IN $sourceIds AND pref.accountId = $accountId
|
|
2052
|
+
OPTIONAL MATCH (pref)-[:OBSERVED_IN]->(conv:Conversation)
|
|
2053
|
+
RETURN pref.confidence AS confidence,
|
|
2054
|
+
collect(DISTINCT conv.conversationId) AS convIds`,
|
|
2055
|
+
{ sourceIds: update.mergeSourceIds, accountId }
|
|
2056
|
+
);
|
|
2057
|
+
const maxConf = sourcesResult.records.reduce(
|
|
2058
|
+
(max, r) => Math.max(max, r.get("confidence")),
|
|
2059
|
+
INITIAL_CONFIDENCE
|
|
2060
|
+
);
|
|
2061
|
+
const allConvIds = /* @__PURE__ */ new Set();
|
|
2062
|
+
for (const r of sourcesResult.records) {
|
|
2063
|
+
for (const id of r.get("convIds")) {
|
|
2064
|
+
if (id) allConvIds.add(id);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
if (conversationId) allConvIds.add(conversationId);
|
|
2068
|
+
const preferenceId = randomUUID();
|
|
2069
|
+
let embedding2 = null;
|
|
2070
|
+
try {
|
|
2071
|
+
embedding2 = await embed(`${update.category}: ${update.key} \u2014 ${update.value}`);
|
|
2072
|
+
} catch {
|
|
2073
|
+
}
|
|
2074
|
+
const props = {
|
|
2075
|
+
preferenceId,
|
|
2076
|
+
accountId,
|
|
2077
|
+
userId,
|
|
2078
|
+
category: update.category,
|
|
2079
|
+
key: update.key,
|
|
2080
|
+
value: update.value,
|
|
2081
|
+
confidence: maxConf,
|
|
2082
|
+
source: update.source,
|
|
2083
|
+
observedAt: now,
|
|
2084
|
+
createdAt: now,
|
|
2085
|
+
updatedAt: now,
|
|
2086
|
+
scope: "admin"
|
|
2087
|
+
};
|
|
2088
|
+
if (embedding2) props.embedding = embedding2;
|
|
2089
|
+
await txc.run(
|
|
2090
|
+
`MATCH (up:UserProfile {accountId: $accountId, userId: $userId})
|
|
2091
|
+
CREATE (pref:Preference $props)
|
|
2092
|
+
CREATE (up)-[:HAS_PREFERENCE]->(pref)`,
|
|
2093
|
+
{ accountId, userId, props }
|
|
2094
|
+
);
|
|
2095
|
+
for (const convId of allConvIds) {
|
|
2096
|
+
await txc.run(
|
|
2097
|
+
`MATCH (pref:Preference {preferenceId: $preferenceId})
|
|
2098
|
+
MATCH (conv:Conversation {conversationId: $convId})
|
|
2099
|
+
MERGE (pref)-[:OBSERVED_IN]->(conv)`,
|
|
2100
|
+
{ preferenceId, convId }
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
2103
|
+
await txc.run(
|
|
2104
|
+
`MATCH (pref:Preference)
|
|
2105
|
+
WHERE pref.preferenceId IN $sourceIds AND pref.accountId = $accountId
|
|
2106
|
+
DETACH DELETE pref`,
|
|
2107
|
+
{ sourceIds: update.mergeSourceIds, accountId }
|
|
2108
|
+
);
|
|
2109
|
+
await txc.commit();
|
|
2110
|
+
written++;
|
|
2111
|
+
} catch (mergeErr) {
|
|
2112
|
+
await txc.rollback();
|
|
2113
|
+
console.error(`[profile-reflection] Merge failed for ${update.category}/${update.key}: ${mergeErr instanceof Error ? mergeErr.message : String(mergeErr)}`);
|
|
2114
|
+
}
|
|
2115
|
+
continue;
|
|
2116
|
+
}
|
|
2117
|
+
let embedding = null;
|
|
2118
|
+
try {
|
|
2119
|
+
embedding = await embed(`${update.category}: ${update.key} \u2014 ${update.value}`);
|
|
2120
|
+
} catch {
|
|
2121
|
+
}
|
|
2122
|
+
const newPrefId = randomUUID();
|
|
2123
|
+
const mode = update.mode || "reinforce";
|
|
2124
|
+
const mergeParams = {
|
|
2125
|
+
accountId,
|
|
2126
|
+
userId,
|
|
2127
|
+
category: update.category,
|
|
2128
|
+
key: update.key,
|
|
2129
|
+
newPrefId,
|
|
2130
|
+
value: update.value,
|
|
2131
|
+
source: update.source,
|
|
2132
|
+
initialConfidence: INITIAL_CONFIDENCE,
|
|
2133
|
+
increment: REINFORCEMENT_INCREMENT,
|
|
2134
|
+
decrement: 0.3,
|
|
2135
|
+
mode,
|
|
2136
|
+
now
|
|
2137
|
+
};
|
|
2138
|
+
if (embedding) mergeParams.embedding = embedding;
|
|
2139
|
+
const mergeResult = await session.run(
|
|
2140
|
+
`MATCH (up:UserProfile {accountId: $accountId, userId: $userId})
|
|
2141
|
+
MERGE (up)-[:HAS_PREFERENCE]->(pref:Preference {accountId: $accountId, userId: $userId, category: $category, key: $key})
|
|
2142
|
+
ON CREATE SET
|
|
2143
|
+
pref.preferenceId = $newPrefId,
|
|
2144
|
+
pref.value = $value,
|
|
2145
|
+
pref.confidence = $initialConfidence,
|
|
2146
|
+
pref.source = $source,
|
|
2147
|
+
pref.observedAt = $now,
|
|
2148
|
+
pref.createdAt = $now,
|
|
2149
|
+
pref.updatedAt = $now,
|
|
2150
|
+
pref.scope = 'admin'
|
|
2151
|
+
ON MATCH SET
|
|
2152
|
+
pref.value = $value,
|
|
2153
|
+
pref.source = $source,
|
|
2154
|
+
pref.confidence = CASE $mode
|
|
2155
|
+
WHEN 'reinforce' THEN CASE WHEN pref.confidence + $increment * (1.0 - pref.confidence) > 1.0 THEN 1.0 ELSE pref.confidence + $increment * (1.0 - pref.confidence) END
|
|
2156
|
+
WHEN 'contradict' THEN CASE WHEN pref.confidence - $decrement < 0.0 THEN 0.0 ELSE pref.confidence - $decrement END
|
|
2157
|
+
ELSE pref.confidence
|
|
2158
|
+
END,
|
|
2159
|
+
pref.observedAt = $now,
|
|
2160
|
+
pref.updatedAt = $now
|
|
2161
|
+
${embedding ? "SET pref.embedding = $embedding" : ""}
|
|
2162
|
+
RETURN pref.preferenceId AS preferenceId`,
|
|
2163
|
+
mergeParams
|
|
2164
|
+
);
|
|
2165
|
+
const writtenPrefId = mergeResult.records[0]?.get("preferenceId");
|
|
2166
|
+
if (conversationId && writtenPrefId) {
|
|
2167
|
+
await session.run(
|
|
2168
|
+
`MATCH (pref:Preference {preferenceId: $preferenceId, accountId: $accountId})
|
|
2169
|
+
MATCH (conv:Conversation {conversationId: $conversationId})
|
|
2170
|
+
MERGE (pref)-[:OBSERVED_IN]->(conv)`,
|
|
2171
|
+
{ preferenceId: writtenPrefId, accountId, conversationId }
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
written++;
|
|
2175
|
+
} catch (itemErr) {
|
|
2176
|
+
console.error(`[profile-reflection] Failed to write ${update.category}/${update.key}: ${itemErr instanceof Error ? itemErr.message : String(itemErr)}`);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
if (written > 0) {
|
|
2180
|
+
await session.run(
|
|
2181
|
+
`MATCH (up:UserProfile {accountId: $accountId, userId: $userId})
|
|
2182
|
+
SET up.profileVersion = coalesce(up.profileVersion, 0) + 1,
|
|
2183
|
+
up.updatedAt = $now`,
|
|
2184
|
+
{ accountId, userId, now }
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
console.error(`[profile-reflection] Wrote ${written}/${updates.length} preference updates for userId=${userId} accountId=${accountId.slice(0, 8)}\u2026`);
|
|
2188
|
+
return written;
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
console.error(`[profile-reflection] writeReflectionPreferences failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2191
|
+
return written;
|
|
2192
|
+
} finally {
|
|
2193
|
+
await session.close();
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
var AGENT_FILE_ROLES = [
|
|
2197
|
+
{ filename: "IDENTITY.md", role: "identity" },
|
|
2198
|
+
{ filename: "SOUL.md", role: "soul" },
|
|
2199
|
+
{ filename: "KNOWLEDGE.md", role: "knowledge" },
|
|
2200
|
+
{ filename: "KNOWLEDGE-SUMMARY.md", role: "knowledge-summary" }
|
|
2201
|
+
];
|
|
2202
|
+
var ROLE_TO_EDGE = {
|
|
2203
|
+
identity: "HAS_IDENTITY",
|
|
2204
|
+
soul: "HAS_SOUL",
|
|
2205
|
+
knowledge: "HAS_KNOWLEDGE",
|
|
2206
|
+
"knowledge-summary": "HAS_KNOWLEDGE"
|
|
2207
|
+
};
|
|
2208
|
+
function assertSafeAgentSlug(slug) {
|
|
2209
|
+
if (!slug || slug.includes("/") || slug.includes("\\") || slug.includes("..")) {
|
|
2210
|
+
throw new Error(`[agent-graph] refusing unsafe slug=${JSON.stringify(slug)}`);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
function agentAttachmentId(accountId, slug, role) {
|
|
2214
|
+
return `agent:${accountId}:${slug}:${role}`;
|
|
2215
|
+
}
|
|
2216
|
+
async function projectAgent(accountId, accountDir, slug) {
|
|
2217
|
+
const start = Date.now();
|
|
2218
|
+
const account8 = accountId.slice(0, 8);
|
|
2219
|
+
try {
|
|
2220
|
+
assertSafeAgentSlug(slug);
|
|
2221
|
+
} catch (err) {
|
|
2222
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2223
|
+
console.error(
|
|
2224
|
+
`[agent-graph] project FAILED slug=${slug} account=${account8} error="${msg}"`
|
|
2225
|
+
);
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
const agentDir = resolve2(accountDir, "agents", slug);
|
|
2229
|
+
const configPath = resolve2(agentDir, "config.json");
|
|
2230
|
+
let config;
|
|
2231
|
+
let presentRoles = [];
|
|
2232
|
+
try {
|
|
2233
|
+
if (!existsSync2(configPath)) {
|
|
2234
|
+
console.error(
|
|
2235
|
+
`[agent-graph] project FAILED slug=${slug} account=${account8} error="config.json missing at ${configPath}"`
|
|
2236
|
+
);
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
config = JSON.parse(readFileSync2(configPath, "utf-8"));
|
|
2240
|
+
for (const { filename, role } of AGENT_FILE_ROLES) {
|
|
2241
|
+
const filePath = resolve2(agentDir, filename);
|
|
2242
|
+
if (!existsSync2(filePath)) continue;
|
|
2243
|
+
const body = readFileSync2(filePath, "utf-8");
|
|
2244
|
+
presentRoles.push({ role, body, edge: ROLE_TO_EDGE[role] });
|
|
2245
|
+
}
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2248
|
+
console.error(
|
|
2249
|
+
`[agent-graph] project FAILED slug=${slug} account=${account8} error="${msg}"`
|
|
2250
|
+
);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
const session = getSession();
|
|
2254
|
+
try {
|
|
2255
|
+
await session.run(
|
|
2256
|
+
`MERGE (a:Agent {accountId: $accountId, slug: $slug})
|
|
2257
|
+
ON CREATE SET a.createdAt = datetime()
|
|
2258
|
+
SET a.displayName = $displayName,
|
|
2259
|
+
a.status = $status,
|
|
2260
|
+
a.model = $model,
|
|
2261
|
+
a.liveMemory = $liveMemory,
|
|
2262
|
+
a.knowledgeKeywords = $knowledgeKeywords,
|
|
2263
|
+
a.role = 'agent',
|
|
2264
|
+
a.updatedAt = datetime()
|
|
2265
|
+
RETURN a.slug AS slug`,
|
|
2266
|
+
{
|
|
2267
|
+
accountId,
|
|
2268
|
+
slug,
|
|
2269
|
+
displayName: config.displayName ?? slug,
|
|
2270
|
+
status: config.status ?? "unknown",
|
|
2271
|
+
model: config.model ?? "",
|
|
2272
|
+
liveMemory: config.liveMemory ?? false,
|
|
2273
|
+
knowledgeKeywords: config.knowledgeKeywords ?? []
|
|
2274
|
+
}
|
|
2275
|
+
);
|
|
2276
|
+
for (const { role, body, edge } of presentRoles) {
|
|
2277
|
+
const attachmentId = agentAttachmentId(accountId, slug, role);
|
|
2278
|
+
await session.run(
|
|
2279
|
+
`MATCH (a:Agent {accountId: $accountId, slug: $slug})
|
|
2280
|
+
MERGE (k:KnowledgeDocument {attachmentId: $attachmentId})
|
|
2281
|
+
ON CREATE SET k.createdAt = datetime()
|
|
2282
|
+
SET k.accountId = $accountId,
|
|
2283
|
+
k.role = $role,
|
|
2284
|
+
k.name = $name,
|
|
2285
|
+
k.text = $text,
|
|
2286
|
+
k.scope = 'admin',
|
|
2287
|
+
k.updatedAt = datetime()
|
|
2288
|
+
MERGE (a)-[:${edge}]->(k)
|
|
2289
|
+
RETURN k.attachmentId AS attachmentId`,
|
|
2290
|
+
{
|
|
2291
|
+
accountId,
|
|
2292
|
+
slug,
|
|
2293
|
+
attachmentId,
|
|
2294
|
+
role,
|
|
2295
|
+
name: `${slug} ${role}`,
|
|
2296
|
+
text: body
|
|
2297
|
+
}
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
const usesResult = await session.run(
|
|
2301
|
+
`MATCH (a:Agent {accountId: $accountId, slug: $slug})
|
|
2302
|
+
MATCH (k:KnowledgeDocument {accountId: $accountId})
|
|
2303
|
+
WHERE $slug IN coalesce(k.agents, [])
|
|
2304
|
+
AND NOT k.attachmentId STARTS WITH $namespacePrefix
|
|
2305
|
+
MERGE (a)-[:USES_KNOWLEDGE]->(k)
|
|
2306
|
+
RETURN count(k) AS uses`,
|
|
2307
|
+
{
|
|
2308
|
+
accountId,
|
|
2309
|
+
slug,
|
|
2310
|
+
namespacePrefix: `agent:${accountId}:${slug}:`
|
|
2311
|
+
}
|
|
2312
|
+
);
|
|
2313
|
+
const uses = usesResult.records[0]?.get("uses");
|
|
2314
|
+
const usesCount = typeof uses === "number" ? uses : uses && typeof uses.toNumber === "function" ? uses.toNumber() : 0;
|
|
2315
|
+
const ms = Date.now() - start;
|
|
2316
|
+
console.error(
|
|
2317
|
+
`[agent-graph] project slug=${slug} account=${account8} docs=${presentRoles.length} uses=${usesCount} ms=${ms}`
|
|
2318
|
+
);
|
|
2319
|
+
} catch (err) {
|
|
2320
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2321
|
+
console.error(
|
|
2322
|
+
`[agent-graph] project FAILED slug=${slug} account=${account8} error="${msg}"`
|
|
2323
|
+
);
|
|
2324
|
+
throw err;
|
|
2325
|
+
} finally {
|
|
2326
|
+
await session.close();
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
async function deleteAgentProjection(accountId, slug) {
|
|
2330
|
+
const start = Date.now();
|
|
2331
|
+
const account8 = accountId.slice(0, 8);
|
|
2332
|
+
assertSafeAgentSlug(slug);
|
|
2333
|
+
const session = getSession();
|
|
2334
|
+
try {
|
|
2335
|
+
await session.run(
|
|
2336
|
+
`MATCH (a:Agent {accountId: $accountId, slug: $slug})
|
|
2337
|
+
DETACH DELETE a
|
|
2338
|
+
WITH 1 AS _
|
|
2339
|
+
UNWIND $attachmentIds AS aid
|
|
2340
|
+
OPTIONAL MATCH (k:KnowledgeDocument {accountId: $accountId, attachmentId: aid})
|
|
2341
|
+
WHERE k.attachmentId STARTS WITH $namespacePrefix
|
|
2342
|
+
DETACH DELETE k`,
|
|
2343
|
+
{
|
|
2344
|
+
accountId,
|
|
2345
|
+
slug,
|
|
2346
|
+
namespacePrefix: `agent:${accountId}:${slug}:`,
|
|
2347
|
+
attachmentIds: ["identity", "soul", "knowledge", "knowledge-summary"].map((role) => agentAttachmentId(accountId, slug, role))
|
|
2348
|
+
}
|
|
2349
|
+
);
|
|
2350
|
+
const ms = Date.now() - start;
|
|
2351
|
+
console.error(
|
|
2352
|
+
`[agent-graph] delete slug=${slug} account=${account8} ms=${ms}`
|
|
2353
|
+
);
|
|
2354
|
+
} catch (err) {
|
|
2355
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2356
|
+
console.error(
|
|
2357
|
+
`[agent-graph] delete FAILED slug=${slug} account=${account8} error="${msg}"`
|
|
2358
|
+
);
|
|
2359
|
+
throw err;
|
|
2360
|
+
} finally {
|
|
2361
|
+
await session.close();
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// app/lib/claude-agent/account.ts
|
|
2366
|
+
import { resolve as resolve3 } from "path";
|
|
2367
|
+
import { readFileSync as readFileSync4, readdirSync as readdirSync3, existsSync as existsSync4, statSync as statSync3 } from "fs";
|
|
2368
|
+
|
|
2369
|
+
// ../lib/brand-templating/src/index.ts
|
|
2370
|
+
import { join } from "path";
|
|
2371
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
2372
|
+
var PLACEHOLDER = "{{productName}}";
|
|
2373
|
+
var cachedProductName = null;
|
|
2374
|
+
function brandJsonPath() {
|
|
2375
|
+
const platformRoot = process.env.MAXY_PLATFORM_ROOT;
|
|
2376
|
+
if (!platformRoot) {
|
|
2377
|
+
throw new Error(
|
|
2378
|
+
"[skill-loader] MAXY_PLATFORM_ROOT not set \u2014 cannot resolve brand.json"
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
return join(platformRoot, "config", "brand.json");
|
|
2382
|
+
}
|
|
2383
|
+
function getBrandProductName() {
|
|
2384
|
+
if (cachedProductName !== null) return cachedProductName;
|
|
2385
|
+
const path = brandJsonPath();
|
|
2386
|
+
if (!existsSync3(path)) {
|
|
2387
|
+
throw new Error(`[skill-loader] brand.json missing at ${path}`);
|
|
2388
|
+
}
|
|
2389
|
+
let parsed;
|
|
2390
|
+
try {
|
|
2391
|
+
parsed = JSON.parse(readFileSync3(path, "utf-8"));
|
|
2392
|
+
} catch (err) {
|
|
2393
|
+
throw new Error(
|
|
2394
|
+
`[skill-loader] brand.json unreadable at ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
2395
|
+
);
|
|
2396
|
+
}
|
|
2397
|
+
const value = parsed.productName;
|
|
2398
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
2399
|
+
throw new Error(
|
|
2400
|
+
`[skill-loader] brand.json at ${path} has missing or empty productName`
|
|
2401
|
+
);
|
|
2402
|
+
}
|
|
2403
|
+
cachedProductName = value;
|
|
2404
|
+
return value;
|
|
2405
|
+
}
|
|
2406
|
+
function substituteBrandPlaceholders(content, sourcePath) {
|
|
2407
|
+
if (!content.includes(PLACEHOLDER)) return content;
|
|
2408
|
+
let productName;
|
|
2409
|
+
try {
|
|
2410
|
+
productName = getBrandProductName();
|
|
2411
|
+
} catch (err) {
|
|
2412
|
+
console.error(
|
|
2413
|
+
`[skill-loader] ERROR: brand.json missing \u2014 cannot resolve productName for skill ${sourcePath}`
|
|
2414
|
+
);
|
|
2415
|
+
throw err;
|
|
2416
|
+
}
|
|
2417
|
+
const occurrences = content.split(PLACEHOLDER).length - 1;
|
|
2418
|
+
const substituted = content.split(PLACEHOLDER).join(productName);
|
|
2419
|
+
console.log(
|
|
2420
|
+
`[skill-loader] brand-substituted productName=${productName} skill=${sourcePath} occurrences=${occurrences}`
|
|
2421
|
+
);
|
|
2422
|
+
return substituted;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
// app/lib/claude-agent/account.ts
|
|
2426
|
+
var PLATFORM_ROOT2 = process.env.MAXY_PLATFORM_ROOT ?? resolve3(process.cwd(), "..");
|
|
2427
|
+
var ACCOUNTS_DIR = resolve3(PLATFORM_ROOT2, "..", "data/accounts");
|
|
2428
|
+
if (!existsSync4(PLATFORM_ROOT2)) {
|
|
2429
|
+
throw new Error(
|
|
2430
|
+
`PLATFORM_ROOT does not exist: ${PLATFORM_ROOT2}
|
|
2431
|
+
Set the MAXY_PLATFORM_ROOT environment variable to the absolute path of the platform directory.`
|
|
2432
|
+
);
|
|
2433
|
+
}
|
|
2434
|
+
function resolveAccount() {
|
|
2435
|
+
if (!existsSync4(ACCOUNTS_DIR)) return null;
|
|
2436
|
+
const usersFilePath = resolve3(PLATFORM_ROOT2, "config", "users.json");
|
|
2437
|
+
let usersJsonUserId = null;
|
|
2438
|
+
if (existsSync4(usersFilePath)) {
|
|
2439
|
+
try {
|
|
2440
|
+
const raw = readFileSync4(usersFilePath, "utf-8").trim();
|
|
2441
|
+
if (raw) {
|
|
2442
|
+
const users = JSON.parse(raw);
|
|
2443
|
+
if (users.length > 0) {
|
|
2444
|
+
usersJsonUserId = users[0].userId;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
} catch {
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
const entries = readdirSync3(ACCOUNTS_DIR, { withFileTypes: true });
|
|
2451
|
+
let fallback = null;
|
|
2452
|
+
for (const entry of entries) {
|
|
2453
|
+
if (!entry.isDirectory()) continue;
|
|
2454
|
+
const configPath = resolve3(ACCOUNTS_DIR, entry.name, "account.json");
|
|
2455
|
+
if (!existsSync4(configPath)) continue;
|
|
2456
|
+
const raw = readFileSync4(configPath, "utf-8");
|
|
2457
|
+
let config;
|
|
2458
|
+
try {
|
|
2459
|
+
config = JSON.parse(raw);
|
|
2460
|
+
} catch {
|
|
2461
|
+
console.error(`[maxy] account.json is corrupt at ${configPath} \u2014 skipping`);
|
|
2462
|
+
continue;
|
|
2463
|
+
}
|
|
2464
|
+
if (!config.adminModel || !config.publicModel) {
|
|
2465
|
+
throw new Error(
|
|
2466
|
+
`[maxy] account.json at ${configPath} is missing required model fields (adminModel / publicModel). Update account.json with valid model identifiers.`
|
|
2467
|
+
);
|
|
2468
|
+
}
|
|
2469
|
+
const result = {
|
|
2470
|
+
accountId: config.accountId,
|
|
2471
|
+
accountDir: resolve3(ACCOUNTS_DIR, entry.name),
|
|
2472
|
+
config
|
|
2473
|
+
};
|
|
2474
|
+
if (usersJsonUserId && config.admins?.some((a) => a.userId === usersJsonUserId)) {
|
|
2475
|
+
return result;
|
|
2476
|
+
}
|
|
2477
|
+
if (!fallback) {
|
|
2478
|
+
fallback = result;
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
if (usersJsonUserId && fallback) {
|
|
2482
|
+
console.warn(
|
|
2483
|
+
`[maxy] resolveAccount: no account matches users.json userId ${usersJsonUserId} \u2014 falling back to ${fallback.accountId}`
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
return fallback;
|
|
2487
|
+
}
|
|
2488
|
+
function readAgentFile(accountDir, agentName, filename) {
|
|
2489
|
+
const filePath = resolve3(accountDir, "agents", agentName, filename);
|
|
2490
|
+
if (!existsSync4(filePath)) return null;
|
|
2491
|
+
const raw = readFileSync4(filePath, "utf-8");
|
|
2492
|
+
if (filename.endsWith(".md")) {
|
|
2493
|
+
return substituteBrandPlaceholders(raw, filePath);
|
|
2494
|
+
}
|
|
2495
|
+
return raw;
|
|
2496
|
+
}
|
|
2497
|
+
function readIdentity(accountDir, agentName) {
|
|
2498
|
+
return readAgentFile(accountDir, agentName, "IDENTITY.md");
|
|
2499
|
+
}
|
|
2500
|
+
var RESERVED_SLUGS = /* @__PURE__ */ new Set(["admin", "api", "assets", "brand", "bot", "privacy"]);
|
|
2501
|
+
var SLUG_PATTERN = /^[a-z][a-z0-9-]{2,49}$/;
|
|
2502
|
+
function validateAgentSlug(slug) {
|
|
2503
|
+
if (!SLUG_PATTERN.test(slug)) return false;
|
|
2504
|
+
if (RESERVED_SLUGS.has(slug)) return false;
|
|
2505
|
+
return true;
|
|
2506
|
+
}
|
|
2507
|
+
function resolveDefaultAgentSlug(accountDir) {
|
|
2508
|
+
const configPath = resolve3(accountDir, "account.json");
|
|
2509
|
+
if (!existsSync4(configPath)) {
|
|
2510
|
+
console.error("[agent-resolve] account.json not found \u2014 cannot resolve defaultAgent");
|
|
2511
|
+
return null;
|
|
2512
|
+
}
|
|
2513
|
+
let config;
|
|
2514
|
+
try {
|
|
2515
|
+
config = JSON.parse(readFileSync4(configPath, "utf-8"));
|
|
2516
|
+
} catch (err) {
|
|
2517
|
+
console.error("[agent-resolve] failed to read account.json:", err);
|
|
2518
|
+
return null;
|
|
2519
|
+
}
|
|
2520
|
+
if (!config.defaultAgent) {
|
|
2521
|
+
console.error("[agent-resolve] defaultAgent not configured in account.json \u2014 set it via the connect-whatsapp skill");
|
|
2522
|
+
return null;
|
|
2523
|
+
}
|
|
2524
|
+
const agentConfigPath = resolve3(accountDir, "agents", config.defaultAgent, "config.json");
|
|
2525
|
+
if (!existsSync4(agentConfigPath)) {
|
|
2526
|
+
console.error(`[agent-resolve] defaultAgent="${config.defaultAgent}" has no config.json at ${agentConfigPath}`);
|
|
2527
|
+
return null;
|
|
2528
|
+
}
|
|
2529
|
+
return config.defaultAgent;
|
|
2530
|
+
}
|
|
2531
|
+
function estimateTokens(text) {
|
|
2532
|
+
return Math.ceil(text.length / 4);
|
|
2533
|
+
}
|
|
2534
|
+
function resolveAgentConfig(accountDir, agentName) {
|
|
2535
|
+
let model = null;
|
|
2536
|
+
let plugins = null;
|
|
2537
|
+
let status = null;
|
|
2538
|
+
let displayName = null;
|
|
2539
|
+
let image = null;
|
|
2540
|
+
let imageShape = null;
|
|
2541
|
+
let showAgentName = false;
|
|
2542
|
+
let liveMemory = false;
|
|
2543
|
+
let knowledgeKeywords = null;
|
|
2544
|
+
let accessMode = "open";
|
|
2545
|
+
const MAX_KNOWLEDGE_KEYWORDS = 5;
|
|
2546
|
+
const configRaw = readAgentFile(accountDir, agentName, "config.json");
|
|
2547
|
+
if (configRaw) {
|
|
2548
|
+
let parsed;
|
|
2549
|
+
try {
|
|
2550
|
+
parsed = JSON.parse(configRaw);
|
|
2551
|
+
} catch {
|
|
2552
|
+
console.warn(`[agent-config] ${agentName}/config.json: invalid JSON \u2014 using defaults`);
|
|
2553
|
+
parsed = {};
|
|
2554
|
+
}
|
|
2555
|
+
model = typeof parsed.model === "string" ? parsed.model : null;
|
|
2556
|
+
plugins = Array.isArray(parsed.plugins) ? parsed.plugins : null;
|
|
2557
|
+
status = typeof parsed.status === "string" ? parsed.status : null;
|
|
2558
|
+
displayName = typeof parsed.displayName === "string" ? parsed.displayName : null;
|
|
2559
|
+
image = typeof parsed.image === "string" ? parsed.image : null;
|
|
2560
|
+
if (typeof parsed.imageShape === "string" && ["circle", "rounded"].includes(parsed.imageShape)) {
|
|
2561
|
+
imageShape = parsed.imageShape;
|
|
2562
|
+
}
|
|
2563
|
+
if (parsed.showAgentName === true) {
|
|
2564
|
+
showAgentName = true;
|
|
2565
|
+
} else if (parsed.showAgentName === "none") {
|
|
2566
|
+
showAgentName = "none";
|
|
2567
|
+
}
|
|
2568
|
+
if (image || imageShape || showAgentName) {
|
|
2569
|
+
console.log(`[agent-config] ${agentName}: image=${image || "(none)"} imageShape=${imageShape || "(none)"} showAgentName=${showAgentName}`);
|
|
2570
|
+
}
|
|
2571
|
+
if (typeof parsed.accessMode === "string" && ["gated", "paid"].includes(parsed.accessMode)) {
|
|
2572
|
+
accessMode = parsed.accessMode;
|
|
2573
|
+
}
|
|
2574
|
+
if (typeof parsed.liveMemory === "boolean") {
|
|
2575
|
+
liveMemory = parsed.liveMemory;
|
|
2576
|
+
} else if (typeof parsed.liveMemory === "string") {
|
|
2577
|
+
const lower = parsed.liveMemory.toLowerCase();
|
|
2578
|
+
if (lower === "true") {
|
|
2579
|
+
liveMemory = true;
|
|
2580
|
+
console.warn(`[agent-config] ${agentName}: liveMemory is string "true" \u2014 coercing to boolean. Fix the config to use a boolean value.`);
|
|
2581
|
+
} else if (lower === "false") {
|
|
2582
|
+
liveMemory = false;
|
|
2583
|
+
console.warn(`[agent-config] ${agentName}: liveMemory is string "false" \u2014 coercing to boolean. Fix the config to use a boolean value.`);
|
|
2584
|
+
} else {
|
|
2585
|
+
throw new Error(`[agent-config] ${agentName}: liveMemory has invalid string value "${parsed.liveMemory}" \u2014 expected boolean or "true"/"false"`);
|
|
2586
|
+
}
|
|
2587
|
+
} else if (parsed.liveMemory !== void 0 && parsed.liveMemory !== null) {
|
|
2588
|
+
throw new Error(`[agent-config] ${agentName}: liveMemory has invalid type ${typeof parsed.liveMemory} \u2014 expected boolean or "true"/"false"`);
|
|
2589
|
+
}
|
|
2590
|
+
if (Array.isArray(parsed.knowledgeKeywords) && parsed.knowledgeKeywords.length > 0) {
|
|
2591
|
+
const filtered = parsed.knowledgeKeywords.filter((k) => typeof k === "string" && k.trim()).map((k) => k.replace(/,/g, "").trim().toLowerCase()).filter(Boolean);
|
|
2592
|
+
if (filtered.length > MAX_KNOWLEDGE_KEYWORDS) {
|
|
2593
|
+
console.warn(`[agent-config] ${agentName}: knowledgeKeywords has ${filtered.length} entries \u2014 capping at ${MAX_KNOWLEDGE_KEYWORDS}`);
|
|
2594
|
+
}
|
|
2595
|
+
knowledgeKeywords = filtered.length > 0 ? filtered.slice(0, MAX_KNOWLEDGE_KEYWORDS) : null;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
let knowledge = null;
|
|
2599
|
+
let knowledgeBaked = false;
|
|
2600
|
+
const agentDir = resolve3(accountDir, "agents", agentName);
|
|
2601
|
+
const knowledgePath = resolve3(agentDir, "KNOWLEDGE.md");
|
|
2602
|
+
const summaryPath = resolve3(agentDir, "KNOWLEDGE-SUMMARY.md");
|
|
2603
|
+
const hasKnowledge = existsSync4(knowledgePath);
|
|
2604
|
+
const hasSummary = existsSync4(summaryPath);
|
|
2605
|
+
if (hasKnowledge && hasSummary) {
|
|
2606
|
+
const knowledgeMtime = statSync3(knowledgePath).mtimeMs;
|
|
2607
|
+
const summaryMtime = statSync3(summaryPath).mtimeMs;
|
|
2608
|
+
if (summaryMtime >= knowledgeMtime) {
|
|
2609
|
+
knowledge = readFileSync4(summaryPath, "utf-8");
|
|
2610
|
+
} else {
|
|
2611
|
+
console.warn(`[agent-config] ${agentName}: KNOWLEDGE-SUMMARY.md is stale (KNOWLEDGE.md is newer) \u2014 using full knowledge`);
|
|
2612
|
+
knowledge = readFileSync4(knowledgePath, "utf-8");
|
|
2613
|
+
}
|
|
2614
|
+
knowledgeBaked = true;
|
|
2615
|
+
} else if (hasKnowledge) {
|
|
2616
|
+
knowledge = readFileSync4(knowledgePath, "utf-8");
|
|
2617
|
+
knowledgeBaked = true;
|
|
2618
|
+
}
|
|
2619
|
+
let budget = null;
|
|
2620
|
+
const identityRaw = readAgentFile(accountDir, agentName, "IDENTITY.md");
|
|
2621
|
+
const soulRaw = readAgentFile(accountDir, agentName, "SOUL.md");
|
|
2622
|
+
if (identityRaw || soulRaw || knowledge) {
|
|
2623
|
+
const identityTokens = identityRaw ? estimateTokens(identityRaw) : 0;
|
|
2624
|
+
const soulTokens = soulRaw ? estimateTokens(soulRaw) : 0;
|
|
2625
|
+
const knowledgeTokens = knowledge ? estimateTokens(knowledge) : 0;
|
|
2626
|
+
budget = {
|
|
2627
|
+
identity: identityTokens,
|
|
2628
|
+
soul: soulTokens,
|
|
2629
|
+
knowledge: knowledgeTokens,
|
|
2630
|
+
plugins: 0,
|
|
2631
|
+
total: identityTokens + soulTokens + knowledgeTokens
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
2634
|
+
return { model, plugins, status, displayName, image, imageShape, showAgentName, knowledge, knowledgeBaked, liveMemory, knowledgeKeywords, budget, accessMode };
|
|
2635
|
+
}
|
|
2636
|
+
function getDefaultAccountId() {
|
|
2637
|
+
return resolveAccount()?.accountId ?? null;
|
|
2638
|
+
}
|
|
2639
|
+
function resolveUserAccounts(userId) {
|
|
2640
|
+
if (!existsSync4(ACCOUNTS_DIR)) return [];
|
|
2641
|
+
const results = [];
|
|
2642
|
+
const entries = readdirSync3(ACCOUNTS_DIR, { withFileTypes: true });
|
|
2643
|
+
for (const entry of entries) {
|
|
2644
|
+
if (!entry.isDirectory()) continue;
|
|
2645
|
+
const configPath = resolve3(ACCOUNTS_DIR, entry.name, "account.json");
|
|
2646
|
+
if (!existsSync4(configPath)) continue;
|
|
2647
|
+
let config;
|
|
2648
|
+
try {
|
|
2649
|
+
config = JSON.parse(readFileSync4(configPath, "utf-8"));
|
|
2650
|
+
} catch {
|
|
2651
|
+
console.error(`[session] account.json corrupt at ${configPath} \u2014 skipping`);
|
|
2652
|
+
continue;
|
|
2653
|
+
}
|
|
2654
|
+
const adminEntry = config.admins?.find((a) => a.userId === userId);
|
|
2655
|
+
if (adminEntry) {
|
|
2656
|
+
results.push({
|
|
2657
|
+
accountId: config.accountId,
|
|
2658
|
+
accountDir: resolve3(ACCOUNTS_DIR, entry.name),
|
|
2659
|
+
config,
|
|
2660
|
+
role: adminEntry.role
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
return results;
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
// app/lib/claude-agent/session-store.ts
|
|
2668
|
+
function findFirstSubstantiveUserMessage(turns) {
|
|
2669
|
+
for (const t of turns) {
|
|
2670
|
+
if (t.role !== "user") continue;
|
|
2671
|
+
if (isMessageUseful(t.content)) return t.content;
|
|
2672
|
+
}
|
|
2673
|
+
return null;
|
|
2674
|
+
}
|
|
2675
|
+
var sessionStore = /* @__PURE__ */ new Map();
|
|
2676
|
+
function getSession2(sessionKey) {
|
|
2677
|
+
return sessionStore.get(sessionKey);
|
|
2678
|
+
}
|
|
2679
|
+
setSessionStoreRef(sessionStore);
|
|
2680
|
+
function registerSession(sessionKey, agentType, accountId, agentName, userId, userName, role) {
|
|
2681
|
+
const existing = sessionStore.get(sessionKey);
|
|
2682
|
+
if (existing) {
|
|
2683
|
+
existing.agentType = agentType;
|
|
2684
|
+
existing.accountId = accountId;
|
|
2685
|
+
existing.agentName = agentName ?? existing.agentName;
|
|
2686
|
+
existing.userId = userId ?? existing.userId;
|
|
2687
|
+
existing.userName = userName ?? existing.userName;
|
|
2688
|
+
existing.role = role ?? existing.role;
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
sessionStore.set(sessionKey, { createdAt: Date.now(), agentType, accountId, agentName, userId, userName, role });
|
|
2692
|
+
}
|
|
2693
|
+
function registerResumedSession(sessionKey, accountId, agentName, conversationId, messages) {
|
|
2694
|
+
const messageHistory = messages.map((m) => ({
|
|
2695
|
+
role: m.role,
|
|
2696
|
+
content: m.content,
|
|
2697
|
+
timestamp: m.timestamp ?? Date.now()
|
|
2698
|
+
}));
|
|
2699
|
+
sessionStore.set(sessionKey, {
|
|
2700
|
+
createdAt: Date.now(),
|
|
2701
|
+
agentType: "public",
|
|
2702
|
+
accountId,
|
|
2703
|
+
agentName,
|
|
2704
|
+
conversationId,
|
|
2705
|
+
messageHistory
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
function getSessionMessages(sessionKey) {
|
|
2709
|
+
return sessionStore.get(sessionKey)?.messageHistory;
|
|
2710
|
+
}
|
|
2711
|
+
function clearSessionHistory(sessionKey) {
|
|
2712
|
+
const session = sessionStore.get(sessionKey);
|
|
2713
|
+
if (!session) return void 0;
|
|
2714
|
+
const previousConversationId = session.conversationId;
|
|
2715
|
+
session.agentSessionId = void 0;
|
|
2716
|
+
session.pendingCompactionSummary = void 0;
|
|
2717
|
+
session.stalledSubagents = void 0;
|
|
2718
|
+
session.pendingTrimmedMessages = void 0;
|
|
2719
|
+
session.pendingCommitmentOffers = void 0;
|
|
2720
|
+
session.pendingTurns = void 0;
|
|
2721
|
+
return previousConversationId;
|
|
2722
|
+
}
|
|
2723
|
+
function bufferPendingTurn(sessionKey, turn) {
|
|
2724
|
+
const session = sessionStore.get(sessionKey);
|
|
2725
|
+
if (!session) {
|
|
2726
|
+
console.error(`[conversation-gate] bufferPendingTurn: session not found sessionKey=${sessionKey.slice(0, 8)}\u2026`);
|
|
2727
|
+
return;
|
|
2728
|
+
}
|
|
2729
|
+
if (!session.pendingTurns) session.pendingTurns = [];
|
|
2730
|
+
session.pendingTurns.push(turn);
|
|
2731
|
+
console.log(`[conversation-gate] ${(/* @__PURE__ */ new Date()).toISOString()} buffered sessionKey=${sessionKey.slice(0, 8)} role=${turn.role} turnCount=${session.pendingTurns.filter((t) => t.role === "user").length}`);
|
|
2732
|
+
}
|
|
2733
|
+
function getPendingTurnCount(sessionKey) {
|
|
2734
|
+
const buf = sessionStore.get(sessionKey)?.pendingTurns;
|
|
2735
|
+
if (!buf) return 0;
|
|
2736
|
+
let n = 0;
|
|
2737
|
+
for (const t of buf) if (t.role === "user") n++;
|
|
2738
|
+
return n;
|
|
2739
|
+
}
|
|
2740
|
+
function drainPendingTurns(sessionKey) {
|
|
2741
|
+
const session = sessionStore.get(sessionKey);
|
|
2742
|
+
if (!session?.pendingTurns || session.pendingTurns.length === 0) return void 0;
|
|
2743
|
+
const drained = session.pendingTurns;
|
|
2744
|
+
session.pendingTurns = void 0;
|
|
2745
|
+
return drained;
|
|
2746
|
+
}
|
|
2747
|
+
async function maybeFlushConversationBuffer(sessionKey, agentType, accountId) {
|
|
2748
|
+
const sk8 = sessionKey.slice(0, 8);
|
|
2749
|
+
const session = sessionStore.get(sessionKey);
|
|
2750
|
+
if (!session) {
|
|
2751
|
+
console.log(`[admin/conversation-flush] sessionKey=${sk8} agentType=${agentType} result=missing-session`);
|
|
2752
|
+
return null;
|
|
2753
|
+
}
|
|
2754
|
+
if (session.conversationId) {
|
|
2755
|
+
console.log(`[admin/conversation-flush] sessionKey=${sk8} agentType=${agentType} result=already-flushed conversationId=${session.conversationId.slice(0, 8)}`);
|
|
2756
|
+
return { conversationId: session.conversationId, buffered: [] };
|
|
2757
|
+
}
|
|
2758
|
+
const bufferedCount = session.pendingTurns?.length ?? 0;
|
|
2759
|
+
if (bufferedCount === 0) {
|
|
2760
|
+
console.log(`[admin/conversation-flush] sessionKey=${sk8} agentType=${agentType} result=empty-buffer`);
|
|
2761
|
+
return null;
|
|
2762
|
+
}
|
|
2763
|
+
if (session.flushInFlight) return session.flushInFlight;
|
|
2764
|
+
const attempt = (async () => {
|
|
2765
|
+
let conversationId = null;
|
|
2766
|
+
if (agentType === "admin") {
|
|
2767
|
+
const userId = session.userId;
|
|
2768
|
+
if (!userId) {
|
|
2769
|
+
console.error(`[admin/conversation-flush] sessionKey=${sk8} agentType=admin result=missing-userId bufferedCount=${bufferedCount}`);
|
|
2770
|
+
return null;
|
|
2771
|
+
}
|
|
2772
|
+
conversationId = await createNewAdminConversation(userId, accountId, sessionKey, session.userName);
|
|
2773
|
+
} else {
|
|
2774
|
+
conversationId = await ensureConversation(accountId, "public", sessionKey, void 0, session.agentName, void 0);
|
|
2775
|
+
}
|
|
2776
|
+
if (!conversationId) {
|
|
2777
|
+
console.error(`[admin/conversation-flush] sessionKey=${sk8} agentType=${agentType} result=writer-failed bufferedCount=${bufferedCount}`);
|
|
2778
|
+
return null;
|
|
2779
|
+
}
|
|
2780
|
+
session.conversationId = conversationId;
|
|
2781
|
+
if (agentType === "admin" && session.agentSessionId) {
|
|
2782
|
+
setConversationAgentSessionId(conversationId, session.agentSessionId).catch(() => {
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2785
|
+
renameStreamLogsOnFlush(resolve4(ACCOUNTS_DIR, accountId), sessionKey, conversationId);
|
|
2786
|
+
const buffered = drainPendingTurns(sessionKey) ?? [];
|
|
2787
|
+
for (const turn of buffered) {
|
|
2788
|
+
persistMessage(conversationId, turn.role, turn.content, accountId, turn.tokens, turn.timestamp, turn.sender, turn.components, turn.attachments).catch((err) => {
|
|
2789
|
+
console.error(`[admin/conversation-flush] replay persistMessage failed role=${turn.role}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2790
|
+
});
|
|
2791
|
+
}
|
|
2792
|
+
console.log(`[admin/conversation-flush] sessionKey=${sk8} agentType=${agentType} result=ok conversationId=${conversationId.slice(0, 8)} bufferedMessages=${buffered.length}`);
|
|
2793
|
+
return { conversationId, buffered };
|
|
2794
|
+
})();
|
|
2795
|
+
session.flushInFlight = attempt;
|
|
2796
|
+
try {
|
|
2797
|
+
return await attempt;
|
|
2798
|
+
} finally {
|
|
2799
|
+
if (session.flushInFlight === attempt) session.flushInFlight = void 0;
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
function isDmChannelSessionKey(sessionKey) {
|
|
2803
|
+
return sessionKey.startsWith("whatsapp:") || sessionKey.startsWith("telegram:");
|
|
2804
|
+
}
|
|
2805
|
+
function getAgentNameForSession(sessionKey) {
|
|
2806
|
+
return sessionStore.get(sessionKey)?.agentName;
|
|
2807
|
+
}
|
|
2808
|
+
function listAdminSessionsInProgress(accountId, userId) {
|
|
2809
|
+
const rows = [];
|
|
2810
|
+
for (const [sessionKey, session] of Array.from(sessionStore.entries())) {
|
|
2811
|
+
if (session.agentType !== "admin") continue;
|
|
2812
|
+
if (session.accountId !== accountId) continue;
|
|
2813
|
+
if (session.userId !== userId) continue;
|
|
2814
|
+
if (session.conversationId) continue;
|
|
2815
|
+
rows.push({ sessionKey, createdAt: session.createdAt });
|
|
2816
|
+
}
|
|
2817
|
+
rows.sort((a, b) => b.createdAt - a.createdAt);
|
|
2818
|
+
return rows;
|
|
2819
|
+
}
|
|
2820
|
+
function validateSession(sessionKey, agentType) {
|
|
2821
|
+
const session = sessionStore.get(sessionKey);
|
|
2822
|
+
if (!session) return { ok: false, reason: "session-not-registered" };
|
|
2823
|
+
if (session.agentType !== agentType) return { ok: false, reason: "agent-type-mismatch" };
|
|
2824
|
+
if (Date.now() - session.createdAt > 24 * 60 * 60 * 1e3) {
|
|
2825
|
+
sessionStore.delete(sessionKey);
|
|
2826
|
+
return { ok: false, reason: "session-expired-age" };
|
|
2827
|
+
}
|
|
2828
|
+
if (session.grantExpiresAt && Date.now() > session.grantExpiresAt) {
|
|
2829
|
+
sessionStore.delete(sessionKey);
|
|
2830
|
+
return { ok: false, reason: "grant-expired" };
|
|
2831
|
+
}
|
|
2832
|
+
return { ok: true };
|
|
2833
|
+
}
|
|
2834
|
+
function storeAgentSessionId(sessionKey, agentSessionId) {
|
|
2835
|
+
const session = sessionStore.get(sessionKey);
|
|
2836
|
+
if (session) {
|
|
2837
|
+
session.agentSessionId = agentSessionId;
|
|
2838
|
+
console.error(`[session-store] storeAgentSessionId sessionKey=${sessionKey.slice(0, 12)}\u2026 sessionId=${agentSessionId.slice(0, 8)}\u2026`);
|
|
2839
|
+
if (session.agentType === "admin" && session.conversationId) {
|
|
2840
|
+
setConversationAgentSessionId(session.conversationId, agentSessionId).catch(() => {
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
} else {
|
|
2844
|
+
console.error(`[session-store] storeAgentSessionId SKIPPED \u2014 no session entry sessionKey=${sessionKey.slice(0, 12)}\u2026 sessionId=${agentSessionId.slice(0, 8)}\u2026`);
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
function getAgentSessionId(sessionKey) {
|
|
2848
|
+
return sessionStore.get(sessionKey)?.agentSessionId;
|
|
2849
|
+
}
|
|
2850
|
+
function setAgentSessionId(sessionKey, agentSessionId) {
|
|
2851
|
+
const session = sessionStore.get(sessionKey);
|
|
2852
|
+
if (!session) return false;
|
|
2853
|
+
session.agentSessionId = agentSessionId;
|
|
2854
|
+
return true;
|
|
2855
|
+
}
|
|
2856
|
+
function storePendingCompactionSummary(sessionKey, summary) {
|
|
2857
|
+
const session = sessionStore.get(sessionKey);
|
|
2858
|
+
if (session) session.pendingCompactionSummary = summary;
|
|
2859
|
+
}
|
|
2860
|
+
function consumePendingCompactionSummary(sessionKey) {
|
|
2861
|
+
const session = sessionStore.get(sessionKey);
|
|
2862
|
+
if (!session) return void 0;
|
|
2863
|
+
const summary = session.pendingCompactionSummary;
|
|
2864
|
+
delete session.pendingCompactionSummary;
|
|
2865
|
+
return summary;
|
|
2866
|
+
}
|
|
2867
|
+
function getAccountIdForSession(sessionKey) {
|
|
2868
|
+
return sessionStore.get(sessionKey)?.accountId;
|
|
2869
|
+
}
|
|
2870
|
+
function getUserIdForSession(sessionKey) {
|
|
2871
|
+
return sessionStore.get(sessionKey)?.userId;
|
|
2872
|
+
}
|
|
2873
|
+
function getUserNameForSession(sessionKey) {
|
|
2874
|
+
return sessionStore.get(sessionKey)?.userName;
|
|
2875
|
+
}
|
|
2876
|
+
function getRoleForSession(sessionKey) {
|
|
2877
|
+
return sessionStore.get(sessionKey)?.role;
|
|
2878
|
+
}
|
|
2879
|
+
function getConversationIdForSession(sessionKey) {
|
|
2880
|
+
return sessionStore.get(sessionKey)?.conversationId;
|
|
2881
|
+
}
|
|
2882
|
+
function setConversationIdForSession(sessionKey, conversationId) {
|
|
2883
|
+
const session = sessionStore.get(sessionKey);
|
|
2884
|
+
if (!session) return false;
|
|
2885
|
+
session.conversationId = conversationId;
|
|
2886
|
+
return true;
|
|
2887
|
+
}
|
|
2888
|
+
function unregisterSession(sessionKey) {
|
|
2889
|
+
return sessionStore.delete(sessionKey);
|
|
2890
|
+
}
|
|
2891
|
+
function getGroupSlugForSession(sessionKey) {
|
|
2892
|
+
return sessionStore.get(sessionKey)?.groupSlug;
|
|
2893
|
+
}
|
|
2894
|
+
function getVisitorIdForSession(sessionKey) {
|
|
2895
|
+
return sessionStore.get(sessionKey)?.visitorId;
|
|
2896
|
+
}
|
|
2897
|
+
function setGroupContextForSession(sessionKey, context) {
|
|
2898
|
+
const session = sessionStore.get(sessionKey);
|
|
2899
|
+
if (!session) return false;
|
|
2900
|
+
session.groupSlug = context.groupSlug;
|
|
2901
|
+
session.groupName = context.groupName;
|
|
2902
|
+
session.conversationId = context.conversationId;
|
|
2903
|
+
session.visitorId = context.visitorId;
|
|
2904
|
+
session.senderDisplayName = context.senderDisplayName;
|
|
2905
|
+
return true;
|
|
2906
|
+
}
|
|
2907
|
+
function registerGrantSession(sessionKey, accountId, agentName, opts) {
|
|
2908
|
+
sessionStore.set(sessionKey, {
|
|
2909
|
+
createdAt: Date.now(),
|
|
2910
|
+
agentType: "public",
|
|
2911
|
+
accountId,
|
|
2912
|
+
agentName,
|
|
2913
|
+
grantId: opts.grantId,
|
|
2914
|
+
grantExpiresAt: opts.grantExpiresAt,
|
|
2915
|
+
grantStatus: opts.grantStatus,
|
|
2916
|
+
grantDisplayName: opts.grantDisplayName,
|
|
2917
|
+
grantContactValue: opts.grantContactValue,
|
|
2918
|
+
setupRequired: opts.setupRequired
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
function getGrantForSession(sessionKey) {
|
|
2922
|
+
const session = sessionStore.get(sessionKey);
|
|
2923
|
+
if (!session?.grantId) return void 0;
|
|
2924
|
+
return {
|
|
2925
|
+
grantId: session.grantId,
|
|
2926
|
+
grantExpiresAt: session.grantExpiresAt,
|
|
2927
|
+
grantStatus: session.grantStatus,
|
|
2928
|
+
grantDisplayName: session.grantDisplayName,
|
|
2929
|
+
grantContactValue: session.grantContactValue,
|
|
2930
|
+
setupRequired: session.setupRequired
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2933
|
+
function completeGrantSetup(sessionKey) {
|
|
2934
|
+
const session = sessionStore.get(sessionKey);
|
|
2935
|
+
if (session) {
|
|
2936
|
+
session.setupRequired = false;
|
|
2937
|
+
session.grantStatus = "active";
|
|
2938
|
+
}
|
|
2939
|
+
}
|
|
2940
|
+
function getMessageHistory(sessionKey) {
|
|
2941
|
+
const session = sessionStore.get(sessionKey);
|
|
2942
|
+
if (!session) return [];
|
|
2943
|
+
if (!session.messageHistory) session.messageHistory = [];
|
|
2944
|
+
return session.messageHistory;
|
|
2945
|
+
}
|
|
2946
|
+
function appendMessage(sessionKey, role, content, timestamp) {
|
|
2947
|
+
if (!content) return;
|
|
2948
|
+
const session = sessionStore.get(sessionKey);
|
|
2949
|
+
if (!session) {
|
|
2950
|
+
console.error(`[managed] appendMessage: session not found for key ${sessionKey.slice(0, 8)}\u2026 (store size: ${sessionStore.size})`);
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
if (!session.messageHistory) session.messageHistory = [];
|
|
2954
|
+
session.messageHistory.push({ role, content, timestamp: timestamp ?? Date.now() });
|
|
2955
|
+
}
|
|
2956
|
+
function storePendingTrimmedMessages(sessionKey, messages) {
|
|
2957
|
+
const session = sessionStore.get(sessionKey);
|
|
2958
|
+
if (session) session.pendingTrimmedMessages = messages;
|
|
2959
|
+
}
|
|
2960
|
+
function consumePendingTrimmedMessages(sessionKey) {
|
|
2961
|
+
const session = sessionStore.get(sessionKey);
|
|
2962
|
+
if (!session) return void 0;
|
|
2963
|
+
const messages = session.pendingTrimmedMessages;
|
|
2964
|
+
delete session.pendingTrimmedMessages;
|
|
2965
|
+
return messages;
|
|
2966
|
+
}
|
|
2967
|
+
function storeStalledSubagent(sessionKey, info) {
|
|
2968
|
+
const session = sessionStore.get(sessionKey);
|
|
2969
|
+
if (!session) {
|
|
2970
|
+
console.error(`[stall-recovery] storeStalledSubagent: session not found for key ${sessionKey.slice(0, 8)}\u2026 \u2014 stall context lost`);
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
if (!session.stalledSubagents) session.stalledSubagents = [];
|
|
2974
|
+
session.stalledSubagents.push(info);
|
|
2975
|
+
}
|
|
2976
|
+
function consumeStalledSubagents(sessionKey) {
|
|
2977
|
+
const session = sessionStore.get(sessionKey);
|
|
2978
|
+
if (!session) return void 0;
|
|
2979
|
+
const stalls = session.stalledSubagents;
|
|
2980
|
+
delete session.stalledSubagents;
|
|
2981
|
+
return stalls && stalls.length > 0 ? stalls : void 0;
|
|
2982
|
+
}
|
|
2983
|
+
function setPendingCommitmentOffers(sessionKey, offers) {
|
|
2984
|
+
const session = sessionStore.get(sessionKey);
|
|
2985
|
+
if (session) session.pendingCommitmentOffers = offers;
|
|
2986
|
+
}
|
|
2987
|
+
function getAgentTypeForSession(sessionKey) {
|
|
2988
|
+
return sessionStore.get(sessionKey)?.agentType;
|
|
2989
|
+
}
|
|
2990
|
+
function clearMessageHistory(sessionKey) {
|
|
2991
|
+
const session = sessionStore.get(sessionKey);
|
|
2992
|
+
if (session) session.messageHistory = [];
|
|
2993
|
+
}
|
|
2994
|
+
var clearAgentSessionIdHandlers = [];
|
|
2995
|
+
function onClearAgentSessionId(handler) {
|
|
2996
|
+
clearAgentSessionIdHandlers.push(handler);
|
|
2997
|
+
}
|
|
2998
|
+
function clearAgentSessionId(sessionKey, reason) {
|
|
2999
|
+
const session = sessionStore.get(sessionKey);
|
|
3000
|
+
if (session) {
|
|
3001
|
+
session.agentSessionId = void 0;
|
|
3002
|
+
console.error(`[session-store] clearAgentSessionId sessionKey=${sessionKey.slice(0, 12)}\u2026 reason=${reason}`);
|
|
3003
|
+
} else {
|
|
3004
|
+
console.error(`[session-store] clearAgentSessionId SKIPPED \u2014 no session entry sessionKey=${sessionKey.slice(0, 12)}\u2026 reason=${reason}`);
|
|
3005
|
+
}
|
|
3006
|
+
for (const handler of clearAgentSessionIdHandlers) {
|
|
3007
|
+
try {
|
|
3008
|
+
handler(sessionKey, reason);
|
|
3009
|
+
} catch (err) {
|
|
3010
|
+
console.error(`[session-store] clearAgentSessionId handler threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
3011
|
+
}
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// app/lib/claude-agent/client-pool.ts
|
|
3016
|
+
var AsyncQueue = class {
|
|
3017
|
+
buffer = [];
|
|
3018
|
+
resolvers = [];
|
|
3019
|
+
closed = false;
|
|
3020
|
+
push(item) {
|
|
3021
|
+
if (this.closed) return;
|
|
3022
|
+
const resolver = this.resolvers.shift();
|
|
3023
|
+
if (resolver) resolver({ value: item, done: false });
|
|
3024
|
+
else this.buffer.push(item);
|
|
3025
|
+
}
|
|
3026
|
+
close() {
|
|
3027
|
+
if (this.closed) return;
|
|
3028
|
+
this.closed = true;
|
|
3029
|
+
while (this.resolvers.length > 0) {
|
|
3030
|
+
const r = this.resolvers.shift();
|
|
3031
|
+
r({ value: void 0, done: true });
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
[Symbol.asyncIterator]() {
|
|
3035
|
+
return {
|
|
3036
|
+
next: () => new Promise((resolve5) => {
|
|
3037
|
+
if (this.buffer.length > 0) {
|
|
3038
|
+
resolve5({ value: this.buffer.shift(), done: false });
|
|
3039
|
+
} else if (this.closed) {
|
|
3040
|
+
resolve5({ value: void 0, done: true });
|
|
3041
|
+
} else {
|
|
3042
|
+
this.resolvers.push(resolve5);
|
|
3043
|
+
}
|
|
3044
|
+
}),
|
|
3045
|
+
return: async () => {
|
|
3046
|
+
this.close();
|
|
3047
|
+
return { value: void 0, done: true };
|
|
3048
|
+
}
|
|
3049
|
+
};
|
|
3050
|
+
}
|
|
3051
|
+
};
|
|
3052
|
+
var clientPool = /* @__PURE__ */ new Map();
|
|
3053
|
+
var DEFAULT_IDLE_MS = 30 * 60 * 1e3;
|
|
3054
|
+
var idleEvictMs = (() => {
|
|
3055
|
+
const v = Number(process.env.CLAUDE_CLIENT_IDLE_MS);
|
|
3056
|
+
return Number.isFinite(v) && v > 0 ? v : DEFAULT_IDLE_MS;
|
|
3057
|
+
})();
|
|
3058
|
+
var ABORT_SDK_TIMEOUT_MS = 2e3;
|
|
3059
|
+
function acquireClient(sessionKey, opts, streamLog) {
|
|
3060
|
+
const existing = clientPool.get(sessionKey);
|
|
3061
|
+
if (existing) {
|
|
3062
|
+
existing.lastUsedAt = Date.now();
|
|
3063
|
+
existing.turnsServed += 1;
|
|
3064
|
+
safeWrite(
|
|
3065
|
+
streamLog,
|
|
3066
|
+
`[${isoTs()}] [client-warm-reuse] sessionKey=${sk(sessionKey)} ageMs=${Date.now() - existing.createdAt} turnsServed=${existing.turnsServed} cachedTokens=${existing.cachedTokensLastTurn}
|
|
3067
|
+
`
|
|
3068
|
+
);
|
|
3069
|
+
return { entry: existing, isCold: false };
|
|
3070
|
+
}
|
|
3071
|
+
const userQueue = new AsyncQueue();
|
|
3072
|
+
const abortController = new AbortController();
|
|
3073
|
+
let q;
|
|
3074
|
+
try {
|
|
3075
|
+
const sdkOptions = opts.buildSdkOptions();
|
|
3076
|
+
q = query({ prompt: userQueue, options: { ...sdkOptions, abortController } });
|
|
3077
|
+
} catch (err) {
|
|
3078
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3079
|
+
safeWrite(
|
|
3080
|
+
streamLog,
|
|
3081
|
+
`[${isoTs()}] [client-spawn-error] sessionKey=${sk(sessionKey)} reason=${JSON.stringify(reason.slice(0, 200))}
|
|
3082
|
+
`
|
|
3083
|
+
);
|
|
3084
|
+
throw err;
|
|
3085
|
+
}
|
|
3086
|
+
const entry = {
|
|
3087
|
+
query: q,
|
|
3088
|
+
userQueue,
|
|
3089
|
+
abortController,
|
|
3090
|
+
inflight: null,
|
|
3091
|
+
createdAt: Date.now(),
|
|
3092
|
+
lastUsedAt: Date.now(),
|
|
3093
|
+
turnsServed: 1,
|
|
3094
|
+
resumedFromSessionId: opts.resumedFromSessionId,
|
|
3095
|
+
cachedTokensLastTurn: -1,
|
|
3096
|
+
accountId: opts.accountId,
|
|
3097
|
+
accountDir: opts.accountDir,
|
|
3098
|
+
logKey: opts.logKey
|
|
3099
|
+
};
|
|
3100
|
+
clientPool.set(sessionKey, entry);
|
|
3101
|
+
safeWrite(
|
|
3102
|
+
streamLog,
|
|
3103
|
+
`[${isoTs()}] [client-cold-create] sessionKey=${sk(sessionKey)} resumedFrom=${opts.resumedFromSessionId ?? "none"} createdAtMs=${entry.createdAt}
|
|
3104
|
+
`
|
|
3105
|
+
);
|
|
3106
|
+
return { entry, isCold: true };
|
|
3107
|
+
}
|
|
3108
|
+
function pushUserMessage(entry, content) {
|
|
3109
|
+
const msg = {
|
|
3110
|
+
type: "user",
|
|
3111
|
+
message: { role: "user", content },
|
|
3112
|
+
parent_tool_use_id: null
|
|
3113
|
+
};
|
|
3114
|
+
entry.userQueue.push(msg);
|
|
3115
|
+
}
|
|
3116
|
+
function setInflight(entry, promise) {
|
|
3117
|
+
entry.inflight = promise;
|
|
3118
|
+
promise.finally(() => {
|
|
3119
|
+
if (entry.inflight === promise) entry.inflight = null;
|
|
3120
|
+
}).catch(() => {
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
function recordCachedTokens(entry, cachedTokens) {
|
|
3124
|
+
entry.cachedTokensLastTurn = cachedTokens;
|
|
3125
|
+
}
|
|
3126
|
+
function getActiveClient(sessionKey) {
|
|
3127
|
+
return clientPool.get(sessionKey);
|
|
3128
|
+
}
|
|
3129
|
+
function appendAbortLine(entry, line) {
|
|
3130
|
+
const path = resolvePath(entry.accountDir, "logs", `claude-agent-stream-${entry.logKey}.log`);
|
|
3131
|
+
try {
|
|
3132
|
+
appendFileSync2(path, line);
|
|
3133
|
+
} catch {
|
|
3134
|
+
}
|
|
3135
|
+
console.error(line.trimEnd());
|
|
3136
|
+
}
|
|
3137
|
+
async function interruptClient(sessionKey, _streamLog) {
|
|
3138
|
+
void _streamLog;
|
|
3139
|
+
const entry = clientPool.get(sessionKey);
|
|
3140
|
+
if (!entry) return;
|
|
3141
|
+
const startedAt = Date.now();
|
|
3142
|
+
let resolved = false;
|
|
3143
|
+
const timeout = new Promise(
|
|
3144
|
+
(resolve5) => setTimeout(() => resolve5("timeout"), ABORT_SDK_TIMEOUT_MS)
|
|
3145
|
+
);
|
|
3146
|
+
const sdkAbort = entry.query.interrupt().then(() => {
|
|
3147
|
+
resolved = true;
|
|
3148
|
+
return "ok";
|
|
3149
|
+
}).catch((err) => {
|
|
3150
|
+
resolved = true;
|
|
3151
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
3152
|
+
});
|
|
3153
|
+
const result = await Promise.race([sdkAbort, timeout]);
|
|
3154
|
+
const durationMs = Date.now() - startedAt;
|
|
3155
|
+
if (result === "timeout" && !resolved) {
|
|
3156
|
+
appendAbortLine(
|
|
3157
|
+
entry,
|
|
3158
|
+
`[${isoTs()}] [client-abort] sessionKey=${sk(sessionKey)} method=signal durationMs=${durationMs}
|
|
3159
|
+
`
|
|
3160
|
+
);
|
|
3161
|
+
evictClient(sessionKey, "abort-signal-fallback", null);
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
if (typeof result === "object" && "error" in result) {
|
|
3165
|
+
appendAbortLine(
|
|
3166
|
+
entry,
|
|
3167
|
+
`[${isoTs()}] [client-abort] sessionKey=${sk(sessionKey)} method=sdk durationMs=${durationMs} error=${JSON.stringify(result.error.slice(0, 120))}
|
|
3168
|
+
`
|
|
3169
|
+
);
|
|
3170
|
+
evictClient(sessionKey, "abort-error", null);
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
appendAbortLine(
|
|
3174
|
+
entry,
|
|
3175
|
+
`[${isoTs()}] [client-abort] sessionKey=${sk(sessionKey)} method=sdk durationMs=${durationMs}
|
|
3176
|
+
`
|
|
3177
|
+
);
|
|
3178
|
+
}
|
|
3179
|
+
function evictClient(sessionKey, reason, streamLog) {
|
|
3180
|
+
const entry = clientPool.get(sessionKey);
|
|
3181
|
+
if (!entry) return false;
|
|
3182
|
+
const ageMs = Date.now() - entry.createdAt;
|
|
3183
|
+
clientPool.delete(sessionKey);
|
|
3184
|
+
try {
|
|
3185
|
+
entry.userQueue.close();
|
|
3186
|
+
} catch {
|
|
3187
|
+
}
|
|
3188
|
+
try {
|
|
3189
|
+
entry.query.close();
|
|
3190
|
+
} catch {
|
|
3191
|
+
}
|
|
3192
|
+
const line = `[${isoTs()}] [client-evict] reason=${reason} sessionKey=${sk(sessionKey)} ageMs=${ageMs} turnsServed=${entry.turnsServed}
|
|
3193
|
+
`;
|
|
3194
|
+
if (streamLog) {
|
|
3195
|
+
safeWrite(streamLog, line);
|
|
3196
|
+
} else {
|
|
3197
|
+
appendAbortLine(entry, line);
|
|
3198
|
+
}
|
|
3199
|
+
return true;
|
|
3200
|
+
}
|
|
3201
|
+
function acquireOneShotClient(sessionKey, opts, streamLog) {
|
|
3202
|
+
const userQueue = new AsyncQueue();
|
|
3203
|
+
const abortController = new AbortController();
|
|
3204
|
+
let q;
|
|
3205
|
+
try {
|
|
3206
|
+
const sdkOptions = opts.buildSdkOptions();
|
|
3207
|
+
q = query({ prompt: userQueue, options: { ...sdkOptions, abortController } });
|
|
3208
|
+
} catch (err) {
|
|
3209
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3210
|
+
safeWrite(
|
|
3211
|
+
streamLog,
|
|
3212
|
+
`[${isoTs()}] [client-spawn-error] sessionKey=${sk(sessionKey)} site=compaction-one-shot reason=${JSON.stringify(reason.slice(0, 200))}
|
|
3213
|
+
`
|
|
3214
|
+
);
|
|
3215
|
+
throw err;
|
|
3216
|
+
}
|
|
3217
|
+
const entry = {
|
|
3218
|
+
query: q,
|
|
3219
|
+
userQueue,
|
|
3220
|
+
abortController,
|
|
3221
|
+
inflight: null,
|
|
3222
|
+
createdAt: Date.now(),
|
|
3223
|
+
lastUsedAt: Date.now(),
|
|
3224
|
+
turnsServed: 1,
|
|
3225
|
+
resumedFromSessionId: opts.resumedFromSessionId,
|
|
3226
|
+
cachedTokensLastTurn: -1,
|
|
3227
|
+
accountId: opts.accountId,
|
|
3228
|
+
accountDir: opts.accountDir,
|
|
3229
|
+
logKey: opts.logKey
|
|
3230
|
+
};
|
|
3231
|
+
safeWrite(
|
|
3232
|
+
streamLog,
|
|
3233
|
+
`[${isoTs()}] [client-cold-create] reason=compaction-one-shot sessionKey=${sk(sessionKey)} createdAtMs=${entry.createdAt}
|
|
3234
|
+
`
|
|
3235
|
+
);
|
|
3236
|
+
let evicted = false;
|
|
3237
|
+
const evict = (reason) => {
|
|
3238
|
+
if (evicted) return;
|
|
3239
|
+
evicted = true;
|
|
3240
|
+
const ageMs = Date.now() - entry.createdAt;
|
|
3241
|
+
try {
|
|
3242
|
+
entry.userQueue.close();
|
|
3243
|
+
} catch {
|
|
3244
|
+
}
|
|
3245
|
+
try {
|
|
3246
|
+
entry.query.close();
|
|
3247
|
+
} catch {
|
|
3248
|
+
}
|
|
3249
|
+
safeWrite(
|
|
3250
|
+
streamLog,
|
|
3251
|
+
`[${isoTs()}] [client-evict] reason=${reason} sessionKey=${sk(sessionKey)} ageMs=${ageMs}
|
|
3252
|
+
`
|
|
3253
|
+
);
|
|
3254
|
+
};
|
|
3255
|
+
return { entry, evict };
|
|
3256
|
+
}
|
|
3257
|
+
function recordCrash(sessionKey, reason, streamLog) {
|
|
3258
|
+
const entry = clientPool.get(sessionKey);
|
|
3259
|
+
if (!entry) return;
|
|
3260
|
+
const tail = JSON.stringify(reason.slice(-512));
|
|
3261
|
+
clientPool.delete(sessionKey);
|
|
3262
|
+
try {
|
|
3263
|
+
entry.userQueue.close();
|
|
3264
|
+
} catch {
|
|
3265
|
+
}
|
|
3266
|
+
try {
|
|
3267
|
+
entry.query.close();
|
|
3268
|
+
} catch {
|
|
3269
|
+
}
|
|
3270
|
+
safeWrite(
|
|
3271
|
+
streamLog,
|
|
3272
|
+
`[${isoTs()}] [client-crash] sessionKey=${sk(sessionKey)} reason=${JSON.stringify(reason.slice(0, 80))} tail=${tail}
|
|
3273
|
+
`
|
|
3274
|
+
);
|
|
3275
|
+
}
|
|
3276
|
+
var IDLE_TICK_MS = 6e4;
|
|
3277
|
+
var idleTickHandle = null;
|
|
3278
|
+
function evictIdleTick() {
|
|
3279
|
+
const now = Date.now();
|
|
3280
|
+
for (const [sessionKey, entry] of Array.from(clientPool.entries())) {
|
|
3281
|
+
if (entry.inflight !== null) continue;
|
|
3282
|
+
if (now - entry.lastUsedAt < idleEvictMs) continue;
|
|
3283
|
+
const ageMs = now - entry.createdAt;
|
|
3284
|
+
clientPool.delete(sessionKey);
|
|
3285
|
+
try {
|
|
3286
|
+
entry.userQueue.close();
|
|
3287
|
+
} catch {
|
|
3288
|
+
}
|
|
3289
|
+
try {
|
|
3290
|
+
entry.query.close();
|
|
3291
|
+
} catch {
|
|
3292
|
+
}
|
|
3293
|
+
console.error(
|
|
3294
|
+
`[${isoTs()}] [client-evict] reason=idle sessionKey=${sk(sessionKey)} ageMs=${ageMs} idleMs=${now - entry.lastUsedAt} turnsServed=${entry.turnsServed}`
|
|
3295
|
+
);
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
function startIdleEvictTick() {
|
|
3299
|
+
if (idleTickHandle) return;
|
|
3300
|
+
idleTickHandle = setInterval(evictIdleTick, IDLE_TICK_MS);
|
|
3301
|
+
if (idleTickHandle.unref) idleTickHandle.unref();
|
|
3302
|
+
}
|
|
3303
|
+
function stopIdleEvictTick() {
|
|
3304
|
+
if (idleTickHandle) {
|
|
3305
|
+
clearInterval(idleTickHandle);
|
|
3306
|
+
idleTickHandle = null;
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
startIdleEvictTick();
|
|
3310
|
+
onClearAgentSessionId((sessionKey, reason) => {
|
|
3311
|
+
const entry = clientPool.get(sessionKey);
|
|
3312
|
+
if (!entry) return;
|
|
3313
|
+
const ageMs = Date.now() - entry.createdAt;
|
|
3314
|
+
clientPool.delete(sessionKey);
|
|
3315
|
+
try {
|
|
3316
|
+
entry.userQueue.close();
|
|
3317
|
+
} catch {
|
|
3318
|
+
}
|
|
3319
|
+
try {
|
|
3320
|
+
entry.query.close();
|
|
3321
|
+
} catch {
|
|
3322
|
+
}
|
|
3323
|
+
console.error(
|
|
3324
|
+
`[${isoTs()}] [client-evict] reason=clearAgentSessionId-${reason} sessionKey=${sk(sessionKey)} ageMs=${ageMs} turnsServed=${entry.turnsServed}`
|
|
3325
|
+
);
|
|
3326
|
+
});
|
|
3327
|
+
function sk(sessionKey) {
|
|
3328
|
+
return sessionKey.length > 12 ? sessionKey.slice(0, 12) + "\u2026" : sessionKey;
|
|
3329
|
+
}
|
|
3330
|
+
function safeWrite(stream, line) {
|
|
3331
|
+
if (!stream || stream.destroyed) return;
|
|
3332
|
+
if (stream.writableEnded) return;
|
|
3333
|
+
try {
|
|
3334
|
+
stream.write(line);
|
|
3335
|
+
} catch {
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
function _poolSnapshotForTest() {
|
|
3339
|
+
const now = Date.now();
|
|
3340
|
+
return Array.from(clientPool.entries()).map(([sessionKey, entry]) => ({
|
|
3341
|
+
sessionKey,
|
|
3342
|
+
ageMs: now - entry.createdAt,
|
|
3343
|
+
idleMs: now - entry.lastUsedAt,
|
|
3344
|
+
turnsServed: entry.turnsServed,
|
|
3345
|
+
inflight: entry.inflight !== null
|
|
3346
|
+
}));
|
|
3347
|
+
}
|
|
3348
|
+
function _evictAllForTest(reason = "test-tear-down") {
|
|
3349
|
+
for (const sessionKey of Array.from(clientPool.keys())) {
|
|
3350
|
+
const entry = clientPool.get(sessionKey);
|
|
3351
|
+
if (!entry) continue;
|
|
3352
|
+
clientPool.delete(sessionKey);
|
|
3353
|
+
try {
|
|
3354
|
+
entry.userQueue.close();
|
|
3355
|
+
} catch {
|
|
3356
|
+
}
|
|
3357
|
+
try {
|
|
3358
|
+
entry.query.close();
|
|
3359
|
+
} catch {
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
void reason;
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
export {
|
|
3366
|
+
substituteBrandPlaceholders,
|
|
3367
|
+
PLATFORM_ROOT2 as PLATFORM_ROOT,
|
|
3368
|
+
ACCOUNTS_DIR,
|
|
3369
|
+
resolveAccount,
|
|
3370
|
+
readAgentFile,
|
|
3371
|
+
readIdentity,
|
|
3372
|
+
validateAgentSlug,
|
|
3373
|
+
resolveDefaultAgentSlug,
|
|
3374
|
+
resolveAgentConfig,
|
|
3375
|
+
getDefaultAccountId,
|
|
3376
|
+
resolveUserAccounts,
|
|
3377
|
+
HAIKU_MODEL,
|
|
3378
|
+
contextWindow,
|
|
3379
|
+
getSession,
|
|
3380
|
+
runBootMigrations,
|
|
3381
|
+
embed,
|
|
3382
|
+
GREETING_DIRECTIVE,
|
|
3383
|
+
ensureConversation,
|
|
3384
|
+
findRecentConversation,
|
|
3385
|
+
findGroupBySlug,
|
|
3386
|
+
getGroupParticipants,
|
|
3387
|
+
checkGroupMembership,
|
|
3388
|
+
bindVisitorToGroup,
|
|
3389
|
+
getMessagesSince,
|
|
3390
|
+
getGroupMessagesForContext,
|
|
3391
|
+
backfillNullUserIdConversations,
|
|
3392
|
+
fetchBranding,
|
|
3393
|
+
persistToolCall,
|
|
3394
|
+
persistMessage,
|
|
3395
|
+
setConversationAgentSessionId,
|
|
3396
|
+
getAgentSessionIdForConversation,
|
|
3397
|
+
getRecentMessages,
|
|
3398
|
+
markComponentSubmitted,
|
|
3399
|
+
verifyConversationOwnership,
|
|
3400
|
+
verifyAndGetConversationUpdatedAt,
|
|
3401
|
+
searchMessages,
|
|
3402
|
+
listAdminSessions,
|
|
3403
|
+
deleteConversation,
|
|
3404
|
+
generateSessionLabel,
|
|
3405
|
+
autoLabelSession,
|
|
3406
|
+
renameConversation,
|
|
3407
|
+
getUserTimezone,
|
|
3408
|
+
loadAdminUserName,
|
|
3409
|
+
writeAdminUserAndPerson,
|
|
3410
|
+
loadUserProfile,
|
|
3411
|
+
loadSessionContext,
|
|
3412
|
+
loadOnboardingStep,
|
|
3413
|
+
writeReflectionPreferences,
|
|
3414
|
+
projectAgent,
|
|
3415
|
+
deleteAgentProjection,
|
|
3416
|
+
isoTs,
|
|
3417
|
+
isBrowserTool,
|
|
3418
|
+
runFailureDiagnostic,
|
|
3419
|
+
agentLogStream,
|
|
3420
|
+
preConversationLogStream,
|
|
3421
|
+
sigtermFlushStreamLogs,
|
|
3422
|
+
preflushStreamLogKey,
|
|
3423
|
+
findFirstSubstantiveUserMessage,
|
|
3424
|
+
getSession2,
|
|
3425
|
+
registerSession,
|
|
3426
|
+
registerResumedSession,
|
|
3427
|
+
getSessionMessages,
|
|
3428
|
+
clearSessionHistory,
|
|
3429
|
+
bufferPendingTurn,
|
|
3430
|
+
getPendingTurnCount,
|
|
3431
|
+
maybeFlushConversationBuffer,
|
|
3432
|
+
isDmChannelSessionKey,
|
|
3433
|
+
getAgentNameForSession,
|
|
3434
|
+
listAdminSessionsInProgress,
|
|
3435
|
+
validateSession,
|
|
3436
|
+
storeAgentSessionId,
|
|
3437
|
+
getAgentSessionId,
|
|
3438
|
+
setAgentSessionId,
|
|
3439
|
+
storePendingCompactionSummary,
|
|
3440
|
+
consumePendingCompactionSummary,
|
|
3441
|
+
getAccountIdForSession,
|
|
3442
|
+
getUserIdForSession,
|
|
3443
|
+
getUserNameForSession,
|
|
3444
|
+
getRoleForSession,
|
|
3445
|
+
getConversationIdForSession,
|
|
3446
|
+
setConversationIdForSession,
|
|
3447
|
+
unregisterSession,
|
|
3448
|
+
getGroupSlugForSession,
|
|
3449
|
+
getVisitorIdForSession,
|
|
3450
|
+
setGroupContextForSession,
|
|
3451
|
+
registerGrantSession,
|
|
3452
|
+
getGrantForSession,
|
|
3453
|
+
completeGrantSetup,
|
|
3454
|
+
getMessageHistory,
|
|
3455
|
+
appendMessage,
|
|
3456
|
+
storePendingTrimmedMessages,
|
|
3457
|
+
consumePendingTrimmedMessages,
|
|
3458
|
+
storeStalledSubagent,
|
|
3459
|
+
consumeStalledSubagents,
|
|
3460
|
+
setPendingCommitmentOffers,
|
|
3461
|
+
getAgentTypeForSession,
|
|
3462
|
+
clearMessageHistory,
|
|
3463
|
+
clearAgentSessionId,
|
|
3464
|
+
acquireClient,
|
|
3465
|
+
pushUserMessage,
|
|
3466
|
+
setInflight,
|
|
3467
|
+
recordCachedTokens,
|
|
3468
|
+
getActiveClient,
|
|
3469
|
+
interruptClient,
|
|
3470
|
+
evictClient,
|
|
3471
|
+
acquireOneShotClient,
|
|
3472
|
+
recordCrash,
|
|
3473
|
+
startIdleEvictTick,
|
|
3474
|
+
stopIdleEvictTick,
|
|
3475
|
+
_poolSnapshotForTest,
|
|
3476
|
+
_evictAllForTest
|
|
3477
|
+
};
|