jsfe 1.5.0 → 1.7.0

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.
Files changed (100) hide show
  1. package/README.md +45 -4
  2. package/dist/forms/action-projection.d.ts +4 -0
  3. package/dist/forms/action-projection.d.ts.map +1 -0
  4. package/dist/forms/action-projection.js +21 -0
  5. package/dist/forms/action-recovery.d.ts +12 -0
  6. package/dist/forms/action-recovery.d.ts.map +1 -0
  7. package/dist/forms/action-recovery.js +125 -0
  8. package/dist/forms/action-tools.d.ts +29 -0
  9. package/dist/forms/action-tools.d.ts.map +1 -0
  10. package/dist/forms/action-tools.js +6 -0
  11. package/dist/forms/address-flows.d.ts +2 -0
  12. package/dist/forms/address-flows.d.ts.map +1 -0
  13. package/dist/forms/address-flows.js +39 -0
  14. package/dist/forms/choice-flow.d.ts +419 -0
  15. package/dist/forms/choice-flow.d.ts.map +1 -0
  16. package/dist/forms/choice-flow.js +707 -0
  17. package/dist/forms/contracts.d.ts +237 -0
  18. package/dist/forms/contracts.d.ts.map +1 -0
  19. package/dist/forms/contracts.js +278 -0
  20. package/dist/forms/flows.d.ts +611 -0
  21. package/dist/forms/flows.d.ts.map +1 -0
  22. package/dist/forms/flows.js +675 -0
  23. package/dist/forms/identity-flows.d.ts +17 -0
  24. package/dist/forms/identity-flows.d.ts.map +1 -0
  25. package/dist/forms/identity-flows.js +302 -0
  26. package/dist/forms/index.d.ts +7 -0
  27. package/dist/forms/index.d.ts.map +1 -0
  28. package/dist/forms/index.js +10 -0
  29. package/dist/forms/prompt-compiler.d.ts +3 -0
  30. package/dist/forms/prompt-compiler.d.ts.map +1 -0
  31. package/dist/forms/prompt-compiler.js +30 -0
  32. package/dist/helpers/address.d.ts +27 -0
  33. package/dist/helpers/address.d.ts.map +1 -0
  34. package/dist/helpers/address.js +29 -0
  35. package/dist/helpers/answer.d.ts +5 -0
  36. package/dist/helpers/answer.d.ts.map +1 -1
  37. package/dist/helpers/answer.js +5 -3
  38. package/dist/helpers/choice-offer.d.ts +3 -0
  39. package/dist/helpers/choice-offer.d.ts.map +1 -0
  40. package/dist/helpers/choice-offer.js +6 -0
  41. package/dist/helpers/form-actions.d.ts +13 -0
  42. package/dist/helpers/form-actions.d.ts.map +1 -0
  43. package/dist/helpers/form-actions.js +144 -0
  44. package/dist/helpers/form-sources.d.ts +7 -0
  45. package/dist/helpers/form-sources.d.ts.map +1 -0
  46. package/dist/helpers/form-sources.js +47 -0
  47. package/dist/helpers/form-state.d.ts +94 -0
  48. package/dist/helpers/form-state.d.ts.map +1 -0
  49. package/dist/helpers/form-state.js +870 -0
  50. package/dist/helpers/index.d.ts +114 -1
  51. package/dist/helpers/index.d.ts.map +1 -1
  52. package/dist/helpers/index.js +9 -4
  53. package/dist/helpers/match-inputs.d.ts +14 -0
  54. package/dist/helpers/match-inputs.d.ts.map +1 -1
  55. package/dist/helpers/match-inputs.js +154 -41
  56. package/dist/helpers/money.d.ts +12 -0
  57. package/dist/helpers/money.d.ts.map +1 -0
  58. package/dist/helpers/money.js +54 -0
  59. package/dist/helpers/spoken-money.d.ts +20 -0
  60. package/dist/helpers/spoken-money.d.ts.map +1 -0
  61. package/dist/helpers/spoken-money.js +143 -0
  62. package/dist/host/action-journal.d.ts +98 -0
  63. package/dist/host/action-journal.d.ts.map +1 -0
  64. package/dist/host/action-journal.js +218 -0
  65. package/dist/host/action-session.d.ts +12 -0
  66. package/dist/host/action-session.d.ts.map +1 -0
  67. package/dist/host/action-session.js +29 -0
  68. package/dist/host/capabilities.d.ts +12 -0
  69. package/dist/host/capabilities.d.ts.map +1 -0
  70. package/dist/host/capabilities.js +43 -0
  71. package/dist/host/checkpoint-codec-error.d.ts +6 -0
  72. package/dist/host/checkpoint-codec-error.d.ts.map +1 -0
  73. package/dist/host/checkpoint-codec-error.js +4 -0
  74. package/dist/host/index.d.ts +7 -0
  75. package/dist/host/index.d.ts.map +1 -1
  76. package/dist/host/index.js +7 -0
  77. package/dist/host/intent-stages.d.ts +155 -0
  78. package/dist/host/intent-stages.d.ts.map +1 -0
  79. package/dist/host/intent-stages.js +256 -0
  80. package/dist/host/node.d.ts +17 -0
  81. package/dist/host/node.d.ts.map +1 -0
  82. package/dist/host/node.js +64 -0
  83. package/dist/host/recovery-identity.d.ts +16 -0
  84. package/dist/host/recovery-identity.d.ts.map +1 -0
  85. package/dist/host/recovery-identity.js +73 -0
  86. package/dist/host/recovery-policy.d.ts +5 -0
  87. package/dist/host/recovery-policy.d.ts.map +1 -0
  88. package/dist/host/recovery-policy.js +54 -0
  89. package/dist/host/runtime-manifest.d.ts +3 -0
  90. package/dist/host/runtime-manifest.d.ts.map +1 -0
  91. package/dist/host/runtime-manifest.js +38 -0
  92. package/dist/index.d.ts +75 -19
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +239 -46
  95. package/dist/runtime-identity.d.ts +3 -0
  96. package/dist/runtime-identity.d.ts.map +1 -0
  97. package/dist/runtime-identity.js +1 -0
  98. package/docs/development-release.md +23 -0
  99. package/docs/forms.md +122 -0
  100. package/package.json +15 -4
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { ACTION_TOOLS } from './forms/action-tools.js';
2
+ import { resumableActionSession, restoreActionSession, projectActionRecoverySession } from './forms/action-recovery.js';
3
+ import { createActionRecoveryIdentity, actionRecoveryIdentityMatches } from './host/recovery-identity.js';
1
4
  /**
2
5
  * Copyright (c) 2025 InstantAIguru.com
3
6
 
@@ -16,6 +19,7 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FO
16
19
  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
17
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
18
21
  **/
22
+ import { buildFlowStage, buildParameterStage, hasStageParameters } from './host/intent-stages.js';
19
23
  /*
20
24
  // Deploy new version to NPM:
21
25
  npm version patch -m "chore(release): %s"
@@ -107,6 +111,8 @@ const DEFAULT_MESSAGE_REGISTRY = {
107
111
  flow_resumed_with_guidance: "Resuming {{flowPrompt}} - Type 'cancel flow' or 'help' for options.",
108
112
  flow_completed: "{{flowPrompt}} completed successfully.",
109
113
  flow_completed_generic: "Flow completed.",
114
+ action_recovery_incompatible: "I cannot safely finish checking your earlier request right now. I have not repeated it or processed your latest message.",
115
+ action_recovery_input_deferred: "I recovered the earlier result above. Your latest message was not processed; please send it again if you still need help.",
110
116
  flow_cancelled: "Cancelled {{flowPrompt}}.",
111
117
  flow_help_general: "Processing {{flowPrompt}} - You can also 'cancel flow' or request 'help'.",
112
118
  flow_help_payment: "Processing {{flowPrompt}} - You can also 'cancel flow' or request 'help'.",
@@ -150,6 +156,8 @@ const DEFAULT_MESSAGE_REGISTRY = {
150
156
  flow_resumed_with_guidance: "Continuando {{flowPrompt}} - Escriba 'cancelar flujo' o 'ayuda' para opciones.",
151
157
  flow_completed: "{{flowPrompt}} completado exitosamente.",
152
158
  flow_completed_generic: "Flujo completado.",
159
+ action_recovery_incompatible: "No puedo terminar de verificar su solicitud anterior de forma segura en este momento. No la he repetido ni he procesado su último mensaje.",
160
+ action_recovery_input_deferred: "Recuperé el resultado anterior que aparece arriba. Su último mensaje no se procesó; envíelo de nuevo si todavía necesita ayuda.",
153
161
  flow_cancelled: "{{flowPrompt}} cancelado.",
154
162
  flow_help_general: "Procesando {{flowPrompt}} - También puede 'cancelar flujo' o solicitar 'ayuda'.",
155
163
  flow_help_payment: "Procesando {{flowPrompt}} - También puede 'cancelar flujo' o solicitar 'ayuda'.",
@@ -2010,6 +2018,28 @@ function switchToPreviousStack(engine) {
2010
2018
  throw error;
2011
2019
  }
2012
2020
  }
2021
+ // Mutable execution data must belong to its activation, never the registered template.
2022
+ // Plain programmatic definitions may contain callbacks; preserve those references while
2023
+ // cloning their surrounding data rather than rejecting them with structuredClone.
2024
+ function cloneFlowValue(value, seen = new WeakMap()) {
2025
+ if (value === null || typeof value !== 'object')
2026
+ return value;
2027
+ if (seen.has(value))
2028
+ return seen.get(value);
2029
+ const prototype = Object.getPrototypeOf(value);
2030
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
2031
+ return structuredClone(value);
2032
+ }
2033
+ const copy = Array.isArray(value) ? new Array(value.length) : Object.create(prototype);
2034
+ seen.set(value, copy);
2035
+ for (const key of Object.keys(value)) {
2036
+ Object.defineProperty(copy, key, {
2037
+ value: cloneFlowValue(value[key], seen),
2038
+ enumerable: true, writable: true, configurable: true
2039
+ });
2040
+ }
2041
+ return copy;
2042
+ }
2013
2043
  function getInitialVariables(engine, flowDefinition, inheritedVariables) {
2014
2044
  let variables;
2015
2045
  if (inheritedVariables) {
@@ -2019,7 +2049,7 @@ function getInitialVariables(engine, flowDefinition, inheritedVariables) {
2019
2049
  if (flowDefinition?.variables) {
2020
2050
  for (const [varName, varDef] of Object.entries(flowDefinition.variables)) {
2021
2051
  if (varDef.value !== undefined) {
2022
- variables[varName] = varDef.value; // Modify in-place on shared reference
2052
+ variables[varName] = cloneFlowValue(varDef.value); // Fresh defaults; the caller's variable container stays shared
2023
2053
  }
2024
2054
  }
2025
2055
  }
@@ -2031,7 +2061,7 @@ function getInitialVariables(engine, flowDefinition, inheritedVariables) {
2031
2061
  if (flowDefinition?.variables) {
2032
2062
  for (const [varName, varDef] of Object.entries(flowDefinition.variables)) {
2033
2063
  if (varDef.value !== undefined) {
2034
- variables[varName] = varDef.value;
2064
+ variables[varName] = cloneFlowValue(varDef.value);
2035
2065
  }
2036
2066
  }
2037
2067
  }
@@ -2122,7 +2152,7 @@ async function isFlowActivated(input, engine, userId = 'anonymous') {
2122
2152
  flowName: flow.name,
2123
2153
  flowId: flow.id,
2124
2154
  flowVersion: flow.version,
2125
- flowStepsStack: [...flow.steps].reverse(),
2155
+ flowStepsStack: cloneFlowValue(flow.steps).reverse(),
2126
2156
  contextStack: [{ role: 'user', content: sanitizeInput(input), timestamp: Date.now() }], // Keep activation input in context for reference
2127
2157
  inputStack: [], // Start empty - only SAY-GET responses go here
2128
2158
  variables, // Start with global variables + flow definition variables + extracted parameters
@@ -2675,8 +2705,14 @@ function enforceParameterTypes(flow, parameters) {
2675
2705
  const n = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN);
2676
2706
  converted = Number.isFinite(n) && (type === 'number' || Number.isInteger(n)) ? n : undefined;
2677
2707
  }
2708
+ else if (p.jsonSchema && type === 'object') {
2709
+ converted = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
2710
+ }
2711
+ else if (p.jsonSchema && type === 'array') {
2712
+ converted = Array.isArray(value) ? value : undefined;
2713
+ }
2678
2714
  else {
2679
- converted = value; // object, array or another declared type - not checked here
2715
+ converted = value; // Legacy parameters without an extraction schema retain their existing behavior
2680
2716
  }
2681
2717
  if (converted !== undefined) {
2682
2718
  result[p.name] = converted;
@@ -2711,11 +2747,60 @@ function buildDetectFlowRequest(input, lastChatTurn, flows) {
2711
2747
  name: p.name,
2712
2748
  description: p.description,
2713
2749
  ...(p.type !== undefined ? { type: p.type } : {}),
2714
- ...(Array.isArray(p.enum) ? { enum: [...p.enum] } : {})
2750
+ ...(Array.isArray(p.enum) ? { enum: [...p.enum] } : {}),
2751
+ ...(p.jsonSchema ? { jsonSchema: structuredClone(p.jsonSchema) } : {})
2715
2752
  }))
2716
2753
  }))
2717
2754
  };
2718
2755
  }
2756
+ /**
2757
+ * Two-stage intent detection (engine.intentDetection = 'two-stage'): stage 1 picks the flow, stage 2 extracts the
2758
+ * selected flow's parameters. Each stage is ONE aiCallback call whose positional arguments are the LLM instructions and
2759
+ * whose 4th argument carries both executors' (jsfe/host intent-stages); the host replies with the canonical JSON
2760
+ * ({ flowName } / { parameters }). A stage-1 failure reaches the host as before; a stage-2 failure starts the flow
2761
+ * without its parameters (it asks for them) rather than losing the turn.
2762
+ */
2763
+ async function detectFlowInTwoStages(input, engine, flows) {
2764
+ const { input: cleanInput, conversation } = buildDetectFlowRequest(input, engine.lastChatTurn, []);
2765
+ const stage1 = buildFlowStage(cleanInput, conversation, flows.map(f => ({ id: f.id, name: f.name, description: String(f.description ?? '') })));
2766
+ const reply1 = parseStageReply(await fetchAiResponse(stage1.llm.system, stage1.llm.user, engine.aiCallback, engine.aiTimeOut, stageSchema('detect_flow', stage1.llm.schema), stage1));
2767
+ const said = String(reply1.flowName ?? 'None');
2768
+ // only an offered (primary) flow is ever activated; it calls its workers with the arguments it holds
2769
+ const flow = /^(none|null)$/i.test(said) ? null : flows.find(f => f.name.toLowerCase() === said.toLowerCase()) || null;
2770
+ if (!flow) {
2771
+ if (!/^(none|null)$/i.test(said))
2772
+ logger.error(`Flow "${said}" not found in flows menu`);
2773
+ else
2774
+ logger.info(`No flow activated for input: "${input}"`);
2775
+ return null;
2776
+ }
2777
+ else if (!hasStageParameters(flow.parameters)) {
2778
+ return { flow, parameters: {} };
2779
+ }
2780
+ else {
2781
+ try {
2782
+ const stage2 = buildParameterStage(cleanInput, conversation, flow);
2783
+ const reply2 = parseStageReply(await fetchAiResponse(stage2.llm.system, stage2.llm.user, engine.aiCallback, engine.aiTimeOut, stageSchema('extract_parameters', stage2.llm.schema), stage2));
2784
+ const given = reply2.parameters && typeof reply2.parameters === 'object' ? reply2.parameters : {};
2785
+ return { flow, parameters: enforceParameterEnums(flow, enforceParameterTypes(flow, ownParametersOnly(flow, given))) };
2786
+ }
2787
+ catch (error) {
2788
+ logger.warn(`detectFlowWithParameters: stage 2 failed for "${flow.name}" (${error.message}) - the flow starts without its parameters`);
2789
+ return { flow, parameters: {} };
2790
+ }
2791
+ }
2792
+ }
2793
+ /** The OpenAI-compatible response_format wrapper a host already reads from the 3rd argument. */
2794
+ function stageSchema(name, schema) {
2795
+ return JSON.stringify({ type: 'json_schema', json_schema: { name, strict: true, schema } });
2796
+ }
2797
+ function parseStageReply(text) {
2798
+ const t = String(text ?? '').replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
2799
+ const parsed = JSON.parse((t.match(/\{[\s\S]*\}/) || [t])[0]);
2800
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2801
+ throw new Error('intent stage: the reply is not a JSON object');
2802
+ return parsed;
2803
+ }
2719
2804
  export async function getFlowForInput(input, engine) {
2720
2805
  const result = await detectFlowWithParameters(input, engine);
2721
2806
  return result ? result.flow : null;
@@ -2740,6 +2825,22 @@ export async function detectFlowWithParameters(input, engine) {
2740
2825
  const primaryFlows = flowsMenu.filter(flow => flow.primary === true);
2741
2826
  const flowsForIntentDetection = primaryFlows.length > 0 ? primaryFlows : flowsMenu;
2742
2827
  logger.debug(`Using ${flowsForIntentDetection.length} flows for intent detection (${primaryFlows.length} primary flows available)`);
2828
+ // A host start (ContextEntry.start) answers detection for this turn: that flow, with its arguments.
2829
+ if (engine.hostStart) {
2830
+ const wanted = engine.hostStart;
2831
+ const flow = flowsMenu.find(f => f.id === wanted.flow || f.name === wanted.flow);
2832
+ if (!flow) {
2833
+ logger.warn(`Host start: flow "${wanted.flow}" is not in the flows menu`);
2834
+ return null;
2835
+ }
2836
+ else {
2837
+ const given = wanted.arguments && typeof wanted.arguments === 'object' ? wanted.arguments : {};
2838
+ return { flow, parameters: enforceParameterEnums(flow, enforceParameterTypes(flow, ownParametersOnly(flow, given))) };
2839
+ }
2840
+ }
2841
+ else {
2842
+ // no host start: intent detection decides
2843
+ }
2743
2844
  // First try direct name or id matching (case-insensitive) - check all flows
2744
2845
  const directMatch = flowsMenu.find(flow => flow.name.toLowerCase() === input.toLowerCase() || (flow.id.toLowerCase() === input.toLowerCase()));
2745
2846
  if (directMatch) {
@@ -2751,6 +2852,12 @@ export async function detectFlowWithParameters(input, engine) {
2751
2852
  logger.info(`No flow matched for input: "${input}" (no AI)`);
2752
2853
  return null;
2753
2854
  }
2855
+ if (engine.intentDetection === 'two-stage') {
2856
+ return await detectFlowInTwoStages(input, engine, flowsForIntentDetection);
2857
+ }
2858
+ else {
2859
+ // the original single call, below
2860
+ }
2754
2861
  // If AI callback is present, use AI for intent detection with primary flows only
2755
2862
  const task = "Considering the chat history when available and applicable, decide if the user input should trigger any available flow. If a flow is triggered, extract any relevant parameters defined in the flow's 'parameters' section.";
2756
2863
  const rules = `- Return the exact flow name if a match is found
@@ -3280,7 +3387,7 @@ async function handleToolStep(currentFlowFrame, engine) {
3280
3387
  flowName: rebootFlow.name,
3281
3388
  flowId: rebootFlow.id,
3282
3389
  flowVersion: rebootFlow.version,
3283
- flowStepsStack: [...rebootFlow.steps].reverse(),
3390
+ flowStepsStack: cloneFlowValue(rebootFlow.steps).reverse(),
3284
3391
  contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
3285
3392
  inputStack: [input],
3286
3393
  variables: variables, // Fresh variables for reboot flow (with injected params)
@@ -3337,7 +3444,7 @@ async function handleToolStep(currentFlowFrame, engine) {
3337
3444
  flowName: onFailFlow.name,
3338
3445
  flowId: onFailFlow.id,
3339
3446
  flowVersion: onFailFlow.version,
3340
- flowStepsStack: [...onFailFlow.steps].reverse(),
3447
+ flowStepsStack: cloneFlowValue(onFailFlow.steps).reverse(),
3341
3448
  contextStack: [...currentFlowFrame.contextStack], // Inherit context
3342
3449
  inputStack: [...currentFlowFrame.inputStack],
3343
3450
  variables: variables,
@@ -3651,6 +3758,11 @@ function handleSetStep(currentFlowFrame, engine) {
3651
3758
  returnType: 'auto'
3652
3759
  }, engine)
3653
3760
  : step.value;
3761
+ if (step.requireTrue === true && interpolatedValue !== true) {
3762
+ const error = new Error(`Required SET step ${step.id || ''} did not complete successfully`);
3763
+ error.name = 'JSFERequiredSetFailed';
3764
+ throw error;
3765
+ }
3654
3766
  if (currentFlowFrame && currentFlowFrame.variables !== undefined) {
3655
3767
  currentFlowFrame.variables[step.variable] = interpolatedValue;
3656
3768
  logger.info(`SET step: stored '${interpolatedValue}' in variable '${step.variable}'`);
@@ -3961,7 +4073,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
3961
4073
  flowName: subFlow.name,
3962
4074
  flowId: subFlow.id,
3963
4075
  flowVersion: subFlow.version,
3964
- flowStepsStack: [...subFlow.steps].reverse(),
4076
+ flowStepsStack: cloneFlowValue(subFlow.steps).reverse(),
3965
4077
  contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
3966
4078
  inputStack: [input],
3967
4079
  variables, // Fresh variables for reboot flow
@@ -3981,7 +4093,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
3981
4093
  currentFlowFrame.flowName = subFlow.name;
3982
4094
  currentFlowFrame.flowId = subFlow.id;
3983
4095
  currentFlowFrame.flowVersion = subFlow.version;
3984
- currentFlowFrame.flowStepsStack = [...subFlow.steps].reverse();
4096
+ currentFlowFrame.flowStepsStack = cloneFlowValue(subFlow.steps).reverse();
3985
4097
  addToContextStack(currentFlowFrame.contextStack, 'user', input);
3986
4098
  currentFlowFrame.inputStack.push(input);
3987
4099
  // Inject parameters into current variables — or, for an isolated flow, start this frame's own scope
@@ -3989,13 +4101,18 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
3989
4101
  currentFlowFrame.variables = Object.assign(getInitialVariables(engine, subFlow), flowParameters);
3990
4102
  currentFlowFrame.isolatedScope = true;
3991
4103
  }
3992
- else if (Object.keys(flowParameters).length > 0) {
3993
- logger.info(`Injecting parameters into variables for flow ${subFlow.name}`, flowParameters);
3994
- Object.assign(currentFlowFrame.variables, flowParameters);
3995
- logger.info(`Variables after injection:`, currentFlowFrame.variables);
3996
- }
3997
4104
  else {
3998
- // no parameters, shared scope: the frame keeps its variables
4105
+ // Legacy shared replacement preserves the current variable container. New workers may
4106
+ // explicitly initialize target defaults that are still undefined; accepted progress stays.
4107
+ currentFlowFrame.variables ||= {};
4108
+ if (step.initializeDefaults === true) {
4109
+ for (const [name, definition] of Object.entries(subFlow.variables || {})) {
4110
+ if (currentFlowFrame.variables[name] === undefined && definition.value !== undefined) {
4111
+ currentFlowFrame.variables[name] = cloneFlowValue(definition.value);
4112
+ }
4113
+ }
4114
+ }
4115
+ Object.assign(currentFlowFrame.variables, flowParameters);
3999
4116
  }
4000
4117
  // Update transaction
4001
4118
  TransactionManager.fail(currentFlowFrame.transaction, `Replaced by flow ${subFlow.name}`);
@@ -4015,7 +4132,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
4015
4132
  flowName: subFlow.name,
4016
4133
  flowId: subFlow.id,
4017
4134
  flowVersion: subFlow.version,
4018
- flowStepsStack: [...subFlow.steps].reverse(),
4135
+ flowStepsStack: cloneFlowValue(subFlow.steps).reverse(),
4019
4136
  contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
4020
4137
  inputStack: [input],
4021
4138
  variables, // Inherit + merge flow definition variables (isolated: its own)
@@ -5136,32 +5253,19 @@ function evaluateJavaScriptExpression(expression, context, preserveObjects = fal
5136
5253
  }
5137
5254
  return value;
5138
5255
  });
5139
- // Build function body that evaluates the expression
5140
- // When preserveObjects is true (e.g., SET steps), don't auto-stringify objects
5141
- const functionBody = preserveObjects ? `
5142
- "use strict";
5256
+ // Keep evaluator temporaries outside the generated function: a flow may legitimately declare `result`.
5257
+ const evaluatorFunction = new Function(...validParamNames, `"use strict"; return (${expression});`);
5143
5258
  try {
5144
- return (${expression});
5145
- } catch (e) {
5146
- throw new Error('Expression evaluation failed: ' + e.message);
5259
+ const evaluated = evaluatorFunction(...paramValues);
5260
+ if (!preserveObjects && typeof evaluated === 'object' && evaluated !== null &&
5261
+ !(evaluated.__userInput === true && evaluated.__literal === true)) {
5262
+ return JSON.stringify(evaluated);
5263
+ }
5264
+ return evaluated;
5265
+ }
5266
+ catch (error) {
5267
+ throw new Error('Expression evaluation failed: ' + error.message);
5147
5268
  }
5148
- ` : `
5149
- "use strict";
5150
- try {
5151
- const result = (${expression});
5152
- // Auto-stringify complex data structures when used in string contexts
5153
- if (typeof result === 'object' && result !== null &&
5154
- !(result.__userInput === true && result.__literal === true)) {
5155
- return JSON.stringify(result);
5156
- }
5157
- return result;
5158
- } catch (e) {
5159
- throw new Error('Expression evaluation failed: ' + e.message);
5160
- }
5161
- `;
5162
- // Create and execute function with only valid parameter names
5163
- const evaluatorFunction = new Function(...validParamNames, functionBody);
5164
- return evaluatorFunction(...paramValues);
5165
5269
  }
5166
5270
  /**
5167
5271
  * Security-aware variable setter for user input with AI-powered voice cleanup
@@ -5511,7 +5615,7 @@ async function handlePendingInterruptionSwitch(engine, userId) {
5511
5615
  flowName: targetFlow,
5512
5616
  flowId: targetFlowDefinition.id,
5513
5617
  flowVersion: targetFlowDefinition.version || '1.0',
5514
- flowStepsStack: [...targetFlowDefinition.steps].reverse(),
5618
+ flowStepsStack: cloneFlowValue(targetFlowDefinition.steps).reverse(),
5515
5619
  contextStack: [],
5516
5620
  inputStack: [],
5517
5621
  variables: getInitialVariables(engine, targetFlowDefinition), // Fresh variables for first flow activation
@@ -5576,7 +5680,7 @@ async function handleRegularFlowInterruption(intentAnalysis, engine, userId) {
5576
5680
  flowName: targetFlow,
5577
5681
  flowId: targetFlowDefinition.id,
5578
5682
  flowVersion: targetFlowDefinition.version || '1.0',
5579
- flowStepsStack: [...targetFlowDefinition.steps].reverse(),
5683
+ flowStepsStack: cloneFlowValue(targetFlowDefinition.steps).reverse(),
5580
5684
  contextStack: [], // Start with empty context - don't use interruption input
5581
5685
  inputStack: [], // Start with empty input stack - new flow will get proper input next
5582
5686
  variables: getInitialVariables(engine, targetFlowDefinition), // Fresh variables for interrupting flow activation
@@ -5885,6 +5989,21 @@ export function applyWraps(flowsMenu) {
5885
5989
  }
5886
5990
  }
5887
5991
  export class WorkflowEngine {
5992
+ /** Host-injected durable action journal. Without it common performers fail closed before effects. */
5993
+ actionJournal;
5994
+ actionRecoveryIdentityPromise;
5995
+ /** Loaded definitions are immutable after activation. Hot reload constructs a new engine. */
5996
+ async getActionRecoveryIdentity() {
5997
+ if (this.actionJournal) {
5998
+ for (const method of ['claim', 'complete', 'checkpoint', 'pending']) {
5999
+ if (typeof this.actionJournal[method] !== 'function')
6000
+ throw new Error('Durable action journal requires ' + method);
6001
+ }
6002
+ }
6003
+ if (!this.actionRecoveryIdentityPromise)
6004
+ this.actionRecoveryIdentityPromise = createActionRecoveryIdentity({ flows: this.flowsMenu, tools: this.toolsRegistry });
6005
+ return this.actionRecoveryIdentityPromise;
6006
+ }
5888
6007
  cargo;
5889
6008
  flowsMenu;
5890
6009
  toolsRegistry;
@@ -5903,6 +6022,15 @@ export class WorkflowEngine {
5903
6022
  * warning on a mismatch. Diagnostic only — a result is never altered or withheld because of it.
5904
6023
  */
5905
6024
  validateToolReturns = false;
6025
+ /**
6026
+ * Opt-in (default 'single-call'): how intent detection asks aiCallback. 'two-stage': stage 1 picks the flow
6027
+ * (from the primaries' descriptions only), stage 2 extracts the SELECTED flow's parameters; each call carries
6028
+ * both executors' instructions (`llm`, `systemOne`) as its 4th argument — see jsfe/host intent-stages.
6029
+ * 'single-call': the original one call that picks a flow and extracts parameters for every flow at once.
6030
+ */
6031
+ intentDetection = 'single-call';
6032
+ /** This turn's host start (ContextEntry.start), or null. */
6033
+ hostStart = null;
5906
6034
  /**
5907
6035
  * Opt-in (default false): log one warning when a tool returns a failure (`success: false` or a
5908
6036
  * non-zero `error`), with the tool and its message, digit runs masked. Diagnostic only.
@@ -5985,8 +6113,18 @@ export class WorkflowEngine {
5985
6113
  logger = withPiiRedaction(hostLogger); // Assign to global logger (a SAY-GET's `pii` answer never reaches it)
5986
6114
  this.aiCallback = aiCallback;
5987
6115
  this.flowsMenu = applyWraps(flowsMenu);
5988
- this.toolsRegistry = toolsRegistry;
5989
- this.APPROVED_FUNCTIONS = APPROVED_FUNCTIONS;
6116
+ if (toolsRegistry.some(tool => ACTION_TOOLS.some(internal => internal.id === tool.id)))
6117
+ throw new Error('Internal JSFE action tool identities are reserved');
6118
+ this.toolsRegistry = [...toolsRegistry, ...ACTION_TOOLS];
6119
+ const receipt = (reply) => reply;
6120
+ this.APPROVED_FUNCTIONS = { ...APPROVED_FUNCTIONS,
6121
+ jsfeClaimAction: async ({ record, action }) => receipt(this.actionJournal
6122
+ ? await this.actionJournal.claim({ sessionId: this.sessionId, record, action, checkpoint: resumableActionSession(this.sessionContext, this.flowsMenu, await this.getActionRecoveryIdentity()) })
6123
+ : { status: 'hold', outcome: { status: 'unknown', reason: 'durable_journal_unavailable' } }),
6124
+ jsfeCompleteAction: async ({ record, action, outcome }) => receipt(this.actionJournal
6125
+ ? await this.actionJournal.complete({ sessionId: this.sessionId, record, action, outcome, checkpoint: resumableActionSession(this.sessionContext, this.flowsMenu, await this.getActionRecoveryIdentity()) })
6126
+ : { status: 'unknown', outcome: { status: 'unknown', reason: 'durable_journal_unavailable' } }),
6127
+ };
5990
6128
  this.globalVariables = globalVariables ? { ...globalVariables } : {}; // Copy global variables to prevent external mutations
5991
6129
  this.aiTimeOut = aiTimeOut ?? 2000; // Default to 2000ms (2 seconds)
5992
6130
  // if validateOnInit is not provided, default to true
@@ -6050,7 +6188,8 @@ export class WorkflowEngine {
6050
6188
  async updateActivity(contextEntry, engineSessionContext) {
6051
6189
  try {
6052
6190
  this.cargo = engineSessionContext.cargo || {};
6053
- logger.info(`Received Cargo: ${JSON.stringify(this.cargo, (k, v) => (/^(cvv|cvc|cvv2|cardcode|securitycode)$/i.test(k) && v ? '•••' : v))}`);
6191
+ // a security code under any key that names one (a form stores it as `card_cvv`)
6192
+ logger.info(`Received Cargo: ${JSON.stringify(this.cargo, (k, v) => (/(cvv|cvc|card_?code|security_?code)/i.test(k) && v ? '•••' : v))}`);
6054
6193
  this.sessionId = engineSessionContext.sessionId;
6055
6194
  this.createdAt = engineSessionContext.createdAt;
6056
6195
  // Store reference to session context - no copying needed! Engine works directly with session data
@@ -6065,6 +6204,8 @@ export class WorkflowEngine {
6065
6204
  this.sessionContext.lastTurnToolCalls = [];
6066
6205
  // Same one-shot semantics for a DISPATCH record (see recordDispatch).
6067
6206
  delete this.sessionContext.lastTurnDispatch;
6207
+ // A host start belongs to this turn only (ContextEntry.start).
6208
+ this.hostStart = contextEntry && contextEntry.role === 'user' && contextEntry.start && typeof contextEntry.start.flow === 'string' ? contextEntry.start : null;
6068
6209
  // DIAGNOSTIC: Log flowStepsStack state from loaded session (before any processing)
6069
6210
  if (engineSessionContext.flowStacks) {
6070
6211
  for (let si = 0; si < engineSessionContext.flowStacks.length; si++) {
@@ -6112,6 +6253,50 @@ export class WorkflowEngine {
6112
6253
  if (engineSessionContext) {
6113
6254
  engineSessionContext.lastActivity = this.lastActivity;
6114
6255
  }
6256
+ // Recover durable effects before the new message can mutate an older saved session.
6257
+ // A successfully saved session carries its observed sequence even when acknowledgement failed.
6258
+ delete engineSessionContext.lastTurnRecovery;
6259
+ let recoveredAction = false;
6260
+ let suspendedRecoveryStacks;
6261
+ if (contextEntry.role === 'user' && this.actionJournal) {
6262
+ const recoveryIdentity = await this.getActionRecoveryIdentity();
6263
+ const pending = await this.actionJournal.pending({ sessionId: this.sessionId });
6264
+ const applied = (this.cargo.actionJournalApplied || {});
6265
+ // A journal sequence orders writes, not execution progress. Track consumption per attempt.
6266
+ const record = (pending?.records || []).filter((row) => row.checkpoint && Number(row.sequence) > Number(applied[row.key] || 0))
6267
+ .sort((a, b) => Number(a.sequence) - Number(b.sequence)).at(-1);
6268
+ const recovery = record ? { key: record.key, sequence: record.sequence, status: record.status, session: record.checkpoint, record } : null;
6269
+ if (recovery) {
6270
+ if (!actionRecoveryIdentityMatches(recoveryIdentity, recovery.session.actionRecoveryIdentity)) {
6271
+ engineSessionContext.lastTurnToolCalls = [];
6272
+ delete engineSessionContext.lastFlowOutcome;
6273
+ delete engineSessionContext.lastTurnDispatch;
6274
+ this.hostStart = null;
6275
+ engineSessionContext.lastTurnRecovery = { key: record.key, sequence: record.sequence, status: record.status, hold: true, reason: 'incompatible_recovery_identity', deferredInput: contextEntry };
6276
+ engineSessionContext.response = getSystemMessage(this, 'action_recovery_incompatible');
6277
+ return engineSessionContext;
6278
+ }
6279
+ const snapshotProgress = recovery.session.actionRecoveryProgress;
6280
+ const changedEpoch = (engineSessionContext.actionRecoveryEpoch || null) !== (recovery.session.actionRecoveryEpoch || null);
6281
+ const alreadyAdvanced = Number.isSafeInteger(snapshotProgress) && Number(engineSessionContext.actionRecoveryProgress || 0) >= Number(snapshotProgress);
6282
+ const snapshotApplied = recovery.session.cargo?.actionJournalApplied || {};
6283
+ const hasNewerApplied = Object.entries(applied).some(([key, sequence]) => Number(sequence) > Number(snapshotApplied[key] || 0));
6284
+ const invalidatedSinceCheckpoint = Number(engineSessionContext.actionRecoveryPolicy?.revision || 0) > Number(recovery.session.actionRecoveryPolicy?.revision || 0);
6285
+ if (changedEpoch || alreadyAdvanced || hasNewerApplied || invalidatedSinceCheckpoint) {
6286
+ suspendedRecoveryStacks = projectActionRecoverySession(engineSessionContext, recovery, contextEntry, this.flowsMenu, recoveryIdentity);
6287
+ }
6288
+ else {
6289
+ restoreActionSession(engineSessionContext, recovery, contextEntry, recoveryIdentity);
6290
+ }
6291
+ this.cargo = engineSessionContext.cargo;
6292
+ this.reconstructVariableReferences();
6293
+ engineSessionContext.lastTurnToolCalls = [];
6294
+ delete engineSessionContext.lastFlowOutcome;
6295
+ delete engineSessionContext.lastTurnDispatch;
6296
+ this.hostStart = null;
6297
+ recoveredAction = true;
6298
+ }
6299
+ }
6115
6300
  // Role-based processing logic
6116
6301
  if (contextEntry.role === 'user') {
6117
6302
  // A sensitive answer (SAY-GET `pii`) is kept out of every log line of this turn, raw and as its digits
@@ -6122,10 +6307,16 @@ export class WorkflowEngine {
6122
6307
  // Detect intent to activate a flow or switch flows
6123
6308
  let responseOrNull;
6124
6309
  try {
6125
- responseOrNull = await processActivity(said, userId, this);
6310
+ if (!recoveredAction && this.actionJournal)
6311
+ engineSessionContext.actionRecoveryProgress = Number(engineSessionContext.actionRecoveryProgress || 0) + 1;
6312
+ responseOrNull = recoveredAction ? await playFlowFrame(this) : await processActivity(said, userId, this);
6126
6313
  }
6127
6314
  finally {
6128
6315
  piiInputs = [];
6316
+ if (suspendedRecoveryStacks) {
6317
+ engineSessionContext.flowStacks = suspendedRecoveryStacks;
6318
+ this.reconstructVariableReferences();
6319
+ }
6129
6320
  }
6130
6321
  // Anomaly guard: a flow is STILL ACTIVE but produced no response.
6131
6322
  // Detect activity by frame EXISTENCE, not by the response value: a frame
@@ -6155,6 +6346,8 @@ export class WorkflowEngine {
6155
6346
  err.name = 'JSFEActiveFlowEmptyResponse';
6156
6347
  throw err;
6157
6348
  }
6349
+ if (recoveredAction)
6350
+ responseOrNull = [responseOrNull, getSystemMessage(this, 'action_recovery_input_deferred')].filter(Boolean).join('\n\n');
6158
6351
  // Store user turn in lastChatTurn if not in a flow
6159
6352
  if (responseOrNull === null) {
6160
6353
  this.sessionContext.lastChatTurn.user = contextEntry;
@@ -0,0 +1,3 @@
1
+ /** Replaced in dist by the post-build stamper. Legacy execution does not require a stamp. */
2
+ export declare const JSFE_RUNTIME_FINGERPRINT: string;
3
+ //# sourceMappingURL=runtime-identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtime-identity.d.ts","sourceRoot":"","sources":["../src/runtime-identity.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,eAAO,MAAM,wBAAwB,EAAE,MAAkB,CAAC"}
@@ -0,0 +1 @@
1
+ export const JSFE_RUNTIME_FINGERPRINT = "sha256:c17204fa29bf43386d3e2433a775108403a9999f7f3d63a9da4761f685c1ecfd";
@@ -0,0 +1,23 @@
1
+ # Reproducible native JSFE development candidate
2
+
3
+ The native form/performer implementation spans the engine, host and tenant flow generator. Testing an unversioned sibling symlink does not establish that a clean consumer installation will use the same engine.
4
+
5
+ A local candidate is an unpublished prerelease. Freeze and test its source, run `npm run clean`, `npm run build` and `npm test`, then use `npm pack --ignore-scripts` to create an immutable artifact. Never replace a candidate tarball with different bytes under the same version. If runtime source changes afterward, create the next prerelease version.
6
+
7
+ Both consumers pin the identical tarball under `vendor/jsfe/` using an exact `file:` dependency and lockfile integrity. Record the tarball SHA-256, package version, engine runtime fingerprint, source revisions and dirty source digest, and Node/npm versions. Verify each installed consumer's bytes with `flow-tests/lib/runtime-build.mjs`; matching package version labels alone are insufficient. Exercise a clean `npm ci` installation before accepting the candidate.
8
+
9
+ Acceptance includes engine tests, host journal/save-boundary/invalidation tests, tenant `npm run test:accept`, real-engine production-response-shape journeys, the independent designer-skill example and bilingual live intent/parameter probes. Include late completion after newer saved work, versioned receipt acknowledgment races, expiry/reset recovery, strict projector failure, optional follow-up outcomes and explicit confirmation clarification. Exercise the actual storage adapter and conversation helpers with full session checkpoints and high-entropy history, checking exact decoded round trips, complete item bounds and removal of acknowledged checkpoint payloads. Customer/API effects in these checks use local stubs. Model credential access is a separately authorized test operation. Do not retain credentials in reports.
10
+
11
+ The original `ai-ar` behavioral suites remain runnable in original mode. Native mode declares deliberate interaction changes explicitly and preserves independent payload, consent, subject, failure and retry expectations. There are no blanket exclusions for failed suites. Stale artifacts, crashed or empty selections and failed assertions fail acceptance.
12
+
13
+ Compatibility remains explicit. The published `amountInAnswer` and `isAmountAnswer` helpers retain their legacy defaults. USD grammar uses `readMoneyAmount`/`isMoneyAmount` with `{currency:'USD',cardinals:true,intent:'payment'}`, or a FORM amount profile with that object under `money`; cardinal parsing and payment intent are optional flags. Shared `FLOW` replacement retains its current variables unless the step declares `initializeDefaults:true`, which fills only undefined target defaults. Native builders opt in; loaded legacy flows are not rewritten. Mutable declared defaults and authored step data are private to each activation, while shared flow variables still use the caller's container. `SET requireTrue:true` requires a literal true result; absent or false retains ordinary SET behavior.
14
+
15
+ An absent `config.jsfe` group defaults to `{durableActions:false,sessionEncoding:'plain'}`; omitted keys default independently to those values. Unknown keys, invalid types and unsupported encodings reject configuration. `durableActions:true` attaches the action journal; legacy turns otherwise perform no journal reads. `sessionEncoding:'br-v1'` enables encoded conversation writes through Node-only `jsfe/host/node`; keep universal `jsfe/host` free of Node dependencies. Journal-internal encoding is separate from conversation policy. Codec bounds default to 16 MiB decoded JSON and 350 KiB encoded binary; the host additionally rejects complete DynamoDB items above its conservative 400 KiB bound. Unsupported/corrupt envelopes and codec limits raise `CheckpointCodecError`; complete-item overflow raises `JSFEStorageSizeLimit`. A failed decode must not replace the conversation, and a failed save must not acknowledge action receipts.
16
+
17
+ Deploy compatible readers across the fleet before enabling `br-v1` for a tenant. New readers accept historical plaintext and supported encoded rows; pre-reader hosts cannot read envelopes. Switching back to `plain` converts an existing encoded row only on a successful, size-bounded save, without trimming; dormant rows remain encoded and an oversized plaintext result blocks conversion. Before an older-reader rollback, stop new native starts, keep pending native continuations on capable routing, losslessly CAS-convert every target encoded row and verify none remain. Toggling policy or resetting sessions is not a migration.
18
+
19
+ Durable restore/project requires an exact identity match for `{protocol:1,runtime:'sha256:...',definitions:'<digest>'}`. Missing, invalid or different identities HOLD recovery: preserve current state, defer new input, and perform no projector, effect or acknowledgment. The definition digest includes canonical flows and nonsecret tool execution declarations; loaded definitions are immutable for an engine instance, so hot reload requires a new engine. This deliberately limits recovery across runtime or definition changes; it is not a production rollout approval.
20
+
21
+ Native performer declarations include `contract.requires: ['durableActions']`. `assertFlowHostCapabilities` checks each declared requirement against an explicitly supplied boolean capability; it never enables one. The host rejects unmet requirements before constructing an engine. Legacy flows without capability declarations keep the default-off behavior. The flow-set switch preflight uses the same helper; an explicit `--jsfe-policy` JSON file can merge approved policy keys in the same tenant configuration write as the flows and tools. Omitting the option preserves the current policy and rejects a native set when its requirements are unmet. Compression remains a separate storage decision.
22
+
23
+ Publishing the package, deploying the host or uploading tenant/system flow sets are separate release actions requiring the owner's authorization. A release should replace local tarball dependencies with the exact published package whose bytes were accepted, and deploy matched host/system/tenant contracts together. No deployment is implied by a passing local candidate.