@yeaft/webchat-agent 1.0.37 → 1.0.39

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.37",
3
+ "version": "1.0.39",
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",
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
@@ -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) ─
@@ -85,10 +85,10 @@ export function extractPriorPlan(messages, vpId) {
85
85
  }
86
86
 
87
87
  /**
88
- * Return a copy of the messages array with `_meta` stripped from every
89
- * message. The serialisers (anthropic/openai-responses) read this; it is
90
- * NEVER part of the wire payload. Cheap because we only shallow-clone the
91
- * messages that actually have `_meta`.
88
+ * Return a copy of the messages array with engine-private metadata stripped
89
+ * from every message. The serialisers (anthropic/openai-responses) read this;
90
+ * these fields are NEVER part of the wire payload. Cheap because we only
91
+ * shallow-clone the messages that actually have private fields.
92
92
  *
93
93
  * @param {object[]} messages
94
94
  * @returns {object[]}
@@ -97,9 +97,10 @@ export function stripMetaForWire(messages) {
97
97
  if (!Array.isArray(messages)) return messages;
98
98
  let mutated = false;
99
99
  const out = messages.map(m => {
100
- if (m && typeof m === 'object' && '_meta' in m) {
100
+ if (m && typeof m === 'object'
101
+ && ('_meta' in m || '_runtimeTurnId' in m || '_partialTurn' in m)) {
101
102
  mutated = true;
102
- const { _meta, ...rest } = m;
103
+ const { _meta, _runtimeTurnId, _partialTurn, ...rest } = m;
103
104
  return rest;
104
105
  }
105
106
  return m;
@@ -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
 
@@ -1690,6 +1692,37 @@ function sendSessionOutputFrame(data, { sessionId, chatId, vpId, turnId, threadI
1690
1692
  });
1691
1693
  }
1692
1694
 
1695
+ export function buildSkillSlashCommands(skillManager) {
1696
+ if (!skillManager || typeof skillManager.list !== 'function') return { commands: [], descriptions: {} };
1697
+ const commands = [];
1698
+ const descriptions = {};
1699
+ for (const skill of skillManager.list()) {
1700
+ if (!skill?.name || typeof skill.name !== 'string') continue;
1701
+ const commandName = `${SKILL_COMMAND_PREFIX}${skill.name}`;
1702
+ commands.push(commandName);
1703
+ descriptions[commandName] = skill.description || skill.trigger || 'Load Yeaft skill';
1704
+ }
1705
+ commands.sort((a, b) => a.localeCompare(b));
1706
+ return { commands, descriptions };
1707
+ }
1708
+
1709
+ function broadcastSkillSlashCommands(sessionLike) {
1710
+ const { commands, descriptions } = buildSkillSlashCommands(sessionLike?.skillManager);
1711
+ const slashCommands = [...new Set([...(ctx.slashCommands || []), ...commands])];
1712
+ const slashCommandDescriptions = {
1713
+ ...(ctx.slashCommandDescriptions || {}),
1714
+ ...descriptions,
1715
+ };
1716
+ ctx.slashCommands = slashCommands;
1717
+ ctx.slashCommandDescriptions = slashCommandDescriptions;
1718
+ sendToServer({
1719
+ type: 'slash_commands_update',
1720
+ conversationId: '__preload__',
1721
+ slashCommands,
1722
+ slashCommandDescriptions,
1723
+ });
1724
+ }
1725
+
1693
1726
  /** Send a Yeaft Session metadata event over the legacy-compatible envelope. */
1694
1727
  function sendSessionEvent(event, { sessionId, chatId, vpId, turnId, threadId } = {}) {
1695
1728
  sendToServer({
@@ -2577,6 +2610,23 @@ function handleEngineEvent(event, hctx) {
2577
2610
  isError: !!event.isError,
2578
2611
  });
2579
2612
  }
2613
+ if (hctx.sessionId && hctx.turnId && !hctx.skipPartialHistory) {
2614
+ const appendedPrompts = Array.isArray(hctx.appendedUserPrompts) ? hctx.appendedUserPrompts : [];
2615
+ const prompts = hctx.includeInitialPrompt && typeof hctx.prompt === 'string'
2616
+ ? [hctx.prompt, ...appendedPrompts]
2617
+ : appendedPrompts;
2618
+ appendTurnToSessionHistory(
2619
+ hctx.sessionId,
2620
+ hctx.threadId || event.threadId || 'main',
2621
+ hctx.vpId,
2622
+ prompts,
2623
+ hctx.assistantTextParts || [],
2624
+ hctx.toolCallsAccum || [],
2625
+ hctx.toolResultsAccum || [],
2626
+ hctx.thinkingBlocksAccum || [],
2627
+ { turnId: hctx.turnId, partial: true },
2628
+ );
2629
+ }
2580
2630
  sendSessionOutputFrame({
2581
2631
  type: 'user',
2582
2632
  tool_use_result: [{
@@ -3358,6 +3408,7 @@ async function ensureSessionLoaded() {
3358
3408
 
3359
3409
  yeaftConversationId = `yeaft-${Date.now()}`;
3360
3410
  hydrateYeaftStatusFromSession(session, { reason: 'session_ready', emitEvent: true });
3411
+ broadcastSkillSlashCommands(session);
3361
3412
 
3362
3413
  // Per-group history is hydrated lazily on first `getOrCreateSessionHistory`
3363
3414
  // — there's no global "all conversations" tape any more.
@@ -3598,6 +3649,9 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3598
3649
  vpEngine = getOrCreateVpEngine(sessionId, vpId, threadId);
3599
3650
  if (thread) thread.engine = vpEngine;
3600
3651
 
3652
+ const inboundInjectedBy = inboundEnvelope?.msg?.meta?.injectedBy;
3653
+ const inboundIsInternal = inboundInjectedBy === 'route_forward' || inboundInjectedBy === 'task_result';
3654
+
3601
3655
  handlerCtx = {
3602
3656
  assistantTextParts,
3603
3657
  toolCallsAccum,
@@ -3610,6 +3664,9 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3610
3664
  threadId,
3611
3665
  thread,
3612
3666
  appendedUserPrompts,
3667
+ prompt,
3668
+ includeInitialPrompt: !inboundIsInternal,
3669
+ skipPartialHistory: false,
3613
3670
  markTurnEnd,
3614
3671
  };
3615
3672
  // Always trim the snapshot before passing to engine.query. This is
@@ -3651,10 +3708,8 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3651
3708
  // the source VP's tool action. Do not append it as a visible prompt for
3652
3709
  // the target VP turn; otherwise UI replay can show a trailing handoff
3653
3710
  // block after the target response.
3654
- const inboundInjectedBy = inboundEnvelope?.msg?.meta?.injectedBy;
3655
- const inboundIsInternal = inboundInjectedBy === 'route_forward' || inboundInjectedBy === 'task_result';
3656
3711
  const visiblePrompts = inboundIsInternal ? appendedUserPrompts : [prompt, ...appendedUserPrompts];
3657
- appendTurnToSessionHistory(sessionId, threadId, vpId, visiblePrompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum);
3712
+ appendTurnToSessionHistory(sessionId, threadId, vpId, visiblePrompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, { turnId });
3658
3713
 
3659
3714
  sendSessionOutputFrame({
3660
3715
  type: 'assistant',
@@ -3766,8 +3821,9 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3766
3821
  }
3767
3822
 
3768
3823
  /**
3769
- * Atomically append a completed VP-turn's messages to the GROUP'S
3770
- * conversation history. Called once at turn end (not during streaming).
3824
+ * Atomically append a completed or partial VP-turn's messages to the GROUP'S
3825
+ * conversation history. Partial writes are replaced by the final write when
3826
+ * the same runtime turn completes.
3771
3827
  *
3772
3828
  * Note: this does NOT see the engine's collapsed form — it appends the
3773
3829
  * raw user prompt(s) + the per-VP assistant text + tool results. Related
@@ -3779,15 +3835,20 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
3779
3835
  * a session, this in-memory tape carries the un-collapsed form — which
3780
3836
  * is fine because each VP turn's `engine.query` re-collapses on the fly.
3781
3837
  */
3782
- function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum) {
3783
- if (!sessionId) return;
3784
- const history = getOrCreateSessionHistory(sessionId);
3838
+ function buildTurnHistoryEntries(threadId, vpId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, opts = {}) {
3839
+ const entries = [];
3840
+ const runtimeTurnId = typeof opts.turnId === 'string' && opts.turnId ? opts.turnId : null;
3841
+ const markEntry = (entry) => {
3842
+ if (runtimeTurnId) entry._runtimeTurnId = runtimeTurnId;
3843
+ if (opts.partial) entry._partialTurn = true;
3844
+ return entry;
3845
+ };
3785
3846
  const promptList = Array.isArray(prompts) ? prompts : [prompts];
3786
3847
  for (const prompt of promptList) {
3787
3848
  if (typeof prompt === 'string' && prompt.trim()) {
3788
3849
  // user rows intentionally carry NO speakerVpId — every VP in the
3789
3850
  // session should see the prompt in their history.
3790
- history.push({ role: 'user', content: prompt, threadId: threadId || 'main' });
3851
+ entries.push(markEntry({ role: 'user', content: prompt, threadId: threadId || 'main' }));
3791
3852
  }
3792
3853
  }
3793
3854
 
@@ -3821,7 +3882,7 @@ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistan
3821
3882
  : { thinking: tb.thinking, signature: tb.signature }
3822
3883
  ));
3823
3884
  }
3824
- history.push(assistantMsg);
3885
+ entries.push(markEntry(assistantMsg));
3825
3886
 
3826
3887
  for (const tr of toolResultsAccum) {
3827
3888
  const toolMsg = {
@@ -3832,8 +3893,30 @@ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistan
3832
3893
  threadId: threadId || 'main',
3833
3894
  };
3834
3895
  if (vpId) toolMsg.speakerVpId = vpId;
3835
- history.push(toolMsg);
3896
+ entries.push(markEntry(toolMsg));
3897
+ }
3898
+ }
3899
+ return entries;
3900
+ }
3901
+
3902
+ function appendTurnToSessionHistory(sessionId, threadId, vpId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, opts = {}) {
3903
+ if (!sessionId) return;
3904
+ const history = getOrCreateSessionHistory(sessionId);
3905
+ const nextEntries = buildTurnHistoryEntries(threadId, vpId, prompts, assistantTextParts, toolCallsAccum, toolResultsAccum, thinkingBlocksAccum, opts);
3906
+ if (nextEntries.length === 0) return;
3907
+ const runtimeTurnId = typeof opts.turnId === 'string' && opts.turnId ? opts.turnId : null;
3908
+ if (runtimeTurnId) {
3909
+ let insertAt = history.length;
3910
+ for (let i = history.length - 1; i >= 0; i--) {
3911
+ if (history[i]?._runtimeTurnId === runtimeTurnId) {
3912
+ insertAt = i;
3913
+ history.splice(i, 1);
3914
+ }
3836
3915
  }
3916
+ if (insertAt > history.length) insertAt = history.length;
3917
+ history.splice(insertAt, 0, ...nextEntries);
3918
+ } else {
3919
+ history.push(...nextEntries);
3837
3920
  }
3838
3921
  }
3839
3922