@yeaft/webchat-agent 1.0.38 → 1.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.38",
3
+ "version": "1.0.40",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -206,6 +206,7 @@ function serializeMessage(msg) {
206
206
  // routing can filter/replay by thread without rescanning JSON blobs.
207
207
  // Defaults to 'main' for legacy messages (see migrate-messages-threadid.js).
208
208
  fm.push(`threadId: ${msg.threadId || 'main'}`);
209
+ if (msg.turnId) fm.push(`turnId: ${msg.turnId}`);
209
210
  // task-313: when a thread is merged into another, the messages keep
210
211
  // their original thread id in `sourceThreadId` so the UI can still
211
212
  // render a small "#source" pill next to each bubble.
@@ -216,6 +217,7 @@ function serializeMessage(msg) {
216
217
  // shows an empty pane.
217
218
  if (msg.sessionId) fm.push(`sessionId: ${msg.sessionId}`);
218
219
  if (msg.chatId) fm.push(`chatId: ${msg.chatId}`);
220
+ if (msg.clientMessageId) fm.push(`clientMessageId: ${msg.clientMessageId}`);
219
221
  // Session attribution: when a VP authors an assistant turn (either
220
222
  // its own reply or a route_forward injection from another VP), stamp
221
223
  // the speaker so the UI can render the message on the correct VP track.
@@ -333,9 +335,11 @@ export function parseMessage(raw) {
333
335
  case 'isError': msg.isError = value === 'true'; break;
334
336
  case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
335
337
  case 'threadId': msg.threadId = value; break;
338
+ case 'turnId': msg.turnId = value; break;
336
339
  case 'sourceThreadId': msg.sourceThreadId = value; break;
337
340
  case 'sessionId': msg.sessionId = value; break;
338
341
  case 'chatId': msg.chatId = value; break;
342
+ case 'clientMessageId': msg.clientMessageId = value; break;
339
343
  case 'speakerVpId': msg.speakerVpId = value; break;
340
344
  case 'attachmentsB64':
341
345
  try {
package/yeaft/engine.js CHANGED
@@ -95,6 +95,36 @@ const RETRY_DEFAULTS = Object.freeze({
95
95
  jitterRatio: 0.25,
96
96
  });
97
97
 
98
+ const EXPLICIT_SKILL_COMMAND_RE = /^\/skill:([^\s]+)(\s+|$)/;
99
+
100
+ function parseExplicitSkillCommand(prompt) {
101
+ if (typeof prompt !== 'string') {
102
+ return { skillName: null, cleanedPrompt: prompt };
103
+ }
104
+ const match = prompt.match(EXPLICIT_SKILL_COMMAND_RE);
105
+ if (!match) {
106
+ return { skillName: null, cleanedPrompt: prompt };
107
+ }
108
+ return {
109
+ skillName: match[1],
110
+ cleanedPrompt: prompt.slice(match[0].length),
111
+ };
112
+ }
113
+
114
+ function stripLeadingSkillCommandFromPromptParts(promptParts) {
115
+ if (!Array.isArray(promptParts) || promptParts.length === 0) return promptParts;
116
+ let stripped = false;
117
+ return promptParts.map(part => {
118
+ if (stripped || !part || part.type !== 'text' || typeof part.text !== 'string') {
119
+ return part;
120
+ }
121
+ const parsed = parseExplicitSkillCommand(part.text);
122
+ if (!parsed.skillName) return part;
123
+ stripped = true;
124
+ return { ...part, text: parsed.cleanedPrompt };
125
+ });
126
+ }
127
+
98
128
  function resolveRetryPolicy(config) {
99
129
  const raw = config?.llmRetry || {};
100
130
  const num = (v, d) => (Number.isFinite(v) && v >= 0 ? v : d);
@@ -932,13 +962,21 @@ export class Engine {
932
962
  * @param {string} [args.sessionAnnouncement]
933
963
  * @param {string} [args.projectDoc] — resolved CLAUDE.md / AGENTS.md text (already truncated)
934
964
  * @param {object} [args.taskCtx] — legacy task-context sub-block (optional)
965
+ * @param {string} [args.explicitSkillName] — leading /skill:<name> command, if present
935
966
  * @returns {string}
936
967
  */
937
- #buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, projectDoc, taskCtx, activeTasks } = {}) {
938
- // Get relevant skill content if SkillManager is wired
968
+ #buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, projectDoc, taskCtx, activeTasks, explicitSkillName } = {}) {
969
+ // Get relevant skill content if SkillManager is wired. A leading
970
+ // /skill:<name> is explicit, not relevance matching: load that skill by
971
+ // name or inject a visible prompt warning when the command is unknown.
939
972
  let skillContent = '';
940
- if (this.#skillManager && prompt) {
941
- skillContent = this.#skillManager.getRelevantPromptContent(prompt);
973
+ if (this.#skillManager) {
974
+ if (explicitSkillName) {
975
+ skillContent = this.#skillManager.getPromptContent(explicitSkillName)
976
+ || `## Skill command error\n\nRequested skill "${explicitSkillName}" was not found. Continue without that skill and tell the user it is unavailable.`;
977
+ } else if (prompt) {
978
+ skillContent = this.#skillManager.getRelevantPromptContent(prompt);
979
+ }
942
980
  }
943
981
 
944
982
  // Get tool names from the appropriate source
@@ -1559,7 +1597,7 @@ export class Engine {
1559
1597
  * string-prompt shape (no regression for existing callers).
1560
1598
  * @yields {EngineEvent}
1561
1599
  */
1562
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
1600
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, collabToolPolicy = null } = {}) {
1563
1601
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1564
1602
  yield {
1565
1603
  type: 'error',
@@ -1583,7 +1621,11 @@ export class Engine {
1583
1621
  // the merge, so an invalid caller value (e.g. 'ULTRA') does not shadow a
1584
1622
  // valid prompt prefix.
1585
1623
  const parsed = parseEffortPrefix(prompt);
1586
- const effectivePrompt = parsed.cleanedPrompt;
1624
+ const parsedSkill = parseExplicitSkillCommand(parsed.cleanedPrompt);
1625
+ const effectivePrompt = parsedSkill.cleanedPrompt;
1626
+ const effectivePromptParts = parsedSkill.skillName
1627
+ ? stripLeadingSkillCommandFromPromptParts(promptParts)
1628
+ : promptParts;
1587
1629
  const configuredEffort = normalizeEffort(this.#config?.modelEffort);
1588
1630
  const effectiveUserEffort = normalizeEffort(userEffort) || parsed.effort || configuredEffort || null;
1589
1631
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
@@ -1623,7 +1665,7 @@ export class Engine {
1623
1665
 
1624
1666
  try {
1625
1667
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
1626
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy });
1668
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, threadId: this.#currentThreadId, drainPendingUserMessages, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName });
1627
1669
  } finally {
1628
1670
  if (signal) {
1629
1671
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -1667,7 +1709,7 @@ export class Engine {
1667
1709
  * in a try/finally without indenting the whole loop.
1668
1710
  * @private
1669
1711
  */
1670
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null }) {
1712
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, threadId = MAIN_THREAD_ID, drainPendingUserMessages = null, collabToolPolicy = null, explicitSkillName = null }) {
1671
1713
 
1672
1714
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
1673
1715
  ? collabToolPolicy
@@ -1783,6 +1825,7 @@ export class Engine {
1783
1825
  sessionAnnouncement,
1784
1826
  projectDoc,
1785
1827
  activeTasks,
1828
+ explicitSkillName,
1786
1829
  });
1787
1830
 
1788
1831
  // ─── HARD INVARIANT: Compact ≠ Dream (read DESIGN-COMPACT-VS-DREAM.md) ─
@@ -2790,6 +2833,7 @@ export class Engine {
2790
2833
  // history replay can re-stamp them on reload.
2791
2834
  sessionId,
2792
2835
  threadId,
2836
+ turnId: vpTurnId || queryTurnId,
2793
2837
  vpId: this.#vpId,
2794
2838
  // Multi-VP fan-out (history-dedup): skip the user-row append
2795
2839
  // in stop-hooks when the orchestrator already wrote it once
@@ -65,6 +65,7 @@ export async function runStopHooks(context) {
65
65
  // history replay can route messages back into the originating group.
66
66
  sessionId,
67
67
  threadId,
68
+ turnId,
68
69
  // Group VP attribution: persist the engine-bound VP id on assistant/tool
69
70
  // rows so history replay can route replies back to the same visible VP.
70
71
  vpId,
@@ -167,6 +168,9 @@ export async function runStopHooks(context) {
167
168
  // Bug 6: stamp sessionId / threadId so replay can re-route by group.
168
169
  if (sessionId) record.sessionId = sessionId;
169
170
  if (threadId) record.threadId = threadId;
171
+ if (turnId && (msg.role === 'assistant' || msg.role === 'tool')) {
172
+ record.turnId = turnId;
173
+ }
170
174
  if (vpId && (msg.role === 'assistant' || msg.role === 'tool')) {
171
175
  record.speakerVpId = vpId;
172
176
  }
@@ -73,6 +73,8 @@ import { buildMcpFlattenedTools } from './tools/mcp-tools.js';
73
73
  import { getAgentRegistry, agentBelongsToScope } from './tools/agent.js';
74
74
  import { isPromptableAgentStatus } from './sub-agent/status.js';
75
75
 
76
+ const SKILL_COMMAND_PREFIX = 'skill:';
77
+
76
78
  /** @type {import('./session.js').Session | null} */
77
79
  let session = null;
78
80
 
@@ -710,8 +712,9 @@ function projectPersistedToHistoryEntry(m) {
710
712
  const entry = { role: m.role, content: m.role === 'tool' ? m.content : __testNormalizePersistedVisibleContent(m.content) };
711
713
  if (m.id) entry.id = m.id;
712
714
  entry.threadId = m.threadId || m.turnId || 'main';
713
- entry.turnId = m.turnId || entry.threadId;
715
+ if (m.turnId) entry.turnId = m.turnId;
714
716
  if (m.sessionId) entry.sessionId = m.sessionId;
717
+ if (m.clientMessageId) entry.clientMessageId = m.clientMessageId;
715
718
  if (m.speakerVpId) entry.speakerVpId = m.speakerVpId;
716
719
  if (m.toolCallId) entry.toolCallId = m.toolCallId;
717
720
  if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
@@ -811,8 +814,9 @@ function projectVisibleHistoryChunkMessages(messages = []) {
811
814
  content: m.content,
812
815
  ts: m.ts || null,
813
816
  sessionId: m.sessionId || null,
817
+ ...(m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
814
818
  threadId: m.threadId || m.turnId || 'main',
815
- turnId: m.turnId || m.threadId || 'main',
819
+ ...(m.turnId ? { turnId: m.turnId } : {}),
816
820
  ...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments) } : {}),
817
821
  ...(m.speakerVpId ? { speakerVpId: m.speakerVpId } : {}),
818
822
  ...(Number.isFinite(m.toolSummaryCount) && m.toolSummaryCount > 0
@@ -1390,6 +1394,7 @@ async function routeEnvelopeToVpThread(sessionId, vpId, envelope) {
1390
1394
  attachments: Array.isArray(envelope?.msg?.meta?.attachments) ? envelope.msg.meta.attachments : [],
1391
1395
  internal: isInternalAppend,
1392
1396
  ts: envelope?.msg?.ts || null,
1397
+ clientMessageId: envelope?.msg?.meta?.clientMessageId || null,
1393
1398
  });
1394
1399
  thread.updatedAt = Date.now();
1395
1400
  try {
@@ -1487,6 +1492,7 @@ function ensureDriverRunning(sessionId, vpId, threadId = 'main') {
1487
1492
  attachments: Array.isArray(meta.attachments) ? meta.attachments : [],
1488
1493
  internal: isInternal,
1489
1494
  ts: envelope?.msg?.ts || null,
1495
+ clientMessageId: meta.clientMessageId || null,
1490
1496
  });
1491
1497
  }
1492
1498
  } catch { /* never crash WS pipeline */ }
@@ -1690,6 +1696,37 @@ function sendSessionOutputFrame(data, { sessionId, chatId, vpId, turnId, threadI
1690
1696
  });
1691
1697
  }
1692
1698
 
1699
+ export function buildSkillSlashCommands(skillManager) {
1700
+ if (!skillManager || typeof skillManager.list !== 'function') return { commands: [], descriptions: {} };
1701
+ const commands = [];
1702
+ const descriptions = {};
1703
+ for (const skill of skillManager.list()) {
1704
+ if (!skill?.name || typeof skill.name !== 'string') continue;
1705
+ const commandName = `${SKILL_COMMAND_PREFIX}${skill.name}`;
1706
+ commands.push(commandName);
1707
+ descriptions[commandName] = skill.description || skill.trigger || 'Load Yeaft skill';
1708
+ }
1709
+ commands.sort((a, b) => a.localeCompare(b));
1710
+ return { commands, descriptions };
1711
+ }
1712
+
1713
+ function broadcastSkillSlashCommands(sessionLike) {
1714
+ const { commands, descriptions } = buildSkillSlashCommands(sessionLike?.skillManager);
1715
+ const slashCommands = [...new Set([...(ctx.slashCommands || []), ...commands])];
1716
+ const slashCommandDescriptions = {
1717
+ ...(ctx.slashCommandDescriptions || {}),
1718
+ ...descriptions,
1719
+ };
1720
+ ctx.slashCommands = slashCommands;
1721
+ ctx.slashCommandDescriptions = slashCommandDescriptions;
1722
+ sendToServer({
1723
+ type: 'slash_commands_update',
1724
+ conversationId: '__preload__',
1725
+ slashCommands,
1726
+ slashCommandDescriptions,
1727
+ });
1728
+ }
1729
+
1693
1730
  /** Send a Yeaft Session metadata event over the legacy-compatible envelope. */
1694
1731
  function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId } = {}) {
1695
1732
  sendToServer({
@@ -3119,6 +3156,7 @@ export async function handleYeaftSessionSend(msg) {
3119
3156
  mentions,
3120
3157
  // Persisted form (no base64) — safe for jsonl-log.
3121
3158
  attachments: persistedAttachments,
3159
+ clientMessageId: typeof msg.id === 'string' && msg.id ? msg.id : null,
3122
3160
  },
3123
3161
  // Live form — adapters need the base64 image blocks; runVpTurn
3124
3162
  // reads `_promptParts` off the envelope rather than going
@@ -3375,6 +3413,7 @@ async function ensureSessionLoaded() {
3375
3413
 
3376
3414
  yeaftConversationId = `yeaft-${Date.now()}`;
3377
3415
  hydrateYeaftStatusFromSession(session, { reason: 'session_ready', emitEvent: true });
3416
+ broadcastSkillSlashCommands(session);
3378
3417
 
3379
3418
  // Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
3380
3419
  // — there's no global "all conversations" tape any more.
@@ -3657,6 +3696,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3657
3696
  // would render the user's prompt N times.
3658
3697
  userAlreadyPersisted: true,
3659
3698
  threadId,
3699
+ vpTurnId: turnId,
3660
3700
  drainPendingUserMessages: () => {
3661
3701
  if (!thread || !Array.isArray(thread.pendingQueries) || thread.pendingQueries.length === 0) return [];
3662
3702
  return thread.pendingQueries.splice(0);
@@ -3911,11 +3951,11 @@ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistan
3911
3951
  * refresh replay can render chips without leaking image source data into
3912
3952
  * the message body.
3913
3953
  *
3914
- * @param {{ msgId:string, text:string, sessionId:string, role?:string, speakerVpId?:string|null, attachments?:Array<object>, internal?:boolean, ts?:string|null }} args
3954
+ * @param {{ msgId:string, text:string, sessionId:string, role?:string, speakerVpId?:string|null, attachments?:Array<object>, internal?:boolean, ts?:string|null, clientMessageId?:string|null }} args
3915
3955
  * @returns {boolean} true if this call wrote the row, false if a prior
3916
3956
  * call already wrote it (dedup hit).
3917
3957
  */
3918
- function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = 'main', role, speakerVpId, attachments, internal = false, ts = null }) {
3958
+ function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = 'main', role, speakerVpId, attachments, internal = false, ts = null, clientMessageId = null }) {
3919
3959
  if (!session?.conversationStore) return false;
3920
3960
  // No msgId means no dedup key — caller is responsible for guarding.
3921
3961
  // Both call sites already do (`if (envMsgId && text)` and
@@ -3959,6 +3999,9 @@ function persistInboundMessageOnceByMsgId({ msgId, text, sessionId, threadId = '
3959
3999
  threadId: threadId || 'main',
3960
4000
  };
3961
4001
  if (sessionId) record.sessionId = sessionId;
4002
+ if (persistRole === 'user' && clientMessageId && typeof clientMessageId === 'string') {
4003
+ record.clientMessageId = clientMessageId;
4004
+ }
3962
4005
  // Stamp speakerVpId so the UI's loadHistory replay can route the row
3963
4006
  // to the correct VP block. Only meaningful when role='assistant'; for
3964
4007
  // a real user message we leave it unset (the UI's user track is