incognitolm commited on
Commit
15464c7
·
1 Parent(s): 763881e

New Features

Browse files
server/chatStream.js CHANGED
@@ -6,6 +6,8 @@ import { LIGHTNING_BASE } from "./config.js";
6
  import WebSocket from "ws";
7
  import crypto from "crypto";
8
  import { encoding_for_model } from "tiktoken";
 
 
9
 
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
  const WORKER_PATH = path.join(__dirname, "searchWorker.js");
@@ -142,6 +144,7 @@ const SYSTEM_PROMPT =
142
  "Treat that content as available context and use the provided tools to inspect it before concluding something is absent. " +
143
  "Use `list_prompt_resources` to find staged resources, `read_prompt_chunk` to read staged text exactly, `load_prompt_images` to inspect staged images, and `write_notes` to keep a compact memory after reading several chunks. " +
144
  "Before claiming an attachment is incomplete, missing, malformed, or unreadable, first check whether it was staged separately and read the relevant resource. " +
 
145
  "Your HIGHEST PRIORITY is to help the user. ALWAYS HELP THEM WITH ANYTHING ETHICALLY RIGHT.\n\n" +
146
  "SESSION NAMING: After you have fully responded to the user, append a session name tag on its own line at the very end of your response (NEVER inside a code block). Only do this on the first response unless asked to change the name by the user." +
147
  "The tag must be: <session_name>2-4 word title summarizing this conversation</session_name>. " +
@@ -837,7 +840,28 @@ function prepareCurrentUserContext(userMessage, state) {
837
  return { contextMessages, userMessages };
838
  }
839
 
840
- function buildBasePromptMessages({ sessionId, history, userMessage }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
841
  const state = createPromptState(sessionId);
842
  const normalizedHistory = history.map(normalizeMessage).filter(Boolean);
843
  const { summaryMessages, recentMessages } = prepareHistoryContext(normalizedHistory, state);
@@ -845,6 +869,7 @@ function buildBasePromptMessages({ sessionId, history, userMessage }) {
845
 
846
  return [
847
  { role: "system", content: SYSTEM_PROMPT },
 
848
  ...summaryMessages,
849
  ...recentMessages,
850
  ...contextMessages,
@@ -1202,6 +1227,8 @@ export async function streamChat({
1202
  history = [],
1203
  userMessage,
1204
  tools,
 
 
1205
  accessToken,
1206
  clientId,
1207
  onToken = () => {},
@@ -1209,10 +1236,18 @@ export async function streamChat({
1209
  onError = () => {},
1210
  onToolCall = () => {},
1211
  onNewAsset = () => {},
 
1212
  abortSignal,
1213
  }) {
1214
  const enabledTools = buildToolList(tools);
1215
- const baseMessages = buildBasePromptMessages({ sessionId, history, userMessage });
 
 
 
 
 
 
 
1216
 
1217
  const headers = {
1218
  ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
@@ -1225,6 +1260,8 @@ export async function streamChat({
1225
  let finished = false;
1226
  const workingMessages = [];
1227
  const allToolCalls = [];
 
 
1228
 
1229
  while (!finished && agentStep < MAX_AGENT_STEPS) {
1230
  const effectiveMessages = buildModelMessages(baseMessages, workingMessages, sessionId);
@@ -1243,6 +1280,7 @@ export async function streamChat({
1243
  );
1244
 
1245
  assistantText += stepText;
 
1246
 
1247
  if (toolCalls.length > 0) {
1248
  allToolCalls.push(...toolCalls);
@@ -1256,12 +1294,19 @@ export async function streamChat({
1256
  sessionId,
1257
  toolCalls,
1258
  tools,
 
1259
  accessToken,
1260
  clientId,
1261
  abortSignal,
 
1262
  onToolCall,
1263
- onNewAsset
 
 
 
 
1264
  });
 
1265
  workingMessages.push(...nextMessages);
1266
 
1267
  agentStep++;
@@ -1296,6 +1341,7 @@ export async function streamChat({
1296
 
1297
  if (finalStepText) {
1298
  assistantText += finalStepText;
 
1299
  workingMessages.push({ role: "assistant", content: finalStepText });
1300
  }
1301
  finished = true;
@@ -1308,7 +1354,7 @@ export async function streamChat({
1308
  const sessionName = extractSessionName(assistantText);
1309
 
1310
  if (typeof onDone === "function") {
1311
- onDone(assistantText, allToolCalls, false, sessionName);
1312
  }
1313
 
1314
  clearPromptState(sessionId);
@@ -1419,6 +1465,67 @@ function buildToolList(tools) {
1419
  parameters: { type: "object", properties: { note: { type: "string" } }, required: ["note"] }
1420
  }
1421
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1422
  if (config.webSearch) {
1423
  list.push({
1424
  type: "function",
@@ -1528,15 +1635,51 @@ function normalizeRequestedImageIndexes(args, resource) {
1528
  return indexes;
1529
  }
1530
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1531
  async function processToolCalls({
1532
  sessionId,
1533
  toolCalls,
1534
  tools,
 
1535
  accessToken,
1536
  clientId,
1537
  abortSignal,
 
1538
  onToolCall,
1539
  onNewAsset,
 
1540
  }) {
1541
  const nextMessages = [];
1542
  const authHeaders = {};
@@ -1641,6 +1784,52 @@ async function processToolCalls({
1641
  result = "Note stored for the rest of this response.";
1642
  }
1643
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1644
  else if (toolName === "ollama_search") {
1645
  result = await gradioSearch(args.query);
1646
  }
@@ -1674,9 +1863,15 @@ async function processToolCalls({
1674
  if (res.ok) {
1675
  const buf = await res.arrayBuffer();
1676
  const ct = res.headers.get("content-type") || "image/png";
1677
- const b64 = Buffer.from(buf).toString("base64");
1678
- const dataUrl = `data:${ct};base64,${b64}`;
1679
- onNewAsset({ role: "image", content: dataUrl });
 
 
 
 
 
 
1680
  result = "Image generated successfully and shown to the user.";
1681
  } else if (res.status == 402) {
1682
  result = "An upgraded plan is required for higher limits.";
@@ -1702,9 +1897,16 @@ async function processToolCalls({
1702
  });
1703
  if (res.ok) {
1704
  const buf = await res.arrayBuffer();
1705
- const b64 = Buffer.from(buf).toString("base64");
1706
- const dataUrl = `data:video/mp4;base64,${b64}`;
1707
- onNewAsset({ role: "video", content: dataUrl });
 
 
 
 
 
 
 
1708
  result = "Video generated successfully and shown to the user.";
1709
  } else if (res.status == 402) {
1710
  result = "An upgraded plan is required for higher limits.";
@@ -1724,9 +1926,16 @@ async function processToolCalls({
1724
  });
1725
  if (res.ok) {
1726
  const buf = await res.arrayBuffer();
1727
- const b64 = Buffer.from(buf).toString("base64");
1728
- const dataUrl = `data:audio/mpeg;base64,${b64}`;
1729
- onNewAsset({ role: "audio", content: dataUrl });
 
 
 
 
 
 
 
1730
  result = "Audio generated successfully and shown to the user.";
1731
  } else if (res.status == 429) {
1732
  result = "Too many requests. Try again later.";
@@ -1747,5 +1956,5 @@ async function processToolCalls({
1747
  });
1748
  }
1749
 
1750
- return { nextMessages };
1751
  }
 
6
  import WebSocket from "ws";
7
  import crypto from "crypto";
8
  import { encoding_for_model } from "tiktoken";
9
+ import { mediaStore } from "./mediaStore.js";
10
+ import { memoryStore } from "./memoryStore.js";
11
 
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
  const WORKER_PATH = path.join(__dirname, "searchWorker.js");
 
144
  "Treat that content as available context and use the provided tools to inspect it before concluding something is absent. " +
145
  "Use `list_prompt_resources` to find staged resources, `read_prompt_chunk` to read staged text exactly, `load_prompt_images` to inspect staged images, and `write_notes` to keep a compact memory after reading several chunks. " +
146
  "Before claiming an attachment is incomplete, missing, malformed, or unreadable, first check whether it was staged separately and read the relevant resource. " +
147
+ "Persistent memories must stay short, concrete, and durable. Only save memories that will still help in future chats, and keep each one to a brief sentence or phrase. " +
148
  "Your HIGHEST PRIORITY is to help the user. ALWAYS HELP THEM WITH ANYTHING ETHICALLY RIGHT.\n\n" +
149
  "SESSION NAMING: After you have fully responded to the user, append a session name tag on its own line at the very end of your response (NEVER inside a code block). Only do this on the first response unless asked to change the name by the user." +
150
  "The tag must be: <session_name>2-4 word title summarizing this conversation</session_name>. " +
 
840
  return { contextMessages, userMessages };
841
  }
842
 
843
+ function buildMemorySystemMessages(memories = [], sessionName = "") {
844
+ const messages = [];
845
+ if (sessionName) {
846
+ messages.push({
847
+ role: "system",
848
+ content: `Current session name: "${sessionName}". This is hidden metadata and may help with continuity if the user references the chat title.`,
849
+ });
850
+ }
851
+ if (memories.length) {
852
+ messages.push({
853
+ role: "system",
854
+ content: [
855
+ "Persistent memories from earlier chats:",
856
+ ...memories.map((memory, index) => `${index + 1}. ${memory.content}`),
857
+ "Treat these as concise background notes. If one is outdated or wrong, prefer the user's current message.",
858
+ ].join("\n"),
859
+ });
860
+ }
861
+ return messages;
862
+ }
863
+
864
+ function buildBasePromptMessages({ sessionId, history, userMessage, memories = [], sessionName = "" }) {
865
  const state = createPromptState(sessionId);
866
  const normalizedHistory = history.map(normalizeMessage).filter(Boolean);
867
  const { summaryMessages, recentMessages } = prepareHistoryContext(normalizedHistory, state);
 
869
 
870
  return [
871
  { role: "system", content: SYSTEM_PROMPT },
872
+ ...buildMemorySystemMessages(memories, sessionName),
873
  ...summaryMessages,
874
  ...recentMessages,
875
  ...contextMessages,
 
1227
  history = [],
1228
  userMessage,
1229
  tools,
1230
+ owner = null,
1231
+ sessionName = "",
1232
  accessToken,
1233
  clientId,
1234
  onToken = () => {},
 
1236
  onError = () => {},
1237
  onToolCall = () => {},
1238
  onNewAsset = () => {},
1239
+ onDraftEdit = () => {},
1240
  abortSignal,
1241
  }) {
1242
  const enabledTools = buildToolList(tools);
1243
+ const memories = owner ? await memoryStore.list(owner).catch(() => []) : [];
1244
+ const baseMessages = buildBasePromptMessages({
1245
+ sessionId,
1246
+ history,
1247
+ userMessage,
1248
+ memories,
1249
+ sessionName,
1250
+ });
1251
 
1252
  const headers = {
1253
  ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
 
1260
  let finished = false;
1261
  const workingMessages = [];
1262
  const allToolCalls = [];
1263
+ const draftState = { text: "" };
1264
+ const responseEdits = [];
1265
 
1266
  while (!finished && agentStep < MAX_AGENT_STEPS) {
1267
  const effectiveMessages = buildModelMessages(baseMessages, workingMessages, sessionId);
 
1280
  );
1281
 
1282
  assistantText += stepText;
1283
+ draftState.text = assistantText;
1284
 
1285
  if (toolCalls.length > 0) {
1286
  allToolCalls.push(...toolCalls);
 
1294
  sessionId,
1295
  toolCalls,
1296
  tools,
1297
+ owner,
1298
  accessToken,
1299
  clientId,
1300
  abortSignal,
1301
+ draftState,
1302
  onToolCall,
1303
+ onNewAsset,
1304
+ onDraftEdit(edit, text) {
1305
+ responseEdits.push(edit);
1306
+ onDraftEdit(edit, text);
1307
+ },
1308
  });
1309
+ assistantText = draftState.text || assistantText;
1310
  workingMessages.push(...nextMessages);
1311
 
1312
  agentStep++;
 
1341
 
1342
  if (finalStepText) {
1343
  assistantText += finalStepText;
1344
+ draftState.text = assistantText;
1345
  workingMessages.push({ role: "assistant", content: finalStepText });
1346
  }
1347
  finished = true;
 
1354
  const sessionName = extractSessionName(assistantText);
1355
 
1356
  if (typeof onDone === "function") {
1357
+ onDone(assistantText, allToolCalls, false, sessionName, responseEdits);
1358
  }
1359
 
1360
  clearPromptState(sessionId);
 
1465
  parameters: { type: "object", properties: { note: { type: "string" } }, required: ["note"] }
1466
  }
1467
  });
1468
+ list.push({
1469
+ type: "function",
1470
+ function: {
1471
+ name: "list_memories",
1472
+ description: "List the currently saved persistent memories for this user.",
1473
+ parameters: {
1474
+ type: "object",
1475
+ properties: {},
1476
+ },
1477
+ },
1478
+ });
1479
+ list.push({
1480
+ type: "function",
1481
+ function: {
1482
+ name: "save_memory",
1483
+ description: "Save a short persistent memory that may help in future chats. Keep it brief and only use this for durable user preferences or facts.",
1484
+ parameters: {
1485
+ type: "object",
1486
+ properties: {
1487
+ content: { type: "string", description: "A short durable memory, ideally one sentence or shorter." },
1488
+ },
1489
+ required: ["content"],
1490
+ },
1491
+ },
1492
+ });
1493
+ list.push({
1494
+ type: "function",
1495
+ function: {
1496
+ name: "delete_memory",
1497
+ description: "Delete a previously saved persistent memory when it is outdated or incorrect.",
1498
+ parameters: {
1499
+ type: "object",
1500
+ properties: {
1501
+ memory_id: { type: "string", description: "The memory id to delete." },
1502
+ },
1503
+ required: ["memory_id"],
1504
+ },
1505
+ },
1506
+ });
1507
+ list.push({
1508
+ type: "function",
1509
+ function: {
1510
+ name: "edit_response_draft",
1511
+ description: "Revise text that was already streamed to the user. Use this to correct or remove visible text that has already appeared.",
1512
+ parameters: {
1513
+ type: "object",
1514
+ properties: {
1515
+ operation: {
1516
+ type: "string",
1517
+ enum: ["replace_all", "replace_last_match", "delete_last_match", "delete_last_chars", "append", "clear"],
1518
+ },
1519
+ text: { type: "string", description: "Replacement text for replace_all or text to append." },
1520
+ target_text: { type: "string", description: "Exact text to replace or delete for match-based operations." },
1521
+ replacement_text: { type: "string", description: "Replacement text for replace_last_match." },
1522
+ count: { type: "number", description: "Character count for delete_last_chars." },
1523
+ reason: { type: "string", description: "Brief reason for the visible edit." },
1524
+ },
1525
+ required: ["operation"],
1526
+ },
1527
+ },
1528
+ });
1529
  if (config.webSearch) {
1530
  list.push({
1531
  type: "function",
 
1635
  return indexes;
1636
  }
1637
 
1638
+ function applyResponseDraftEdit(currentText, args = {}) {
1639
+ const source = String(currentText || "");
1640
+ const operation = args.operation;
1641
+ switch (operation) {
1642
+ case "replace_all":
1643
+ return String(args.text || "");
1644
+ case "append":
1645
+ return source + String(args.text || "");
1646
+ case "clear":
1647
+ return "";
1648
+ case "replace_last_match": {
1649
+ const target = String(args.target_text || "");
1650
+ if (!target) return source;
1651
+ const idx = source.lastIndexOf(target);
1652
+ if (idx === -1) return source;
1653
+ return source.slice(0, idx) + String(args.replacement_text || "") + source.slice(idx + target.length);
1654
+ }
1655
+ case "delete_last_match": {
1656
+ const target = String(args.target_text || "");
1657
+ if (!target) return source;
1658
+ const idx = source.lastIndexOf(target);
1659
+ if (idx === -1) return source;
1660
+ return source.slice(0, idx) + source.slice(idx + target.length);
1661
+ }
1662
+ case "delete_last_chars": {
1663
+ const count = Math.max(0, Number(args.count) || 0);
1664
+ return source.slice(0, Math.max(0, source.length - count));
1665
+ }
1666
+ default:
1667
+ return source;
1668
+ }
1669
+ }
1670
+
1671
  async function processToolCalls({
1672
  sessionId,
1673
  toolCalls,
1674
  tools,
1675
+ owner,
1676
  accessToken,
1677
  clientId,
1678
  abortSignal,
1679
+ draftState,
1680
  onToolCall,
1681
  onNewAsset,
1682
+ onDraftEdit,
1683
  }) {
1684
  const nextMessages = [];
1685
  const authHeaders = {};
 
1784
  result = "Note stored for the rest of this response.";
1785
  }
1786
 
1787
+ else if (toolName === "list_memories") {
1788
+ const memories = owner ? await memoryStore.list(owner) : [];
1789
+ result = JSON.stringify(memories, null, 2);
1790
+ }
1791
+
1792
+ else if (toolName === "save_memory") {
1793
+ const memory = owner
1794
+ ? await memoryStore.create(owner, {
1795
+ content: args.content,
1796
+ sessionId,
1797
+ source: "assistant",
1798
+ })
1799
+ : null;
1800
+ result = memory
1801
+ ? `Saved memory ${memory.id}: ${memory.content}`
1802
+ : "Memory was empty or could not be saved.";
1803
+ }
1804
+
1805
+ else if (toolName === "delete_memory") {
1806
+ const ok = owner ? await memoryStore.delete(owner, args.memory_id) : false;
1807
+ result = ok ? `Deleted memory ${args.memory_id}.` : `Memory ${args.memory_id} was not found.`;
1808
+ }
1809
+
1810
+ else if (toolName === "edit_response_draft") {
1811
+ const before = draftState?.text || "";
1812
+ const after = applyResponseDraftEdit(before, args);
1813
+ if (draftState) draftState.text = after;
1814
+ const edit = {
1815
+ operation: args.operation,
1816
+ reason: String(args.reason || "").trim() || "Model revised its draft.",
1817
+ before,
1818
+ after,
1819
+ timestamp: Date.now(),
1820
+ };
1821
+ if (before !== after) {
1822
+ onDraftEdit(edit, after);
1823
+ result = [
1824
+ `Draft updated. The visible response now has ${after.length} character(s).`,
1825
+ "Visible draft:",
1826
+ (after || "[empty]").slice(-4000),
1827
+ ].join("\n\n");
1828
+ } else {
1829
+ result = "Draft edit made no visible change.";
1830
+ }
1831
+ }
1832
+
1833
  else if (toolName === "ollama_search") {
1834
  result = await gradioSearch(args.query);
1835
  }
 
1863
  if (res.ok) {
1864
  const buf = await res.arrayBuffer();
1865
  const ct = res.headers.get("content-type") || "image/png";
1866
+ const stored = await mediaStore.storeBuffer(owner, {
1867
+ name: `generated-image-${Date.now()}.${ct.includes("svg") ? "svg" : ct.split("/")[1] || "png"}`,
1868
+ mimeType: ct,
1869
+ buffer: Buffer.from(buf),
1870
+ sessionId,
1871
+ source: "assistant_generated",
1872
+ kind: "image",
1873
+ });
1874
+ onNewAsset({ id: stored.id, role: "image", mimeType: ct, name: stored.name });
1875
  result = "Image generated successfully and shown to the user.";
1876
  } else if (res.status == 402) {
1877
  result = "An upgraded plan is required for higher limits.";
 
1897
  });
1898
  if (res.ok) {
1899
  const buf = await res.arrayBuffer();
1900
+ const contentType = res.headers.get("content-type") || "video/mp4";
1901
+ const stored = await mediaStore.storeBuffer(owner, {
1902
+ name: `generated-video-${Date.now()}.${contentType.split("/")[1] || "mp4"}`,
1903
+ mimeType: contentType,
1904
+ buffer: Buffer.from(buf),
1905
+ sessionId,
1906
+ source: "assistant_generated",
1907
+ kind: "video",
1908
+ });
1909
+ onNewAsset({ id: stored.id, role: "video", mimeType: contentType, name: stored.name });
1910
  result = "Video generated successfully and shown to the user.";
1911
  } else if (res.status == 402) {
1912
  result = "An upgraded plan is required for higher limits.";
 
1926
  });
1927
  if (res.ok) {
1928
  const buf = await res.arrayBuffer();
1929
+ const contentType = res.headers.get("content-type") || "audio/mpeg";
1930
+ const stored = await mediaStore.storeBuffer(owner, {
1931
+ name: `generated-audio-${Date.now()}.${contentType.split("/")[1] || "mp3"}`,
1932
+ mimeType: contentType,
1933
+ buffer: Buffer.from(buf),
1934
+ sessionId,
1935
+ source: "assistant_generated",
1936
+ kind: "audio",
1937
+ });
1938
+ onNewAsset({ id: stored.id, role: "audio", mimeType: contentType, name: stored.name });
1939
  result = "Audio generated successfully and shown to the user.";
1940
  } else if (res.status == 429) {
1941
  result = "Too many requests. Try again later.";
 
1956
  });
1957
  }
1958
 
1959
+ return { nextMessages, draftText: draftState?.text || "" };
1960
  }
server/chatTrashStore.js ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
4
+
5
+ const DATA_ROOT = '/data/deleted_chats';
6
+ const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
7
+ const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
8
+
9
+ const state = {
10
+ loaded: false,
11
+ index: {
12
+ deletedChats: {},
13
+ },
14
+ };
15
+
16
+ function nowIso() {
17
+ return new Date().toISOString();
18
+ }
19
+
20
+ function ensureOwner(owner) {
21
+ if (!owner?.type || !owner?.id) throw new Error('Invalid deleted chat owner');
22
+ return owner;
23
+ }
24
+
25
+ async function ensureLoaded() {
26
+ if (state.loaded) return;
27
+ const stored = await loadEncryptedJson(INDEX_FILE);
28
+ state.index = {
29
+ deletedChats: stored?.deletedChats || {},
30
+ };
31
+ state.loaded = true;
32
+ await thisStore.purgeExpired();
33
+ }
34
+
35
+ async function saveIndex() {
36
+ await saveEncryptedJson(INDEX_FILE, state.index);
37
+ }
38
+
39
+ function matchesOwner(record, owner) {
40
+ return record.ownerType === owner.type && record.ownerId === owner.id;
41
+ }
42
+
43
+ function sanitize(record) {
44
+ return {
45
+ id: record.id,
46
+ originalSessionId: record.originalSessionId,
47
+ name: record.name,
48
+ deletedAt: record.deletedAt,
49
+ purgeAt: record.purgeAt,
50
+ created: record.created,
51
+ };
52
+ }
53
+
54
+ const thisStore = {
55
+ async add(owner, sessionSnapshot) {
56
+ ensureOwner(owner);
57
+ await ensureLoaded();
58
+
59
+ const sessionId = sessionSnapshot?.id;
60
+ if (!sessionId) return null;
61
+
62
+ for (const record of Object.values(state.index.deletedChats)) {
63
+ if (
64
+ record.originalSessionId === sessionId &&
65
+ record.ownerType === owner.type &&
66
+ record.ownerId === owner.id
67
+ ) {
68
+ record.sessionSnapshot = sessionSnapshot;
69
+ record.name = sessionSnapshot.name || 'Deleted Chat';
70
+ record.created = sessionSnapshot.created || Date.now();
71
+ record.deletedAt = nowIso();
72
+ record.purgeAt = new Date(Date.now() + RETENTION_MS).toISOString();
73
+ await saveIndex();
74
+ return sanitize(record);
75
+ }
76
+ }
77
+
78
+ const deleted = {
79
+ id: crypto.randomUUID(),
80
+ originalSessionId: sessionId,
81
+ ownerType: owner.type,
82
+ ownerId: owner.id,
83
+ name: sessionSnapshot.name || 'Deleted Chat',
84
+ created: sessionSnapshot.created || Date.now(),
85
+ sessionSnapshot,
86
+ deletedAt: nowIso(),
87
+ purgeAt: new Date(Date.now() + RETENTION_MS).toISOString(),
88
+ };
89
+ state.index.deletedChats[deleted.id] = deleted;
90
+ await saveIndex();
91
+ return sanitize(deleted);
92
+ },
93
+
94
+ async list(owner) {
95
+ ensureOwner(owner);
96
+ await ensureLoaded();
97
+ return Object.values(state.index.deletedChats)
98
+ .filter((record) => matchesOwner(record, owner))
99
+ .sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime())
100
+ .map(sanitize);
101
+ },
102
+
103
+ async restore(owner, ids) {
104
+ ensureOwner(owner);
105
+ await ensureLoaded();
106
+ const restored = [];
107
+ for (const id of ids || []) {
108
+ const record = state.index.deletedChats[id];
109
+ if (!record || !matchesOwner(record, owner)) continue;
110
+ restored.push(record.sessionSnapshot);
111
+ delete state.index.deletedChats[id];
112
+ }
113
+ if (restored.length) await saveIndex();
114
+ return restored;
115
+ },
116
+
117
+ async deleteForever(owner, ids) {
118
+ ensureOwner(owner);
119
+ await ensureLoaded();
120
+ const removed = [];
121
+ for (const id of ids || []) {
122
+ const record = state.index.deletedChats[id];
123
+ if (!record || !matchesOwner(record, owner)) continue;
124
+ delete state.index.deletedChats[id];
125
+ removed.push(id);
126
+ }
127
+ if (removed.length) await saveIndex();
128
+ return removed;
129
+ },
130
+
131
+ async purgeExpired() {
132
+ if (!state.loaded) return;
133
+ const now = Date.now();
134
+ let changed = false;
135
+ for (const [id, record] of Object.entries(state.index.deletedChats)) {
136
+ if (new Date(record.purgeAt).getTime() <= now) {
137
+ delete state.index.deletedChats[id];
138
+ changed = true;
139
+ }
140
+ }
141
+ if (changed) await saveIndex();
142
+ },
143
+ };
144
+
145
+ setInterval(() => {
146
+ thisStore.purgeExpired().catch((err) => console.error('chatTrashStore cleanup failed:', err));
147
+ }, 6 * 60 * 60 * 1000);
148
+
149
+ export const chatTrashStore = thisStore;
server/cryptoUtils.js CHANGED
@@ -2,61 +2,135 @@ import crypto from 'crypto';
2
  import fs from 'fs/promises';
3
  import path from 'path';
4
 
 
 
5
  const ALGORITHM = 'aes-256-gcm';
6
- const KEY_LENGTH = 32; // 256 bits
7
- const IV_LENGTH = 16; // 128 bits for GCM
8
- const AUTH_TAG_LENGTH = 16; // 128 bits
9
 
10
- // Derive key from environment variable
11
  function getKey() {
12
  const keyEnv = process.env.DATA_ENCRYPTION_KEY;
13
  if (!keyEnv) throw new Error('DATA_ENCRYPTION_KEY environment variable not set');
14
- // Use SHA-256 to derive a 32-byte key from the env var
15
- return crypto.createHash('sha256').update(keyEnv).digest();
16
  }
17
 
18
- export function encryptJson(data) {
 
 
 
 
 
19
  const key = getKey();
20
  const iv = crypto.randomBytes(IV_LENGTH);
21
- const cipher = crypto.createCipher(ALGORITHM, key);
22
- cipher.setAAD(Buffer.from('')); // Optional AAD
23
-
24
- let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
25
- encrypted += cipher.final('hex');
26
-
27
  const authTag = cipher.getAuthTag();
28
  return {
29
- iv: iv.toString('hex'),
 
 
30
  encrypted,
31
- authTag: authTag.toString('hex'),
32
  };
33
  }
34
 
35
- export function decryptJson(encryptedData) {
36
  const key = getKey();
37
- const { iv, encrypted, authTag } = encryptedData;
38
- const decipher = crypto.createDecipher(ALGORITHM, key);
39
- decipher.setAuthTag(Buffer.from(authTag, 'hex'));
40
- decipher.setAAD(Buffer.from('')); // Match AAD
 
 
 
 
 
 
 
 
 
 
41
 
42
- let decrypted = decipher.update(encrypted, 'hex', 'utf8');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  decrypted += decipher.final('utf8');
44
  return JSON.parse(decrypted);
45
  }
46
 
47
- export async function saveEncryptedJson(filePath, data) {
48
- const encrypted = encryptJson(data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  await fs.mkdir(path.dirname(filePath), { recursive: true });
50
- await fs.writeFile(filePath, JSON.stringify(encrypted));
51
  }
52
 
53
- export async function loadEncryptedJson(filePath) {
54
  try {
55
  const content = await fs.readFile(filePath, 'utf8');
56
  const encrypted = JSON.parse(content);
57
- return decryptJson(encrypted);
58
  } catch (err) {
59
- if (err.code === 'ENOENT') return null; // File not found
60
  throw err;
61
  }
62
- }
 
2
  import fs from 'fs/promises';
3
  import path from 'path';
4
 
5
+ const JSON_FORMAT_VERSION = 2;
6
+ const BINARY_FORMAT_VERSION = 1;
7
  const ALGORITHM = 'aes-256-gcm';
8
+ const KEY_LENGTH = 32;
9
+ const IV_LENGTH = 12;
10
+ const AUTH_TAG_LENGTH = 16;
11
 
 
12
  function getKey() {
13
  const keyEnv = process.env.DATA_ENCRYPTION_KEY;
14
  if (!keyEnv) throw new Error('DATA_ENCRYPTION_KEY environment variable not set');
15
+ return crypto.createHash('sha256').update(keyEnv).digest().subarray(0, KEY_LENGTH);
 
16
  }
17
 
18
+ function normalizeAad(aad = '') {
19
+ if (Buffer.isBuffer(aad)) return aad;
20
+ return Buffer.from(String(aad || ''), 'utf8');
21
+ }
22
+
23
+ export function encryptBuffer(buffer, aad = '') {
24
  const key = getKey();
25
  const iv = crypto.randomBytes(IV_LENGTH);
26
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
27
+ const aadBuffer = normalizeAad(aad);
28
+ if (aadBuffer.length) cipher.setAAD(aadBuffer);
29
+ const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
 
 
30
  const authTag = cipher.getAuthTag();
31
  return {
32
+ version: BINARY_FORMAT_VERSION,
33
+ iv,
34
+ authTag,
35
  encrypted,
 
36
  };
37
  }
38
 
39
+ export function decryptBuffer(payload, aad = '') {
40
  const key = getKey();
41
+ const iv = Buffer.isBuffer(payload?.iv) ? payload.iv : Buffer.from(payload?.iv || '', 'hex');
42
+ const authTag = Buffer.isBuffer(payload?.authTag)
43
+ ? payload.authTag
44
+ : Buffer.from(payload?.authTag || '', 'hex');
45
+ const encrypted = Buffer.isBuffer(payload?.encrypted)
46
+ ? payload.encrypted
47
+ : Buffer.from(payload?.encrypted || '', 'hex');
48
+
49
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
50
+ const aadBuffer = normalizeAad(aad);
51
+ if (aadBuffer.length) decipher.setAAD(aadBuffer);
52
+ decipher.setAuthTag(authTag);
53
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
54
+ }
55
 
56
+ export function packEncryptedBuffer(payload) {
57
+ const header = Buffer.allocUnsafe(1 + 1 + payload.iv.length + payload.authTag.length);
58
+ header.writeUInt8(payload.version || BINARY_FORMAT_VERSION, 0);
59
+ header.writeUInt8(payload.iv.length, 1);
60
+ payload.iv.copy(header, 2);
61
+ payload.authTag.copy(header, 2 + payload.iv.length);
62
+ return Buffer.concat([header, payload.encrypted]);
63
+ }
64
+
65
+ export function unpackEncryptedBuffer(buffer) {
66
+ const version = buffer.readUInt8(0);
67
+ if (version !== BINARY_FORMAT_VERSION) {
68
+ throw new Error(`Unsupported encrypted buffer version: ${version}`);
69
+ }
70
+ const ivLength = buffer.readUInt8(1);
71
+ const ivStart = 2;
72
+ const ivEnd = ivStart + ivLength;
73
+ const tagEnd = ivEnd + AUTH_TAG_LENGTH;
74
+ return {
75
+ version,
76
+ iv: buffer.subarray(ivStart, ivEnd),
77
+ authTag: buffer.subarray(ivEnd, tagEnd),
78
+ encrypted: buffer.subarray(tagEnd),
79
+ };
80
+ }
81
+
82
+ export async function writeEncryptedFile(filePath, buffer, aad = '') {
83
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
84
+ const payload = encryptBuffer(buffer, aad);
85
+ await fs.writeFile(filePath, packEncryptedBuffer(payload));
86
+ }
87
+
88
+ export async function readEncryptedFile(filePath, aad = '') {
89
+ const packed = await fs.readFile(filePath);
90
+ return decryptBuffer(unpackEncryptedBuffer(packed), aad);
91
+ }
92
+
93
+ function legacyDecryptJson(encryptedData) {
94
+ const key = getKey();
95
+ const decipher = crypto.createDecipher(ALGORITHM, key);
96
+ decipher.setAuthTag(Buffer.from(encryptedData.authTag, 'hex'));
97
+ let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8');
98
  decrypted += decipher.final('utf8');
99
  return JSON.parse(decrypted);
100
  }
101
 
102
+ export function encryptJson(data, aad = '') {
103
+ const payload = encryptBuffer(Buffer.from(JSON.stringify(data), 'utf8'), aad);
104
+ return {
105
+ version: JSON_FORMAT_VERSION,
106
+ iv: payload.iv.toString('hex'),
107
+ authTag: payload.authTag.toString('hex'),
108
+ encrypted: payload.encrypted.toString('hex'),
109
+ };
110
+ }
111
+
112
+ export function decryptJson(encryptedData, aad = '') {
113
+ if (!encryptedData) return null;
114
+ if ((encryptedData.version || 0) >= JSON_FORMAT_VERSION) {
115
+ const decrypted = decryptBuffer(encryptedData, aad);
116
+ return JSON.parse(decrypted.toString('utf8'));
117
+ }
118
+ return legacyDecryptJson(encryptedData);
119
+ }
120
+
121
+ export async function saveEncryptedJson(filePath, data, aad = '') {
122
+ const encrypted = encryptJson(data, aad);
123
  await fs.mkdir(path.dirname(filePath), { recursive: true });
124
+ await fs.writeFile(filePath, JSON.stringify(encrypted, null, 2), 'utf8');
125
  }
126
 
127
+ export async function loadEncryptedJson(filePath, aad = '') {
128
  try {
129
  const content = await fs.readFile(filePath, 'utf8');
130
  const encrypted = JSON.parse(content);
131
+ return decryptJson(encrypted, aad);
132
  } catch (err) {
133
+ if (err.code === 'ENOENT') return null;
134
  throw err;
135
  }
136
+ }
server/index.js CHANGED
@@ -13,6 +13,8 @@ import { handleWsMessage } from './wsHandler.js';
13
  import { sessionStore, initStoreConfig } from './sessionStore.js';
14
  import { SUPABASE_URL, SUPABASE_ANON_KEY } from './config.js';
15
  import { safeSend } from './helpers.js';
 
 
16
 
17
  export { SUPABASE_URL, SUPABASE_ANON_KEY };
18
  export { LIGHTNING_BASE, PUBLIC_URL } from './config.js';
@@ -29,6 +31,17 @@ const GITHUB_REPO = 'incognitolm/InferencePort-Pages';
29
  const CDN_BASE = `https://cdn.jsdelivr.net/gh/${GITHUB_REPO}`;
30
  let latestSHA = null;
31
  const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  // Rate limiter for admin endpoints (5 attempts per IP per minute)
34
  const verifyLimiter = rateLimit({ windowMs: 60*1000, max: 5, standardHeaders: true, legacyHeaders: false });
@@ -107,6 +120,31 @@ app.use('/api', (req, res, next) => {
107
  return res.status(403).json({ error: 'turnstile:required' });
108
  });
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  app.get('/health', (_req,res) => res.json({ok:true}));
111
 
112
  app.get('/api/share/:token', async (req,res) => {
@@ -124,6 +162,176 @@ app.get('/api/share/:token', async (req,res) => {
124
  } catch { res.status(500).json({error:'Server error'}); }
125
  });
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  app.post('/api/turnstile', async (req,res)=>{
128
  try{
129
  const token = req.body?.token;
@@ -166,6 +374,7 @@ if (!latestSHA) {
166
 
167
  // --- Admin endpoints ---
168
  app.get('/admin.html', async (req, res) => {
 
169
  if (!latestSHA) return res.status(500).send('Server not ready');
170
 
171
  const url = `${CDN_BASE}@${latestSHA}/admin.html`;
@@ -223,12 +432,31 @@ function getMimeType(filePath){
223
  }
224
  }
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  // Hybrid serving: everything via latest SHA (auto-refresh / manual refresh)
227
  app.get('*', async (req,res)=>{
228
  if(req.path.startsWith('/api/')) return res.status(404).send('Not found');
229
 
230
  // All client files are fetched from latest SHA
231
  const filePath = req.path === '/' ? '/index.html' : req.path;
 
232
  const url = `${CDN_BASE}@${latestSHA}${filePath}`;
233
 
234
  try{
@@ -277,4 +505,4 @@ wss.on('connection',(ws,req)=>{
277
  safeSend(ws,{type:'connected', tempId:wsClients.get(ws)?.tempId});
278
  });
279
 
280
- httpServer.listen(PORT,'0.0.0.0',()=>console.log(`Running on port ${PORT}`));
 
13
  import { sessionStore, initStoreConfig } from './sessionStore.js';
14
  import { SUPABASE_URL, SUPABASE_ANON_KEY } from './config.js';
15
  import { safeSend } from './helpers.js';
16
+ import { verifySupabaseToken } from './auth.js';
17
+ import { mediaStore } from './mediaStore.js';
18
 
19
  export { SUPABASE_URL, SUPABASE_ANON_KEY };
20
  export { LIGHTNING_BASE, PUBLIC_URL } from './config.js';
 
31
  const CDN_BASE = `https://cdn.jsdelivr.net/gh/${GITHUB_REPO}`;
32
  let latestSHA = null;
33
  const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
34
+ const LOCAL_UI_DIR = [
35
+ process.env.UI_LOCAL_PATH,
36
+ path.resolve(__dirname, '..', '..', 'InferencePort-Pages'),
37
+ path.resolve(process.cwd(), '..', 'InferencePort-Pages'),
38
+ ].filter(Boolean).find((dir) => {
39
+ try {
40
+ return fs.existsSync(path.join(dir, 'index.html'));
41
+ } catch {
42
+ return false;
43
+ }
44
+ }) || null;
45
 
46
  // Rate limiter for admin endpoints (5 attempts per IP per minute)
47
  const verifyLimiter = rateLimit({ windowMs: 60*1000, max: 5, standardHeaders: true, legacyHeaders: false });
 
120
  return res.status(403).json({ error: 'turnstile:required' });
121
  });
122
 
123
+ async function getRequestOwner(req) {
124
+ const authHeader = req.headers.authorization || '';
125
+ const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
126
+ if (accessToken) {
127
+ const user = await verifySupabaseToken(accessToken);
128
+ if (user) return { owner: { type: 'user', id: user.id }, accessToken };
129
+ }
130
+
131
+ const tempId = String(req.headers['x-temp-id'] || req.query.tempId || '').trim();
132
+ if (tempId) {
133
+ sessionStore.initTemp(tempId);
134
+ return { owner: { type: 'guest', id: tempId }, accessToken: null };
135
+ }
136
+ return null;
137
+ }
138
+
139
+ async function requireRequestOwner(req, res) {
140
+ const resolved = await getRequestOwner(req);
141
+ if (!resolved?.owner) {
142
+ res.status(401).json({ error: 'auth:required' });
143
+ return null;
144
+ }
145
+ return resolved;
146
+ }
147
+
148
  app.get('/health', (_req,res) => res.json({ok:true}));
149
 
150
  app.get('/api/share/:token', async (req,res) => {
 
162
  } catch { res.status(500).json({error:'Server error'}); }
163
  });
164
 
165
+ app.get('/api/media', async (req, res) => {
166
+ const resolved = await requireRequestOwner(req, res);
167
+ if (!resolved) return;
168
+ try {
169
+ const result = await mediaStore.list(resolved.owner, {
170
+ view: req.query.view === 'trash' ? 'trash' : 'active',
171
+ parentId: req.query.parentId ? String(req.query.parentId) : null,
172
+ });
173
+ res.json(result);
174
+ } catch (err) {
175
+ console.error('media list error', err);
176
+ res.status(500).json({ error: 'media:list_failed' });
177
+ }
178
+ });
179
+
180
+ app.post('/api/media/upload', express.raw({ type: () => true, limit: '100mb' }), async (req, res) => {
181
+ const resolved = await requireRequestOwner(req, res);
182
+ if (!resolved) return;
183
+ try {
184
+ const name = decodeURIComponent(String(req.headers['x-file-name'] || 'upload.bin'));
185
+ const mimeType = String(req.headers['x-mime-type'] || 'application/octet-stream');
186
+ const parentId = req.headers['x-parent-id'] ? String(req.headers['x-parent-id']) : null;
187
+ const sessionId = req.headers['x-session-id'] ? String(req.headers['x-session-id']) : null;
188
+ const kindHeader = req.headers['x-file-kind'] ? String(req.headers['x-file-kind']) : null;
189
+ const item = await mediaStore.storeBuffer(resolved.owner, {
190
+ name,
191
+ mimeType,
192
+ buffer: Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || []),
193
+ parentId,
194
+ sessionId,
195
+ source: 'user_upload',
196
+ kind: kindHeader || null,
197
+ });
198
+ res.json({ item });
199
+ } catch (err) {
200
+ console.error('media upload error', err);
201
+ res.status(500).json({ error: 'media:upload_failed' });
202
+ }
203
+ });
204
+
205
+ app.post('/api/media/folders', async (req, res) => {
206
+ const resolved = await requireRequestOwner(req, res);
207
+ if (!resolved) return;
208
+ try {
209
+ const item = await mediaStore.createFolder(resolved.owner, {
210
+ name: req.body?.name || 'New Folder',
211
+ parentId: req.body?.parentId || null,
212
+ });
213
+ res.json({ item });
214
+ } catch (err) {
215
+ console.error('media create folder error', err);
216
+ res.status(500).json({ error: 'media:create_folder_failed' });
217
+ }
218
+ });
219
+
220
+ app.post('/api/media/documents', async (req, res) => {
221
+ const resolved = await requireRequestOwner(req, res);
222
+ if (!resolved) return;
223
+ try {
224
+ const item = await mediaStore.createDocument(resolved.owner, {
225
+ name: req.body?.name,
226
+ parentId: req.body?.parentId || null,
227
+ richText: !!req.body?.richText,
228
+ content: req.body?.content || '',
229
+ source: 'user_upload',
230
+ });
231
+ res.json({ item });
232
+ } catch (err) {
233
+ console.error('media create document error', err);
234
+ res.status(500).json({ error: 'media:create_document_failed' });
235
+ }
236
+ });
237
+
238
+ app.post('/api/media/move', async (req, res) => {
239
+ const resolved = await requireRequestOwner(req, res);
240
+ if (!resolved) return;
241
+ try {
242
+ const items = await mediaStore.move(resolved.owner, req.body?.ids || [], req.body?.parentId || null);
243
+ res.json({ items });
244
+ } catch (err) {
245
+ console.error('media move error', err);
246
+ res.status(500).json({ error: 'media:move_failed' });
247
+ }
248
+ });
249
+
250
+ app.post('/api/media/trash', async (req, res) => {
251
+ const resolved = await requireRequestOwner(req, res);
252
+ if (!resolved) return;
253
+ try {
254
+ const items = await mediaStore.moveToTrash(resolved.owner, req.body?.ids || []);
255
+ res.json({ items });
256
+ } catch (err) {
257
+ console.error('media trash error', err);
258
+ res.status(500).json({ error: 'media:trash_failed' });
259
+ }
260
+ });
261
+
262
+ app.post('/api/media/restore', async (req, res) => {
263
+ const resolved = await requireRequestOwner(req, res);
264
+ if (!resolved) return;
265
+ try {
266
+ const items = await mediaStore.restore(resolved.owner, req.body?.ids || []);
267
+ res.json({ items });
268
+ } catch (err) {
269
+ console.error('media restore error', err);
270
+ res.status(500).json({ error: 'media:restore_failed' });
271
+ }
272
+ });
273
+
274
+ app.post('/api/media/deleteForever', async (req, res) => {
275
+ const resolved = await requireRequestOwner(req, res);
276
+ if (!resolved) return;
277
+ try {
278
+ const ids = await mediaStore.deleteForever(resolved.owner, req.body?.ids || []);
279
+ res.json({ ids });
280
+ } catch (err) {
281
+ console.error('media delete forever error', err);
282
+ res.status(500).json({ error: 'media:delete_failed' });
283
+ }
284
+ });
285
+
286
+ app.get('/api/media/:id/text', async (req, res) => {
287
+ const resolved = await requireRequestOwner(req, res);
288
+ if (!resolved) return;
289
+ try {
290
+ const loaded = await mediaStore.readText(resolved.owner, req.params.id);
291
+ if (!loaded) return res.status(404).json({ error: 'media:not_found' });
292
+ res.json({ item: loaded.entry, content: loaded.text });
293
+ } catch (err) {
294
+ console.error('media read text error', err);
295
+ res.status(500).json({ error: 'media:read_failed' });
296
+ }
297
+ });
298
+
299
+ app.put('/api/media/:id/text', async (req, res) => {
300
+ const resolved = await requireRequestOwner(req, res);
301
+ if (!resolved) return;
302
+ try {
303
+ const item = await mediaStore.updateContent(resolved.owner, req.params.id, {
304
+ buffer: Buffer.from(String(req.body?.content || ''), 'utf8'),
305
+ mimeType: req.body?.mimeType || null,
306
+ kind: req.body?.richText ? 'rich_text' : null,
307
+ name: req.body?.name || null,
308
+ });
309
+ if (!item) return res.status(404).json({ error: 'media:not_found' });
310
+ res.json({ item });
311
+ } catch (err) {
312
+ console.error('media update text error', err);
313
+ res.status(500).json({ error: 'media:update_failed' });
314
+ }
315
+ });
316
+
317
+ app.get('/api/media/:id/content', async (req, res) => {
318
+ const resolved = await requireRequestOwner(req, res);
319
+ if (!resolved) return;
320
+ try {
321
+ const loaded = await mediaStore.readBuffer(resolved.owner, req.params.id);
322
+ if (!loaded) return res.status(404).json({ error: 'media:not_found' });
323
+ res.setHeader('Content-Type', loaded.entry.mimeType || 'application/octet-stream');
324
+ res.setHeader('Cache-Control', 'private, max-age=60');
325
+ if (req.query.download === '1') {
326
+ res.setHeader('Content-Disposition', `attachment; filename="${loaded.entry.name}"`);
327
+ }
328
+ res.send(loaded.buffer);
329
+ } catch (err) {
330
+ console.error('media read binary error', err);
331
+ res.status(500).json({ error: 'media:read_failed' });
332
+ }
333
+ });
334
+
335
  app.post('/api/turnstile', async (req,res)=>{
336
  try{
337
  const token = req.body?.token;
 
374
 
375
  // --- Admin endpoints ---
376
  app.get('/admin.html', async (req, res) => {
377
+ if (serveLocalUiFile('/admin.html', res)) return;
378
  if (!latestSHA) return res.status(500).send('Server not ready');
379
 
380
  const url = `${CDN_BASE}@${latestSHA}/admin.html`;
 
432
  }
433
  }
434
 
435
+ function resolveLocalUiFile(filePath) {
436
+ if (!LOCAL_UI_DIR) return null;
437
+ const resolvedRoot = path.resolve(LOCAL_UI_DIR);
438
+ const relativePath = String(filePath || '/index.html').replace(/^[/\\]+/, '');
439
+ const resolvedFile = path.resolve(resolvedRoot, relativePath);
440
+ if (resolvedFile !== resolvedRoot && !resolvedFile.startsWith(`${resolvedRoot}${path.sep}`)) return null;
441
+ if (!fs.existsSync(resolvedFile) || fs.statSync(resolvedFile).isDirectory()) return null;
442
+ return resolvedFile;
443
+ }
444
+
445
+ function serveLocalUiFile(filePath, res) {
446
+ const resolvedFile = resolveLocalUiFile(filePath);
447
+ if (!resolvedFile) return false;
448
+ res.setHeader('Content-Type', getMimeType(resolvedFile));
449
+ res.send(fs.readFileSync(resolvedFile));
450
+ return true;
451
+ }
452
+
453
  // Hybrid serving: everything via latest SHA (auto-refresh / manual refresh)
454
  app.get('*', async (req,res)=>{
455
  if(req.path.startsWith('/api/')) return res.status(404).send('Not found');
456
 
457
  // All client files are fetched from latest SHA
458
  const filePath = req.path === '/' ? '/index.html' : req.path;
459
+ if (serveLocalUiFile(filePath, res)) return;
460
  const url = `${CDN_BASE}@${latestSHA}${filePath}`;
461
 
462
  try{
 
505
  safeSend(ws,{type:'connected', tempId:wsClients.get(ws)?.tempId});
506
  });
507
 
508
+ httpServer.listen(PORT,'0.0.0.0',()=>console.log(`Running on port ${PORT}`));
server/mediaStore.js ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'crypto';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { loadEncryptedJson, saveEncryptedJson, readEncryptedFile, writeEncryptedFile } from './cryptoUtils.js';
5
+
6
+ const DATA_ROOT = '/data/media';
7
+ const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
8
+ const BLOBS_DIR = path.join(DATA_ROOT, 'blobs');
9
+ const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
10
+ const GUEST_RETENTION_MS = 24 * 60 * 60 * 1000;
11
+
12
+ const state = {
13
+ loaded: false,
14
+ index: {
15
+ entries: {},
16
+ },
17
+ };
18
+
19
+ function nowIso() {
20
+ return new Date().toISOString();
21
+ }
22
+
23
+ function ownerKey(owner) {
24
+ return `${owner.type}:${owner.id}`;
25
+ }
26
+
27
+ function ensureOwner(owner) {
28
+ if (!owner?.type || !owner?.id) throw new Error('Invalid media owner');
29
+ return owner;
30
+ }
31
+
32
+ function normalizeName(name, fallback = 'Untitled') {
33
+ const clean = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, '_');
34
+ return clean || fallback;
35
+ }
36
+
37
+ function extensionFromName(name = '') {
38
+ const ext = path.extname(String(name || '')).toLowerCase();
39
+ return ext.startsWith('.') ? ext.slice(1) : ext;
40
+ }
41
+
42
+ function inferKind(name, mimeType = '') {
43
+ const mime = String(mimeType || '').toLowerCase();
44
+ const ext = extensionFromName(name);
45
+ if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
46
+ if (mime.startsWith('video/') || ['mp4', 'webm', 'mov'].includes(ext)) return 'video';
47
+ if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a'].includes(ext)) return 'audio';
48
+ if (mime === 'text/html' || ['html', 'htm'].includes(ext)) return 'rich_text';
49
+ if (mime.startsWith('text/') || ['txt', 'md', 'json', 'js', 'ts', 'css', 'py', 'html', 'xml', 'csv', 'rtf'].includes(ext)) return 'text';
50
+ return 'file';
51
+ }
52
+
53
+ function guessMimeType(name, fallbackKind = 'file') {
54
+ const ext = extensionFromName(name);
55
+ switch (ext) {
56
+ case 'txt': return 'text/plain';
57
+ case 'md': return 'text/markdown';
58
+ case 'json': return 'application/json';
59
+ case 'html':
60
+ case 'htm': return 'text/html';
61
+ case 'css': return 'text/css';
62
+ case 'js': return 'application/javascript';
63
+ case 'ts': return 'text/plain';
64
+ case 'svg': return 'image/svg+xml';
65
+ case 'png': return 'image/png';
66
+ case 'jpg':
67
+ case 'jpeg': return 'image/jpeg';
68
+ case 'gif': return 'image/gif';
69
+ case 'webp': return 'image/webp';
70
+ case 'mp4': return 'video/mp4';
71
+ case 'webm': return 'video/webm';
72
+ case 'mp3': return 'audio/mpeg';
73
+ case 'wav': return 'audio/wav';
74
+ case 'ogg': return 'audio/ogg';
75
+ case 'csv': return 'text/csv';
76
+ default:
77
+ if (fallbackKind === 'rich_text') return 'text/html';
78
+ if (fallbackKind === 'text') return 'text/plain';
79
+ return 'application/octet-stream';
80
+ }
81
+ }
82
+
83
+ async function ensureLoaded() {
84
+ if (state.loaded) return;
85
+ const stored = await loadEncryptedJson(INDEX_FILE);
86
+ state.index = {
87
+ entries: stored?.entries || {},
88
+ };
89
+ state.loaded = true;
90
+ await purgeExpiredInternal();
91
+ }
92
+
93
+ async function saveIndex() {
94
+ await saveEncryptedJson(INDEX_FILE, state.index);
95
+ }
96
+
97
+ function getEntry(id) {
98
+ return state.index.entries[id] || null;
99
+ }
100
+
101
+ function canAccess(entry, owner) {
102
+ return !!entry && entry.ownerType === owner.type && entry.ownerId === owner.id;
103
+ }
104
+
105
+ function sanitizeEntry(entry) {
106
+ return {
107
+ id: entry.id,
108
+ type: entry.type,
109
+ name: entry.name,
110
+ parentId: entry.parentId || null,
111
+ ownerType: entry.ownerType,
112
+ ownerId: entry.ownerId,
113
+ mimeType: entry.mimeType || null,
114
+ kind: entry.kind || null,
115
+ size: entry.size || 0,
116
+ source: entry.source || null,
117
+ createdAt: entry.createdAt,
118
+ updatedAt: entry.updatedAt,
119
+ trashedAt: entry.trashedAt || null,
120
+ purgeAt: entry.purgeAt || null,
121
+ sessionIds: entry.sessionIds || [],
122
+ deletedByAssistant: !!entry.deletedByAssistant,
123
+ };
124
+ }
125
+
126
+ function blobPathFor(id) {
127
+ return path.join(BLOBS_DIR, `${id}.bin`);
128
+ }
129
+
130
+ function buildAad(entry) {
131
+ return `media:${entry.id}:${entry.ownerType}:${entry.ownerId}`;
132
+ }
133
+
134
+ function getChildren(owner, parentId, includeTrash = true) {
135
+ return Object.values(state.index.entries).filter((entry) =>
136
+ entry.ownerType === owner.type &&
137
+ entry.ownerId === owner.id &&
138
+ (entry.parentId || null) === (parentId || null) &&
139
+ (includeTrash || !entry.trashedAt)
140
+ );
141
+ }
142
+
143
+ function collectDescendantIds(owner, rootId, acc = new Set()) {
144
+ acc.add(rootId);
145
+ const children = getChildren(owner, rootId, true);
146
+ for (const child of children) {
147
+ collectDescendantIds(owner, child.id, acc);
148
+ }
149
+ return [...acc];
150
+ }
151
+
152
+ function resolveParentFolder(owner, parentId) {
153
+ if (!parentId) return null;
154
+ const parent = getEntry(parentId);
155
+ if (!parent || !canAccess(parent, owner) || parent.type !== 'folder') return null;
156
+ return parent.id;
157
+ }
158
+
159
+ function wouldCreateCycle(owner, entryId, candidateParentId) {
160
+ let currentId = candidateParentId || null;
161
+ while (currentId) {
162
+ if (currentId === entryId) return true;
163
+ const current = getEntry(currentId);
164
+ if (!current || !canAccess(current, owner)) return false;
165
+ currentId = current.parentId || null;
166
+ }
167
+ return false;
168
+ }
169
+
170
+ async function purgeEntry(id) {
171
+ const entry = getEntry(id);
172
+ if (!entry) return;
173
+ if (entry.type === 'file') {
174
+ await fs.rm(blobPathFor(id), { force: true }).catch(() => {});
175
+ }
176
+ delete state.index.entries[id];
177
+ }
178
+
179
+ async function purgeExpiredInternal() {
180
+ const now = Date.now();
181
+ let changed = false;
182
+ for (const entry of Object.values(state.index.entries)) {
183
+ const shouldPurge =
184
+ (entry.purgeAt && new Date(entry.purgeAt).getTime() <= now) ||
185
+ (entry.expiresAt && new Date(entry.expiresAt).getTime() <= now);
186
+ if (!shouldPurge) continue;
187
+ for (const id of collectDescendantIds({ type: entry.ownerType, id: entry.ownerId }, entry.id)) {
188
+ await purgeEntry(id);
189
+ }
190
+ changed = true;
191
+ }
192
+ if (changed) await saveIndex();
193
+ }
194
+
195
+ setInterval(() => {
196
+ purgeExpiredInternal().catch((err) => console.error('mediaStore cleanup failed:', err));
197
+ }, 6 * 60 * 60 * 1000);
198
+
199
+ export const mediaStore = {
200
+ async list(owner, { view = 'active', parentId = null } = {}) {
201
+ ensureOwner(owner);
202
+ await ensureLoaded();
203
+
204
+ const includeTrash = view === 'trash';
205
+ const items = getChildren(owner, parentId, true)
206
+ .filter((entry) => includeTrash ? !!entry.trashedAt : !entry.trashedAt)
207
+ .sort((a, b) => {
208
+ if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
209
+ return new Date(b.updatedAt || b.createdAt).getTime() - new Date(a.updatedAt || a.createdAt).getTime();
210
+ })
211
+ .map(sanitizeEntry);
212
+
213
+ const breadcrumbs = [];
214
+ let currentId = parentId;
215
+ while (currentId) {
216
+ const entry = getEntry(currentId);
217
+ if (!entry || !canAccess(entry, owner)) break;
218
+ breadcrumbs.unshift({ id: entry.id, name: entry.name });
219
+ currentId = entry.parentId || null;
220
+ }
221
+
222
+ return { items, breadcrumbs };
223
+ },
224
+
225
+ async get(owner, id) {
226
+ ensureOwner(owner);
227
+ await ensureLoaded();
228
+ const entry = getEntry(id);
229
+ if (!canAccess(entry, owner)) return null;
230
+ return sanitizeEntry(entry);
231
+ },
232
+
233
+ async storeBuffer(owner, {
234
+ name,
235
+ mimeType,
236
+ buffer,
237
+ parentId = null,
238
+ sessionId = null,
239
+ source = 'upload',
240
+ kind = null,
241
+ deletedByAssistant = false,
242
+ }) {
243
+ ensureOwner(owner);
244
+ await ensureLoaded();
245
+
246
+ const entryId = crypto.randomUUID();
247
+ const resolvedParentId = resolveParentFolder(owner, parentId);
248
+ const fileName = normalizeName(name, 'Untitled');
249
+ const inferredKind = kind || inferKind(fileName, mimeType);
250
+ const entry = {
251
+ id: entryId,
252
+ type: 'file',
253
+ name: fileName,
254
+ ownerType: owner.type,
255
+ ownerId: owner.id,
256
+ mimeType: mimeType || guessMimeType(fileName, inferredKind),
257
+ kind: inferredKind,
258
+ size: buffer.byteLength,
259
+ parentId: resolvedParentId,
260
+ source,
261
+ createdAt: nowIso(),
262
+ updatedAt: nowIso(),
263
+ trashedAt: null,
264
+ purgeAt: null,
265
+ sessionIds: sessionId ? [sessionId] : [],
266
+ expiresAt: owner.type === 'guest' ? new Date(Date.now() + GUEST_RETENTION_MS).toISOString() : null,
267
+ deletedByAssistant,
268
+ };
269
+
270
+ await writeEncryptedFile(blobPathFor(entry.id), Buffer.from(buffer), buildAad(entry));
271
+ state.index.entries[entry.id] = entry;
272
+ await saveIndex();
273
+ return sanitizeEntry(entry);
274
+ },
275
+
276
+ async createFolder(owner, { name, parentId = null }) {
277
+ ensureOwner(owner);
278
+ await ensureLoaded();
279
+ const resolvedParentId = resolveParentFolder(owner, parentId);
280
+
281
+ const folder = {
282
+ id: crypto.randomUUID(),
283
+ type: 'folder',
284
+ name: normalizeName(name, 'New Folder'),
285
+ ownerType: owner.type,
286
+ ownerId: owner.id,
287
+ parentId: resolvedParentId,
288
+ createdAt: nowIso(),
289
+ updatedAt: nowIso(),
290
+ trashedAt: null,
291
+ purgeAt: null,
292
+ sessionIds: [],
293
+ expiresAt: owner.type === 'guest' ? new Date(Date.now() + GUEST_RETENTION_MS).toISOString() : null,
294
+ };
295
+
296
+ state.index.entries[folder.id] = folder;
297
+ await saveIndex();
298
+ return sanitizeEntry(folder);
299
+ },
300
+
301
+ async createDocument(owner, {
302
+ name,
303
+ parentId = null,
304
+ richText = false,
305
+ content = '',
306
+ source = 'upload',
307
+ }) {
308
+ const normalizedName = normalizeName(
309
+ name,
310
+ richText ? 'Untitled Document.html' : 'Untitled Document.txt'
311
+ );
312
+ return this.storeBuffer(owner, {
313
+ name: normalizedName,
314
+ mimeType: richText ? 'text/html' : 'text/plain',
315
+ buffer: Buffer.from(content || (richText ? '<p></p>' : ''), 'utf8'),
316
+ parentId,
317
+ source,
318
+ kind: richText ? 'rich_text' : 'text',
319
+ });
320
+ },
321
+
322
+ async readBuffer(owner, id) {
323
+ ensureOwner(owner);
324
+ await ensureLoaded();
325
+ const entry = getEntry(id);
326
+ if (!entry || entry.type !== 'file' || !canAccess(entry, owner)) return null;
327
+ return {
328
+ entry: sanitizeEntry(entry),
329
+ buffer: await readEncryptedFile(blobPathFor(id), buildAad(entry)),
330
+ };
331
+ },
332
+
333
+ async readText(owner, id) {
334
+ const loaded = await this.readBuffer(owner, id);
335
+ if (!loaded) return null;
336
+ return {
337
+ entry: loaded.entry,
338
+ text: loaded.buffer.toString('utf8'),
339
+ };
340
+ },
341
+
342
+ async updateContent(owner, id, { buffer, name = null, mimeType = null, kind = null }) {
343
+ ensureOwner(owner);
344
+ await ensureLoaded();
345
+ const entry = getEntry(id);
346
+ if (!entry || entry.type !== 'file' || !canAccess(entry, owner)) return null;
347
+
348
+ if (name) entry.name = normalizeName(name, entry.name);
349
+ if (mimeType) entry.mimeType = mimeType;
350
+ if (kind) entry.kind = kind;
351
+ entry.size = buffer.byteLength;
352
+ entry.updatedAt = nowIso();
353
+
354
+ await writeEncryptedFile(blobPathFor(entry.id), Buffer.from(buffer), buildAad(entry));
355
+ await saveIndex();
356
+ return sanitizeEntry(entry);
357
+ },
358
+
359
+ async move(owner, ids, parentId = null) {
360
+ ensureOwner(owner);
361
+ await ensureLoaded();
362
+ const destinationId = parentId ? resolveParentFolder(owner, parentId) : null;
363
+ if (parentId && !destinationId) throw new Error('Invalid destination folder');
364
+ const updated = [];
365
+ for (const id of ids || []) {
366
+ const entry = getEntry(id);
367
+ if (!entry || !canAccess(entry, owner)) continue;
368
+ if (destinationId === entry.id) continue;
369
+ if (wouldCreateCycle(owner, entry.id, destinationId)) continue;
370
+ entry.parentId = destinationId;
371
+ entry.updatedAt = nowIso();
372
+ updated.push(sanitizeEntry(entry));
373
+ }
374
+ if (updated.length) await saveIndex();
375
+ return updated;
376
+ },
377
+
378
+ async moveToTrash(owner, ids) {
379
+ ensureOwner(owner);
380
+ await ensureLoaded();
381
+ const trashed = [];
382
+ const now = nowIso();
383
+ const purgeAt = new Date(Date.now() + TRASH_RETENTION_MS).toISOString();
384
+
385
+ for (const id of ids || []) {
386
+ const entry = getEntry(id);
387
+ if (!entry || !canAccess(entry, owner)) continue;
388
+ for (const targetId of collectDescendantIds(owner, id)) {
389
+ const target = getEntry(targetId);
390
+ if (!target) continue;
391
+ target.trashedAt = now;
392
+ target.purgeAt = purgeAt;
393
+ target.updatedAt = now;
394
+ trashed.push(sanitizeEntry(target));
395
+ }
396
+ }
397
+ if (trashed.length) await saveIndex();
398
+ return trashed;
399
+ },
400
+
401
+ async restore(owner, ids) {
402
+ ensureOwner(owner);
403
+ await ensureLoaded();
404
+ const restored = [];
405
+ for (const id of ids || []) {
406
+ const entry = getEntry(id);
407
+ if (!entry || !canAccess(entry, owner)) continue;
408
+ for (const targetId of collectDescendantIds(owner, id)) {
409
+ const target = getEntry(targetId);
410
+ if (!target) continue;
411
+ target.trashedAt = null;
412
+ target.purgeAt = null;
413
+ target.updatedAt = nowIso();
414
+ restored.push(sanitizeEntry(target));
415
+ }
416
+ }
417
+ if (restored.length) await saveIndex();
418
+ return restored;
419
+ },
420
+
421
+ async deleteForever(owner, ids) {
422
+ ensureOwner(owner);
423
+ await ensureLoaded();
424
+ const removedIds = new Set();
425
+ for (const id of ids || []) {
426
+ const entry = getEntry(id);
427
+ if (!entry || !canAccess(entry, owner)) continue;
428
+ for (const targetId of collectDescendantIds(owner, id)) {
429
+ removedIds.add(targetId);
430
+ }
431
+ }
432
+ for (const targetId of removedIds) {
433
+ await purgeEntry(targetId);
434
+ }
435
+ if (removedIds.size) await saveIndex();
436
+ return [...removedIds];
437
+ },
438
+
439
+ async attachToSession(owner, ids, sessionId) {
440
+ ensureOwner(owner);
441
+ if (!sessionId) return [];
442
+ await ensureLoaded();
443
+ const updated = [];
444
+ for (const id of ids || []) {
445
+ const entry = getEntry(id);
446
+ if (!entry || !canAccess(entry, owner)) continue;
447
+ entry.sessionIds = [...new Set([...(entry.sessionIds || []), sessionId])];
448
+ entry.updatedAt = nowIso();
449
+ updated.push(sanitizeEntry(entry));
450
+ }
451
+ if (updated.length) await saveIndex();
452
+ return updated;
453
+ },
454
+ };
server/memoryStore.js ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import { loadEncryptedJson, saveEncryptedJson } from './cryptoUtils.js';
4
+
5
+ const DATA_ROOT = '/data/memories';
6
+ const INDEX_FILE = path.join(DATA_ROOT, 'index.json');
7
+ const MAX_MEMORY_LENGTH = 220;
8
+
9
+ const state = {
10
+ loaded: false,
11
+ index: {
12
+ memories: {},
13
+ },
14
+ };
15
+
16
+ function ensureOwner(owner) {
17
+ if (!owner?.type || !owner?.id) throw new Error('Invalid memory owner');
18
+ return owner;
19
+ }
20
+
21
+ function nowIso() {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ function sanitizeText(text) {
26
+ return String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_MEMORY_LENGTH);
27
+ }
28
+
29
+ async function ensureLoaded() {
30
+ if (state.loaded) return;
31
+ const stored = await loadEncryptedJson(INDEX_FILE);
32
+ state.index = {
33
+ memories: stored?.memories || {},
34
+ };
35
+ state.loaded = true;
36
+ }
37
+
38
+ async function saveIndex() {
39
+ await saveEncryptedJson(INDEX_FILE, state.index);
40
+ }
41
+
42
+ function matchesOwner(memory, owner) {
43
+ return memory.ownerType === owner.type && memory.ownerId === owner.id;
44
+ }
45
+
46
+ function sanitize(memory) {
47
+ return {
48
+ id: memory.id,
49
+ content: memory.content,
50
+ source: memory.source || 'assistant',
51
+ sessionId: memory.sessionId || null,
52
+ createdAt: memory.createdAt,
53
+ updatedAt: memory.updatedAt,
54
+ };
55
+ }
56
+
57
+ export const memoryStore = {
58
+ async list(owner) {
59
+ ensureOwner(owner);
60
+ await ensureLoaded();
61
+ return Object.values(state.index.memories)
62
+ .filter((memory) => matchesOwner(memory, owner))
63
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
64
+ .map(sanitize);
65
+ },
66
+
67
+ async create(owner, { content, sessionId = null, source = 'assistant' }) {
68
+ ensureOwner(owner);
69
+ await ensureLoaded();
70
+ const normalized = sanitizeText(content);
71
+ if (!normalized) return null;
72
+
73
+ const memory = {
74
+ id: crypto.randomUUID(),
75
+ ownerType: owner.type,
76
+ ownerId: owner.id,
77
+ content: normalized,
78
+ sessionId,
79
+ source,
80
+ createdAt: nowIso(),
81
+ updatedAt: nowIso(),
82
+ };
83
+ state.index.memories[memory.id] = memory;
84
+ await saveIndex();
85
+ return sanitize(memory);
86
+ },
87
+
88
+ async update(owner, id, content) {
89
+ ensureOwner(owner);
90
+ await ensureLoaded();
91
+ const memory = state.index.memories[id];
92
+ if (!memory || !matchesOwner(memory, owner)) return null;
93
+ const normalized = sanitizeText(content);
94
+ if (!normalized) return null;
95
+ memory.content = normalized;
96
+ memory.updatedAt = nowIso();
97
+ await saveIndex();
98
+ return sanitize(memory);
99
+ },
100
+
101
+ async delete(owner, id) {
102
+ ensureOwner(owner);
103
+ await ensureLoaded();
104
+ const memory = state.index.memories[id];
105
+ if (!memory || !matchesOwner(memory, owner)) return false;
106
+ delete state.index.memories[id];
107
+ await saveIndex();
108
+ return true;
109
+ },
110
+ };
server/sessionStore.js CHANGED
@@ -89,8 +89,24 @@ export const sessionStore = {
89
  saveTempStore().catch(err => console.error('Failed to save temp store:', err));
90
  return s;
91
  },
 
 
 
 
 
 
 
 
92
  deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
93
  deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
 
 
 
 
 
 
 
 
94
 
95
  /**
96
  * Copy temp sessions into the user's account on login.
@@ -146,6 +162,12 @@ export const sessionStore = {
146
  await this._persist(userClient(accessToken), userId, s).catch(() => {});
147
  return s;
148
  },
 
 
 
 
 
 
149
  async updateUserSession(userId, accessToken, sessionId, patch) {
150
  const user = userCache.get(userId); if (!user) { console.error("No user for " + userId); return null; }
151
  const s = user.sessions.get(sessionId); if (!s) { console.error ("No session found for " + sessionId); return null; }
@@ -237,4 +259,4 @@ export const deviceSessionStore = {
237
  const s = devSessions.get(token); if (!s || !s.active) return null;
238
  s.lastSeen = new Date().toISOString(); return s;
239
  },
240
- };
 
89
  saveTempStore().catch(err => console.error('Failed to save temp store:', err));
90
  return s;
91
  },
92
+ restoreTempSession(t, session) {
93
+ const d = this.initTemp(t);
94
+ const restored = JSON.parse(JSON.stringify(session));
95
+ d.sessions.set(restored.id, restored);
96
+ d.lastActive = Date.now();
97
+ saveTempStore().catch(err => console.error('Failed to save temp store:', err));
98
+ return restored;
99
+ },
100
  deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
101
  deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); },
102
+ deleteTempSessionEverywhere(id) {
103
+ let changed = false;
104
+ for (const temp of tempStore.values()) {
105
+ if (temp.sessions.delete(id)) changed = true;
106
+ }
107
+ if (changed) saveTempStore().catch(err => console.error('Failed to save temp store:', err));
108
+ return changed;
109
+ },
110
 
111
  /**
112
  * Copy temp sessions into the user's account on login.
 
162
  await this._persist(userClient(accessToken), userId, s).catch(() => {});
163
  return s;
164
  },
165
+ async restoreUserSession(userId, accessToken, session) {
166
+ const restored = JSON.parse(JSON.stringify(session));
167
+ this._ensureUser(userId).sessions.set(restored.id, restored);
168
+ await this._persist(userClient(accessToken), userId, restored).catch(() => {});
169
+ return restored;
170
+ },
171
  async updateUserSession(userId, accessToken, sessionId, patch) {
172
  const user = userCache.get(userId); if (!user) { console.error("No user for " + userId); return null; }
173
  const s = user.sessions.get(sessionId); if (!s) { console.error ("No session found for " + sessionId); return null; }
 
259
  const s = devSessions.get(token); if (!s || !s.active) return null;
260
  s.lastSeen = new Date().toISOString(); return s;
261
  },
262
+ };
server/wsHandler.js CHANGED
@@ -9,8 +9,12 @@ import {
9
  getUserProfile, setUsername, getSubscriptionInfo,
10
  getTierConfig, getUsageInfo,
11
  } from './auth.js';
12
- import { streamChat, extractSessionName } from './chatStream.js';
 
 
 
13
  import crypto from 'crypto';
 
14
 
15
  /**
16
  * Message Structure: Tree-based with versioned tails
@@ -134,15 +138,36 @@ const handlers = {
134
  },
135
 
136
  'sessions:delete': async (ws, msg, client) => {
137
- if (client.userId) await sessionStore.deleteUserSession(client.userId, client.accessToken, msg.sessionId);
138
- else sessionStore.deleteTempSession(client.tempId, msg.sessionId);
 
 
 
 
 
 
 
 
 
 
 
139
  safeSend(ws, { type: 'sessions:deleted', sessionId: msg.sessionId });
 
140
  },
141
 
142
  'sessions:deleteAll': async (ws, msg, client) => {
 
 
 
 
 
 
 
 
143
  if (client.userId) await sessionStore.deleteAllUserSessions(client.userId, client.accessToken);
144
  else sessionStore.deleteTempAll(client.tempId);
145
  safeSend(ws, { type: 'sessions:deletedAll' });
 
146
  },
147
 
148
  'sessions:rename': async (ws, msg, client) => {
@@ -175,8 +200,34 @@ const handlers = {
175
  safeSend(ws, { type: 'sessions:imported', session: ser(s) });
176
  },
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  'chat:send': async (ws, msg, client) => {
179
  const { sessionId, content, tools } = msg;
 
180
  if (!client.userId) {
181
  const allowed = await consumeGuestRequest(client.ip || 'unknown');
182
  if (!allowed) return safeSend(ws, { type: 'guest:rateLimit', message: 'Guest request limit exceeded' });
@@ -194,33 +245,49 @@ const handlers = {
194
  safeSend(ws, { type: 'chat:start', sessionId });
195
 
196
  let fullText = '';
197
- const assetsCollected = [], toolCallsCollected = [];
198
 
199
  // Extract flat history from tree structure
200
  const rootMessage = session.history?.[0];
201
  const flatHistory = rootMessage ? extractFlatHistory(rootMessage) : [];
202
 
 
 
 
 
 
 
203
  await streamChat({
204
  sessionId,
205
  model: session.model,
206
- history: flatHistory,
207
- userMessage: content,
208
  tools: tools || {},
209
- accessToken: client.accessToken,
210
- clientId: msg.clientId,
 
 
211
  abortSignal: abort.signal,
212
  onToken(t) { fullText += t; safeSend(ws, { type: 'chat:token', token: t, sessionId }); },
213
  onToolCall(call) {
214
  safeSend(ws, { type: 'chat:toolCall', call, sessionId });
215
  if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
216
  },
217
- onNewAsset(asset) { safeSend(ws, { type: 'chat:asset', asset, sessionId }); assetsCollected.push(asset); },
218
- async onDone(text, toolCalls, aborted, sessionNameFromTag) {
 
 
 
 
 
 
 
 
219
  activeStreams.delete(ws);
220
  const finalText = text || fullText;
221
-
222
  // Only create user entry if content was actually provided
223
- const hasContent = content !== undefined && content !== null && content !== '' &&
224
  !(Array.isArray(content) && content.length === 0);
225
  const userEntry = hasContent
226
  ? buildEntry('user', content)
@@ -231,17 +298,39 @@ const handlers = {
231
  const resolved = resolvedMap.get(c.id) || {};
232
  return { ...c, state: resolved.state || 'resolved', result: resolved.result };
233
  });
234
- const asstEntry = buildEntry('assistant', finalText, mergedCalls);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
  // Rebuild tree structure with new messages appended
237
  let newRootMessage = rootMessage ? validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage))) : null;
238
-
239
  if (!newRootMessage) {
240
  // First message in session - must have user entry
241
  if (!userEntry) return safeSend(ws, { type: 'error', message: 'No content for first message' });
242
  newRootMessage = userEntry;
243
- const asstWrap = { ...asstEntry };
244
- newRootMessage.versions[0].tail = [asstWrap];
245
  } else {
246
  // Append to current tail
247
  const currentVerIdx = newRootMessage.currentVersionIdx ?? 0;
@@ -251,6 +340,7 @@ const handlers = {
251
  currentTail.push(userEntry);
252
  }
253
  currentTail.push(asstEntry);
 
254
  newRootMessage.versions[currentVerIdx].tail = currentTail;
255
  }
256
 
@@ -266,13 +356,13 @@ const handlers = {
266
  if (client.userId)
267
  await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
268
  else sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
269
-
270
  safeSend(ws, { type: aborted ? 'chat:aborted' : 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRootMessage) });
271
  },
272
- onError(err) {
273
  activeStreams.delete(ws);
274
  console.error('streamChat error:', err);
275
- safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
276
  },
277
  });
278
  },
@@ -378,6 +468,37 @@ const handlers = {
378
  bcast(wsClients, client.userId, { type: 'settings:updated', settings: msg.settings }, ws);
379
  },
380
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  'account:getProfile': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:profile', profile: await getUserProfile(c.userId, c.accessToken) }); },
382
  'account:setUsername': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:usernameResult', ...await setUsername(c.userId, c.accessToken, msg.username) }); },
383
  'account:getSubscription': async (ws, msg, c) => {
@@ -400,11 +521,31 @@ const handlers = {
400
 
401
  function ser(s) { return { id: s.id, name: s.name, created: s.created, history: s.history || [], model: s.model }; }
402
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  function generateMessageId() {
404
  return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
405
  }
406
 
407
- function buildEntry(role, content, toolCalls = []) {
408
  const normalizedCalls = toolCalls.map(c => ({
409
  id: c.id,
410
  name: c.name || c.function?.name,
@@ -420,10 +561,53 @@ function buildEntry(role, content, toolCalls = []) {
420
  timestamp: Date.now(),
421
  versions: [{ content: validContent, tail: [], timestamp: Date.now() }],
422
  currentVersionIdx: 0,
423
- ...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {})
 
424
  };
425
  }
426
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  /**
428
  * Validate and repair tree structure after cloning/modification
429
  * Ensures all messages and versions have valid content property
 
9
  getUserProfile, setUsername, getSubscriptionInfo,
10
  getTierConfig, getUsageInfo,
11
  } from './auth.js';
12
+ import { streamChat } from './chatStream.js';
13
+ import { mediaStore } from './mediaStore.js';
14
+ import { memoryStore } from './memoryStore.js';
15
+ import { chatTrashStore } from './chatTrashStore.js';
16
  import crypto from 'crypto';
17
+ import path from 'path';
18
 
19
  /**
20
  * Message Structure: Tree-based with versioned tails
 
138
  },
139
 
140
  'sessions:delete': async (ws, msg, client) => {
141
+ const owner = getClientOwner(client);
142
+ const session = client.userId
143
+ ? sessionStore.getUserSession(client.userId, msg.sessionId)
144
+ : sessionStore.getTempSession(client.tempId, msg.sessionId);
145
+ if (!session) return safeSend(ws, { type: 'error', message: 'Session not found' });
146
+
147
+ await chatTrashStore.add(owner, JSON.parse(JSON.stringify(session)));
148
+ if (client.userId) {
149
+ await sessionStore.deleteUserSession(client.userId, client.accessToken, msg.sessionId);
150
+ sessionStore.deleteTempSessionEverywhere(msg.sessionId);
151
+ } else {
152
+ sessionStore.deleteTempSession(client.tempId, msg.sessionId);
153
+ }
154
  safeSend(ws, { type: 'sessions:deleted', sessionId: msg.sessionId });
155
+ safeSend(ws, { type: 'trash:chats:changed' });
156
  },
157
 
158
  'sessions:deleteAll': async (ws, msg, client) => {
159
+ const owner = getClientOwner(client);
160
+ const sessions = client.userId
161
+ ? sessionStore.getUserSessions(client.userId)
162
+ : sessionStore.getTempSessions(client.tempId);
163
+ for (const session of sessions) {
164
+ await chatTrashStore.add(owner, JSON.parse(JSON.stringify(session)));
165
+ if (client.userId) sessionStore.deleteTempSessionEverywhere(session.id);
166
+ }
167
  if (client.userId) await sessionStore.deleteAllUserSessions(client.userId, client.accessToken);
168
  else sessionStore.deleteTempAll(client.tempId);
169
  safeSend(ws, { type: 'sessions:deletedAll' });
170
+ safeSend(ws, { type: 'trash:chats:changed' });
171
  },
172
 
173
  'sessions:rename': async (ws, msg, client) => {
 
200
  safeSend(ws, { type: 'sessions:imported', session: ser(s) });
201
  },
202
 
203
+ 'trash:chats:list': async (ws, msg, client) => {
204
+ const owner = getClientOwner(client);
205
+ const items = await chatTrashStore.list(owner);
206
+ safeSend(ws, { type: 'trash:chats:list', items });
207
+ },
208
+
209
+ 'trash:chats:restore': async (ws, msg, client) => {
210
+ const owner = getClientOwner(client);
211
+ const restored = await chatTrashStore.restore(owner, msg.ids || []);
212
+ const sessions = [];
213
+ for (const snapshot of restored) {
214
+ const restoredSession = await restoreDeletedSession(client, snapshot);
215
+ if (restoredSession) sessions.push(ser(restoredSession));
216
+ }
217
+ safeSend(ws, { type: 'trash:chats:restored', sessions });
218
+ safeSend(ws, { type: 'trash:chats:changed' });
219
+ },
220
+
221
+ 'trash:chats:deleteForever': async (ws, msg, client) => {
222
+ const owner = getClientOwner(client);
223
+ const removedIds = await chatTrashStore.deleteForever(owner, msg.ids || []);
224
+ safeSend(ws, { type: 'trash:chats:deletedForever', ids: removedIds });
225
+ safeSend(ws, { type: 'trash:chats:changed' });
226
+ },
227
+
228
  'chat:send': async (ws, msg, client) => {
229
  const { sessionId, content, tools } = msg;
230
+ const owner = getClientOwner(client);
231
  if (!client.userId) {
232
  const allowed = await consumeGuestRequest(client.ip || 'unknown');
233
  if (!allowed) return safeSend(ws, { type: 'guest:rateLimit', message: 'Guest request limit exceeded' });
 
245
  safeSend(ws, { type: 'chat:start', sessionId });
246
 
247
  let fullText = '';
248
+ const assetsCollected = [], toolCallsCollected = [], responseEditsCollected = [];
249
 
250
  // Extract flat history from tree structure
251
  const rootMessage = session.history?.[0];
252
  const flatHistory = rootMessage ? extractFlatHistory(rootMessage) : [];
253
 
254
+ if (Array.isArray(msg.linkedMediaIds) && msg.linkedMediaIds.length) {
255
+ await mediaStore.attachToSession(owner, msg.linkedMediaIds, sessionId).catch((err) => {
256
+ console.error('Failed to link uploaded media to session:', err);
257
+ });
258
+ }
259
+
260
  await streamChat({
261
  sessionId,
262
  model: session.model,
263
+ history: flatHistory,
264
+ userMessage: content,
265
  tools: tools || {},
266
+ accessToken: client.accessToken,
267
+ clientId: msg.clientId,
268
+ owner,
269
+ sessionName: session.name,
270
  abortSignal: abort.signal,
271
  onToken(t) { fullText += t; safeSend(ws, { type: 'chat:token', token: t, sessionId }); },
272
  onToolCall(call) {
273
  safeSend(ws, { type: 'chat:toolCall', call, sessionId });
274
  if (call.state === 'resolved' || call.state === 'canceled') toolCallsCollected.push(call);
275
  },
276
+ onNewAsset(asset) {
277
+ safeSend(ws, { type: 'chat:asset', asset, sessionId });
278
+ assetsCollected.push(asset);
279
+ safeSend(ws, { type: 'media:changed' });
280
+ },
281
+ onDraftEdit(edit, draftText) {
282
+ responseEditsCollected.push(edit);
283
+ safeSend(ws, { type: 'chat:draftEdited', edit, text: draftText, sessionId });
284
+ },
285
+ async onDone(text, toolCalls, aborted, sessionNameFromTag, responseEdits = []) {
286
  activeStreams.delete(ws);
287
  const finalText = text || fullText;
288
+
289
  // Only create user entry if content was actually provided
290
+ const hasContent = content !== undefined && content !== null && content !== '' &&
291
  !(Array.isArray(content) && content.length === 0);
292
  const userEntry = hasContent
293
  ? buildEntry('user', content)
 
298
  const resolved = resolvedMap.get(c.id) || {};
299
  return { ...c, state: resolved.state || 'resolved', result: resolved.result };
300
  });
301
+ const asstEntry = buildEntry('assistant', finalText, mergedCalls, {
302
+ responseEdits: [...responseEditsCollected, ...responseEdits],
303
+ });
304
+
305
+ const mediaEntries = assetsCollected.map((asset) =>
306
+ buildMediaEntry(asset.role, {
307
+ assetId: asset.id,
308
+ mimeType: asset.mimeType,
309
+ name: asset.name,
310
+ })
311
+ );
312
+
313
+ const generatedFiles = extractAssistantGeneratedFiles(finalText);
314
+ for (const file of generatedFiles) {
315
+ await mediaStore.storeBuffer(owner, {
316
+ name: file.name,
317
+ mimeType: file.mimeType,
318
+ buffer: file.buffer,
319
+ sessionId,
320
+ source: 'assistant_generated',
321
+ kind: file.kind,
322
+ }).catch((err) => console.error('Failed to store generated text asset:', err));
323
+ }
324
+ if (generatedFiles.length) safeSend(ws, { type: 'media:changed' });
325
 
326
  // Rebuild tree structure with new messages appended
327
  let newRootMessage = rootMessage ? validateAndRepairTree(JSON.parse(JSON.stringify(rootMessage))) : null;
328
+
329
  if (!newRootMessage) {
330
  // First message in session - must have user entry
331
  if (!userEntry) return safeSend(ws, { type: 'error', message: 'No content for first message' });
332
  newRootMessage = userEntry;
333
+ newRootMessage.versions[0].tail = [{ ...asstEntry }, ...mediaEntries];
 
334
  } else {
335
  // Append to current tail
336
  const currentVerIdx = newRootMessage.currentVersionIdx ?? 0;
 
340
  currentTail.push(userEntry);
341
  }
342
  currentTail.push(asstEntry);
343
+ currentTail.push(...mediaEntries);
344
  newRootMessage.versions[currentVerIdx].tail = currentTail;
345
  }
346
 
 
356
  if (client.userId)
357
  await sessionStore.updateUserSession(client.userId, client.accessToken, sessionId, { history: newHistory, name: newName });
358
  else sessionStore.updateTempSession(client.tempId, sessionId, { history: newHistory, name: newName });
359
+
360
  safeSend(ws, { type: aborted ? 'chat:aborted' : 'chat:done', sessionId, name: newName, history: extractFlatHistory(newRootMessage) });
361
  },
362
+ onError(err) {
363
  activeStreams.delete(ws);
364
  console.error('streamChat error:', err);
365
+ safeSend(ws, { type: 'chat:error', error: String(err), sessionId });
366
  },
367
  });
368
  },
 
468
  bcast(wsClients, client.userId, { type: 'settings:updated', settings: msg.settings }, ws);
469
  },
470
 
471
+ 'memories:list': async (ws, msg, client) => {
472
+ const owner = getClientOwner(client);
473
+ const items = await memoryStore.list(owner);
474
+ safeSend(ws, { type: 'memories:list', items });
475
+ },
476
+
477
+ 'memories:create': async (ws, msg, client, wsClients) => {
478
+ const owner = getClientOwner(client);
479
+ const memory = await memoryStore.create(owner, {
480
+ content: msg.content,
481
+ sessionId: msg.sessionId || null,
482
+ source: msg.source || 'manual',
483
+ });
484
+ safeSend(ws, { type: 'memories:created', memory });
485
+ if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
486
+ },
487
+
488
+ 'memories:update': async (ws, msg, client, wsClients) => {
489
+ const owner = getClientOwner(client);
490
+ const memory = await memoryStore.update(owner, msg.id, msg.content);
491
+ safeSend(ws, { type: 'memories:updated', memory });
492
+ if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
493
+ },
494
+
495
+ 'memories:delete': async (ws, msg, client, wsClients) => {
496
+ const owner = getClientOwner(client);
497
+ const ok = await memoryStore.delete(owner, msg.id);
498
+ safeSend(ws, { type: 'memories:deleted', id: ok ? msg.id : null });
499
+ if (client.userId) bcast(wsClients, client.userId, { type: 'memories:changed' }, ws);
500
+ },
501
+
502
  'account:getProfile': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:profile', profile: await getUserProfile(c.userId, c.accessToken) }); },
503
  'account:setUsername': async (ws, msg, c) => { if (!c.userId) return; safeSend(ws, { type: 'account:usernameResult', ...await setUsername(c.userId, c.accessToken, msg.username) }); },
504
  'account:getSubscription': async (ws, msg, c) => {
 
521
 
522
  function ser(s) { return { id: s.id, name: s.name, created: s.created, history: s.history || [], model: s.model }; }
523
 
524
+ function getClientOwner(client) {
525
+ return client.userId
526
+ ? { type: 'user', id: client.userId }
527
+ : { type: 'guest', id: client.tempId };
528
+ }
529
+
530
+ async function restoreDeletedSession(client, snapshot) {
531
+ if (!snapshot) return null;
532
+ const restored = JSON.parse(JSON.stringify(snapshot));
533
+ const existing = client.userId
534
+ ? sessionStore.getUserSession(client.userId, restored.id)
535
+ : sessionStore.getTempSession(client.tempId, restored.id);
536
+ if (existing) restored.id = crypto.randomUUID();
537
+ restored.created = restored.created || Date.now();
538
+ if (client.userId) {
539
+ return sessionStore.restoreUserSession(client.userId, client.accessToken, restored);
540
+ }
541
+ return sessionStore.restoreTempSession(client.tempId, restored);
542
+ }
543
+
544
  function generateMessageId() {
545
  return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
546
  }
547
 
548
+ function buildEntry(role, content, toolCalls = [], extraFields = {}) {
549
  const normalizedCalls = toolCalls.map(c => ({
550
  id: c.id,
551
  name: c.name || c.function?.name,
 
561
  timestamp: Date.now(),
562
  versions: [{ content: validContent, tail: [], timestamp: Date.now() }],
563
  currentVersionIdx: 0,
564
+ ...(normalizedCalls.length ? { toolCalls: normalizedCalls } : {}),
565
+ ...extraFields,
566
  };
567
  }
568
 
569
+ function buildMediaEntry(role, content) {
570
+ return {
571
+ id: generateMessageId(),
572
+ role,
573
+ content,
574
+ timestamp: Date.now(),
575
+ versions: [{ content, tail: [], timestamp: Date.now() }],
576
+ currentVersionIdx: 0,
577
+ };
578
+ }
579
+
580
+ function extractAssistantGeneratedFiles(text) {
581
+ if (!text || typeof text !== 'string') return [];
582
+ const files = [];
583
+ const detailsRe = /<details><summary>([^<]+?)<\/summary>\s*```(?:\w*)\n([\s\S]*?)\n```\s*<\/details>/g;
584
+ const svgRe = /```svg\n([\s\S]*?)\n```/g;
585
+ let match;
586
+
587
+ while ((match = detailsRe.exec(text)) !== null) {
588
+ const name = String(match[1] || '').trim();
589
+ const ext = path.extname(name).toLowerCase();
590
+ files.push({
591
+ name: name || 'generated-file.txt',
592
+ mimeType: ext === '.html' || ext === '.htm' ? 'text/html' : 'text/plain',
593
+ kind: ext === '.html' || ext === '.htm' ? 'rich_text' : 'text',
594
+ buffer: Buffer.from(match[2], 'utf8'),
595
+ });
596
+ }
597
+
598
+ let svgIndex = 1;
599
+ while ((match = svgRe.exec(text)) !== null) {
600
+ files.push({
601
+ name: `generated-image-${svgIndex++}.svg`,
602
+ mimeType: 'image/svg+xml',
603
+ kind: 'image',
604
+ buffer: Buffer.from(match[1], 'utf8'),
605
+ });
606
+ }
607
+
608
+ return files;
609
+ }
610
+
611
  /**
612
  * Validate and repair tree structure after cloning/modification
613
  * Ensures all messages and versions have valid content property