@xuda.io/ai_module 1.1.5652 → 1.1.5654

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/index.mjs CHANGED
@@ -426,6 +426,66 @@ const account_msa = await import(`${module_path}/account_module/index_msa.mjs`);
426
426
  const drive_msa = await import(`${module_path}/drive_module/index_msa.mjs`);
427
427
  const misc_msa = await import(`${module_path}/misc_module/index_msa.mjs`);
428
428
 
429
+ // Did the user press Stop on this run?
430
+ //
431
+ // abort_job stamps `abort: true` on the job doc and nothing else. The doc only
432
+ // flips to stat 4 when the worker calls update_job and that call sees the flag,
433
+ // and a chat run never calls update_job: it reports progress over the websocket
434
+ // (emitToDashboard), not through the job. So every poll here used to watch a
435
+ // stat that could not change, and Stop did nothing but leave the response
436
+ // streaming. Read the flag itself, and keep the stat + "job vanished" cases so
437
+ // non-chat runs (which do go through update_job) behave exactly as before.
438
+ // Only an EXPLICIT stop counts. `abort_job` (jobs_module) records a Stop by writing
439
+ // `abort: true` onto the job, and stat 4 is the cancelled state, so those two are the whole
440
+ // signal. "I could not read the job" is not one of them, and it used to be: a `code < 0` from
441
+ // get_job_info was read as aborted and killed the run on the spot.
442
+ //
443
+ // That is reachable, not theoretical. jobs_module stores a job in memcached under a 600s TTL
444
+ // refreshed only by a progress write, and the chat path's update_job calls are commented out
445
+ // in favour of stream_phase, so a run stops being findable after ten minutes. Everything past
446
+ // that point (a long tool chain, a slow generation, a retry) was cut off mid-answer and
447
+ // reported to the user as "Stopped." as though they had pressed the button themselves. An
448
+ // unreadable job now simply means "no stop was requested": the worst case is a run that
449
+ // finishes work nobody is waiting for any more, which is a great deal better than silently
450
+ // truncating one somebody is.
451
+ const is_job_aborted = async function (job_id) {
452
+ if (!job_id) return false;
453
+ try {
454
+ const job_info = await jobs_ms.get_job_info({ job_id });
455
+ return job_info?.data?.abort === true || job_info?.data?.stat === 4;
456
+ } catch (err) {
457
+ return false;
458
+ }
459
+ };
460
+
461
+ // Watch for Stop for as long as a response stream is open, and cut the stream when it comes.
462
+ //
463
+ // The per-chunk check inside the read loop only runs when a chunk actually arrives, and a
464
+ // reasoning model can sit silent for twenty seconds before its first token. That silence is
465
+ // exactly when the user reaches for Stop, and the loop is parked on `await` the whole time, so
466
+ // the run stayed unstoppable until it started talking (measured: 26s from Stop to stop).
467
+ // Destroying the reader makes the parked for-await throw, which lands in the same abort path
468
+ // the per-chunk check uses. Returns the function that cancels the watch.
469
+ const watch_job_abort = function (job_id, output_stream) {
470
+ if (!job_id || !output_stream) return () => {};
471
+ let checking = false;
472
+ const timer = setInterval(async () => {
473
+ if (checking) return;
474
+ checking = true;
475
+ try {
476
+ if (await is_job_aborted(job_id)) {
477
+ clearInterval(timer);
478
+ try {
479
+ output_stream.destroy?.(new Error('aborted'));
480
+ } catch (err) {}
481
+ }
482
+ } finally {
483
+ checking = false;
484
+ }
485
+ }, 500);
486
+ return () => clearInterval(timer);
487
+ };
488
+
429
489
  var open_ai_status = {};
430
490
  const report_ai_status = function (model, err) {
431
491
  open_ai_status[model] = { stat: err ? 'error' : 'ok', date: new Date(), err };
@@ -463,6 +523,64 @@ try {
463
523
  }
464
524
  const model = _conf.default_ai_model;
465
525
 
526
+ // A run that dies between the model asking for a tool and that tool's output being written
527
+ // leaves a `function_call` in the OpenAI conversation with no `function_call_output` against
528
+ // it, and the Responses API then rejects the WHOLE conversation, for ever:
529
+ // "400 No tool output found for function call call_...". The thread is not degraded, it is
530
+ // finished. Every later turn fails identically, Retry included, and there is nothing the user
531
+ // can do from the chat to recover it. Boaz hit this on a Shorts Maker thread whose
532
+ // modify_short call was cut off: three sends in a row came back as error cards.
533
+ //
534
+ // Interruptions are not rare (Stop, a pm2 restart mid-run, a tool that throws, a job whose
535
+ // record expired), so repair on the way IN rather than waiting for the failure: drop any
536
+ // function_call that never got its output and the thread carries on from the last consistent
537
+ // point, with the model simply deciding again what to call. Deleting the call rather than
538
+ // inventing an output for it is deliberate, an invented output is a lie the model then reasons
539
+ // from, while a dropped call is simply a decision it never made.
540
+ //
541
+ // `items` is passed in by callers that were already listing them (ai_chat_conversation fetches
542
+ // the page anyway for its `after` cursor, so there it costs nothing) and fetched here for
543
+ // callers that were not. Sound on a partial page either way: an output is always NEWER than its
544
+ // call and the page is in `desc` order, so any function_call visible in the window has its
545
+ // output visible as well if it ever had one.
546
+ //
547
+ // Never throws. A thread that cannot be repaired still gets its turn, and just fails the way it
548
+ // already did. Returns the items that survived, so a caller reading a cursor off them cannot
549
+ // end up pointing at something this just deleted.
550
+ const drop_orphaned_tool_calls = async function (conversation_reference_id, items) {
551
+ if (!conversation_reference_id) return items || [];
552
+
553
+ let list = items;
554
+ if (!list) {
555
+ try {
556
+ const ret = await client.conversations.items.list(conversation_reference_id, { order: 'desc' });
557
+ list = ret?.data || [];
558
+ } catch (err) {
559
+ console.error(`[drop_orphaned_tool_calls] could not read ${conversation_reference_id}: ${err?.message || err}`);
560
+ return [];
561
+ }
562
+ }
563
+
564
+ const answered = new Set();
565
+ for (const item of list) {
566
+ if (item?.type === 'function_call_output' && item.call_id) answered.add(item.call_id);
567
+ }
568
+ const orphans = list.filter((item) => item?.type === 'function_call' && item.call_id && !answered.has(item.call_id));
569
+ if (!orphans.length) return list;
570
+
571
+ const dropped = new Set();
572
+ for (const orphan of orphans) {
573
+ try {
574
+ await client.conversations.items.delete(orphan.id, { conversation_id: conversation_reference_id });
575
+ dropped.add(orphan.id);
576
+ console.log(`[drop_orphaned_tool_calls] dropped orphaned tool call ${orphan.name || ''} ${orphan.call_id} from ${conversation_reference_id}`);
577
+ } catch (err) {
578
+ console.error(`[drop_orphaned_tool_calls] could not drop ${orphan.call_id}: ${err?.message || err}`);
579
+ }
580
+ }
581
+ return list.filter((item) => !dropped.has(item.id));
582
+ };
583
+
466
584
  // UI-138: the catalog code to draw a TRANSPARENT-background image edit with.
467
585
  //
468
586
  // These edits used to name 'chatgpt-image-latest' directly, but ai_model_aliases maps
@@ -1491,17 +1609,28 @@ const check_studio_doc_tool = tool({
1491
1609
  // only when ws_dashboard says this user is NOT watching that conversation.
1492
1610
  const CHAT_PRESENCE_TIMEOUT_MS = 4000;
1493
1611
 
1494
- // The dashboard route is /dashboard/<tab>/<referenceId>. An ai_chat is keyed by the
1495
- // conversation itself; every other tab is keyed by whatever the conversation hangs
1496
- // off (the contact, the agent, the app).
1612
+ // The dashboard route is /dashboard/<tab>/<referenceId>, and a CONVERSATION is addressed at
1613
+ // /dashboard/ai_chats/<conversation_id> whatever it hangs off: that is the route the
1614
+ // dashboard itself navigates to after the first send inside an agent (AiChat.vue), so it
1615
+ // opens an agent's or a contact's thread just as well as a plain ai_chat.
1616
+ //
1617
+ // UI-187: it used to send anything with a reference to /dashboard/<reference_type>/<id>
1618
+ // instead, so an agent's alert opened the AGENT (its detail view, listing every past chat)
1619
+ // and a contact's opened the CONTACT. The alert says an answer is ready and then landed you
1620
+ // somewhere you still had to go looking for it. Boaz: "clicking on the toast/fcm should open
1621
+ // the conversation".
1622
+ //
1623
+ // Two exceptions keep their entity route because the conversation has no standalone page
1624
+ // there: an app's thread lives inside the app panel, and a `dashboard` conversation is the
1625
+ // home composer itself.
1497
1626
  const chat_finished_link = function (conversation_doc, conversation_id) {
1498
1627
  const base = embed_origin();
1499
1628
  const reference_type = conversation_doc?.reference_type;
1500
1629
  const reference_id = conversation_doc?.reference_id;
1501
- if (!reference_type || reference_type === 'ai_chats') return `${base}/dashboard/ai_chats/${conversation_id}`;
1502
1630
  if (reference_type === 'dashboard') return `${base}/dashboard`;
1503
1631
  if (reference_type === 'studio') return reference_id ? `${base}/dashboard/apps/${reference_id}` : `${base}/dashboard/apps`;
1504
- if (!reference_id) return `${base}/dashboard`;
1632
+ if (conversation_id) return `${base}/dashboard/ai_chats/${conversation_id}`;
1633
+ if (!reference_type || !reference_id) return `${base}/dashboard`;
1505
1634
  return `${base}/dashboard/${reference_type}/${reference_id}`;
1506
1635
  };
1507
1636
 
@@ -1521,6 +1650,41 @@ const chat_finished_summary = function (text) {
1521
1650
  return plain.length > 160 ? `${plain.slice(0, 157)}...` : plain;
1522
1651
  };
1523
1652
 
1653
+ // The alert should look like the chat it came from, so it carries that chat's own
1654
+ // picture: the agent's image, the generated chat thumbnail, or the contact's avatar,
1655
+ // the same one the chat card shows. Returns undefined when the chat has no picture yet,
1656
+ // and notification_module then falls back to the app icon as before. Never throws: an
1657
+ // alert without a picture is still an alert.
1658
+ const chat_finished_image = async function (uid, conversation_doc) {
1659
+ try {
1660
+ const studio_meta = conversation_doc?.studio_meta;
1661
+ const agent_image = studio_meta?.agent_image?.[0]?.file_url || studio_meta?.agent_marketplace_image?.[0]?.file_url;
1662
+ if (agent_image) return agent_image;
1663
+
1664
+ // Same field the chat card reads, so the alert and the card agree.
1665
+ const chat_image = conversation_doc?.chat_image?.[0]?.file_url;
1666
+ if (chat_image) return chat_image;
1667
+
1668
+ const reference_type = conversation_doc?.reference_type;
1669
+ const reference_id = conversation_doc?.reference_id;
1670
+ if (!reference_id) return undefined;
1671
+
1672
+ if (reference_type === 'contacts') {
1673
+ const contact = await get_contact_info(uid, null, reference_id);
1674
+ return contact?.profile_picture || contact?.profile_avatar || undefined;
1675
+ }
1676
+
1677
+ if (reference_type === 'ai_agents') {
1678
+ const account_profile_info = await get_active_account_profile_info(uid);
1679
+ const agent_doc = await load_ai_agent_doc(account_profile_info?.app_id, reference_id);
1680
+ return agent_doc?.studio_meta?.agent_image?.[0]?.file_url || agent_doc?.agent_image?.[0]?.file_url || undefined;
1681
+ }
1682
+ } catch (err) {
1683
+ console.error(`[chat_finished_image] ${err?.message || err}`);
1684
+ }
1685
+ return undefined;
1686
+ };
1687
+
1524
1688
  // Some flows can reach a second terminal event in one turn (a stream that completed and
1525
1689
  // then failed while its result was being persisted), and each one closes the stream. The
1526
1690
  // user only wants to be told once per chat, so keep the last alert per conversation and
@@ -1547,6 +1711,7 @@ const notify_chat_finished = async function ({ uid, conversation_id, conversatio
1547
1711
  if (!presence || presence.code < 0 || presence.data !== false) return;
1548
1712
 
1549
1713
  const title = String(conversation_doc?.title || '').trim();
1714
+ const image = await chat_finished_image(uid, conversation_doc);
1550
1715
 
1551
1716
  // Stamped only now that an alert is really going out. Stamping every finished run
1552
1717
  // would let a run nobody needed to hear about (the user was watching it) silence the
@@ -1561,8 +1726,17 @@ const notify_chat_finished = async function ({ uid, conversation_id, conversatio
1561
1726
  notification_msa.submit_notification?.({
1562
1727
  type: 'ai',
1563
1728
  uid_arr: [uid],
1564
- subject: title ? `Ready: ${title}` : 'Your chat is ready',
1729
+ // UI-193: the NAME, and nothing else. Boaz, on a finished pitch-deck run: "keep only
1730
+ // the name in the fcm/toast". It used to lead with "Ready:" and then spend the body
1731
+ // on the first 160 characters of the answer, which on a lock screen is a paragraph of
1732
+ // half-sentences and a truncated URL. The picture says who it is from and the name
1733
+ // says which chat, so the text the notification shows is exactly the name.
1734
+ subject: title || 'Your chat is ready',
1735
+ // Kept for the bell, which is a list you read rather than a line you glance at.
1565
1736
  body: chat_finished_summary(text),
1737
+ push_body: '',
1738
+ // The chat's own picture, on the push and on the toast that stands in for it.
1739
+ ...(image ? { icon: image } : {}),
1566
1740
  delivery_method: ['push'],
1567
1741
  display_type: 'info',
1568
1742
  ref: conversation_id,
@@ -1577,6 +1751,98 @@ const notify_chat_finished = async function ({ uid, conversation_id, conversatio
1577
1751
  }
1578
1752
  };
1579
1753
 
1754
+ // ─── Message from a person ────────────────────────────────────────────────────
1755
+ // UI-193. A person-to-person chat only ever reached the recipient over the socket, as a
1756
+ // conversation_doc_updated the Chats screen redraws off. So a message arrived silently
1757
+ // unless that exact screen happened to be open: nothing on a phone, nothing on another
1758
+ // tab, nothing on any other screen in the dashboard. Same three roads as the chat-finished
1759
+ // alert (system notification, foreground toast, socket toast when there is no live token),
1760
+ // and the same rule about what it says: the sender's NAME and the sender's FACE, with the
1761
+ // message itself kept for the notification center.
1762
+ //
1763
+ // The picture is the one the RECIPIENT has of the sender, not the sender's own copy of
1764
+ // themselves: `connection_contact_id` is the mirror contact in the recipient's book, which
1765
+ // is what their Chats list and contact card already draw, so the alert and the thread it
1766
+ // opens show the same person.
1767
+ //
1768
+ // Who the sender IS, resolved the way the recipient would recognise them. Three roads,
1769
+ // because the first two can both come up empty:
1770
+ // 1. the mirror contact the connection request recorded (`connection_contact_id`),
1771
+ // 2. failing that, the recipient's own contact carrying the sender's uid. The mirror id
1772
+ // is only written for a `contact_connection` request, so a pair connected any other
1773
+ // way (an ai_agent share, for one) has a perfectly good contact on both sides and no
1774
+ // pointer between them. Without this the alert read "New message" with no face,
1775
+ // 3. failing that, the sender's account itself, so a message from someone not yet in
1776
+ // your book still says who it is from instead of going anonymous.
1777
+ const chat_message_sender = async function (to_uid, from_uid, from_contact_id) {
1778
+ let contact_id = from_contact_id;
1779
+ if (!contact_id && from_uid) {
1780
+ try {
1781
+ const to_app_id = await get_account_default_project_id(to_uid);
1782
+ const q = await db_module.find_app_couch_query(to_app_id, {
1783
+ selector: { docType: 'contact', contact_uid: from_uid },
1784
+ fields: ['_id'],
1785
+ limit: 1,
1786
+ });
1787
+ contact_id = q?.docs?.[0]?._id;
1788
+ } catch (err) {
1789
+ /* fall through to the account */
1790
+ }
1791
+ }
1792
+
1793
+ if (contact_id) {
1794
+ const contact = await get_contact_info(to_uid, null, contact_id).catch(() => null);
1795
+ const name = String(contact?.name || '').trim();
1796
+ const image = contact?.profile_picture || contact?.profile_avatar || undefined;
1797
+ if (name || image) return { name, image };
1798
+ }
1799
+
1800
+ if (from_uid) {
1801
+ const acc = await get_account_name({ uid_query: from_uid }).catch(() => null);
1802
+ const d = acc?.data;
1803
+ if (d) {
1804
+ const name = (d.account_type === 'business' ? d.business_name : `${d.first_name || ''} ${d.last_name || ''}`.trim()) || '';
1805
+ return { name: String(name).trim(), image: d.profile_picture || d.profile_avatar || undefined };
1806
+ }
1807
+ }
1808
+
1809
+ return { name: '', image: undefined };
1810
+ };
1811
+
1812
+ const notify_chat_message = async function ({ to_uid, from_uid, from_contact_id, conversation_id, conversation_doc, text }) {
1813
+ try {
1814
+ if (!to_uid || !conversation_id) return;
1815
+
1816
+ // Not while they are looking at it. Same presence gate, and the same reading of a
1817
+ // non-answer: if ws_dashboard cannot say, stay quiet rather than interrupt someone
1818
+ // who is already reading the message.
1819
+ const presence = await Promise.race([
1820
+ ws_dashboard_ms.is_chat_open({ uid: to_uid, conversation_id }),
1821
+ new Promise((resolve) => setTimeout(() => resolve(null), CHAT_PRESENCE_TIMEOUT_MS)),
1822
+ ]);
1823
+ if (!presence || presence.code < 0 || presence.data !== false) return;
1824
+
1825
+ const { name: from_name, image } = await chat_message_sender(to_uid, from_uid, from_contact_id);
1826
+
1827
+ notification_msa.submit_notification?.({
1828
+ type: 'chat',
1829
+ uid_arr: [to_uid],
1830
+ subject: from_name || 'New message',
1831
+ // The message, for the bell. `push_body` empties it on the alert itself.
1832
+ body: chat_finished_summary(text),
1833
+ push_body: '',
1834
+ ...(image ? { icon: image } : {}),
1835
+ delivery_method: ['push'],
1836
+ display_type: 'info',
1837
+ ref: conversation_id,
1838
+ params: { kind: 'chat_message', conversation_id, reference_type: 'contacts' },
1839
+ link: chat_finished_link(conversation_doc, conversation_id),
1840
+ });
1841
+ } catch (err) {
1842
+ console.error(`[notify_chat_message] failed: ${err?.message || err}`);
1843
+ }
1844
+ };
1845
+
1580
1846
  export const execute_codex_request = async function (req_or_ip, prompt_arg, attachments_arg = []) {
1581
1847
  let emitToDashboard = function () {};
1582
1848
  let streamText = function () {};
@@ -2564,6 +2830,10 @@ export const delete_ai_chat = async function (req) {
2564
2830
  }
2565
2831
  }
2566
2832
 
2833
+ // UI-202: the last row this chat gets. A shared chat is given back rather than deleted,
2834
+ // so the row says which of the two happened.
2835
+ log_chat_activity(uid, conversation_id, 'deleted', { by: 'user', note: conversation_doc.shared_from_uid ? 'the share was given back, so the chat left this account with it' : 'the messages, attachments and picture went with it' }, account_profile_info.app_id);
2836
+
2567
2837
  return save_ret;
2568
2838
  } catch (err) {
2569
2839
  return { code: -3, data: err.message };
@@ -2588,6 +2858,9 @@ export const archive_ai_chat = async function (req) {
2588
2858
  const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
2589
2859
  await update_conversation_items_stat(account_profile_info.app_id, 5, conversation_id);
2590
2860
 
2861
+ // UI-202
2862
+ log_chat_activity(uid, conversation_id, 'archived', { by: 'user' }, account_profile_info.app_id);
2863
+
2591
2864
  return save_ret;
2592
2865
  } catch (err) {
2593
2866
  return { code: -3, data: err.message };
@@ -2611,6 +2884,9 @@ export const delete_conversation_item = async function (req) {
2611
2884
  conversation_item_doc.stat = 4;
2612
2885
  conversation_item_doc.ts = Date.now();
2613
2886
  save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_item_doc);
2887
+ // UI-202: a message leaving a thread is the one edit to a chat that cannot be seen by
2888
+ // reading the thread afterwards, which makes it the row most worth having.
2889
+ log_chat_activity(uid, conversation_item_doc.conversation_id, 'message_deleted', { by: 'user', role: conversation_item_doc.role, preview: String(conversation_item_doc.text || '').replace(/\s+/g, ' ').slice(0, 80) }, account_profile_info.app_id);
2614
2890
  const reference_conversation_id = conversation_doc?.reference_conversation_id || conversation_doc?.conversation_obj?.id;
2615
2891
  const reference_item_id = conversation_item_doc?.conversation_item_reference_id;
2616
2892
  const looksLikeConversationItemId = typeof reference_item_id === 'string' && /^(msg|item)_/.test(reference_item_id);
@@ -2650,6 +2926,9 @@ export const unarchive_ai_chat = async function (req) {
2650
2926
  const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
2651
2927
  await update_conversation_items_stat(account_profile_info.app_id, 3, conversation_id);
2652
2928
 
2929
+ // UI-202
2930
+ log_chat_activity(uid, conversation_id, 'unarchived', { by: 'user' }, account_profile_info.app_id);
2931
+
2653
2932
  return save_ret;
2654
2933
  } catch (err) {
2655
2934
  return { code: -3, data: err.message };
@@ -3140,6 +3419,46 @@ const get_user_ai_agents = async function (uid) {
3140
3419
  return await db_module.find_app_couch_query(account_profile_info.app_id, opt);
3141
3420
  };
3142
3421
 
3422
+ // UI-187: agent id -> the run currently in flight for it, as {conversation_id, stat, ts}.
3423
+ // A conversation is created at stat 1 (initiating), moves to 2 while the answer streams and
3424
+ // lands on 3 when it is done, so `stat < 3` IS "this agent is working". When an agent has
3425
+ // more than one live run (a chat and a scheduled job, say) the newest wins: the pill says
3426
+ // the agent is busy, not how many things it is doing.
3427
+ // Never throws. A card without its pill is a smaller failure than an agents list that 500s.
3428
+ // A run that never reached stat 3 is not necessarily still going: a conversation abandoned
3429
+ // mid-flight, or one whose process died, sits at stat 1 or 2 forever. dev has one that has
3430
+ // been "initiating" since 2026-08-02, and without this cap its agent would wear the pill for
3431
+ // the rest of time. Two hours is far past any real answer, including the long research runs.
3432
+ const LIVE_AGENT_CHAT_MAX_AGE_MS = 2 * 60 * 60 * 1000;
3433
+
3434
+ const get_live_agent_conversations = async function (app_id, uid) {
3435
+ const live = new Map();
3436
+ try {
3437
+ const ret = await db_module.find_app_couch_query(app_id, {
3438
+ selector: {
3439
+ docType: 'chat_conversation',
3440
+ uid,
3441
+ reference_type: 'ai_agents',
3442
+ stat: { $lt: 3 },
3443
+ },
3444
+ fields: ['_id', 'reference_id', 'stat', 'ts'],
3445
+ limit: 200,
3446
+ });
3447
+
3448
+ const now = Date.now();
3449
+ for (const doc of ret?.docs || []) {
3450
+ if (!doc.reference_id) continue;
3451
+ if (!doc.ts || now - doc.ts > LIVE_AGENT_CHAT_MAX_AGE_MS) continue;
3452
+ const prev = live.get(doc.reference_id);
3453
+ if (prev && (prev.ts || 0) >= (doc.ts || 0)) continue;
3454
+ live.set(doc.reference_id, { conversation_id: doc._id, stat: doc.stat, ts: doc.ts || 0 });
3455
+ }
3456
+ } catch (err) {
3457
+ console.error(`[get_live_agent_conversations] ${err?.message || err}`);
3458
+ }
3459
+ return live;
3460
+ };
3461
+
3143
3462
  export const get_ai_agents = async function (req, job_id, headers) {
3144
3463
  let { uid, _id, search, filter_type = 'all', limit, skip, agent_id, profile_id } = req;
3145
3464
  const account_profile_info = await get_active_account_profile_info(uid, profile_id);
@@ -3319,6 +3638,19 @@ export const get_ai_agents = async function (req, job_id, headers) {
3319
3638
  docs.push(info_doc);
3320
3639
  }
3321
3640
 
3641
+ // UI-187: which of these agents is answering RIGHT NOW, so the card carries the same
3642
+ // Initiating / Streaming pill the chat card does. The dashboard keeps the pill live off
3643
+ // `conversation_doc_updated` once the tab is open; this is what a page LOADED mid-run
3644
+ // needs, otherwise a reload silently drops the pill until the next stat change.
3645
+ // One query rather than one per agent: `stat < 3` is only ever true of a run in flight,
3646
+ // so the result set is a handful of docs even for a busy account, and it runs against the
3647
+ // account's own project db.
3648
+ const live_by_agent = await get_live_agent_conversations(account_profile_info.app_id, uid);
3649
+ for (const doc of docs) {
3650
+ const live = live_by_agent.get(doc._id);
3651
+ if (live) doc.live_chat = live;
3652
+ }
3653
+
3322
3654
  return { code: 8, data: { docs: [...requests_from.docs, ...docs], total_docs: user_agents.total_docs + requests_from.total_docs } };
3323
3655
  } catch (err) {
3324
3656
  return { code: -8, data: err.message };
@@ -3366,6 +3698,17 @@ export const delete_ai_agent = async function (req) {
3366
3698
  }
3367
3699
  delete_depended_chats(uid, agent_id);
3368
3700
  }
3701
+
3702
+ // UI-78: the last row this agent gets. A share is given back rather than deleted, so
3703
+ // the row says which of the two happened.
3704
+ log_agent_activity(
3705
+ uid,
3706
+ agent_id,
3707
+ 'deleted',
3708
+ { by: 'user', note: prog_doc.studio_meta.shared_from_uid ? 'the share was given back, so the agent left this account with it' : 'conversations attached to this agent were deleted with it' },
3709
+ account_profile_info.app_id
3710
+ );
3711
+
3369
3712
  return save_ret;
3370
3713
  } catch (err) {
3371
3714
  return { code: -9, data: err.message };
@@ -3388,6 +3731,9 @@ export const uninstall_ai_agent = async function (req) {
3388
3731
 
3389
3732
  const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, agent_doc);
3390
3733
 
3734
+ // UI-78
3735
+ log_agent_activity(uid, agent_id, 'uninstalled', { by: 'user', marketplace_id: agent_doc.studio_meta.installed_marketplace_id }, account_profile_info.app_id);
3736
+
3391
3737
  return save_ret;
3392
3738
  } catch (err) {
3393
3739
  return { code: -9, data: err.message };
@@ -3411,6 +3757,9 @@ export const unarchive_ai_agent = async function (req) {
3411
3757
  const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, agent_doc);
3412
3758
  const updated_conversation_items = await update_conversation_stat(account_profile_info.app_id, agent_id, 3, conversation_id);
3413
3759
 
3760
+ // UI-78
3761
+ log_agent_activity(uid, agent_id, 'unarchived', { by: 'user' }, account_profile_info.app_id);
3762
+
3414
3763
  return { code: 1, data: { save_ret, updated_conversation_items } };
3415
3764
  } catch (err) {
3416
3765
  return { code: -9, data: err.message };
@@ -3498,9 +3847,24 @@ export const generate_ai_agent_image = async function (req, job_id, headers) {
3498
3847
  const agent_doc = await db_module.get_app_couch_doc_native(app_id, agent_id);
3499
3848
  if (!agent_doc || !agent_doc._id) return { code: -404, data: 'agent not found' };
3500
3849
 
3501
- update_thumbnail('ai_agent', agent_doc, app_id, uid, job_id, headers, null, null, account_profile_info).catch((err) => {
3502
- console.error('[generate_ai_agent_image]', agent_id, err?.message || err);
3503
- });
3850
+ // UI-189: generation takes tens of seconds, so it reports through the same
3851
+ // studio_meta.prep state the create/update runner uses and the card shows the same
3852
+ // "Making a picture" progress instead of nothing happening until the picture appears.
3853
+ // UI-78
3854
+ log_agent_activity(uid, agent_id, 'image_requested', { by: 'user' }, app_id);
3855
+
3856
+ (async () => {
3857
+ await set_agent_prep(app_id, agent_id, { stat: 2, step: AGENT_PREP_STEPS.image, done: 0, total: 1, started_ts: Date.now() });
3858
+ try {
3859
+ await update_thumbnail('ai_agent', agent_doc, app_id, uid, job_id, headers, null, null, account_profile_info);
3860
+ log_agent_activity(uid, agent_id, 'image_ready', { by: 'ai', source: 'generated' }, app_id);
3861
+ } catch (err) {
3862
+ console.error('[generate_ai_agent_image]', agent_id, err?.message || err);
3863
+ await set_agent_prep(app_id, agent_id, { failed_step: 'image', failed_reason: String(err?.message || err).slice(0, 200) });
3864
+ log_agent_activity(uid, agent_id, 'image_failed', { by: 'ai', error: String(err?.message || err).slice(0, 200) }, app_id);
3865
+ }
3866
+ await set_agent_prep(app_id, agent_id, { stat: 3, step: null, done: 1, total: 1 });
3867
+ })();
3504
3868
 
3505
3869
  return { code: 1, data: { agent_id, started: true } };
3506
3870
  } catch (err) {
@@ -3525,6 +3889,9 @@ export const archive_ai_agent = async function (req) {
3525
3889
  const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, agent_doc);
3526
3890
  const updated_conversation_items = await update_conversation_stat(account_profile_info.app_id, agent_id, 5, conversation_id);
3527
3891
 
3892
+ // UI-78
3893
+ log_agent_activity(uid, agent_id, 'archived', { by: 'user', conversations: updated_conversation_items ? 'the agent conversations were archived with it' : undefined }, account_profile_info.app_id);
3894
+
3528
3895
  return { code: 1, data: { save_ret, updated_conversation_items } };
3529
3896
  } catch (err) {
3530
3897
  return { code: -9, data: err.message };
@@ -3650,6 +4017,10 @@ export const update_ai_agent = async function (req, job_id, headers) {
3650
4017
  try {
3651
4018
  let agent_doc = await await db_module.get_app_couch_doc_native(account_profile_info.app_id, agent_id);
3652
4019
 
4020
+ // UI-78: taken BEFORE the config is overwritten, so the trail can say what the edit
4021
+ // actually changed rather than "the agent was updated".
4022
+ const config_before = _.cloneDeep(agent_doc.agentConfig || {});
4023
+
3653
4024
  agent_doc.ts = Date.now();
3654
4025
  agent_doc.agentConfig = agentConfig;
3655
4026
 
@@ -3670,14 +4041,30 @@ export const update_ai_agent = async function (req, job_id, headers) {
3670
4041
 
3671
4042
  const data = await db_module.save_app_couch_doc_native(account_profile_info.app_id, agent_doc);
3672
4043
 
3673
- setTimeout(async () => {
3674
- await update_ai_agent_properties(agent_doc, account_profile_info.app_id, uid, { name_changed, agent_user_guide_changed, agent_category_changed, agent_subcategory_changed, agent_instructions_changed });
3675
- if (name_changed) {
3676
- await update_thumbnail('ai_agent', agent_doc, account_profile_info.app_id, uid, job_id, headers, null, null, account_profile_info);
3677
- }
3678
- }, 500);
4044
+ // UI-78: one row per edit, naming the fields that moved and their new values (long
4045
+ // free text travels as a length, see diff_agent_config). An edit that changed nothing,
4046
+ // which is what saving an untouched form is, records nothing: a trail of empty
4047
+ // "updated" rows is exactly what made the doc's own ts useless to read.
4048
+ const { changed, values } = diff_agent_config(config_before, agentConfig);
4049
+ if (changed.length) {
4050
+ log_agent_activity(uid, agent_id, 'updated', { by: 'user', changed, values, previous_name: name_changed ? config_before.agent_name : undefined }, account_profile_info.app_id);
4051
+ }
4052
+
4053
+ // UI-189: an edit does the same work a create does, minus whatever it did not change:
4054
+ // the picture is only regenerated on a rename, and attachments are only read when new
4055
+ // ones were added. Same detached, sequenced runner, same progress on the card.
4056
+ run_agent_preparation({
4057
+ agent_doc,
4058
+ app_id: account_profile_info.app_id,
4059
+ uid,
4060
+ job_id,
4061
+ headers,
4062
+ account_profile_info,
4063
+ agentConfig,
4064
+ with_image: name_changed,
4065
+ fields_changed: { name_changed, agent_user_guide_changed, agent_category_changed, agent_subcategory_changed, agent_instructions_changed },
4066
+ });
3679
4067
 
3680
- upload_agent_files(uid, agent_doc);
3681
4068
  return { code: 10, data };
3682
4069
  } catch (err) {
3683
4070
  return { code: -10, data: err.message };
@@ -4041,17 +4428,490 @@ const upload_agent_files = async function (uid, agent_doc) {
4041
4428
  // // }
4042
4429
  // };
4043
4430
 
4431
+ // UI-189: creating an agent is not finished when the save returns. Behind it run the
4432
+ // property pass, the picture generation (tens of seconds, and it costs credits) and, when
4433
+ // the agent was given attachments, a transcribe-and-upload pass per file. Until all of that
4434
+ // lands the agent exists but cannot answer properly: its knowledge is not in its vector
4435
+ // store yet and it has no picture. Boaz: "show progress indication and limit the
4436
+ // use/selections if it still proceesing".
4437
+ //
4438
+ // `studio_meta.prep` is that state, and it is deliberately NOT the doc's `stat`: the
4439
+ // programs checker already writes stat 2 to mean "this agent has check errors"
4440
+ // (controller_module), so overloading it would make the two indistinguishable.
4441
+ // { stat: 2 running | 3 done, step, done, total, started_ts, ts }
4442
+ // Every write goes through a fresh read of the doc so it cannot clobber whatever the
4443
+ // preparation task itself just saved, and any save puts the doc on the changes feed, which
4444
+ // is what pushes `ai_agent_updated` to the dashboard. Never throws: preparation state that
4445
+ // fails to record must not take the preparation down with it.
4446
+ // Short on purpose. These are rendered inside the card's footer strip, which is ~100px wide
4447
+ // on the narrowest agent card, so anything longer truncates to nothing useful ("Reading
4448
+ // att..."). The card's tooltip carries the step and its position in the run.
4449
+ const AGENT_PREP_STEPS = {
4450
+ properties: 'Setting up',
4451
+ files: 'Reading files',
4452
+ image: 'Making picture',
4453
+ };
4454
+
4455
+ const set_agent_prep = async function (app_id, agent_id, patch) {
4456
+ try {
4457
+ const doc = await db_module.get_app_couch_doc_native(app_id, agent_id);
4458
+ if (!doc?._id) return;
4459
+ doc.studio_meta = doc.studio_meta || {};
4460
+ doc.studio_meta.prep = { ...(doc.studio_meta.prep || {}), ...patch, ts: Date.now() };
4461
+ doc.ts = Date.now();
4462
+ await db_module.save_app_couch_doc_native(app_id, doc);
4463
+ } catch (err) {
4464
+ console.error(`[set_agent_prep] ${agent_id}: ${err?.message || err}`);
4465
+ }
4466
+ };
4467
+
4468
+ // Does this agent have attachments still to be read? A tool whose file already carries a
4469
+ // file_id was uploaded on an earlier save, so re-saving an agent nobody changed the files of
4470
+ // must not claim to be reading them again.
4471
+ const agent_has_pending_files = function (agentConfig) {
4472
+ return (agentConfig?.agent_tools || []).some((tool) => {
4473
+ if (tool?.type !== 'file_search') return false;
4474
+ if (!_.isEmpty(tool.file) && !tool.file.file_id) return true;
4475
+ if (!_.isEmpty(tool.youtube) && !tool.youtube.file_id) return true;
4476
+ return false;
4477
+ });
4478
+ };
4479
+
4480
+ // The one place the post-save work runs, for both create and update. Sequenced rather than
4481
+ // fired off in parallel the way it used to be: two chains each doing get-modify-save on the
4482
+ // same doc raced (the picture could land on a revision that predated the uploaded file ids
4483
+ // and drop them), and a progress indicator can only be honest if it knows what is running.
4484
+ // Detached on purpose, the caller returns as soon as the agent exists.
4485
+ const run_agent_preparation = async function ({ agent_doc, app_id, uid, job_id, headers, account_profile_info, agentConfig, with_image, fields_changed }) {
4486
+ const tasks = ['properties'];
4487
+ if (agent_has_pending_files(agentConfig)) tasks.push('files');
4488
+ if (with_image) tasks.push('image');
4489
+
4490
+ let done = 0;
4491
+ await set_agent_prep(app_id, agent_doc._id, { stat: 2, step: AGENT_PREP_STEPS[tasks[0]], done, total: tasks.length, started_ts: Date.now() });
4492
+
4493
+ for (const task of tasks) {
4494
+ await set_agent_prep(app_id, agent_doc._id, { stat: 2, step: AGENT_PREP_STEPS[task], done, total: tasks.length });
4495
+ try {
4496
+ // The properties pass records its own row (it is the only place that knows WHICH
4497
+ // fields the AI actually filled in), see update_ai_agent_properties.
4498
+ if (task === 'properties') await update_ai_agent_properties(agent_doc, app_id, uid, fields_changed || {});
4499
+ if (task === 'files') {
4500
+ await upload_agent_files(uid, agent_doc);
4501
+ log_agent_activity(uid, agent_doc._id, 'files_read', { by: 'ai', count: (agentConfig?.agent_tools || []).filter((t) => t?.type === 'file_search').length }, app_id);
4502
+ }
4503
+ if (task === 'image') {
4504
+ await update_thumbnail('ai_agent', agent_doc, app_id, uid, job_id, headers, null, null, account_profile_info);
4505
+ log_agent_activity(uid, agent_doc._id, 'image_ready', { by: 'ai', source: 'generated' }, app_id);
4506
+ }
4507
+ } catch (err) {
4508
+ // One step failing does not strand the agent in "preparing" forever. The rest still
4509
+ // run and the agent opens for use; what failed is recorded on the doc.
4510
+ console.error(`[run_agent_preparation] ${agent_doc._id} ${task}: ${err?.message || err}`);
4511
+ await set_agent_prep(app_id, agent_doc._id, { failed_step: task, failed_reason: String(err?.message || err).slice(0, 200) });
4512
+ // UI-78: and on the trail, which is the only place it survives the next successful
4513
+ // run (set_agent_prep is overwritten, the trail is appended to).
4514
+ log_agent_activity(uid, agent_doc._id, 'preparation_failed', { step: AGENT_PREP_STEPS[task] || task, error: String(err?.message || err).slice(0, 200) }, app_id);
4515
+ }
4516
+ done += 1;
4517
+ }
4518
+
4519
+ await set_agent_prep(app_id, agent_doc._id, { stat: 3, step: null, done, total: tasks.length });
4520
+ };
4521
+
4522
+ // ─── UI-78: the AI agent activity trail ──────────────────────────────────────────────
4523
+ // Same idea as the contact trail (UI-134, account_module) and deliberately the same shape,
4524
+ // because it is read by the same panel: the agent doc keeps the CURRENT answer to every
4525
+ // question and nothing about how it got there, so "who changed the instructions", "when did
4526
+ // this get its picture", "why is this agent public" had no trace to read back.
4527
+ //
4528
+ // One `agent_activity` doc per event, in the same app db as the agent, keyed on agent_id.
4529
+ // Append-only and deliberately silent: failing to WRITE the trail must never fail the action
4530
+ // being recorded, so every call is wrapped and logged to the console instead of thrown, and
4531
+ // callers do not await it unless they were already awaiting something else on the same line.
4532
+ //
4533
+ // A SHARED or INSTALLED agent is a copy in the receiver's own app db, so each owner gets
4534
+ // their own trail. That is the honest reading: what the sender did to their agent before it
4535
+ // was shared is the sender's history, not the receiver's.
4536
+ const log_agent_activity = async function (uid, agent_id, event, detail = {}, app_id) {
4537
+ try {
4538
+ if (!uid || !agent_id || !event) return null;
4539
+ const app = app_id || (await get_active_account_profile_info(uid))?.app_id;
4540
+ if (!app) return null;
4541
+
4542
+ return await db_module.save_app_couch_doc_native(app, {
4543
+ _id: await _common.xuda_get_uuid('agent_activity'),
4544
+ docType: 'agent_activity',
4545
+ agent_id,
4546
+ uid,
4547
+ event,
4548
+ // Values the UI prints back verbatim, so keep them short and human. Never put a full
4549
+ // instruction set or a tool payload here: the trail is a summary, not a backup.
4550
+ detail,
4551
+ ts: Date.now(),
4552
+ stat: 3,
4553
+ });
4554
+ } catch (err) {
4555
+ console.error('[ai_module] agent activity not recorded:', event, agent_id, err?.message || err);
4556
+ return null;
4557
+ }
4558
+ };
4559
+
4560
+ // The cross-module door onto the same helper, for the places that act on an agent from
4561
+ // outside ai_module (team_module shares one, marketplace_module installs one). Fire and
4562
+ // forget through index_msa, never awaited by those callers.
4563
+ export const log_ai_agent_activity = async function (req) {
4564
+ const { uid, agent_id, event, detail, app_id } = req || {};
4565
+ const ret = await log_agent_activity(uid, agent_id, event, detail || {}, app_id);
4566
+ return { code: ret ? 1 : 0, data: ret ? { agent_id, event } : 'not recorded' };
4567
+ };
4568
+
4569
+ // What an edit actually changed, as a list of field names the UI can print. agentConfig is
4570
+ // the whole form the editor posts back, so a naive "the config changed" row says nothing:
4571
+ // this compares it field by field against what was stored and keeps the ones that moved.
4572
+ // Instructions and the user guide are long free text, so only their LENGTH travels; the
4573
+ // point of the row is that they changed and by roughly how much, not to mirror them.
4574
+ const AGENT_CONFIG_FIELD_LABELS = {
4575
+ agent_name: 'name',
4576
+ agent_instructions: 'instructions',
4577
+ agent_user_guide: 'user guide',
4578
+ agent_category: 'category',
4579
+ agent_subcategory: 'subcategory',
4580
+ agent_industry: 'industry',
4581
+ agent_tags: 'tags',
4582
+ agent_ai_model: 'model',
4583
+ agent_visibility: 'visibility',
4584
+ agent_price: 'price',
4585
+ agent_tools: 'tools',
4586
+ agent_marketplace_image: 'marketplace image',
4587
+ };
4588
+
4589
+ const diff_agent_config = function (before = {}, after = {}) {
4590
+ const changed = [];
4591
+ const values = {};
4592
+
4593
+ for (const key of Object.keys(AGENT_CONFIG_FIELD_LABELS)) {
4594
+ const a = before?.[key];
4595
+ const b = after?.[key];
4596
+ if (_.isEqual(a ?? null, b ?? null)) continue;
4597
+ changed.push(AGENT_CONFIG_FIELD_LABELS[key]);
4598
+
4599
+ switch (key) {
4600
+ case 'agent_instructions':
4601
+ case 'agent_user_guide':
4602
+ values[AGENT_CONFIG_FIELD_LABELS[key]] = `${String(a || '').length} to ${String(b || '').length} characters`;
4603
+ break;
4604
+ case 'agent_tools':
4605
+ values.tools = `${(a || []).length} to ${(b || []).length}`;
4606
+ break;
4607
+ case 'agent_tags':
4608
+ values.tags = (b || []).join(', ').slice(0, 120);
4609
+ break;
4610
+ default:
4611
+ values[AGENT_CONFIG_FIELD_LABELS[key]] = String(b ?? '').slice(0, 120);
4612
+ break;
4613
+ }
4614
+ }
4615
+
4616
+ return { changed, values };
4617
+ };
4618
+
4619
+ // UI-208: an agent edited in STUDIO, which is the one writer that never reaches this module.
4620
+ // The Studio client saves its docs straight into CouchDB with no CPI hop, so the only
4621
+ // server-side witness is controller_module's changes reader, which sees every revision of
4622
+ // every studio doc whoever wrote it. That is also why this cannot simply log what it sees:
4623
+ // the same reader watches the revisions ai_module itself produces (the preparation runner
4624
+ // alone saves several times per create), and a row per revision would bury the real edits.
4625
+ //
4626
+ // Two gates, in cost order. First, did the AGENT actually change? Compared against the
4627
+ // previous revision out of the version history the same reader captured a line earlier, so
4628
+ // a save that only moved preparation state, a picture or a stat writes nothing. Second, did
4629
+ // a server path already narrate this edit? Any activity row within the quiet window means
4630
+ // yes (update_ai_agent logs before the reader gets there), so only a write that came from
4631
+ // outside this module survives to be recorded.
4632
+ const STUDIO_EDIT_QUIET_MS = 30 * 1000;
4633
+
4634
+ export const record_studio_agent_edit = async function (req) {
4635
+ const { app_id, doc } = req || {};
4636
+ try {
4637
+ if (!app_id || !doc?._id) return { code: 0, data: 'nothing to record' };
4638
+ if (doc.docType !== 'studio' || doc?.properties?.menuType !== 'ai_agent') return { code: 0, data: 'not an agent' };
4639
+ const uid = doc?.studio_meta?.createdByUid || doc.uid;
4640
+ if (!uid) return { code: 0, data: 'no owner on the doc' };
4641
+
4642
+ const prev_ret = await db_module.get_studio_doc_previous_version(app_id, doc._id, doc.ts || Date.now());
4643
+ const prev = prev_ret?.code > 0 ? prev_ret.data : null;
4644
+ // No earlier snapshot means this is the first revision history ever saw, and there is
4645
+ // nothing honest to say about what changed.
4646
+ if (!prev) return { code: 0, data: 'no previous revision to compare' };
4647
+
4648
+ const { changed, values } = diff_agent_config(prev.agentConfig || {}, doc.agentConfig || {});
4649
+ // The name lives on properties, not agentConfig, and renaming in Studio is exactly the
4650
+ // kind of edit somebody later goes looking for.
4651
+ const prev_name = prev?.properties?.menuName;
4652
+ const next_name = doc?.properties?.menuName;
4653
+ if (prev_name !== next_name) {
4654
+ changed.unshift('name');
4655
+ values.name = String(next_name ?? '').slice(0, 120);
4656
+ }
4657
+ if (!changed.length) return { code: 0, data: 'the agent itself did not change' };
4658
+
4659
+ const recent = await db_module.find_app_couch_query(app_id, {
4660
+ selector: { docType: 'agent_activity', agent_id: doc._id },
4661
+ limit: 200,
4662
+ });
4663
+ const cutoff = (doc.ts || Date.now()) - STUDIO_EDIT_QUIET_MS;
4664
+ if ((recent?.docs || []).some((row) => (row.ts || 0) >= cutoff)) return { code: 0, data: 'already recorded by the path that made the change' };
4665
+
4666
+ await log_agent_activity(uid, doc._id, 'updated', { changed, values, previous_name: prev_name !== next_name ? prev_name : undefined, source: 'edited outside the dashboard' }, app_id);
4667
+ return { code: 1, data: { agent_id: doc._id, changed } };
4668
+ } catch (err) {
4669
+ console.error('[record_studio_agent_edit]', doc?._id, err?.message || err);
4670
+ return { code: -1, data: err?.message || String(err) };
4671
+ }
4672
+ };
4673
+
4674
+ // Reads the trail for one agent, newest first, exactly the way get_contact_activity does:
4675
+ // rows the server RECORDED, plus rows reconstructed from the agent doc for everything that
4676
+ // happened before the trail existed (which is every agent that already exists today). A
4677
+ // derived row carries the closest honest timestamp the doc has, not the real one, and says
4678
+ // so, so the panel can mark it as reconstructed rather than observed.
4679
+ export const get_ai_agent_activity = async function (req) {
4680
+ const { uid, agent_id, profile_id } = req;
4681
+ try {
4682
+ if (!agent_id) throw new Error('agent_id is missing');
4683
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
4684
+
4685
+ // get_app_couch_doc_native throws a bare couch 'missing' for an id that is not in this
4686
+ // account's app db, which is how an id from another account arrives here. Catch it and
4687
+ // answer the question that was actually asked.
4688
+ let agent_doc;
4689
+ try {
4690
+ agent_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, agent_id);
4691
+ } catch (_) {
4692
+ agent_doc = null;
4693
+ }
4694
+ if (!agent_doc || agent_doc.docType !== 'studio' || agent_doc?.properties?.menuType !== 'ai_agent') throw new Error(`agent ${agent_id} not found`);
4695
+
4696
+ const recorded_ret = await db_module.find_app_couch_query(account_profile_info.app_id, {
4697
+ selector: { docType: 'agent_activity', agent_id },
4698
+ limit: 500,
4699
+ });
4700
+ const recorded = (recorded_ret?.docs || []).map((d) => ({ event: d.event, detail: d.detail || {}, ts: d.ts, derived: false }));
4701
+
4702
+ const derived = [];
4703
+ const add = (event, ts, detail) => {
4704
+ if (!ts) return;
4705
+ // A recorded row always wins: it has the real timestamp and the real inputs.
4706
+ if (recorded.some((r) => r.event === event)) return;
4707
+ derived.push({ event, detail, ts, derived: true });
4708
+ };
4709
+
4710
+ const meta = agent_doc.studio_meta || {};
4711
+ const config = agent_doc.agentConfig || {};
4712
+ const created_ts = agent_doc.date_created_ts || meta.date_created_ts || agent_doc.ts;
4713
+
4714
+ add('created', created_ts, { name: agent_doc?.properties?.menuName, model: config.agent_ai_model, tools: (config.agent_tools || []).length, visibility: config.agent_visibility });
4715
+ if (meta.shared_from_uid) add('shared_with_you', meta.shared_ts || created_ts, { from_uid: meta.shared_from_uid, access_type: meta.shared_access_type });
4716
+ if (meta.installed_from_app_id) add('installed', meta.installed_ts || created_ts, { marketplace_id: meta.installed_marketplace_id });
4717
+ if (meta.agent_assistant_name || config.agent_category || config.agent_industry) {
4718
+ add('properties_filled', meta.thumbnail_request_ts || created_ts, { assistant_name: meta.agent_assistant_name, category: config.agent_category, industry: config.agent_industry });
4719
+ }
4720
+ if (meta.agent_image?.length) add('image_ready', meta.thumbnail_request_ts || created_ts, { source: 'generated' });
4721
+ if (meta.prep?.failed_step) add('preparation_failed', meta.prep?.ts || created_ts, { step: meta.prep.failed_step, error: meta.prep.failed_reason });
4722
+ if (meta.pinned) add('pinned', agent_doc.ts || created_ts, {});
4723
+ // stat carries only the LAST transition, which is exactly what the recorded trail adds
4724
+ // from here on: an archive / unarchive history the doc itself cannot hold.
4725
+ if (agent_doc.stat === 5) add('archived', agent_doc.ts, {});
4726
+ if (agent_doc.stat === 4) add('deleted', agent_doc.ts, {});
4727
+
4728
+ // The marketplace listing is the one part of an agent's story that does NOT live in the
4729
+ // app db: publishing writes a marketplace_ai_agent doc in xuda_marketplace, and the
4730
+ // publish gate's verdict lands there too. It carries a real publish_stat_ts, so unlike
4731
+ // most reconstructed rows this one has an honest time. Never fatal: an agent that was
4732
+ // never published has no listing, and a marketplace read that fails is a missing row,
4733
+ // not a failed trail.
4734
+ //
4735
+ // Only for an agent this account actually OWNS. A marketplace install keeps the source
4736
+ // agent's _id, so the listing would be found from the installed copy as well and every
4737
+ // installer would be told they published it. The installed copy already says where it
4738
+ // came from, which is the true thing to say about it.
4739
+ let listing = null;
4740
+ if (!meta.installed_from_app_id && !meta.shared_from_uid) {
4741
+ try {
4742
+ const listing_ret = await db_module.find_couch_query('xuda_marketplace', {
4743
+ selector: { docType: 'marketplace_ai_agent', prog_id: agent_id },
4744
+ limit: 1,
4745
+ });
4746
+ const found = listing_ret?.docs?.[0] || null;
4747
+ // Second guard for the same reason: the listing belongs to whoever published it.
4748
+ if (found && (!found.app_uid || found.app_uid === uid)) listing = found;
4749
+ } catch (err) {
4750
+ console.error('[get_ai_agent_activity] marketplace listing not read:', agent_id, err?.message || err);
4751
+ }
4752
+ }
4753
+ if (listing) {
4754
+ const listing_ts = listing.publish_stat_ts || listing.stat_ts || listing.ts;
4755
+ if (listing.stat === 3) add('published', listing_ts, { category: listing.agent_category, price: listing.price, approved_by: listing.approval?.reviewed_by });
4756
+ if (listing.stat === 6) add('publish_rejected', listing_ts, { reason: listing.publish_reason || (listing.approval?.reasons || []).join('; ') });
4757
+ if (listing.stat === 1 && listing.approval?.status === 'pending') add('publish_review', listing_ts, { reason: listing.publish_reason || (listing.approval?.reasons || []).join('; ') });
4758
+ }
4759
+
4760
+ const rows = [...recorded, ...derived].sort((a, b) => (b.ts || 0) - (a.ts || 0));
4761
+
4762
+ return {
4763
+ code: 1,
4764
+ data: {
4765
+ agent_id,
4766
+ // What the card shows today, so the panel can head the trail with the outcome.
4767
+ current: {
4768
+ name: agent_doc?.properties?.menuName || null,
4769
+ stat: agent_doc.stat,
4770
+ pinned: !!meta.pinned,
4771
+ visibility: config.agent_visibility || null,
4772
+ model: config.agent_ai_model || null,
4773
+ tools: (config.agent_tools || []).length,
4774
+ shared: !!meta.shared_from_uid,
4775
+ installed: !!meta.installed_from_app_id,
4776
+ // null when the agent was never published, so the panel can tell "private" from
4777
+ // "listed and live" from "held by the publish gate".
4778
+ marketplace_stat: listing ? listing.stat : null,
4779
+ },
4780
+ rows,
4781
+ },
4782
+ };
4783
+ } catch (err) {
4784
+ return { code: -25, data: err.message };
4785
+ }
4786
+ };
4787
+
4788
+ // ─── UI-202: the chat activity trail ──────────────────────────────────────────────────
4789
+ // The third of the same family (contacts UI-134, agents UI-210), and deliberately identical
4790
+ // in shape because one panel renders all of them. A conversation doc keeps the CURRENT
4791
+ // title, category, picture and mood and nothing about when any of them were decided, so
4792
+ // "why is this chat called that", "when was it scored red" and "who archived it" had no
4793
+ // trace. Message-by-message content is NOT in here: the thread itself is that record. This
4794
+ // is what happened TO the chat.
4795
+ const log_chat_activity = async function (uid, conversation_id, event, detail = {}, app_id) {
4796
+ try {
4797
+ if (!uid || !conversation_id || !event) return null;
4798
+ const app = app_id || (await get_active_account_profile_info(uid))?.app_id;
4799
+ if (!app) return null;
4800
+
4801
+ return await db_module.save_app_couch_doc_native(app, {
4802
+ _id: await _common.xuda_get_uuid('chat_activity'),
4803
+ docType: 'chat_activity',
4804
+ conversation_id,
4805
+ uid,
4806
+ event,
4807
+ detail,
4808
+ ts: Date.now(),
4809
+ stat: 3,
4810
+ });
4811
+ } catch (err) {
4812
+ console.error('[ai_module] chat activity not recorded:', event, conversation_id, err?.message || err);
4813
+ return null;
4814
+ }
4815
+ };
4816
+
4817
+ // The cross-module door, for team_module when a chat is shared.
4818
+ export const log_ai_chat_activity = async function (req) {
4819
+ const { uid, conversation_id, event, detail, app_id } = req || {};
4820
+ const ret = await log_chat_activity(uid, conversation_id, event, detail || {}, app_id);
4821
+ return { code: ret ? 1 : 0, data: ret ? { conversation_id, event } : 'not recorded' };
4822
+ };
4823
+
4824
+ export const get_ai_chat_activity = async function (req) {
4825
+ const { uid, conversation_id, profile_id } = req;
4826
+ try {
4827
+ if (!conversation_id) throw new Error('conversation_id is missing');
4828
+ const account_profile_info = await get_active_account_profile_info(uid, profile_id);
4829
+
4830
+ let conversation_doc;
4831
+ try {
4832
+ conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
4833
+ } catch (_) {
4834
+ conversation_doc = null;
4835
+ }
4836
+ if (!conversation_doc || conversation_doc.docType !== 'chat_conversation') throw new Error(`chat ${conversation_id} not found`);
4837
+ if (conversation_doc.uid !== uid && conversation_doc?.account_profile_info?.uid !== uid && conversation_doc?.initiator_uid !== uid) throw new Error('Operation not allowed');
4838
+
4839
+ const recorded_ret = await db_module.find_app_couch_query(account_profile_info.app_id, {
4840
+ selector: { docType: 'chat_activity', conversation_id },
4841
+ limit: 500,
4842
+ });
4843
+ const recorded = (recorded_ret?.docs || []).map((d) => ({ event: d.event, detail: d.detail || {}, ts: d.ts, derived: false }));
4844
+
4845
+ const derived = [];
4846
+ const add = (event, ts, detail) => {
4847
+ if (!ts) return;
4848
+ if (recorded.some((r) => r.event === event)) return;
4849
+ derived.push({ event, detail, ts, derived: true });
4850
+ };
4851
+
4852
+ const created_ts = conversation_doc.date_created_ts || conversation_doc.ts;
4853
+ add('created', created_ts, {
4854
+ type: conversation_doc.conversation_type,
4855
+ model: conversation_doc.model,
4856
+ reference_type: conversation_doc.reference_type,
4857
+ plan_mode: !!conversation_doc.plan_mode,
4858
+ source: conversation_doc.source,
4859
+ });
4860
+ // The title only ever differs from the opening words when the AI named it, which is the
4861
+ // whole reason the row is worth having.
4862
+ if (conversation_doc.title && conversation_doc.prompt && conversation_doc.title !== getFirstNWords(conversation_doc.prompt, 10)) {
4863
+ add('title_set', conversation_doc.ts || created_ts, { by: 'ai', title: conversation_doc.title });
4864
+ }
4865
+ if (conversation_doc?.category_info?.category) add('categorized', conversation_doc.ts || created_ts, { by: 'ai', category: conversation_doc.category_info.category });
4866
+ if (conversation_doc?.chat_image?.length) add('image_ready', conversation_doc.thumbnail_request_ts || created_ts, { by: 'ai' });
4867
+ if (typeof conversation_doc.mood_level === 'number') add('mood_scored', conversation_doc.ts || created_ts, { by: 'ai', mood_level: conversation_doc.mood_level });
4868
+ if (conversation_doc.shared_from_uid) add('shared_with_you', conversation_doc.shared_ts || created_ts, { from_uid: conversation_doc.shared_from_uid });
4869
+ if (conversation_doc.pinned) add('pinned', conversation_doc.ts || created_ts, {});
4870
+ if (conversation_doc.stat === 5) add('archived', conversation_doc.ts, {});
4871
+ if (conversation_doc.stat === 4) add('deleted', conversation_doc.ts, {});
4872
+
4873
+ const rows = [...recorded, ...derived].sort((a, b) => (b.ts || 0) - (a.ts || 0));
4874
+
4875
+ return {
4876
+ code: 1,
4877
+ data: {
4878
+ conversation_id,
4879
+ current: {
4880
+ title: conversation_doc.title || null,
4881
+ stat: conversation_doc.stat,
4882
+ pinned: !!conversation_doc.pinned,
4883
+ type: conversation_doc.conversation_type || null,
4884
+ model: conversation_doc.model || null,
4885
+ category: conversation_doc?.category_info?.category || null,
4886
+ mood_level: typeof conversation_doc.mood_level === 'number' ? conversation_doc.mood_level : null,
4887
+ shared: !!conversation_doc.shared_from_uid,
4888
+ },
4889
+ rows,
4890
+ },
4891
+ };
4892
+ } catch (err) {
4893
+ return { code: -25, data: err.message };
4894
+ }
4895
+ };
4896
+
4044
4897
  const save_agent_status = async function (uid, agent_id, stat, agentConfig) {
4045
4898
  // const project_db = await get_account_project_db(uid);
4046
4899
  const account_profile_info = await get_active_account_profile_info(uid);
4047
4900
 
4048
4901
  try {
4049
4902
  let agent_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, agent_id);
4903
+ // UI-204: this rewrites the whole agentConfig from an AI build pass, so it is a real
4904
+ // edit and the trail has to say so. Same diff as the dashboard's own edit, so an agent
4905
+ // the AI rewrote reads exactly like one a person rewrote, minus who did it.
4906
+ const config_before = _.cloneDeep(agent_doc.agentConfig || {});
4050
4907
  agent_doc.ts = Date.now();
4051
4908
  agent_doc.stat = stat;
4052
4909
  agent_doc.agentConfig = agentConfig;
4053
4910
  const data = await db_module.save_app_couch_doc_native(account_profile_info.app_id, agent_doc);
4054
4911
 
4912
+ const { changed, values } = diff_agent_config(config_before, agentConfig);
4913
+ if (changed.length) log_agent_activity(uid, agent_id, 'updated', { by: 'ai', changed, values, note: 'written by the agent builder' }, account_profile_info.app_id);
4914
+
4055
4915
  return { code: 12, data };
4056
4916
  } catch (err) {
4057
4917
  return { code: -12, data: err.message };
@@ -4081,12 +4941,38 @@ export const create_ai_agent = async function (req, job_id, headers) {
4081
4941
  // const data = await db.insert(agent_doc);
4082
4942
 
4083
4943
  const data = await db_module.save_app_couch_doc_native(account_profile_info.app_id, agent_doc);
4084
- setTimeout(async () => {
4085
- await update_ai_agent_properties(agent_doc, account_profile_info.app_id, uid);
4086
- await update_thumbnail('ai_agent', agent_doc, account_profile_info.app_id, uid, job_id, headers, null, null, account_profile_info);
4087
- }, 500);
4088
4944
 
4089
- upload_agent_files(uid, agent_doc);
4945
+ // UI-78: the first row of this agent's trail. What it was created AS, so a later
4946
+ // "instructions changed" row has a starting point to be read against.
4947
+ log_agent_activity(
4948
+ uid,
4949
+ agent_doc._id,
4950
+ 'created',
4951
+ {
4952
+ by: 'user',
4953
+ name: agentConfig.agent_name,
4954
+ model: agentConfig.agent_ai_model,
4955
+ tools: (agentConfig.agent_tools || []).length,
4956
+ visibility: agentConfig.agent_visibility,
4957
+ instructions_length: String(agentConfig.agent_instructions || '').length,
4958
+ },
4959
+ account_profile_info.app_id
4960
+ );
4961
+
4962
+ // UI-189: a new agent always has all three preparation steps ahead of it. Detached, so
4963
+ // the create call still returns the moment the agent exists; the card shows what is
4964
+ // running and stays un-openable until studio_meta.prep says it is done.
4965
+ run_agent_preparation({
4966
+ agent_doc,
4967
+ app_id: account_profile_info.app_id,
4968
+ uid,
4969
+ job_id,
4970
+ headers,
4971
+ account_profile_info,
4972
+ agentConfig,
4973
+ with_image: true,
4974
+ });
4975
+
4090
4976
  return { code: 13, data };
4091
4977
  } catch (err) {
4092
4978
  return { code: -13, data: err.message };
@@ -4300,26 +5186,37 @@ export const update_ai_agent_properties = async function (doc, app_id, uid, fiel
4300
5186
 
4301
5187
  return ret.data;
4302
5188
  };
5189
+ // UI-78: what this pass DECIDED, not that it ran. The assistant name, category and
5190
+ // industry on the card are written here and nowhere else, so this row is the only answer
5191
+ // to "who chose Fintech". Collected as it goes, because each of the three is conditional:
5192
+ // a pass that only refreshed the assistant name must not claim it picked a category.
5193
+ const filled = {};
5194
+
4303
5195
  if (!db_doc.studio_meta.agent_assistant_name || fields_changed.name_changed || fields_changed.agent_instructions_changed || fields_changed.all) {
4304
5196
  db_doc.studio_meta.agent_assistant_name = await get_agent_assistant_name();
5197
+ filled.assistant_name = db_doc.studio_meta.agent_assistant_name;
4305
5198
  }
4306
5199
  if (!db_doc.agentConfig.agent_category || fields_changed.name_changed || fields_changed.all) {
4307
5200
  db_doc.agentConfig.agent_category = await get_agent_category();
4308
5201
  db_doc.studio_meta.agent_category = db_doc.agentConfig.agent_category;
5202
+ filled.category = db_doc.agentConfig.agent_category;
4309
5203
  }
4310
5204
 
4311
5205
  if (!db_doc.agentConfig.agent_industry || fields_changed.name_changed || fields_changed.all) {
4312
5206
  db_doc.agentConfig.agent_industry = await get_agent_industry();
4313
5207
  db_doc.studio_meta.agent_industry = db_doc.agentConfig.agent_industry;
5208
+ filled.industry = db_doc.agentConfig.agent_industry;
4314
5209
  }
4315
5210
  if (db_doc.agentConfig.agent_user_guide && (fields_changed.agent_user_guide_changed || fields_changed.all)) {
4316
5211
  db_doc.agentConfig.agent_user_guide_fields = await get_agent_user_guide_fields();
4317
5212
  db_doc.studio_meta.agent_user_guide_fields = db_doc.agentConfig.agent_user_guide_fields;
4318
5213
  db_doc.agentConfig.agent_user_guide_steps = await get_agent_user_guide_steps(db_doc.studio_meta.agent_user_guide_fields);
4319
5214
  db_doc.studio_meta.agent_user_guide_steps = db_doc.agentConfig.agent_user_guide_steps;
5215
+ filled.user_guide_form = 'rebuilt from the user guide';
4320
5216
  }
4321
5217
 
4322
5218
  const save_ret = await db_module.save_app_couch_doc_native(app_id, db_doc);
5219
+ if (Object.keys(filled).length) log_agent_activity(uid, db_doc._id, 'properties_filled', { by: 'ai', ...filled }, app_id);
4323
5220
  return save_ret;
4324
5221
  };
4325
5222
 
@@ -5617,6 +6514,16 @@ export const create_conversation = async function (req, job_id, headers) {
5617
6514
  };
5618
6515
  const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
5619
6516
 
6517
+ // UI-202: the first row of this chat's trail. What it was opened AS, so the AI's title
6518
+ // and category rows that follow have something to be read against.
6519
+ log_chat_activity(
6520
+ uid,
6521
+ conversation_doc._id,
6522
+ 'created',
6523
+ { by: 'user', type: conversation_type, model: ai_model, reference_type, plan_mode: normalize_boolean(plan_mode), attachments: (req.attachments || []).length || undefined },
6524
+ account_profile_info.app_id
6525
+ );
6526
+
5620
6527
  let contact_id, contact_doc, recipient_uid, recipient_contact_id;
5621
6528
 
5622
6529
  if (conversation_doc.reference_type === 'contacts' && conversation_type === 'chat') {
@@ -5706,6 +6613,9 @@ const process_conversation = async function (uid, conversation_id, account_profi
5706
6613
  conversation_doc.title = title.data;
5707
6614
 
5708
6615
  await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
6616
+ // UI-202: the chat is renamed from the opening words to whatever the model calls it,
6617
+ // which is the one thing about a chat people ask "where did that come from" about.
6618
+ log_chat_activity(uid, conversation_doc._id, 'title_set', { by: 'ai', title: conversation_doc.title }, account_profile_info.app_id);
5709
6619
  }
5710
6620
  }
5711
6621
  /// categorize prompt
@@ -5749,6 +6659,9 @@ const process_conversation = async function (uid, conversation_id, account_profi
5749
6659
  conversation_doc.category_info = category_info;
5750
6660
  conversation_doc.process_stat = 'full';
5751
6661
  await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
6662
+ // UI-202: the category decides which picture the card wears and how the chat is grouped,
6663
+ // and nothing else records that the AI chose it.
6664
+ if (category_info?.category) log_chat_activity(uid, conversation_doc._id, 'categorized', { by: 'ai', category: category_info.category }, account_profile_info.app_id);
5752
6665
 
5753
6666
  //enable_thumbnail_avatar_generation
5754
6667
  const { data: account_doc } = await db_module.get_couch_doc('xuda_accounts', account_profile_info.uid);
@@ -5761,6 +6674,9 @@ const process_conversation = async function (uid, conversation_id, account_profi
5761
6674
  }
5762
6675
 
5763
6676
  await update_thumbnail(thumbnail_type, conversation_doc, account_profile_info.app_id, uid, job_id, headers, null, null, account_profile_info);
6677
+ // UI-202: which of the two pictures the chat got, since the card looks quite different
6678
+ // for a generated title picture and a stock category one.
6679
+ log_chat_activity(uid, conversation_doc._id, 'image_ready', { by: 'ai', source: thumbnail_type === 'conversation_title' ? 'generated from the title' : 'from the category' }, account_profile_info.app_id);
5764
6680
 
5765
6681
  // if ((conversation_type === 'chat' && enable_thumbnail_avatar_generation) || !category_info.category) {
5766
6682
  // await update_thumbnail('conversation_title', conversation_doc, account_profile_info.app_id, uid, job_id, headers, null, null, account_profile_info);
@@ -6048,8 +6964,15 @@ const update_conversation_mood_level = async function (uid, target_contacts = []
6048
6964
  for await (const target of target_contacts) {
6049
6965
  const account_profile_info = await get_active_account_profile_info(target.uid);
6050
6966
  let conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
6967
+ const previous_mood = conversation_doc.mood_level;
6051
6968
  conversation_doc.mood_level = mood_level_obj.mood_level;
6052
6969
  await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
6970
+ // UI-202: the score that tints the row green or red on the contact timeline, and the
6971
+ // contact card with it. Recorded per SIDE of the exchange, in that side's own app db,
6972
+ // and only when it moved: a run of identical scores says nothing.
6973
+ if (previous_mood !== mood_level_obj.mood_level) {
6974
+ log_chat_activity(target.uid, conversation_id, 'mood_scored', { by: 'ai', mood_level: mood_level_obj.mood_level, previous: typeof previous_mood === 'number' ? previous_mood : undefined }, account_profile_info.app_id);
6975
+ }
6053
6976
 
6054
6977
  if (update_contact) {
6055
6978
  const account_profile_info = await get_active_account_profile_info(target.uid);
@@ -6106,7 +7029,12 @@ const contact_chat_conversation = async function (req, job_id, headers) {
6106
7029
  date_created_ts: Date.now(),
6107
7030
  ts: Date.now(),
6108
7031
  conversation_id,
6109
- text: body,
7032
+ // `body` does not exist in this scope (the request field is `prompt`), so every
7033
+ // person-to-person send died here with a ReferenceError, AFTER the conversation item
7034
+ // had been written into the OpenAI thread and both conversation docs had been saved.
7035
+ // Found while wiring the inbound-message alert below, which cannot fire from a
7036
+ // function that throws before it reaches it.
7037
+ text: prompt,
6110
7038
  reference_id: conversation_doc.reference_id,
6111
7039
  conversation_item_reference_id,
6112
7040
  direction: 'out',
@@ -6115,6 +7043,20 @@ const contact_chat_conversation = async function (req, job_id, headers) {
6115
7043
 
6116
7044
  const save_ret = await db_module.save_app_couch_doc(sender_app_id, out_conversation_item_obj);
6117
7045
 
7046
+ // UI-193: tell the person on the other end. Fire and forget: an alert that fails must
7047
+ // not fail the message, which is already delivered by this point.
7048
+ notify_chat_message({
7049
+ to_uid: receiver_contact_doc.contact_uid,
7050
+ from_uid: uid,
7051
+ // The recipient's own contact for the SENDER, so the alert wears the face and the name
7052
+ // their address book has for me. Often absent (only a contact_connection request
7053
+ // records it), and chat_message_sender falls back from there.
7054
+ from_contact_id: receiver_contact_doc.connection_contact_id,
7055
+ conversation_id,
7056
+ conversation_doc: receiver_conversation_doc,
7057
+ text: prompt,
7058
+ });
7059
+
6118
7060
  update_conversation_mood_level(uid, conversation_id, prompt, uid, receiver_contact_doc.contact_uid, conversation_doc.reference_type === 'contacts', account_profile_info);
6119
7061
 
6120
7062
  return { code: 15, data: save_ret }; ////item
@@ -6558,7 +7500,14 @@ Rules:
6558
7500
  };
6559
7501
 
6560
7502
  const chat_email = async function (req, job_id, headers) {
6561
- const { profile_id, uid, email_id, perform_ai_execution = true, from_mailbox, _thread_reentry, direction } = req;
7503
+ const { profile_id, uid, email_id, perform_ai_execution = true, from_mailbox, _thread_reentry } = req;
7504
+ // A send a person made in the app is outbound by definition. Only the mailbox path can
7505
+ // produce an inbound item and it always says which way the mail went (email_module reads
7506
+ // it off is_sent), so an absent direction here means a user-invoked send: the composer,
7507
+ // or any future caller that forgets. It used to be written through as undefined, which
7508
+ // left the key off the saved item entirely and made a mail we sent indistinguishable
7509
+ // from one we received, on the timeline and in the AI's own reading of the thread.
7510
+ const direction = req.direction || 'out';
6562
7511
  const account_profile_info = await get_active_account_profile_info(uid, profile_id);
6563
7512
  let { prompt: body, conversation_doc, attachments = [], ai_agents } = req;
6564
7513
  // UI-126 (b): a composed send arrives already written and already reviewed by the user, so
@@ -6577,9 +7526,26 @@ const chat_email = async function (req, job_id, headers) {
6577
7526
  const conversation_id = conversation_doc._id;
6578
7527
  const sender_app_id = account_profile_info.app_id;
6579
7528
 
6580
- let email_account_doc = await db_module.get_app_couch_doc_native(sender_app_id, account_profile_info.account_profile_obj?.email_account_id);
7529
+ // UI-161: both of these used to fail as a bare CouchDB `{ message: 'missing' }` with no
7530
+ // clue which id or which database was involved, and the job died there, so an email was
7531
+ // never sent and nothing on screen said why. Each lookup now names what it could not
7532
+ // find. Boaz: "i sent test email to B / it hung".
7533
+ const email_account_id = account_profile_info.account_profile_obj?.email_account_id;
7534
+ let email_account_doc;
7535
+ try {
7536
+ email_account_doc = await db_module.get_app_couch_doc_native(sender_app_id, email_account_id);
7537
+ } catch (err) {
7538
+ console.error(`[chat_email] mailbox doc not found: app_id=${sender_app_id} email_account_id=${email_account_id} profile=${account_profile_info.account_profile_obj?._id}`, err?.message || err);
7539
+ throw new Error(`The mailbox attached to this profile could not be loaded (${email_account_id}). Open Email, go to Profiles, and re-attach it.`);
7540
+ }
6581
7541
 
6582
- let sender_conversation_doc = await db_module.get_app_couch_doc_native(sender_app_id, conversation_id);
7542
+ let sender_conversation_doc;
7543
+ try {
7544
+ sender_conversation_doc = await db_module.get_app_couch_doc_native(sender_app_id, conversation_id);
7545
+ } catch (err) {
7546
+ console.error(`[chat_email] conversation doc not found: app_id=${sender_app_id} conversation_id=${conversation_id}`, err?.message || err);
7547
+ throw new Error(`This conversation could not be loaded (${conversation_id}).`);
7548
+ }
6583
7549
 
6584
7550
  if (sender_conversation_doc.reference_type !== 'contacts') {
6585
7551
  throw new Error('not an contact conversation');
@@ -6750,6 +7716,17 @@ const chat_email = async function (req, job_id, headers) {
6750
7716
 
6751
7717
  return save_ret;
6752
7718
  } catch (err) {
7719
+ // UI-161: this used to swallow the reason, so a send that died anywhere in here surfaced
7720
+ // as a bare CouchDB `{ message: 'missing' }` on stderr with nothing tying it to a
7721
+ // conversation, a contact or a mailbox. The job then ended and no email went out.
7722
+ console.error(`[chat_email] failed: conversation_id=${conversation_doc?._id} contact=${conversation_doc?.reference_id} app_id=${account_profile_info?.app_id} profile=${account_profile_info?.account_profile_obj?._id} mailbox=${account_profile_info?.account_profile_obj?.email_account_id}`, err?.stack || err?.message || err);
7723
+ // UI-161: an expired or revoked OAuth grant is the single most common way sending stops
7724
+ // working, and "Failed to obtain valid access token" tells the person reading it nothing
7725
+ // about what to do. Name the mailbox and the fix.
7726
+ if (/access token|invalid_grant|unauthorized|invalid credentials/i.test(String(err?.message || ''))) {
7727
+ const mailbox_address = email_account_doc?.email || account_profile_info?.account_profile_obj?.email_account_id || 'this mailbox';
7728
+ return { code: -15, data: `Xuda can no longer sign in to ${mailbox_address}, so the email was not sent. Open Email, go to Mailboxes, and reconnect the account.` };
7729
+ }
6753
7730
  return { code: -15, data: err.message };
6754
7731
  }
6755
7732
  };
@@ -7009,11 +7986,8 @@ ${conversation_history || `User (studio): ${prompt}`}
7009
7986
 
7010
7987
  thinking_index += 1;
7011
7988
  try {
7012
- if (job_id) {
7013
- const job_info = await jobs_ms.get_job_info({ job_id });
7014
- if (job_info.code < 0 || job_info.data.stat === 4) {
7015
- return;
7016
- }
7989
+ if (await is_job_aborted(job_id)) {
7990
+ return;
7017
7991
  }
7018
7992
  } catch (error) {}
7019
7993
 
@@ -8035,16 +9009,18 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
8035
9009
  tools: cpi_tools_ret.tools,
8036
9010
  });
8037
9011
 
9012
+ // Running, not Starting: the label stands for the whole call, which for a deck or a video
9013
+ // is a minute and a half. See the matching handler on the chat agent (UI-197).
8038
9014
  agent.on('agent_tool_start', (context, tool) => {
8039
- emitToDashboard('stream_phase', `Starting ${tool?.name?.replaceAll('_', ' ')} tool`, { update: true });
9015
+ emitToDashboard('stream_phase', `Running ${tool?.name?.replaceAll('_', ' ')}`, { update: true });
8040
9016
  });
8041
9017
 
8042
- // Without this the phase keeps shimmering "Starting <tool> tool" after the tool has
9018
+ // Without this the phase keeps shimmering "Running <tool>" after the tool has
8043
9019
  // already returned, so a finished deck reads as still being built. done:true settles
8044
9020
  // the line to a checkmark, and it also means the NEXT tool opens its own line instead
8045
9021
  // of overwriting this one, which is how a multi-tool run becomes a readable trail.
8046
9022
  agent.on('agent_tool_end', (context, tool) => {
8047
- emitToDashboard('stream_phase', `Finished ${tool?.name?.replaceAll('_', ' ')} tool`, { update: true, done: true });
9023
+ emitToDashboard('stream_phase', `Finished ${tool?.name?.replaceAll('_', ' ')}`, { update: true, done: true });
8048
9024
  });
8049
9025
 
8050
9026
  emitToDashboard('stream_phase', 'Submitting dashboard request', { update: true });
@@ -8055,15 +9031,22 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
8055
9031
  if (stream) {
8056
9032
  const output_stream = output.toTextStream({ compatibleWithNodeStreams: true });
8057
9033
  let response_started = false;
8058
- for await (const chunk of output_stream) {
8059
- if (!response_started) {
8060
- response_started = true;
8061
- emitToDashboard('stream_phase', 'Streaming results', { update: true });
8062
- emitToDashboard('response_start');
9034
+ // Same Stop watch the contact/agent chat runs. Without it the dashboard chat streamed
9035
+ // to the end no matter what the user pressed.
9036
+ const stop_abort_watch = watch_job_abort(job_id, output_stream);
9037
+ try {
9038
+ for await (const chunk of output_stream) {
9039
+ if (!response_started) {
9040
+ response_started = true;
9041
+ emitToDashboard('stream_phase', 'Streaming results', { update: true });
9042
+ emitToDashboard('response_start');
9043
+ }
9044
+ const text = chunk.toString();
9045
+ response_text += text;
9046
+ emitToDashboard('stream_delta', text);
8063
9047
  }
8064
- const text = chunk.toString();
8065
- response_text += text;
8066
- emitToDashboard('stream_delta', text);
9048
+ } finally {
9049
+ stop_abort_watch();
8067
9050
  }
8068
9051
  }
8069
9052
 
@@ -8107,6 +9090,26 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
8107
9090
  } catch (e) {}
8108
9091
  return { code: -1, data: { error: 'credit_limit', account_id: err.account_id } };
8109
9092
  }
9093
+ // The user pressed Stop. That is not a failure: say so plainly, flag the stream
9094
+ // end as aborted (the chat-finished alert reads that flag to stay quiet) and skip
9095
+ // the error card and the error log.
9096
+ if ((typeof err === 'string' ? err : err?.message) === 'aborted') {
9097
+ emitToDashboard('stream_phase', 'Stopped', { update: true });
9098
+ emitToDashboard('response_start');
9099
+ streamText('Stopped.');
9100
+ emitToDashboard('stream_end', undefined, { aborted: true });
9101
+
9102
+ try {
9103
+ conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
9104
+ conversation_doc.ts = Date.now();
9105
+ conversation_doc.stat = 3;
9106
+ conversation_doc.process_stat = 'partial';
9107
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, conversation_doc);
9108
+ } catch (e) {}
9109
+
9110
+ return { code: -4, data: 'aborted' };
9111
+ }
9112
+
8110
9113
  const error_message = get_error_message(err, 'dashboard request failed');
8111
9114
  // Never echo the raw error to the client — it can leak provider/quota/host
8112
9115
  // details (e.g. the OpenAI 429 quota text). Log it; show a clean card via the
@@ -8260,7 +9263,23 @@ export const set_agent_tool_consent = async (req) => {
8260
9263
  }
8261
9264
  };
8262
9265
 
8263
- const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_type, prompt_suggestion_activated, chat_suggestion_activated, gtp_token, uid, account_profile_info, job_id, headers, context, app_id }) {
9266
+ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_type, prompt_suggestion_activated, chat_suggestion_activated, agent_explicitly_selected, gtp_token, uid, account_profile_info, job_id, headers, context, app_id }) {
9267
+ // Does this run get the agent's REAL tools, or only the read-only ones?
9268
+ //
9269
+ // The gate below exists so a plain chat cannot fire an agent's write-capable tools at an
9270
+ // account the user never pointed it at. Three cases always qualified: talking to the agent
9271
+ // directly, a prompt suggestion, a clicked suggestion card. The gap was the fourth, and it
9272
+ // is the one people actually use: turning Agent Mode on and PICKING agents. Every tool the
9273
+ // useful agents own is `plugin` (Presentation Builder is create_pptx + modify_pptx +
9274
+ // read_pptx, all plugin), so a picked agent was marked ineligible, dropped from the handoff
9275
+ // list, and the triage router answered alone with no tools. That is why "Update
9276
+ // /Presentations/deck.pptx: make it fancy" came back as a confident paragraph describing an
9277
+ // edit that never happened: nothing could edit anything.
9278
+ //
9279
+ // Explicitly named agents are the user choosing, exactly like opening the agent's own chat.
9280
+ // Auto mode (an EMPTY ai_agents array, meaning "consider all of them") is NOT a choice and
9281
+ // stays gated, so the safety property the gate was written for survives.
9282
+ const agent_tools_allowed = reference_type === 'ai_agents' || prompt_suggestion_activated || chat_suggestion_activated || agent_explicitly_selected;
8264
9283
  let tools = [];
8265
9284
  let tool_resources = {};
8266
9285
  let eligible_agent = true;
@@ -8270,7 +9289,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
8270
9289
  const consent_required = [];
8271
9290
 
8272
9291
  const add_xuda_public_website_tool = function ({ name, description, origin, path_prefix }) {
8273
- if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
9292
+ if (!agent_tools_allowed) {
8274
9293
  eligible_agent = false;
8275
9294
  return;
8276
9295
  }
@@ -8551,7 +9570,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
8551
9570
  }
8552
9571
 
8553
9572
  case 'mcp': {
8554
- if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
9573
+ if (!agent_tools_allowed) {
8555
9574
  eligible_agent = false;
8556
9575
  break;
8557
9576
  }
@@ -8572,7 +9591,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
8572
9591
  }
8573
9592
 
8574
9593
  case 'cpi': {
8575
- if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
9594
+ if (!agent_tools_allowed) {
8576
9595
  eligible_agent = false;
8577
9596
  break;
8578
9597
  }
@@ -8671,7 +9690,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
8671
9690
  }
8672
9691
 
8673
9692
  case 'full_stack_vps': {
8674
- if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
9693
+ if (!agent_tools_allowed) {
8675
9694
  eligible_agent = false;
8676
9695
  break;
8677
9696
  }
@@ -8723,7 +9742,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
8723
9742
  }
8724
9743
 
8725
9744
  case 'image_generate': {
8726
- if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
9745
+ if (!agent_tools_allowed) {
8727
9746
  eligible_agent = false;
8728
9747
  break;
8729
9748
  }
@@ -8798,7 +9817,7 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
8798
9817
  }
8799
9818
 
8800
9819
  case 'ai_agent': {
8801
- if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
9820
+ if (!agent_tools_allowed) {
8802
9821
  eligible_agent = false;
8803
9822
  break;
8804
9823
  }
@@ -8818,22 +9837,31 @@ const get_ai_agent_tools = async function ({ ai_agent_doc, agent_id, reference_t
8818
9837
 
8819
9838
  const user_agents = await db_module.find_app_couch_query(ai_agent_doc?.reference_doc?.studio_meta?.shared_from_app_id || ai_agent_doc?.reference_doc?.studio_meta?.installed_from_app_id || app_id, opt);
8820
9839
  for (const agent_doc of user_agents.docs) {
9840
+ // These are studio docs: the name lives on properties.menuName and the prompt on
9841
+ // agentConfig.agent_instructions. Reading the flat agent_name / agent_instructions
9842
+ // off the doc gave undefined, and `undefined.substring(0, 60)` threw. get_agents
9843
+ // catches per agent, so the throw took the WHOLE agent out of the run list and the
9844
+ // turn died with agent_unavailable. Anything reached only from the agent /
9845
+ // suggestion paths, which is the only place this case runs, failed that way.
9846
+ const sub_name = agent_doc.agent_name || agent_doc?.agentConfig?.agent_name || agent_doc?.properties?.menuName || agent_doc._id;
9847
+ const sub_instructions = agent_doc.agent_instructions || agent_doc?.agentConfig?.agent_instructions || '';
9848
+ if (!sub_name) continue;
8821
9849
  const agent = new Agent({
8822
- name: agent_doc.agent_name.substring(0, 60),
8823
- instructions: agent_doc.agent_instructions,
9850
+ name: String(sub_name).substring(0, 60),
9851
+ instructions: sub_instructions,
8824
9852
  });
8825
9853
 
8826
9854
  tools.push(
8827
9855
  agent.asTool({
8828
- toolName: agent_doc.agent_name,
8829
- toolDescription: agent_doc.agent_instructions,
9856
+ toolName: String(sub_name),
9857
+ toolDescription: sub_instructions,
8830
9858
  }),
8831
9859
  );
8832
9860
  }
8833
9861
  break;
8834
9862
  }
8835
9863
  case 'plugin': {
8836
- if (reference_type !== 'ai_agents' && !prompt_suggestion_activated && !chat_suggestion_activated) {
9864
+ if (!agent_tools_allowed) {
8837
9865
  eligible_agent = false;
8838
9866
  break;
8839
9867
  }
@@ -8943,6 +9971,12 @@ const ai_chat_conversation = async function (req, job_id, headers) {
8943
9971
  const reference_id = conversation_doc.reference_id;
8944
9972
 
8945
9973
  const activate_prompt_suggestions = _.isArray(local_ai_agents) && conversation_doc.reference_type !== 'ai_agents' && !conversation_item_id;
9974
+ // Did the caller NAME the agents, or just switch Agent Mode on? An empty array means
9975
+ // "consider all of them" (auto), which is not a choice; a populated one is the user picking
9976
+ // specific agents in the composer, and that is what earns those agents their real tools in
9977
+ // an ordinary chat. Read off the raw request value: local_ai_agents is about to be filled
9978
+ // with every agent on the account in the auto case, which would erase the difference.
9979
+ const agent_explicitly_selected = _.isArray(ai_agents) && ai_agents.length > 0;
8946
9980
  let prompt_suggestion_activated;
8947
9981
  let chat_suggestion_activated;
8948
9982
  let model = ai_model;
@@ -8953,8 +9987,14 @@ const ai_chat_conversation = async function (req, job_id, headers) {
8953
9987
  // await _utils.delay(1000);
8954
9988
  const conversation_id = conversation_doc._id;
8955
9989
 
9990
+ // Repair an interrupted turn before reusing the thread. See drop_orphaned_tool_calls: a
9991
+ // function_call left without its output kills the whole conversation permanently. The page is
9992
+ // handed over rather than re-fetched, since it was already being read here for the cursor.
8956
9993
  const prev_conversation_items = await client.conversations.items.list(conversation_doc.reference_conversation_id, { order: 'desc' });
8957
- const last_conversation_item = prev_conversation_items?.data?.[0]?.id;
9994
+ const surviving_conversation_items = await drop_orphaned_tool_calls(conversation_doc.reference_conversation_id, prev_conversation_items?.data || []);
9995
+ // Read off the SURVIVING items. This id is the `after` cursor for "what did this turn add",
9996
+ // and a cursor pointing at an item we just deleted is not one.
9997
+ const last_conversation_item = surviving_conversation_items?.[0]?.id;
8958
9998
 
8959
9999
  const prompt_conversation_item_id = await _common.xuda_get_uuid('chat_conversation_item');
8960
10000
  const response_conversation_item_id = await _common.xuda_get_uuid('chat_conversation_item');
@@ -9100,7 +10140,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9100
10140
  let interval = setInterval(async function () {
9101
10141
  const job_info = await jobs_ms.get_job_info({ job_id });
9102
10142
 
9103
- if (job_info.code < 0 || job_info.data.stat === 4) {
10143
+ if (job_info.code < 0 || job_info?.data?.abort === true || job_info.data.stat === 4) {
9104
10144
  clearInterval(interval);
9105
10145
  resolve({ code: -1, data: 'aborted' });
9106
10146
  return;
@@ -9153,8 +10193,14 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9153
10193
  emitToDashboard('stream_phase', 'Analyzing request', { update: true });
9154
10194
 
9155
10195
  const init_agent_hooks = function (agent) {
10196
+ // UI-197: the request is away, so stop claiming to be submitting it. Between the submit
10197
+ // and the first token a reasoning model can sit silent for twenty seconds or more, and
10198
+ // for the whole of it the trail used to read "Submitting chat", which describes work
10199
+ // that finished long ago. The next event (a tool starting, or the first chunk) relabels
10200
+ // this same line, so this costs one extra phase and never a stray trail entry.
9156
10201
  agent.on('agent_start', (context, agent) => {
9157
10202
  // emitToDashboard('stream_start');
10203
+ emitToDashboard('stream_phase', 'Thinking', { update: true });
9158
10204
  });
9159
10205
 
9160
10206
  agent.on('agent_end', (context, output) => {
@@ -9166,15 +10212,19 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9166
10212
  emitToDashboard('agent_handoff', nextAgent.name);
9167
10213
  });
9168
10214
 
10215
+ // "Running", not "Starting": this label is what the user looks at for the WHOLE tool
10216
+ // call, and a plugin that generates a video or builds a deck holds it for a minute and a
10217
+ // half. "Starting ..." shimmering for ninety seconds reads as a request that never got
10218
+ // going. The chat now prints how long the step has been running next to it (UI-197).
9169
10219
  agent.on('agent_tool_start', (context, tool, details) => {
9170
10220
  // emitToDashboard('agent_tool_start', tool.name);
9171
- emitToDashboard('stream_phase', `Starting ${tool?.name?.replaceAll('_', ' ')} tool`, { update: true });
10221
+ emitToDashboard('stream_phase', `Running ${tool?.name?.replaceAll('_', ' ')}`, { update: true });
9172
10222
  });
9173
10223
 
9174
10224
  // See the matching handler on the dashboard agent above: a tool that finished has to
9175
- // say so, or the phase shimmers "Starting ..." for the rest of the response.
10225
+ // say so, or the phase shimmers "Running ..." for the rest of the response.
9176
10226
  agent.on('agent_tool_end', (context, tool, result, details) => {
9177
- emitToDashboard('stream_phase', `Finished ${tool?.name?.replaceAll('_', ' ')} tool`, { update: true, done: true });
10227
+ emitToDashboard('stream_phase', `Finished ${tool?.name?.replaceAll('_', ' ')}`, { update: true, done: true });
9178
10228
  });
9179
10229
  };
9180
10230
  const get_agent_instructions = function (is_agent) {
@@ -9201,11 +10251,18 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9201
10251
 
9202
10252
  const triage_assistant = `
9203
10253
 
9204
- You are Nissim AI Xuda assistant a smart triage router.
10254
+ You are Nissim AI Xuda assistant, a smart triage router.
9205
10255
  Your job is to instantly decide who should handle the user's request:
9206
- - If the question is general or can be answered directly, respond yourself.
9207
- - If it requires a specific specialist, route it to them.
9208
- Only fall back to web search as an absolute last resort — never use it unless strictly necessary.
10256
+ - If a specialist can do it, hand off to them. Do it silently and immediately: transferring
10257
+ IS the answer, so never reply describing the handoff, offering to route, or listing scope
10258
+ options first. The specialist asks its own questions if it needs to.
10259
+ - Only answer yourself when no specialist covers the request and you can settle it from
10260
+ what you already know.
10261
+ - You hold no tools of your own. Anything that has to READ or CHANGE a real thing, a file,
10262
+ a deck, a document, a spreadsheet, a record, or that needs current information from the
10263
+ web, can only be done by a specialist. Saying you have done it is a lie: hand off.
10264
+ - Take the request at face value. If the user asks for a web search, that is not a last
10265
+ resort, it is the request.
9209
10266
 
9210
10267
  ${ai_agents ? 'Never offer suggestions at the end of response' : ''}
9211
10268
 
@@ -9241,8 +10298,18 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9241
10298
  "Hi Sarah! You're Sarah Cohen, our VIP client from Tel Aviv, account #89231. You've been with us since 2022 and usually reach out about trading or portfolio updates. How can I help you today?"
9242
10299
  `.trim();
9243
10300
 
9244
- return is_agent ? identity : triage_assistant + identity;
10301
+ // UI-196: teach this path the clarifying-questions protocol. The dashboard CPI agent and
10302
+ // the vibe path have spoken it for a while; the chat agents never did, so a Shorts Maker
10303
+ // that needed to know which voice to use printed its five options as a markdown list and
10304
+ // waited for the user to type one back. Appended for the routed agent and the triage
10305
+ // agent alike, since either can be the one that ends up needing to ask.
10306
+ return (is_agent ? identity : triage_assistant + identity) + '\n\n' + CLARIFYING_QUESTIONS_INSTRUCTION;
9245
10307
  };
10308
+ // Display name -> agent DOC id, for the run's own agents. The SDK's Agent keeps only the
10309
+ // fields it declares (name, instructions, handoffs, tools, ...), so the `metadata` we pass
10310
+ // is dropped and `_currentAgent` comes back carrying nothing but the name. This is how the
10311
+ // saved conversation item still records which agent answered by id.
10312
+ const agent_id_by_name = {};
9246
10313
  const get_agents = async function () {
9247
10314
  if (reference_type === 'ai_agents') {
9248
10315
  local_ai_agents = [reference_id];
@@ -9261,9 +10328,13 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9261
10328
  const list = [];
9262
10329
 
9263
10330
  let modelSettings = {};
9264
- let eligible_agent = true;
9265
10331
 
9266
10332
  for (const agent_id of local_ai_agents) {
10333
+ // Per agent, NOT once for the loop. This used to be declared outside and AND-ed with
10334
+ // each agent's result, so it could only ever go false: the first ineligible agent
10335
+ // dropped every agent after it too, however eligible those were. On an account with a
10336
+ // few agents that silently emptied the handoff list.
10337
+ let eligible_agent = true;
9267
10338
  try {
9268
10339
  let ai_agent_doc = await load_ai_agent_doc(account_profile_info.app_id, agent_id);
9269
10340
 
@@ -9283,6 +10354,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9283
10354
  reference_type,
9284
10355
  prompt_suggestion_activated,
9285
10356
  chat_suggestion_activated,
10357
+ agent_explicitly_selected,
9286
10358
  gtp_token,
9287
10359
  uid,
9288
10360
  account_profile_info,
@@ -9296,10 +10368,20 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9296
10368
  eligible_agent = eligible_agent && tools_ret.eligible_agent;
9297
10369
  // if (!tools.length) continue;
9298
10370
  // console.log('tool', tools);
9299
- // const agent_name = `${ai_agent_doc._id} ${ai_agent_doc?.properties?.menuName || ''}`;
9300
- const agent_name = `${ai_agent_doc._id}`;
10371
+ // The name is what the ROUTER sees. The SDK builds the handoff tool from it:
10372
+ // `transfer_to_<name>`, described as "Handoff to the <name> agent to handle the
10373
+ // request. <handoffDescription>". Naming it after the doc id gave the router
10374
+ // `transfer_to_agn_102e783c859d` with an empty description, so it had no way to know
10375
+ // one of those ids writes PowerPoint and another searches the web, and it answered
10376
+ // everything itself. Use the agent's own name, and give the SDK the description it
10377
+ // has been appending to nothing. Tool names allow [a-zA-Z0-9_-], so anything else in
10378
+ // a user-chosen name becomes an underscore before the SDK sees it.
10379
+ const agent_display_name = ai_agent_doc?.agentConfig?.agent_name || ai_agent_doc?.reference_doc?.properties?.menuName || ai_agent_doc?.properties?.menuName || '';
10380
+ const agent_name = (agent_display_name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || `${ai_agent_doc._id}`).substring(0, 55);
10381
+ agent_id_by_name[agent_name] = ai_agent_doc?.reference_doc?._id || ai_agent_doc._id;
9301
10382
  const agent = new Agent({
9302
- name: agent_name.substring(0, 55),
10383
+ name: agent_name,
10384
+ handoffDescription: (ai_agent_doc?.agentConfig?.agent_description || ai_agent_doc?.agentConfig?.agent_instructions || '').slice(0, 300),
9303
10385
  instructions:
9304
10386
  ai_agent_doc.agentConfig.agent_instructions +
9305
10387
  (reference_type === 'ai_agents' ? get_agent_instructions() : '') +
@@ -9372,7 +10454,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9372
10454
  clearInterval(interval);
9373
10455
  resolve(null);
9374
10456
  } else {
9375
- if (job_info.code < 0 || job_info.data.stat === 4) {
10457
+ if (job_info.code < 0 || job_info?.data?.abort === true || job_info.data.stat === 4) {
9376
10458
  reject('aborted');
9377
10459
  clearInterval(interval);
9378
10460
  return;
@@ -9396,9 +10478,7 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9396
10478
  const run_agent = function (_agent, prompt, opt) {
9397
10479
  return new Promise(async (resolve, reject) => {
9398
10480
  let interval = setInterval(async function () {
9399
- const job_info = await jobs_ms.get_job_info({ job_id });
9400
-
9401
- if (job_info.code < 0 || job_info.data.stat === 4) {
10481
+ if (await is_job_aborted(job_id)) {
9402
10482
  reject(new Error('aborted'));
9403
10483
  clearInterval(interval);
9404
10484
  }
@@ -9457,14 +10537,24 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9457
10537
  }
9458
10538
  ///////////
9459
10539
 
9460
- if (reference_type === 'ai_agents' || prompt_suggestion_activated || chat_suggestion_activated) {
10540
+ // Picking exactly ONE agent in the composer is the same instruction as opening that
10541
+ // agent's own chat: run it. It used to build a triage router over a list of one, and the
10542
+ // router answered the request itself instead of handing off, so a deliberate
10543
+ // "Update /Presentations/deck.pptx: make it fancy" addressed at Presentation Builder came
10544
+ // back as a paragraph offering to route it, from an agent holding none of the pptx tools.
10545
+ // Several named agents still get a router, because then there IS a routing decision.
10546
+ const run_single_named_agent = agent_explicitly_selected && local_ai_agents.length === 1;
10547
+
10548
+ if (reference_type === 'ai_agents' || prompt_suggestion_activated || chat_suggestion_activated || run_single_named_agent) {
9461
10549
  _agent = agents[0];
9462
10550
  // get_agents() can come back empty (the agent doc failed to load, or every tool it needs
9463
10551
  // is unavailable in this scope). Running with no agent used to throw deep inside the
9464
10552
  // runner and freeze the chat, so fail here with something the user can read.
9465
10553
  if (!_agent) throw 'agent_unavailable';
9466
10554
 
9467
- set_ts_to_agent();
10555
+ // Only meaningful when the thread IS the agent (it stamps reference_id); on a plain
10556
+ // chat there is no agent doc at reference_id to stamp.
10557
+ if (reference_type === 'ai_agents') set_ts_to_agent();
9468
10558
  } else {
9469
10559
  // 3. Build triage agent that can hand off
9470
10560
  _agent = new Agent({
@@ -9477,13 +10567,25 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9477
10567
  });
9478
10568
  }
9479
10569
 
10570
+ // A clicked suggestion runs ON the thread, so it gets the thread. This used to pass ''
10571
+ // ("no context needed"), which cost it the conversation the user was looking at: picking
10572
+ // "Craft pitch slide" under a Xuda pitch deck produced a generic "Introduction to
10573
+ // PowerPoint Basics" deck because the agent could not see a single earlier message.
10574
+ //
10575
+ // `conversation` and `previous_response_id` are mutually exclusive at the API, and the
10576
+ // SDK only drops previous_response_id when conversationId is TRUTHY while forwarding
10577
+ // `conversation` unconditionally, so the '' also went out ALONGSIDE previous_response_id
10578
+ // and every click came back 400 "Mutually exclusive parameters: ''", surfaced as
10579
+ // "I couldn't complete that just now". Sending the thread fixes both: the response the
10580
+ // suggestion hangs off is already in it. The response id stays only as the fallback for
10581
+ // a thread with no conversation object yet.
9480
10582
  let opt = {
9481
- conversationId: chat_suggestion_activated ? '' : conversation_doc.reference_conversation_id, // no context needed chat_suggestion_activated
10583
+ conversationId: conversation_doc.reference_conversation_id,
9482
10584
  context,
9483
10585
  stream,
9484
10586
  };
9485
10587
 
9486
- if (chat_suggestion_activated) {
10588
+ if (chat_suggestion_activated && !opt.conversationId) {
9487
10589
  opt.previousResponseId = conversation_item_doc.conversation_item_reference_id;
9488
10590
  }
9489
10591
 
@@ -9492,6 +10594,30 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9492
10594
  init_agent_hooks(_agent);
9493
10595
  // const output = await runner.run(_agent, prompt, opt);
9494
10596
  const output = await run_agent(_agent, prompt, opt);
10597
+
10598
+ // UI-196: pull the structured clarifying-questions block off the answer, the same protocol
10599
+ // the dashboard CPI agent and the vibe path already use. Until now only those two spoke
10600
+ // it, so an AGENT that needed to ask something wrote its options as a markdown bullet
10601
+ // list ("Available voices you can switch to: alloy, echo, fable...") and the user had to
10602
+ // type one back by hand. The chat already knows how to render a picker for this; the
10603
+ // agent just was never told the protocol. Boaz: "refer to how the questions are prompted
10604
+ // in the aiChat component, so whenever u have a question render it like that".
10605
+ //
10606
+ // Resolved through a function rather than inline here because with `stream: true` the
10607
+ // runner returns as soon as the stream is OPEN: `_currentStep.output` is not the finished
10608
+ // answer until the read loop below has drained it. Called once the text is whole, which
10609
+ // is after that loop, and stream_end then carries the CLEANED prose in `text`:
10610
+ // handleStreamEnd overwrites the visible bubble with it, so the block that was streamed
10611
+ // out chunk by chunk never stays on screen as raw JSON.
10612
+ let final_output_text = '';
10613
+ let chat_questions = null;
10614
+ const resolve_answer = function () {
10615
+ const raw_output_text = output?.state?._currentStep?.output ?? '';
10616
+ const parsed = extract_xuda_questions(raw_output_text);
10617
+ chat_questions = parsed.questions;
10618
+ final_output_text = chat_questions ? parsed.prose || raw_output_text : raw_output_text;
10619
+ };
10620
+
9495
10621
  const done = async function (output) {
9496
10622
  try {
9497
10623
  // const obj = { id: output.state._lastTurnResponse.responseId, ts: Date.now(), ai_agent_id: output.state._currentAgent.name, attachments, conversation_type: 'ai_chat' };
@@ -9521,16 +10647,24 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9521
10647
  date_created_ts: Date.now(),
9522
10648
  ts: Date.now(),
9523
10649
  conversation_id,
9524
- text: output.state._currentStep.output,
10650
+ // The prose only. The questions ride alongside in their own field so a reload
10651
+ // rebuilds the picker instead of printing the JSON block back into the bubble.
10652
+ text: final_output_text,
9525
10653
  reference_id: conversation_doc.reference_id,
9526
10654
  conversation_item_reference_id: output.state._lastTurnResponse.responseId,
9527
10655
  direction: 'in',
9528
10656
  role: 'assistant',
9529
- ai_agent_id: output.state._currentAgent.name,
10657
+ // The agent's DOC id, off the metadata stamped when it was built, not its display
10658
+ // name. get_chat_suggestions compares this against agent ids to leave the agent that
10659
+ // just answered out of the next suggestions, and that comparison only worked while
10660
+ // the name happened to BE the id. Falls back to the name for the triage agent, which
10661
+ // has no doc behind it.
10662
+ ai_agent_id: agent_id_by_name[output.state._currentAgent?.name] || output.state._currentAgent.name,
9530
10663
  job_id,
9531
10664
  prompt_conversation_item_id,
9532
10665
  prompt_suggestions,
9533
10666
  prompt_selected_suggestion,
10667
+ ...(chat_questions ? { questions: chat_questions } : {}),
9534
10668
  };
9535
10669
 
9536
10670
  // if (activate_prompt_suggestions) {
@@ -9581,36 +10715,40 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9581
10715
 
9582
10716
  // 1. Consume the stream exactly once
9583
10717
  let string_debug = '';
9584
- let chunks = 0;
9585
- for await (const chunk of output_stream) {
9586
- // console.log(chunks);
9587
- if (chunks % 10 === 0) {
9588
- const job_info = await jobs_ms.get_job_info({ job_id });
9589
- if (job_info.code < 0 || job_info.data.stat === 4) {
9590
- throw 'aborted';
9591
- }
9592
- }
9593
- chunks++;
9594
- if (!response_start) {
9595
- response_start = true;
10718
+ // Stop is watched on its own timer rather than per chunk: a chunk-counted check is
10719
+ // blind while the model is thinking, and it put a broker round trip in the middle of
10720
+ // the stream. The watch cuts the reader, which throws into the abort path below.
10721
+ const stop_abort_watch = watch_job_abort(job_id, output_stream);
10722
+ try {
10723
+ for await (const chunk of output_stream) {
10724
+ if (!response_start) {
10725
+ response_start = true;
9596
10726
 
9597
- // await update_job('streaming results');
9598
- emitToDashboard('stream_phase', 'Streaming results');
9599
- emitToDashboard('response_start');
10727
+ // await update_job('streaming results');
10728
+ emitToDashboard('stream_phase', 'Streaming results');
10729
+ emitToDashboard('response_start');
10730
+ }
10731
+ const text = chunk.toString();
10732
+ // console.log('[CHUNK]', text);
10733
+ emitToDashboard('stream_delta', text);
10734
+ string_debug += text;
10735
+ // Accumulate it
10736
+ buffer += chunk;
9600
10737
  }
9601
- const text = chunk.toString();
9602
- // console.log('[CHUNK]', text);
9603
- emitToDashboard('stream_delta', text);
9604
- string_debug += text;
9605
- // Accumulate it
9606
- buffer += chunk;
10738
+ } finally {
10739
+ stop_abort_watch();
9607
10740
  }
9608
10741
  // console.log('string_debug', string_debug);
9609
- emitToDashboard('stream_end');
10742
+ resolve_answer();
10743
+ emitToDashboard('stream_end', undefined, chat_questions ? { questions: chat_questions, text: final_output_text } : undefined);
9610
10744
  }
9611
10745
  // else {
9612
10746
  // await update_job('finalizing');
9613
10747
 
10748
+ // Non-streaming runs never reached the resolve above, and `done` persists whatever it
10749
+ // finds in final_output_text, so an empty one would save an empty answer.
10750
+ if (!stream) resolve_answer();
10751
+
9614
10752
  const save_ret = await done(output);
9615
10753
 
9616
10754
  return {
@@ -9632,6 +10770,17 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9632
10770
  const reason = typeof err === 'string' ? err : err?.message || String(err);
9633
10771
  const aborted = reason === 'aborted';
9634
10772
 
10773
+ // The AI PROVIDER's account is out of credit or quota. Worth telling apart from every other
10774
+ // failure, for two reasons. First, "Please try again in a moment" is untrue: a moment will
10775
+ // not fix it, and the card hands the user a Try again button that cannot ever succeed, so
10776
+ // they sit there pressing it. Second, it is not the same thing as the customer running out
10777
+ // of THEIR Xuda credits (validate_credits_limit, checked before the run, with its own
10778
+ // "top up" message), so it must not send them to a billing page that is not the problem.
10779
+ // This is our bill, and the only person who can act on it is whoever owns the platform
10780
+ // account. dev sat on this for an entire session: every chat came back as the generic card
10781
+ // while the log underneath said "You have no credits remaining" 59 times.
10782
+ const provider_out_of_credit = /no credits remaining|insufficient[_ ]quota|exceeded your current quota|billing_hard_limit/i.test(reason);
10783
+
9635
10784
  // A bare stream_end is dropped by the client: handleStreamDelta ignores deltas with no
9636
10785
  // streaming bubble, and handleStreamEnd returns early when that bubble has no text, so the
9637
10786
  // chat keeps ticking on its last phase forever. Open the bubble, say what happened, then
@@ -9641,15 +10790,69 @@ const ai_chat_conversation = async function (req, job_id, headers) {
9641
10790
  emitToDashboard(
9642
10791
  'stream_delta',
9643
10792
  aborted
9644
- ? 'Stopped.'
9645
- : reason === 'agent_unavailable'
9646
- ? "This agent isn't available right now. Please try again in a moment."
9647
- : "I couldn't complete that just now. Please try again in a moment.",
10793
+ ? // Mid-answer, this delta lands straight onto the last word the model wrote
10794
+ // ("...movable typeStopped."), so break the line first.
10795
+ response_started
10796
+ ? '\n\nStopped.'
10797
+ : 'Stopped.'
10798
+ : provider_out_of_credit
10799
+ ? // Never the provider's own sentence: it names the vendor and links their billing
10800
+ // page, neither of which is the customer's business or any use to them.
10801
+ 'The AI service is unavailable right now. This is a problem on our side, not with your account, and retrying will not help until it is fixed.'
10802
+ : reason === 'agent_unavailable'
10803
+ ? "This agent isn't available right now. Please try again in a moment."
10804
+ : "I couldn't complete that just now. Please try again in a moment.",
9648
10805
  );
9649
10806
  // `aborted` is flagged rather than left undefined so the chat-finished alert can tell
9650
10807
  // a stopped run from a finished one. The client only reads specific keys off params,
9651
- // so the extra flag changes nothing it renders.
9652
- emitToDashboard('stream_end', undefined, aborted ? { aborted: true } : { error: true });
10808
+ // so the extra flag changes nothing it renders. `unretryable` is what drops the Try again
10809
+ // button on the error card: offering it for a failure that cannot succeed is worse than
10810
+ // offering nothing, because the user reads the button as "this might work".
10811
+ emitToDashboard('stream_end', undefined, aborted ? { aborted: true } : { error: true, ...(provider_out_of_credit ? { unretryable: true } : {}) });
10812
+
10813
+ // PERSIST the outcome, do not leave it living only on the socket. Everything above is a
10814
+ // websocket push and nothing else: the failure was never written to the thread. So a user
10815
+ // whose socket had dropped, who was on another tab, or who simply reloaded, was left with
10816
+ // their own message and NOTHING under it, which is indistinguishable from a request that
10817
+ // is still thinking. That is exactly what Boaz saw: two prompts, no reply, no error, on a
10818
+ // conversation the server had already closed 4 seconds in.
10819
+ //
10820
+ // Same id the stream used (response_conversation_item_id, already announced on every
10821
+ // emit), so the client's live bubble and the reloaded item are the same message rather
10822
+ // than two. `is_request_error` is what the chat reads to render the error card; a STOPPED
10823
+ // run is not an error, so it keeps the partial answer as an ordinary message, matching
10824
+ // what handleStreamEnd does with the same flags live.
10825
+ //
10826
+ // Its own try/catch: a thread that fails to record a failure must still return the
10827
+ // failure, never a save error thrown from inside the error path.
10828
+ try {
10829
+ await db_module.save_app_couch_doc_native(account_profile_info.app_id, {
10830
+ _id: response_conversation_item_id,
10831
+ stat: 3,
10832
+ docType: 'chat_conversation_item',
10833
+ uid,
10834
+ conversation_type: 'ai_chat',
10835
+ type: 'ai_chat',
10836
+ date_created_ts: Date.now(),
10837
+ ts: Date.now(),
10838
+ conversation_id,
10839
+ // Whatever actually reached the user: on an abort that is the partial answer plus
10840
+ // "Stopped.", on a failure the sentence emitted just above. Built by emitToDashboard
10841
+ // itself, so the stored message cannot drift from the streamed one.
10842
+ text: stream_delta_text,
10843
+ reference_id: conversation_doc.reference_id,
10844
+ direction: 'in',
10845
+ role: 'assistant',
10846
+ job_id,
10847
+ prompt_conversation_item_id,
10848
+ ...(aborted ? { aborted: true } : { is_request_error: true }),
10849
+ // Survives a reload, so a thread reopened tomorrow still shows the card without the
10850
+ // dead button rather than growing one back.
10851
+ ...(provider_out_of_credit ? { unretryable: true } : {}),
10852
+ });
10853
+ } catch (save_err) {
10854
+ console.error('[ai_chat_conversation] failed to persist failure item:', save_err?.message || save_err);
10855
+ }
9653
10856
 
9654
10857
  // Same rule for the HTTP body as for the stream. The raw reason is not safe to hand back:
9655
10858
  // a failed couch call reports the connection string, and that string carries the admin
@@ -9973,6 +11176,15 @@ Do not mention that the reply is automated.
9973
11176
  Use the conversation history for context.
9974
11177
  Return only the email body.`;
9975
11178
 
11179
+ // Same repair the chat path does, for the same reason: this thread is reused on every
11180
+ // incoming message, so one interrupted tool call permanently kills automatic replies to
11181
+ // that contact and nothing here would ever say why. Worse than in the chat, in fact: there
11182
+ // is no person watching to notice it stopped answering and no Retry to press, the replies
11183
+ // just quietly stop. Unlike the chat path there is no item list already in hand, so this
11184
+ // one costs a list call. An auto reply happens once per incoming message, and the price of
11185
+ // skipping it is a contact who never hears back again.
11186
+ await drop_orphaned_tool_calls(contact_doc.contact_reference_conversation_id);
11187
+
9976
11188
  const output = await runner.run(active_agent, prompt, {
9977
11189
  conversationId: contact_doc.contact_reference_conversation_id,
9978
11190
  context,
@@ -10462,7 +11674,11 @@ const get_plugin_tool = async function (app_id, uid, plugin_name, method, prop_d
10462
11674
  }
10463
11675
  };
10464
11676
 
10465
- const create_and_upload_image_to_drive = async function (drive_type, file_path, file_name, prompt, numGenerations, uid, app_id, job_id, headers = {}, is_system, tags = [], account_profile_info, width = 256, height = 256) {
11677
+ // UI-210: `opts.pad` (a 0-1 share, or true for the default) puts the generated subject on a
11678
+ // transparent canvas with a margin around it before upload, for callers whose picture is
11679
+ // drawn full-bleed and therefore needs the room to be in the artwork. Additive and last, so
11680
+ // every existing positional call is untouched.
11681
+ const create_and_upload_image_to_drive = async function (drive_type, file_path, file_name, prompt, numGenerations, uid, app_id, job_id, headers = {}, is_system, tags = [], account_profile_info, width = 256, height = 256, opts = {}) {
10466
11682
  try {
10467
11683
  // 1. Generate the images
10468
11684
  let result = await create_image(uid, prompt, undefined, undefined, numGenerations, width, height, { drive_type, file_path, file_name }, account_profile_info);
@@ -10482,6 +11698,12 @@ const create_and_upload_image_to_drive = async function (drive_type, file_path,
10482
11698
  data = matches[2];
10483
11699
  }
10484
11700
 
11701
+ // UI-210: opt-in margin around the subject, for pictures drawn full-bleed.
11702
+ if (opts.pad) {
11703
+ data = await pad_transparent_subject(data, typeof opts.pad === 'number' ? { scale: opts.pad } : {});
11704
+ ext = 'png';
11705
+ }
11706
+
10485
11707
  // B. Write to temp file
10486
11708
  const buffer = Buffer.from(data, 'base64');
10487
11709
  const originalname = file_name ? `${file_name}.${ext}` : `generated_${Date.now()}_${index}.${ext}`;
@@ -10627,49 +11849,34 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
10627
11849
  const tempOutputPath = path.join(tempDir, `output_${uniqueId}.png`);
10628
11850
  const { is_user } = metadata;
10629
11851
  let filename = email || _id || name;
10630
- const inspect_person_in_image = async function (base64) {
10631
- const ret = await submit_chat_gpt_prompt({
10632
- uid,
10633
- model: _conf.default_ai_model,
10634
- prompt: [
10635
- {
10636
- role: 'user',
10637
- content: [
10638
- {
10639
- type: 'input_text',
10640
- text: `Inspect this profile image. Return whether it contains a real human portrait suitable for an authentic account avatar. Mark it unsuitable if the face is not visible, side-facing, heavily cropped, very blurry, tiny, or not a real person.`,
10641
- },
10642
- {
10643
- type: 'input_image',
10644
- image_url: `data:image/png;base64,${base64}`,
10645
- },
10646
- ],
10647
- },
10648
- ],
10649
- response_format: z.object({
10650
- is_real_person_in_picture: z.boolean().describe('true if a real human portrait is visible'),
10651
- is_front_facing: z.boolean().describe('true if the face is mostly front-facing or only slightly angled'),
10652
- is_face_too_cropped: z.boolean().describe('true if important face/head parts are cut off'),
10653
- is_too_blurry: z.boolean().describe('true if the face is too blurry for an authentic avatar'),
10654
- is_too_small: z.boolean().describe('true if the portrait is too small or low-detail'),
10655
- needs_restoration: z.boolean().describe('true if the photo is old, scanned, scratched, faded, grainy, low-detail, or otherwise degraded such that face-restoration would noticeably improve it'),
10656
- }),
10657
- metadata: { _id, func: 'detect_real_person_in_image' },
10658
- account_profile_info,
10659
- });
10660
-
10661
- const res = JSON5.parse(ret.data);
10662
- return {
10663
- is_real_person_in_picture: Boolean(res?.is_real_person_in_picture),
10664
- is_front_facing: Boolean(res?.is_front_facing),
10665
- is_face_too_cropped: Boolean(res?.is_face_too_cropped),
10666
- is_too_blurry: Boolean(res?.is_too_blurry),
10667
- is_too_small: Boolean(res?.is_too_small),
10668
- needs_restoration: Boolean(res?.needs_restoration),
10669
- };
11852
+ // inspect_profile_picture tests ret.code before reading it; this one did not.
11853
+ // submit_chat_gpt_prompt reports a FAILED call as
11854
+ // { code: -5, data: err.message }, putting prose in the very field the success
11855
+ // path fills with JSON, so a rate limit or a model hiccup arrived here as a
11856
+ // sentence and JSON5 threw on a word of it. The throw escaped all the way to
11857
+ // get_profile_avatar's outer catch, which turned a transient AI failure into a
11858
+ // dead avatar job reading "JSON5: invalid character 'Y' at 1:5".
11859
+ //
11860
+ // Which way to fail matters more than the guard. The caller sends anything it
11861
+ // reads as not-a-person down the FICTIONAL path, so an all-false default would
11862
+ // answer an AI outage by inventing a face and putting it on a Level 2 account
11863
+ // that has just proved whose face it should be. Not knowing must never mean
11864
+ // "not a person": the photo that reaches here has already passed
11865
+ // inspect_profile_picture in the picture window, which is what establishes
11866
+ // that it IS a photograph of a person. So an unknown verdict falls towards
11867
+ // their own photo, and the worst case becomes a plain cut-out of it.
11868
+ const PERSON_INSPECTION_UNKNOWN = {
11869
+ is_real_person_in_picture: true,
11870
+ is_front_facing: true,
11871
+ is_face_too_cropped: false,
11872
+ is_too_blurry: false,
11873
+ is_too_small: false,
11874
+ // No restoration on a guess: it costs a second image round trip and is only
11875
+ // worth spending on evidence.
11876
+ needs_restoration: false,
10670
11877
  };
10671
11878
 
10672
- const detect_face_box = async function (base64) {
11879
+ const inspect_person_in_image = async function (base64) {
10673
11880
  try {
10674
11881
  const ret = await submit_chat_gpt_prompt({
10675
11882
  uid,
@@ -10680,32 +11887,44 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
10680
11887
  content: [
10681
11888
  {
10682
11889
  type: 'input_text',
10683
- text: `Return the bounding box of the visible face in this portrait, from the top of the head (including hair) to just below the chin, ear to ear. Use normalized coordinates in [0,1] where (0,0) is the top-left corner and (1,1) is the bottom-right corner of the image.`,
11890
+ text: `Inspect this profile image. Return whether it contains a real human portrait suitable for an authentic account avatar. Mark it unsuitable if the face is not visible, side-facing, heavily cropped, very blurry, tiny, or not a real person.`,
11891
+ },
11892
+ {
11893
+ type: 'input_image',
11894
+ image_url: `data:image/png;base64,${base64}`,
10684
11895
  },
10685
- { type: 'input_image', image_url: `data:image/png;base64,${base64}` },
10686
11896
  ],
10687
11897
  },
10688
11898
  ],
10689
11899
  response_format: z.object({
10690
- face_top: z.number().min(0).max(1).describe('Top edge of face bbox, normalized'),
10691
- face_left: z.number().min(0).max(1).describe('Left edge of face bbox, normalized'),
10692
- face_width: z.number().min(0).max(1).describe('Width of face bbox, normalized'),
10693
- face_height: z.number().min(0).max(1).describe('Height of face bbox, normalized'),
11900
+ is_real_person_in_picture: z.boolean().describe('true if a real human portrait is visible'),
11901
+ is_front_facing: z.boolean().describe('true if the face is mostly front-facing or only slightly angled'),
11902
+ is_face_too_cropped: z.boolean().describe('true if important face/head parts are cut off'),
11903
+ is_too_blurry: z.boolean().describe('true if the face is too blurry for an authentic avatar'),
11904
+ is_too_small: z.boolean().describe('true if the portrait is too small or low-detail'),
11905
+ needs_restoration: z.boolean().describe('true if the photo is old, scanned, scratched, faded, grainy, low-detail, or otherwise degraded such that face-restoration would noticeably improve it'),
10694
11906
  }),
10695
- metadata: { _id, func: 'detect_face_box' },
11907
+ metadata: { _id, func: 'detect_real_person_in_image' },
10696
11908
  account_profile_info,
10697
11909
  });
11910
+
11911
+ if (!ret || ret.code < 0) {
11912
+ console.error('inspect_person_in_image failed:', ret?.data);
11913
+ return PERSON_INSPECTION_UNKNOWN;
11914
+ }
11915
+
10698
11916
  const res = JSON5.parse(ret.data);
10699
- if (!res || typeof res.face_height !== 'number' || res.face_height <= 0) return null;
10700
11917
  return {
10701
- face_top: Number(res.face_top) || 0,
10702
- face_left: Number(res.face_left) || 0,
10703
- face_width: Number(res.face_width) || 0,
10704
- face_height: Number(res.face_height) || 0,
11918
+ is_real_person_in_picture: Boolean(res?.is_real_person_in_picture),
11919
+ is_front_facing: Boolean(res?.is_front_facing),
11920
+ is_face_too_cropped: Boolean(res?.is_face_too_cropped),
11921
+ is_too_blurry: Boolean(res?.is_too_blurry),
11922
+ is_too_small: Boolean(res?.is_too_small),
11923
+ needs_restoration: Boolean(res?.needs_restoration),
10705
11924
  };
10706
11925
  } catch (err) {
10707
- console.error('detect_face_box failed:', err.message);
10708
- return null;
11926
+ console.error('inspect_person_in_image failed:', err.message);
11927
+ return PERSON_INSPECTION_UNKNOWN;
10709
11928
  }
10710
11929
  };
10711
11930
 
@@ -10929,10 +12148,12 @@ export const get_profile_avatar = async function (profile_picture, uid, prompt,
10929
12148
  const can_create_authentic_avatar = source_quality.is_usable && person_inspection.is_real_person_in_picture && !person_inspection.is_too_blurry && !person_inspection.is_too_small;
10930
12149
 
10931
12150
  if (can_create_authentic_avatar) {
10932
- const face_box = await detect_face_box(imageBase64);
12151
+ // No face box any more. The framing is measured off the cut-out
12152
+ // itself (frameSubjectAsAvatar), so asking a vision model where the
12153
+ // face is bought nothing but a round trip and a number that could be
12154
+ // wrong — and being wrong is what cropped a real avatar's head off.
10933
12155
  imageBase64 = await normalizeAuthenticProfileAvatar(imageBase64, {
10934
12156
  remove_background: !image_blob_ret.is_transparent,
10935
- face_box,
10936
12157
  });
10937
12158
  avatar_source = 'authentic profile';
10938
12159
  } else {
@@ -11202,6 +12423,9 @@ export const conversation_actions = async function (req, job_id, headers) {
11202
12423
  switch (action) {
11203
12424
  case 'print':
11204
12425
  case 'download': {
12426
+ // UI-202: an answer leaving the chat as a file. Same row for both, because print and
12427
+ // download are the same pdf taking two roads out.
12428
+ log_chat_activity(uid, conversation_id, 'answer_downloaded', { by: 'user', action }, account_profile_info.app_id);
11205
12429
  return {
11206
12430
  code: 20,
11207
12431
  data: '',
@@ -11225,6 +12449,9 @@ export const conversation_actions = async function (req, job_id, headers) {
11225
12449
 
11226
12450
  const drive_ret = await drive_ms.upload_drive_file_user({ uid, path: '/' }, job_id, headers, file_obj);
11227
12451
 
12452
+ // UI-202
12453
+ log_chat_activity(uid, conversation_id, 'answer_saved_to_drive', { by: 'user', filename: originalname }, account_profile_info.app_id);
12454
+
11228
12455
  return {
11229
12456
  code: 20,
11230
12457
  data: drive_ret.data,
@@ -11411,7 +12638,9 @@ const create_ai_agent_image = async function (req, job_id, headers) {
11411
12638
  `;
11412
12639
 
11413
12640
  const generate_faceless = async () => {
11414
- const images_arr = await create_and_upload_image_to_drive('studio', 'Progs Thumbnails', ai_agent_id, faceless_prompt, 1, uid, app_id, job_id, headers, false, tags, account_profile_info, 1024, 1024);
12641
+ // UI-210: pad, same as the owner-portrait path below. Both end up on the same card,
12642
+ // drawn full-bleed, so both need the room to be in the artwork.
12643
+ const images_arr = await create_and_upload_image_to_drive('studio', 'Progs Thumbnails', ai_agent_id, faceless_prompt, 1, uid, app_id, job_id, headers, false, tags, account_profile_info, 1024, 1024, { pad: true });
11415
12644
  return { code: 1, data: images_arr[0] };
11416
12645
  };
11417
12646
 
@@ -11487,7 +12716,8 @@ const create_ai_agent_image = async function (req, job_id, headers) {
11487
12716
  Use a consistent palette and emphasize intelligence, clarity, and sophistication.
11488
12717
  `;
11489
12718
 
11490
- const images_arr = await create_and_upload_image_to_drive('studio', 'Progs Thumbnails', ai_agent_id, fallback_prompt, 1, uid, app_id, job_id, headers, false, tags, account_profile_info);
12719
+ // UI-210: the moderation fallback lands on the same card, so it gets the same margin.
12720
+ const images_arr = await create_and_upload_image_to_drive('studio', 'Progs Thumbnails', ai_agent_id, fallback_prompt, 1, uid, app_id, job_id, headers, false, tags, account_profile_info, 1024, 1024, { pad: true });
11491
12721
  report_ai_status(model, err);
11492
12722
  return { code: 1, data: images_arr[0] };
11493
12723
  }
@@ -11496,13 +12726,10 @@ const create_ai_agent_image = async function (req, job_id, headers) {
11496
12726
  }
11497
12727
  imageBase64 = ai_avatar_response.data[0].b64_json;
11498
12728
  account_msa.record_ai_usage(uid, ai_avatar_response.usage.input_tokens, ai_avatar_response.usage.output_tokens, 'agent avatar', prompt, model, { ai_agent_id }, account_profile_info);
12729
+ // UI-210: pad the figure onto its canvas instead of only normalizing the size, so the
12730
+ // card draws it with room around it rather than edge to edge.
11499
12731
  console.log('Normalizing final avatar to 1024 with transparent padding...');
11500
- imageBase64 = await normalizeBase64To1024(imageBase64);
11501
-
11502
- // imageBase64 = await normalizeBase64To1024(imageBase64, {
11503
- // fit: 'contain', // Preserve aspect ratio, fit within 211x211
11504
- // background: { r: 0, g: 0, b: 0, alpha: 0 }, // Transparent background for padding
11505
- // });
12732
+ imageBase64 = await pad_transparent_subject(await normalizeBase64To1024(imageBase64));
11506
12733
 
11507
12734
  const outputBuffer = Buffer.from(imageBase64, 'base64');
11508
12735
  const outputPath = tempOutputPath;
@@ -11646,6 +12873,9 @@ export const pin_ai_chat = async function (req) {
11646
12873
 
11647
12874
  const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
11648
12875
 
12876
+ // UI-202
12877
+ log_chat_activity(uid, conversation_id, 'pinned', { by: 'user' }, account_profile_info.app_id);
12878
+
11649
12879
  ws_dashboard_msa.emit_message_to_dashboard({
11650
12880
  service: 'ai_chat_pinned',
11651
12881
  to: [uid],
@@ -11671,6 +12901,9 @@ export const unpin_ai_chat = async function (req) {
11671
12901
 
11672
12902
  const save_ret = await db_module.save_app_couch_doc(account_profile_info.app_id, conversation_doc);
11673
12903
 
12904
+ // UI-202
12905
+ log_chat_activity(uid, conversation_id, 'unpinned', { by: 'user' }, account_profile_info.app_id);
12906
+
11674
12907
  ws_dashboard_msa.emit_message_to_dashboard({
11675
12908
  service: 'ai_chat_unpinned',
11676
12909
  to: [uid],
@@ -11748,6 +12981,9 @@ export const pin_ai_agent = async function (req, job_id, headers) {
11748
12981
 
11749
12982
  const save_ret = await db_module.save_app_couch_doc(app_id, ai_agent_doc);
11750
12983
 
12984
+ // UI-78
12985
+ log_agent_activity(uid, agent_id, 'pinned', { by: 'user' }, app_id);
12986
+
11751
12987
  ws_dashboard_msa.emit_message_to_dashboard({
11752
12988
  service: 'ai_agent_pinned',
11753
12989
  to: [uid],
@@ -11772,6 +13008,9 @@ export const unpin_ai_agent = async function (req, job_id, headers) {
11772
13008
 
11773
13009
  const save_ret = await db_module.save_app_couch_doc(app_id, ai_agent_doc);
11774
13010
 
13011
+ // UI-78
13012
+ log_agent_activity(uid, agent_id, 'unpinned', { by: 'user' }, app_id);
13013
+
11775
13014
  ws_dashboard_msa.emit_message_to_dashboard({
11776
13015
  service: 'ai_agent_unpinned',
11777
13016
  to: [uid],
@@ -11896,6 +13135,10 @@ export const add_transcript_conversation_item = async function (uid, profile_id,
11896
13135
  try {
11897
13136
  const conversation_item_reference_id = await add_conversation_item(uid, profile_id, conversation_id, `File name: ${filename} with content: ${transcript?.data || transcript || ''}`, conversation_type, reference_id, {});
11898
13137
 
13138
+ // UI-202: a file read into the thread. The message that lands is the transcript, so the
13139
+ // name of the file it came from is only recoverable from here.
13140
+ log_chat_activity(uid, conversation_id, 'transcript_added', { by: 'ai', filename });
13141
+
11899
13142
  report_ai_status('conversations');
11900
13143
  return conversation_item_reference_id;
11901
13144
  } catch (err) {
@@ -12196,81 +13439,78 @@ async function inspectAvatarSourceQuality(base64Image) {
12196
13439
  };
12197
13440
  }
12198
13441
 
13442
+ // The avatar spec, in the module's own words. get_profile_avatar states it in
13443
+ // the prompt it hands the image model on the fictional path: "person in the
13444
+ // center of the picture ... return only a centered head-and-shoulders portrait
13445
+ // facing the camera ... add top margin ... the person should cover the whole
13446
+ // picture", on a transparent background.
13447
+ //
13448
+ // That describes the PRODUCT, not one route to it, so this route — which reaches
13449
+ // the same result with sharp instead of a model — has to land in the same place.
13450
+ // It did not. It framed to a "passport" geometry of its own invention: the head
13451
+ // at 62% of the frame, sized and positioned from a vision model's face box, with
13452
+ // no margin guaranteed anywhere. Two of the four requirements were missed
13453
+ // outright, and when the face box came back short the crop took the crown off
13454
+ // the top of a real account's avatar.
13455
+ //
13456
+ // These two numbers are the "top margin" and a little air at the sides.
13457
+ // Everything else the spec asks for — centred, covering the picture,
13458
+ // transparent — falls out of the composition rather than being tuned.
13459
+ const AVATAR_SIZE = 1024;
13460
+ const AVATAR_TOP_MARGIN = 0.06;
13461
+ const AVATAR_SIDE_MARGIN = 0.02;
13462
+
13463
+ // Frame a background-removed portrait to that spec.
13464
+ //
13465
+ // Measured from the SUBJECT, never from a face box. The background is already
13466
+ // gone by this point, so the cut-out's own bounds say exactly where the person
13467
+ // is — no model in the loop, and nothing that can under-report.
13468
+ async function frameSubjectAsAvatar(segmentedBuffer) {
13469
+ const bounds = await measureOpaqueBounds(segmentedBuffer);
13470
+ if (!bounds) return sharp(segmentedBuffer).png({ force: true }).toBuffer();
13471
+
13472
+ // The subject's own edges rather than every opaque pixel, on BOTH axes. A
13473
+ // speck above the head shrinks the person to make room for a stray pixel; a
13474
+ // speck beside them widens the frame and pushes them off centre, which breaks
13475
+ // the one requirement that is hardest to notice going wrong.
13476
+ const left = bounds.bodyMinX;
13477
+ const top = bounds.crownY;
13478
+ const subjectW = bounds.bodyMaxX - left + 1;
13479
+ const subjectH = bounds.footY - top + 1;
13480
+ if (subjectW < 2 || subjectH < 2) return sharp(segmentedBuffer).png({ force: true }).toBuffer();
13481
+
13482
+ const subject = await sharp(segmentedBuffer).ensureAlpha().extract({ left, top, width: subjectW, height: subjectH }).png({ force: true }).toBuffer();
13483
+
13484
+ // "cover the whole picture": scaled to fill the frame apart from the margins,
13485
+ // so the person is as large as the spec allows instead of sitting at some
13486
+ // fraction of it. Aspect ratio preserved — the smaller of the two fits wins.
13487
+ const scale = Math.min((AVATAR_SIZE * (1 - AVATAR_SIDE_MARGIN * 2)) / subjectW, (AVATAR_SIZE * (1 - AVATAR_TOP_MARGIN)) / subjectH);
13488
+ const w = Math.max(1, Math.round(subjectW * scale));
13489
+ const h = Math.max(1, Math.round(subjectH * scale));
13490
+ const resized = await sharp(subject).resize(w, h, { fit: 'fill' }).png({ force: true }).toBuffer();
13491
+
13492
+ // Centred left to right, the margin above the crown, the shoulders running to
13493
+ // the bottom edge — which is what head-and-shoulders covering the frame looks
13494
+ // like. The clamp only matters for a subject wider than it is tall.
13495
+ return sharp({ create: { width: AVATAR_SIZE, height: AVATAR_SIZE, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
13496
+ .composite([{ input: resized, left: Math.round((AVATAR_SIZE - w) / 2), top: Math.min(Math.round(AVATAR_SIZE * AVATAR_TOP_MARGIN), AVATAR_SIZE - h) }])
13497
+ .png({ force: true })
13498
+ .toBuffer();
13499
+ }
13500
+
12199
13501
  async function normalizeAuthenticProfileAvatar(base64Image, options = {}) {
12200
- const { remove_background = true, face_box = null } = options;
13502
+ const { remove_background = true } = options;
12201
13503
  const inputBuffer = Buffer.from(base64Image, 'base64');
12202
13504
  const orientedBuffer = await sharp(inputBuffer).rotate().png({ force: true }).toBuffer();
12203
- const orientedMeta = await sharp(orientedBuffer).metadata();
12204
13505
  const segmentedBuffer = remove_background ? await removePortraitBackground(orientedBuffer) : orientedBuffer;
12205
13506
 
12206
- const passportBuffer = face_box ? await cropToPassportFrame(segmentedBuffer, face_box, orientedMeta) : await cropToOpaqueBounds(segmentedBuffer);
13507
+ const framedBuffer = await frameSubjectAsAvatar(segmentedBuffer);
12207
13508
 
12208
- const subjectBuffer = await sharp(passportBuffer).modulate({ brightness: 1.02, saturation: 1.04 }).sharpen({ sigma: 0.35, m1: 0.4, m2: 0.2 }).png({ quality: 98, compressionLevel: 8, force: true }).toBuffer();
13509
+ const subjectBuffer = await sharp(framedBuffer).modulate({ brightness: 1.02, saturation: 1.04 }).sharpen({ sigma: 0.35, m1: 0.4, m2: 0.2 }).png({ quality: 98, compressionLevel: 8, force: true }).toBuffer();
12209
13510
 
12210
13511
  return subjectBuffer.toString('base64');
12211
13512
  }
12212
13513
 
12213
- async function cropToPassportFrame(segmentedBuffer, face_box, sourceMeta) {
12214
- const segmentedMeta = await sharp(segmentedBuffer).metadata();
12215
- const sourceW = segmentedMeta.width || sourceMeta.width || 0;
12216
- const sourceH = segmentedMeta.height || sourceMeta.height || 0;
12217
- if (!sourceW || !sourceH) return cropToOpaqueBounds(segmentedBuffer);
12218
-
12219
- const faceCenterX = (face_box.face_left + face_box.face_width / 2) * sourceW;
12220
- const faceCenterY = (face_box.face_top + face_box.face_height / 2) * sourceH;
12221
- const faceHeightPx = face_box.face_height * sourceH;
12222
- if (!(faceHeightPx > 4)) return cropToOpaqueBounds(segmentedBuffer);
12223
-
12224
- const faceFraction = 0.62;
12225
- const frameSize = Math.max(8, Math.round(faceHeightPx / faceFraction));
12226
- const verticalAnchor = 0.42;
12227
- const cropLeft = Math.round(faceCenterX - frameSize / 2);
12228
- const cropTop = Math.round(faceCenterY - frameSize * verticalAnchor);
12229
-
12230
- const padLeft = Math.max(0, -cropLeft);
12231
- const padTop = Math.max(0, -cropTop);
12232
- const padRight = Math.max(0, cropLeft + frameSize - sourceW);
12233
- const padBottom = Math.max(0, cropTop + frameSize - sourceH);
12234
-
12235
- let workingBuffer = segmentedBuffer;
12236
- if (padLeft || padTop || padRight || padBottom) {
12237
- workingBuffer = await sharp(segmentedBuffer)
12238
- .ensureAlpha()
12239
- .extend({
12240
- left: padLeft,
12241
- top: padTop,
12242
- right: padRight,
12243
- bottom: padBottom,
12244
- background: { r: 0, g: 0, b: 0, alpha: 0 },
12245
- })
12246
- .png({ force: true })
12247
- .toBuffer();
12248
- }
12249
-
12250
- const workingMeta = await sharp(workingBuffer).metadata();
12251
- const wW = workingMeta.width || 0;
12252
- const wH = workingMeta.height || 0;
12253
-
12254
- const wantLeft = cropLeft + padLeft;
12255
- const wantTop = cropTop + padTop;
12256
- const extractLeft = Math.max(0, Math.min(Math.max(0, wW - 1), wantLeft));
12257
- const extractTop = Math.max(0, Math.min(Math.max(0, wH - 1), wantTop));
12258
- const extractWidth = Math.max(1, Math.min(wW - extractLeft, frameSize));
12259
- const extractHeight = Math.max(1, Math.min(wH - extractTop, frameSize));
12260
-
12261
- if (extractWidth <= 0 || extractHeight <= 0) {
12262
- console.warn('cropToPassportFrame: degenerate extract region, falling back', { sourceW, sourceH, frameSize, cropLeft, cropTop, wW, wH });
12263
- return cropToOpaqueBounds(segmentedBuffer);
12264
- }
12265
-
12266
- const framed = await sharp(workingBuffer).extract({ left: extractLeft, top: extractTop, width: extractWidth, height: extractHeight }).png({ force: true }).toBuffer();
12267
-
12268
- return sharp(framed)
12269
- .resize(1024, 1024, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
12270
- .png({ force: true })
12271
- .toBuffer();
12272
- }
12273
-
12274
13514
  async function removePortraitBackground(inputBuffer) {
12275
13515
  const orientedBuffer = await sharp(inputBuffer).rotate().png({ force: true }).toBuffer();
12276
13516
  const inputBlob = new Blob([orientedBuffer], { type: 'image/png' });
@@ -12284,9 +13524,11 @@ async function removePortraitBackground(inputBuffer) {
12284
13524
  return Buffer.from(await outputBlob.arrayBuffer());
12285
13525
  }
12286
13526
 
12287
- async function cropToOpaqueBounds(inputBuffer) {
12288
- const image = sharp(inputBuffer).ensureAlpha();
12289
- const { data, info } = await image.raw().toBuffer({ resolveWithObject: true });
13527
+ // Where the cut-out subject actually sits inside the frame. Shared by the two
13528
+ // framing routes: one crops to it, the other takes only its TOP edge, which on a
13529
+ // portrait whose background has been removed is the crown of the head.
13530
+ async function measureOpaqueBounds(inputBuffer) {
13531
+ const { data, info } = await sharp(inputBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
12290
13532
  const { width, height, channels } = info;
12291
13533
  const alphaThreshold = 8;
12292
13534
 
@@ -12294,31 +13536,68 @@ async function cropToOpaqueBounds(inputBuffer) {
12294
13536
  let minY = height;
12295
13537
  let maxX = -1;
12296
13538
  let maxY = -1;
13539
+ const rowCounts = new Int32Array(height);
13540
+ const colCounts = new Int32Array(width);
12297
13541
 
12298
13542
  for (let y = 0; y < height; y++) {
13543
+ let count = 0;
12299
13544
  for (let x = 0; x < width; x++) {
12300
13545
  const alpha = data[(y * width + x) * channels + (channels - 1)];
12301
13546
  if (alpha > alphaThreshold) {
13547
+ count++;
13548
+ colCounts[x]++;
12302
13549
  if (x < minX) minX = x;
12303
13550
  if (x > maxX) maxX = x;
12304
13551
  if (y < minY) minY = y;
12305
13552
  if (y > maxY) maxY = y;
12306
13553
  }
12307
13554
  }
13555
+ rowCounts[y] = count;
12308
13556
  }
12309
13557
 
12310
- if (maxX < 0 || maxY < 0) {
12311
- return sharp(inputBuffer).png({ force: true }).toBuffer();
12312
- }
13558
+ if (maxX < 0 || maxY < 0) return null;
13559
+
13560
+ // The PERSON, as distinct from every opaque pixel. Segmenters leave specks,
13561
+ // and one stray pixel in a corner puts "the top of the head" in the sky, or
13562
+ // widens the subject so the person sits off-centre inside their own frame.
13563
+ // A body is substantial along a row and a column where a speck is not, so each
13564
+ // edge is the first line carrying real extent — measured against the largest
13565
+ // line of this same subject, which needs no outside estimate of how big the
13566
+ // person is.
13567
+ let widestRow = 0;
13568
+ for (let y = minY; y <= maxY; y++) if (rowCounts[y] > widestRow) widestRow = rowCounts[y];
13569
+ let tallestCol = 0;
13570
+ for (let x = minX; x <= maxX; x++) if (colCounts[x] > tallestCol) tallestCol = colCounts[x];
12313
13571
 
12314
- const padX = Math.round((maxX - minX + 1) * 0.04);
12315
- const padY = Math.round((maxY - minY + 1) * 0.04);
12316
- const cropLeft = Math.max(0, minX - padX);
12317
- const cropTop = Math.max(0, minY - padY);
12318
- const cropWidth = Math.min(width - cropLeft, maxX - minX + 1 + padX * 2);
12319
- const cropHeight = Math.min(height - cropTop, maxY - minY + 1 + padY * 2);
13572
+ const rowFloor = Math.max(4, Math.round(widestRow * 0.08));
13573
+ // Looser on the vertical than on the horizontal, deliberately. The outermost
13574
+ // COLUMNS of a real silhouette are genuinely short — the outer edge of a
13575
+ // shoulder is a few dozen pixels tall — so the row threshold applied here
13576
+ // would shave the person's shoulders off.
13577
+ const colFloor = Math.max(4, Math.round(tallestCol * 0.02));
12320
13578
 
12321
- return sharp(inputBuffer).ensureAlpha().extract({ left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight }).png({ force: true }).toBuffer();
13579
+ const firstIndex = (counts, from, to, floor) => {
13580
+ for (let i = from; i <= to; i++) if (counts[i] >= floor) return i;
13581
+ return from;
13582
+ };
13583
+ const lastIndex = (counts, from, to, floor) => {
13584
+ for (let i = to; i >= from; i--) if (counts[i] >= floor) return i;
13585
+ return to;
13586
+ };
13587
+
13588
+ return {
13589
+ minX,
13590
+ minY,
13591
+ maxX,
13592
+ maxY,
13593
+ // The subject's own edges, specks excluded. crownY is the top of the head.
13594
+ crownY: firstIndex(rowCounts, minY, maxY, rowFloor),
13595
+ footY: lastIndex(rowCounts, minY, maxY, rowFloor),
13596
+ bodyMinX: firstIndex(colCounts, minX, maxX, colFloor),
13597
+ bodyMaxX: lastIndex(colCounts, minX, maxX, colFloor),
13598
+ width,
13599
+ height,
13600
+ };
12322
13601
  }
12323
13602
 
12324
13603
  async function restoreFaceWithOpenAI(base64Image, ctx = {}) {
@@ -12356,6 +13635,54 @@ async function restoreFaceWithOpenAI(base64Image, ctx = {}) {
12356
13635
  return outBase64;
12357
13636
  }
12358
13637
 
13638
+ // UI-210: put the agent's portrait on its canvas with room around it.
13639
+ //
13640
+ // Boaz: "i didnt asked to shrink the big avatar instead recreated with padding". The
13641
+ // generated portrait filled its 1024 frame edge to edge, and the card draws it at
13642
+ // width:100% anchored to the bottom, so the figure ran from under the title straight into
13643
+ // the footer strip and read as a crop. Scaling it down in CSS was the wrong answer: that
13644
+ // leaves a hard-edged picture floating in the card's gradient. The margin belongs in the
13645
+ // artwork, so the file itself has space around the figure and still fills the card.
13646
+ //
13647
+ // trim() first, deliberately: the model leaves an arbitrary transparent border of its own,
13648
+ // so measuring the margin from the raw frame would give a different result every
13649
+ // generation. Trimming to the FIGURE and then padding to a fixed share makes every agent
13650
+ // picture sit the same way.
13651
+ //
13652
+ // Never throws. A picture with no padding is a cosmetic miss; losing the picture is not.
13653
+ const pad_transparent_subject = async function (base64, { canvas = 1024, scale = 0.78 } = {}) {
13654
+ try {
13655
+ const input = Buffer.from(base64, 'base64');
13656
+
13657
+ let trimmed = input;
13658
+ try {
13659
+ trimmed = await sharp(input).trim().png().toBuffer();
13660
+ } catch (err) {
13661
+ // Nothing to trim (or a fully opaque image): pad the original instead.
13662
+ }
13663
+
13664
+ const inner = Math.max(1, Math.round(canvas * scale));
13665
+ const resized = await sharp(trimmed)
13666
+ .resize(inner, inner, { fit: 'inside', withoutEnlargement: false, background: { r: 0, g: 0, b: 0, alpha: 0 } })
13667
+ .png()
13668
+ .toBuffer();
13669
+
13670
+ const meta = await sharp(resized).metadata();
13671
+ const left = Math.max(0, Math.round((canvas - (meta.width || inner)) / 2));
13672
+ const top = Math.max(0, Math.round((canvas - (meta.height || inner)) / 2));
13673
+
13674
+ const out = await sharp({ create: { width: canvas, height: canvas, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
13675
+ .composite([{ input: resized, left, top }])
13676
+ .png({ quality: 98, compressionLevel: 8, force: true })
13677
+ .toBuffer();
13678
+
13679
+ return out.toString('base64');
13680
+ } catch (err) {
13681
+ console.error(`[pad_transparent_subject] ${err?.message || err}`);
13682
+ return base64;
13683
+ }
13684
+ };
13685
+
12359
13686
  async function normalizeBase64To1024(
12360
13687
  base64Image,
12361
13688
  resize = {
@@ -18380,9 +19707,21 @@ export const update_widget_settings = async function (req) {
18380
19707
  if (config[key] === '') delete doc.widget_config[key];
18381
19708
  }
18382
19709
  }
19710
+ const was_enabled = !!doc.widget_enabled;
18383
19711
  doc.ts = Date.now();
18384
19712
  await db_module.save_app_couch_doc_native(account_profile_info.app_id, doc);
18385
19713
 
19714
+ // UI-204: the chat widget is a PUBLIC surface of this profile, and it is written here
19715
+ // rather than through update_account_profile, so the profile trail never saw it. On or
19716
+ // off is the part worth reading back; the styling changes travel as "settings changed".
19717
+ account_msa.log_account_profile_activity({
19718
+ uid,
19719
+ app_id: account_profile_info.app_id,
19720
+ profile_id: account_profile_info.account_profile_id,
19721
+ event: typeof enabled === 'boolean' && enabled !== was_enabled ? (enabled ? 'widget_on' : 'widget_off') : 'widget_settings',
19722
+ detail: { by: 'user' },
19723
+ });
19724
+
18386
19725
  return await get_widget_settings({ uid, profile_id });
18387
19726
  } catch (err) {
18388
19727
  return { code: -1, data: err.message || String(err) };
@@ -18503,9 +19842,21 @@ export const update_contact_form_settings = async function (req) {
18503
19842
  if (config[key] === '') delete doc.contact_form_config[key];
18504
19843
  }
18505
19844
  }
19845
+ const was_enabled = !!doc.contact_form_enabled;
18506
19846
  doc.ts = Date.now();
18507
19847
  await db_module.save_app_couch_doc_native(account_profile_info.app_id, doc);
18508
19848
 
19849
+ // UI-204: same as the widget. The contact form is public and is written straight onto
19850
+ // the profile doc, so without this the profile trail could not answer "when did this
19851
+ // start accepting messages from strangers".
19852
+ account_msa.log_account_profile_activity({
19853
+ uid,
19854
+ app_id: account_profile_info.app_id,
19855
+ profile_id: account_profile_info.account_profile_id,
19856
+ event: typeof enabled === 'boolean' && enabled !== was_enabled ? (enabled ? 'contact_form_on' : 'contact_form_off') : 'contact_form_settings',
19857
+ detail: { by: 'user' },
19858
+ });
19859
+
18509
19860
  return await get_contact_form_settings({ uid, profile_id });
18510
19861
  } catch (err) {
18511
19862
  return { code: -1, data: err.message || String(err) };