jsfe 1.6.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 +27 -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 +6 -0
  75. package/dist/host/index.d.ts.map +1 -1
  76. package/dist/host/index.js +6 -0
  77. package/dist/host/intent-stages.d.ts +1 -0
  78. package/dist/host/intent-stages.d.ts.map +1 -1
  79. package/dist/host/intent-stages.js +3 -3
  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 +55 -0
  93. package/dist/index.d.ts.map +1 -1
  94. package/dist/index.js +155 -45
  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
 
@@ -108,6 +111,8 @@ const DEFAULT_MESSAGE_REGISTRY = {
108
111
  flow_resumed_with_guidance: "Resuming {{flowPrompt}} - Type 'cancel flow' or 'help' for options.",
109
112
  flow_completed: "{{flowPrompt}} completed successfully.",
110
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.",
111
116
  flow_cancelled: "Cancelled {{flowPrompt}}.",
112
117
  flow_help_general: "Processing {{flowPrompt}} - You can also 'cancel flow' or request 'help'.",
113
118
  flow_help_payment: "Processing {{flowPrompt}} - You can also 'cancel flow' or request 'help'.",
@@ -151,6 +156,8 @@ const DEFAULT_MESSAGE_REGISTRY = {
151
156
  flow_resumed_with_guidance: "Continuando {{flowPrompt}} - Escriba 'cancelar flujo' o 'ayuda' para opciones.",
152
157
  flow_completed: "{{flowPrompt}} completado exitosamente.",
153
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.",
154
161
  flow_cancelled: "{{flowPrompt}} cancelado.",
155
162
  flow_help_general: "Procesando {{flowPrompt}} - También puede 'cancelar flujo' o solicitar 'ayuda'.",
156
163
  flow_help_payment: "Procesando {{flowPrompt}} - También puede 'cancelar flujo' o solicitar 'ayuda'.",
@@ -2011,6 +2018,28 @@ function switchToPreviousStack(engine) {
2011
2018
  throw error;
2012
2019
  }
2013
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
+ }
2014
2043
  function getInitialVariables(engine, flowDefinition, inheritedVariables) {
2015
2044
  let variables;
2016
2045
  if (inheritedVariables) {
@@ -2020,7 +2049,7 @@ function getInitialVariables(engine, flowDefinition, inheritedVariables) {
2020
2049
  if (flowDefinition?.variables) {
2021
2050
  for (const [varName, varDef] of Object.entries(flowDefinition.variables)) {
2022
2051
  if (varDef.value !== undefined) {
2023
- 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
2024
2053
  }
2025
2054
  }
2026
2055
  }
@@ -2032,7 +2061,7 @@ function getInitialVariables(engine, flowDefinition, inheritedVariables) {
2032
2061
  if (flowDefinition?.variables) {
2033
2062
  for (const [varName, varDef] of Object.entries(flowDefinition.variables)) {
2034
2063
  if (varDef.value !== undefined) {
2035
- variables[varName] = varDef.value;
2064
+ variables[varName] = cloneFlowValue(varDef.value);
2036
2065
  }
2037
2066
  }
2038
2067
  }
@@ -2123,7 +2152,7 @@ async function isFlowActivated(input, engine, userId = 'anonymous') {
2123
2152
  flowName: flow.name,
2124
2153
  flowId: flow.id,
2125
2154
  flowVersion: flow.version,
2126
- flowStepsStack: [...flow.steps].reverse(),
2155
+ flowStepsStack: cloneFlowValue(flow.steps).reverse(),
2127
2156
  contextStack: [{ role: 'user', content: sanitizeInput(input), timestamp: Date.now() }], // Keep activation input in context for reference
2128
2157
  inputStack: [], // Start empty - only SAY-GET responses go here
2129
2158
  variables, // Start with global variables + flow definition variables + extracted parameters
@@ -2676,8 +2705,14 @@ function enforceParameterTypes(flow, parameters) {
2676
2705
  const n = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() !== '' ? Number(value) : NaN);
2677
2706
  converted = Number.isFinite(n) && (type === 'number' || Number.isInteger(n)) ? n : undefined;
2678
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
+ }
2679
2714
  else {
2680
- converted = value; // object, array or another declared type - not checked here
2715
+ converted = value; // Legacy parameters without an extraction schema retain their existing behavior
2681
2716
  }
2682
2717
  if (converted !== undefined) {
2683
2718
  result[p.name] = converted;
@@ -2712,7 +2747,8 @@ function buildDetectFlowRequest(input, lastChatTurn, flows) {
2712
2747
  name: p.name,
2713
2748
  description: p.description,
2714
2749
  ...(p.type !== undefined ? { type: p.type } : {}),
2715
- ...(Array.isArray(p.enum) ? { enum: [...p.enum] } : {})
2750
+ ...(Array.isArray(p.enum) ? { enum: [...p.enum] } : {}),
2751
+ ...(p.jsonSchema ? { jsonSchema: structuredClone(p.jsonSchema) } : {})
2716
2752
  }))
2717
2753
  }))
2718
2754
  };
@@ -3351,7 +3387,7 @@ async function handleToolStep(currentFlowFrame, engine) {
3351
3387
  flowName: rebootFlow.name,
3352
3388
  flowId: rebootFlow.id,
3353
3389
  flowVersion: rebootFlow.version,
3354
- flowStepsStack: [...rebootFlow.steps].reverse(),
3390
+ flowStepsStack: cloneFlowValue(rebootFlow.steps).reverse(),
3355
3391
  contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
3356
3392
  inputStack: [input],
3357
3393
  variables: variables, // Fresh variables for reboot flow (with injected params)
@@ -3408,7 +3444,7 @@ async function handleToolStep(currentFlowFrame, engine) {
3408
3444
  flowName: onFailFlow.name,
3409
3445
  flowId: onFailFlow.id,
3410
3446
  flowVersion: onFailFlow.version,
3411
- flowStepsStack: [...onFailFlow.steps].reverse(),
3447
+ flowStepsStack: cloneFlowValue(onFailFlow.steps).reverse(),
3412
3448
  contextStack: [...currentFlowFrame.contextStack], // Inherit context
3413
3449
  inputStack: [...currentFlowFrame.inputStack],
3414
3450
  variables: variables,
@@ -3722,6 +3758,11 @@ function handleSetStep(currentFlowFrame, engine) {
3722
3758
  returnType: 'auto'
3723
3759
  }, engine)
3724
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
+ }
3725
3766
  if (currentFlowFrame && currentFlowFrame.variables !== undefined) {
3726
3767
  currentFlowFrame.variables[step.variable] = interpolatedValue;
3727
3768
  logger.info(`SET step: stored '${interpolatedValue}' in variable '${step.variable}'`);
@@ -4032,7 +4073,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
4032
4073
  flowName: subFlow.name,
4033
4074
  flowId: subFlow.id,
4034
4075
  flowVersion: subFlow.version,
4035
- flowStepsStack: [...subFlow.steps].reverse(),
4076
+ flowStepsStack: cloneFlowValue(subFlow.steps).reverse(),
4036
4077
  contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
4037
4078
  inputStack: [input],
4038
4079
  variables, // Fresh variables for reboot flow
@@ -4052,7 +4093,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
4052
4093
  currentFlowFrame.flowName = subFlow.name;
4053
4094
  currentFlowFrame.flowId = subFlow.id;
4054
4095
  currentFlowFrame.flowVersion = subFlow.version;
4055
- currentFlowFrame.flowStepsStack = [...subFlow.steps].reverse();
4096
+ currentFlowFrame.flowStepsStack = cloneFlowValue(subFlow.steps).reverse();
4056
4097
  addToContextStack(currentFlowFrame.contextStack, 'user', input);
4057
4098
  currentFlowFrame.inputStack.push(input);
4058
4099
  // Inject parameters into current variables — or, for an isolated flow, start this frame's own scope
@@ -4060,13 +4101,18 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
4060
4101
  currentFlowFrame.variables = Object.assign(getInitialVariables(engine, subFlow), flowParameters);
4061
4102
  currentFlowFrame.isolatedScope = true;
4062
4103
  }
4063
- else if (Object.keys(flowParameters).length > 0) {
4064
- logger.info(`Injecting parameters into variables for flow ${subFlow.name}`, flowParameters);
4065
- Object.assign(currentFlowFrame.variables, flowParameters);
4066
- logger.info(`Variables after injection:`, currentFlowFrame.variables);
4067
- }
4068
4104
  else {
4069
- // 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);
4070
4116
  }
4071
4117
  // Update transaction
4072
4118
  TransactionManager.fail(currentFlowFrame.transaction, `Replaced by flow ${subFlow.name}`);
@@ -4086,7 +4132,7 @@ async function handleSubFlowStep(currentFlowFrame, engine) {
4086
4132
  flowName: subFlow.name,
4087
4133
  flowId: subFlow.id,
4088
4134
  flowVersion: subFlow.version,
4089
- flowStepsStack: [...subFlow.steps].reverse(),
4135
+ flowStepsStack: cloneFlowValue(subFlow.steps).reverse(),
4090
4136
  contextStack: [{ role: 'user', content: input, timestamp: Date.now() }],
4091
4137
  inputStack: [input],
4092
4138
  variables, // Inherit + merge flow definition variables (isolated: its own)
@@ -5207,32 +5253,19 @@ function evaluateJavaScriptExpression(expression, context, preserveObjects = fal
5207
5253
  }
5208
5254
  return value;
5209
5255
  });
5210
- // Build function body that evaluates the expression
5211
- // When preserveObjects is true (e.g., SET steps), don't auto-stringify objects
5212
- const functionBody = preserveObjects ? `
5213
- "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});`);
5214
5258
  try {
5215
- return (${expression});
5216
- } catch (e) {
5217
- 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);
5218
5268
  }
5219
- ` : `
5220
- "use strict";
5221
- try {
5222
- const result = (${expression});
5223
- // Auto-stringify complex data structures when used in string contexts
5224
- if (typeof result === 'object' && result !== null &&
5225
- !(result.__userInput === true && result.__literal === true)) {
5226
- return JSON.stringify(result);
5227
- }
5228
- return result;
5229
- } catch (e) {
5230
- throw new Error('Expression evaluation failed: ' + e.message);
5231
- }
5232
- `;
5233
- // Create and execute function with only valid parameter names
5234
- const evaluatorFunction = new Function(...validParamNames, functionBody);
5235
- return evaluatorFunction(...paramValues);
5236
5269
  }
5237
5270
  /**
5238
5271
  * Security-aware variable setter for user input with AI-powered voice cleanup
@@ -5582,7 +5615,7 @@ async function handlePendingInterruptionSwitch(engine, userId) {
5582
5615
  flowName: targetFlow,
5583
5616
  flowId: targetFlowDefinition.id,
5584
5617
  flowVersion: targetFlowDefinition.version || '1.0',
5585
- flowStepsStack: [...targetFlowDefinition.steps].reverse(),
5618
+ flowStepsStack: cloneFlowValue(targetFlowDefinition.steps).reverse(),
5586
5619
  contextStack: [],
5587
5620
  inputStack: [],
5588
5621
  variables: getInitialVariables(engine, targetFlowDefinition), // Fresh variables for first flow activation
@@ -5647,7 +5680,7 @@ async function handleRegularFlowInterruption(intentAnalysis, engine, userId) {
5647
5680
  flowName: targetFlow,
5648
5681
  flowId: targetFlowDefinition.id,
5649
5682
  flowVersion: targetFlowDefinition.version || '1.0',
5650
- flowStepsStack: [...targetFlowDefinition.steps].reverse(),
5683
+ flowStepsStack: cloneFlowValue(targetFlowDefinition.steps).reverse(),
5651
5684
  contextStack: [], // Start with empty context - don't use interruption input
5652
5685
  inputStack: [], // Start with empty input stack - new flow will get proper input next
5653
5686
  variables: getInitialVariables(engine, targetFlowDefinition), // Fresh variables for interrupting flow activation
@@ -5956,6 +5989,21 @@ export function applyWraps(flowsMenu) {
5956
5989
  }
5957
5990
  }
5958
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
+ }
5959
6007
  cargo;
5960
6008
  flowsMenu;
5961
6009
  toolsRegistry;
@@ -6065,8 +6113,18 @@ export class WorkflowEngine {
6065
6113
  logger = withPiiRedaction(hostLogger); // Assign to global logger (a SAY-GET's `pii` answer never reaches it)
6066
6114
  this.aiCallback = aiCallback;
6067
6115
  this.flowsMenu = applyWraps(flowsMenu);
6068
- this.toolsRegistry = toolsRegistry;
6069
- 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
+ };
6070
6128
  this.globalVariables = globalVariables ? { ...globalVariables } : {}; // Copy global variables to prevent external mutations
6071
6129
  this.aiTimeOut = aiTimeOut ?? 2000; // Default to 2000ms (2 seconds)
6072
6130
  // if validateOnInit is not provided, default to true
@@ -6195,6 +6253,50 @@ export class WorkflowEngine {
6195
6253
  if (engineSessionContext) {
6196
6254
  engineSessionContext.lastActivity = this.lastActivity;
6197
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
+ }
6198
6300
  // Role-based processing logic
6199
6301
  if (contextEntry.role === 'user') {
6200
6302
  // A sensitive answer (SAY-GET `pii`) is kept out of every log line of this turn, raw and as its digits
@@ -6205,10 +6307,16 @@ export class WorkflowEngine {
6205
6307
  // Detect intent to activate a flow or switch flows
6206
6308
  let responseOrNull;
6207
6309
  try {
6208
- 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);
6209
6313
  }
6210
6314
  finally {
6211
6315
  piiInputs = [];
6316
+ if (suspendedRecoveryStacks) {
6317
+ engineSessionContext.flowStacks = suspendedRecoveryStacks;
6318
+ this.reconstructVariableReferences();
6319
+ }
6212
6320
  }
6213
6321
  // Anomaly guard: a flow is STILL ACTIVE but produced no response.
6214
6322
  // Detect activity by frame EXISTENCE, not by the response value: a frame
@@ -6238,6 +6346,8 @@ export class WorkflowEngine {
6238
6346
  err.name = 'JSFEActiveFlowEmptyResponse';
6239
6347
  throw err;
6240
6348
  }
6349
+ if (recoveredAction)
6350
+ responseOrNull = [responseOrNull, getSystemMessage(this, 'action_recovery_input_deferred')].filter(Boolean).join('\n\n');
6241
6351
  // Store user turn in lastChatTurn if not in a flow
6242
6352
  if (responseOrNull === null) {
6243
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.
package/docs/forms.md ADDED
@@ -0,0 +1,122 @@
1
+ # Native forms, providers and performers
2
+
3
+ Implemented locally on 2026-10-19 in `jsfe/src/forms` and `jsfe/src/helpers`. These changes are a local, unpublished development candidate. Record the package version and runtime byte fingerprint; a matching version string alone does not identify a build. No production deployment is implied.
4
+
5
+ This is the current authoring reference. Earlier `collect-input-pattern.md` and `manifests.md` preserve the original design discussion; their carry/lift migration recipe and definition-keyed state examples are superseded.
6
+
7
+ ## Ownership
8
+
9
+ - JSFE owns generic matching, input validation protocol, form instances/dependencies, provider retrieval, confirmation, action identity and durable recovery policy. Import builders and shared declarations from `jsfe/forms`; expression helpers come from `jsfe/helpers`.
10
+ - The host owns credentialed transport, storage adapters and persistence acknowledgments. Shared behavior does not belong in tenant-local or host-local support copies.
11
+ - Tenant modules own catalogs, API payloads, business eligibility and presentation. Pure domain rules may be authored as source functions and embedded at build time. Do not read legacy generators or snapshots to construct runtime steps.
12
+ - `defineForm`, `defineProvider`, `definePerformer`, `defineWorker`, `operationWorkers` and `linkFormSchemas` compile native declarations. `validateFlowContracts` checks contracts and references; it does not prove behavioral parity.
13
+
14
+ ## One instance per owned task
15
+
16
+ `cargo.forms[instance]` contains `definition`, `task`, `parent`, `result_input`, canonical `specs`, `values`, accepted proofs, pending readings, errors, preparation state, `data`, saved arguments and child instances. Instance IDs are opaque. `cargo.formTasks[definition]` identifies an active root; nested ownership is `(parent instance, input slot)`, allowing two address slots or independent tasks to coexist.
17
+
18
+ Use the supplied `form_instance` only for internal reentry. A form resumes its saved arguments and validated progress. A sub-form returns through the same acceptance/invalidation transition as a field. Context service forms use `parent_form` with no `result_input`; they inherit the owner’s task without fabricating a value slot.
19
+
20
+ Never mutate accepted values directly for ordinary input. Use the common acceptance protocol. Explicit business-derived values and service results must have declared owning paths and dependencies.
21
+
22
+ ## Input declarations
23
+
24
+ Use presets (`field`), an owned sub-form (`form`), or a narrow inline `profile`. Include labels and prompts in both languages. Relevant attributes:
25
+
26
+ - `when` / `unless`: relevance and readability. Conditions can reference sibling values, `cargo.*`, or `form.data.*`. Unknown conditions wait; they do not silently count as false.
27
+ - `required_when`: whether a relevant field must be collected. False makes the field optional while retaining voluntary recognition. Unknown blocks completion until its premise is known. Omit for required fields.
28
+ - `depends_on`: preparation/validation waits for committed prerequisite evidence; changing the prerequisite invalidates the dependent transitively.
29
+ - `reset_on`: invalidation only. It does not add a validation prerequisite.
30
+ - `validator`: receives `{form,input,value,values,reading}` and writes `cargo.inputCheck={ok,value?,message?,message_es?}`. Missing result fails closed. Activation and conversational answers use this same path.
31
+ - `prepare`: runs once before first validation or prompt; `refresh` runs before each prompt. `choices_from` / `question_from` read owner `data`. Use `compileFormPrompts` for contextual template prompts; it preserves declarations and generates workers.
32
+ - `activate` / `activate_on`: nominate dormant child entry fields that may propose controller assignments, such as a card number selecting a new-card branch. The controller and nominated entry validate as one atomic group; rejection cannot flip the controller. Use narrow descendant paths, never broad matching of every child field.
33
+ - `validate_always:true`: revalidate even an unchanged value when its business assumptions must be refreshed. Validator `revalidate:true` provides the same explicit cache bypass at validator declaration.
34
+ - `after`: post-commit callback. `any_of` groups can be `exclusive`; conflicting members require clarification.
35
+ - `confirm_against`: explicitly requires clarification when two field interpretations overlap, including current-field priority exceptions.
36
+ - `choices[].keys`: whole-answer keypad aliases, recognized only for the current field. An explicit current keypad key wins an overlapping current-field claim, including an `any_of` group; `confirm_against` can still require clarification. Keys never become aliases for noncurrent fields. `words` are semantic aliases; `current_only` restricts a choice. Grouped digit aliases and marked currency must not steal each other’s spans. English and Spanish word amounts require explicit currency and follow the shared cardinal grammar; sequential card digits are not money.
37
+
38
+ A batch retains independent valid fields if another field fails. Dependent early values remain pending until prerequisites validate. An invalid prerequisite cannot authorize an effect. Structured submissions are read leaf by leaf; objects are never coerced into scalar values. Primary nested extraction schemas derive from the same input declarations.
39
+
40
+ ## Resolution and reentry
41
+
42
+ Resolve overlapping evidence in this order: current field, one matching field at the nearest owning scope, then ancestors. Multiple competing claims at the same applicable scope are clarified. Independent spans fill independent fields. Typed evidence beats an unconstrained text fallback. `confirm_against` deliberately overrides ordinary priority.
43
+
44
+ The common loop asks the first ready missing required input, skips accepted data, unwinds inapplicable children and blocks unresolved prerequisites. A context service may use `stop` and `close_on_stop:true` to close itself without handing a value to its parent. With `resume_on_parent_change:true`, an accepted owner correction closes the stale context service and resumes the same owned form with its retained progress, requiring a fresh confirmation. `RETURN` ends the entire engine transaction; ordinary worker completion uses `END`.
45
+
46
+ Optional follow-ups may declare `incomplete_outcome:{outcome:'resolved',reason:'payment_made'}` to preserve an already completed task’s outcome when collection cannot finish or the user leaves. Accepted outcome values are `resolved`, `unresolved` and `deflected`, with a nonempty reason. A reusable form may instead use `incomplete_outcome:{from:'completed_outcome'}` with a declared object parameter; its runtime value must pass the same validation. The nearest actual owned ancestor supplies a fallback to a child. Another task’s outcome never does. The policy covers missing input specifications, blocked dependencies, exhausted validation/retries and unmatched collection/confirmation dispatch; it does not complete missing inputs or authorize effects. Omit it for ordinary required forms, which retain their existing unresolved outcomes and reasons.
47
+
48
+ For bounded collection recovery, declare `on_exhausted:'recovery-worker'` on an input or FORM. On attempt exhaustion, the resolver checks the failed input/member, its group, then its containing form before walking actual owned ancestors. The worker receives `{form,failed_form,input,failed_input,reason:'attempts_exhausted'}`: `form` and `input` identify the selected policy’s owner, while `failed_form` and `failed_input` identify the original failure. Shared recovery resets only the selected input branch, closes its child without handing off an incomplete value, withdraws confirmation and reboots to that worker. Independent draft fields remain. The callback must deliberately collect a new choice and obtain any required fresh confirmation before an effect; exhaustion is not consent. Worker names and references are validated. With no callback, the existing incomplete-outcome or give-up path applies.
49
+
50
+ The common collector passes `form_context:{form,current,names,questions}` to `ask-choice`. The reader checks declared current-field or unique same-scope form readings before classifying an answer as a stale verification-code echo. A valid ZIP, amount or current menu key must not be discarded merely because its digits occur in the prior OTP. Use this shared context instead of a tenant-specific code filter.
51
+
52
+ Private provider questions during collection preserve the owner and pending verification. Repeated questions join the pending provider request instead of stacking another authentication flow. Independent field clauses are accepted before a provider suspends for verification. Questions cannot become invalid OTP attempts or arbitrary text answers.
53
+
54
+ ## Providers
55
+
56
+ Declare a `provides` catalog: subject, gate (`flow`, verified paths), source (`flow`, result path, subject/freshness policy), facts and fact sets. Each fact has a key, phrasings, source path or dedicated worker, rendering rules and explicit missing-data text. A private catalog requires an explicit gate. Derive `asked` and mid-form question choices with the builder; do not maintain another list.
57
+
58
+ `provide-data`, `load-source` and `answer-facts` retrieve and answer requested facts. An explicit fact request does not require a generic account summary. Source refresh clears old results first and records freshness only after success. Subject changes invalidate incompatible data and verification. `no_gate` may skip rerunning a gate only when its declared verification paths hold; it never bypasses their check.
59
+
60
+ ## Performers
61
+
62
+ Declare each action’s name, effect, subject, gate, execution worker/tool, idempotency or reconciliation policy, exact confirmation where applicable, and outcomes. `definePerformer` gathers inputs, prepares an immutable request, selects one applicable action, confirms it, calls `perform-action`, runs its required pure projector, consumes the exact durable receipt, then presents the stored result. Presenter arguments are `{form,request,result}`; retain that explicit result across nested performers rather than rereading the shared last-result cargo slot.
63
+
64
+ Declare `project:[SET steps]` for deterministic session updates such as payment and case ledgers. Its expression locals are `{form,formSnapshot,request,result,key}`: `form` is the owned instance ID and `formSnapshot` is its state at the recorded action. Projection writes target current `cargo`; detached recovery does not replace current forms with that snapshot. Use the durable key for idempotence, the original result timestamp for ordering and explicit ownership guards to preserve newer task drafts or facts. The compiler accepts only SET expressions with local temporary variables and rejects flow/tool/message orchestration. Projectors must be effect-free; expression failure prevents receipt consumption. Presenters own conversation and optional subsequent performers. Ledger updates belong in `project`, because detached late recovery runs this projector without replaying the presenter, `after` steps or caller continuation.
65
+
66
+ Bind subjects to owner data, including composite identities where necessary: `{account,service}` for cancellation or `{contact,type,reason}` for CRM filing. Compare the prepared subject to the current verification evidence before execution. An old unknown result holds that action scope; it cannot capture unrelated requests.
67
+
68
+ Confirmation is separate from fields. It binds the current form revision, values and prepared request. A correction invalidates that binding. Use `confirm.no_keys` only when a declared menu needs different keypad semantics; default `2` remains NO. Confirmation events receive `{form,event,text}` and reenter through ordinary validation. A YES embedded in other content is not consent. `confirm.clarifications` may list exact whole-answer clarification phrases in both languages, such as `['what does that mean','qué significa eso']`. Matching ignores question punctuation and uses the bounded ordinary unclear-answer retry path. These phrases neither consent nor change the request; other unrelated requests still dispatch normally.
69
+
70
+ ## Durable actions and host save boundary
71
+
72
+ Hosts inject `createActionJournal({storage,namespace})` from `jsfe/host`; storage implements atomic attempt/scope/session-index compare-and-swap. A journal must expose claim, complete, checkpoint and pending recovery. The built-in `jsfe:action-claim` and `jsfe:action-complete` ports are reserved. Missing storage fails closed before native effects. In hy-rag-lambdas, `config.jsfe.durableActions` defaults to false: the journal stays unattached, and ordinary legacy turns make no journal reads or add native recovery progress. Only an explicitly opted-in tenant activates this policy.
73
+
74
+ `engine.getActionRecoveryIdentity()` privately caches a verified build stamp and a canonical digest of the loaded compiled flow and tool execution contracts. The stamp is verified before bundling and remains available inside the bundle; production recovery performs no runtime filesystem scan. Checkpoints carry `{protocol:1,runtime,definitions}`. Before full restoration or pure projection, JSFE requires an exact identity match. Missing, unsupported or different identities hold the pending outcome, preserve current task state, defer the incoming message and run no effect, projector or acknowledgment. A package version label is not a compatibility identity. Loaded definitions are immutable after activation; hot reload constructs a new engine, and execution counters/default data belong to individual frames.
75
+
76
+ The journal records a resumable engine checkpoint before an effect and the result afterward. For an opted-in native tenant, JSFE examines unconsumed versions per action key before consuming a new user message. Only an exact compatible recovery identity proceeds to continuation selection. A storage sequence orders journal writes, not workflow progress. A checkpoint from progress absent in the saved session restores its continuation; a late completion after saved progress runs only its pure projector against the current session, preserving current tasks and never replaying its presenter or caller tail. The arriving input is recorded as deferred recovery metadata. It never treats that message as retroactive consent. A higher journal sequence alone cannot justify restoring an older workflow; per-key applied versions, saved workflow progress, recovery epoch and host invalidation revision determine whether continuation restore is safe.
77
+
78
+ The host saves the resulting session first, then awaits `checkpointActionSession`. Failed or rejected saves never release action scopes. Versioned `{key,sequence}` receipts stay persisted for acknowledgment retry. Freeze those versions at the successful save boundary: an older attempted/unknown receipt must never acknowledge a later completion. Late tool callbacks return results without advancing the active session’s applied watermark. The performer advances it only after consuming and projecting the durable result. Acknowledging an unknown outcome clears its recovery checkpoint but retains its scope hold. No elapsed timeout resolves an uncertain financial action; a balance change alone is not transaction identity evidence. Explicit fresh consent may supersede an uncertain SEND only under a declared send retry policy.
79
+
80
+ Host invalidations use `retainRecoveryState(session,paths)` after changing or deleting host-owned cargo. Newer invalidations, including deletion tombstones, select pure result projection against the current session instead of restoring the older workflow or its private presenter. Explicit conversation resets rotate the persisted recovery epoch: old outcomes can be projected, but discarded workflows cannot resume. Before every fresh effect, `perform-action` reruns the declared `gate.flow` and checks current verification paths; a stale derived permission flag cannot replace that gate.
81
+
82
+ The host’s DynamoDB adapter shares the configured chats table with reserved internal keys. Chat entry points reject those keys. Do not deploy a new durable performer without configuring this port and testing the actual save boundary.
83
+
84
+ ## Checkpoint storage codec
85
+
86
+ The Node host storage boundary imports `encodeCheckpoint`, `decodeCheckpoint` and `isEncodedCheckpoint` from `jsfe/host/node`. This Node-only entry uses asynchronous Brotli; the universal `jsfe/host` entry remains free of Node imports. The version-1 envelope stores binary `Uint8Array` data, the original UTF-8 JSON byte count and a SHA-256 integrity digest. It losslessly round-trips JSON-compatible session data without trimming history, forms or action records.
87
+
88
+ The default limits are 16 MiB of decoded JSON and 350 KiB of encoded binary. Decompression is bounded before decoding, and the host separately checks the complete DynamoDB item against a conservative 400 KiB bound, including all metadata and other fields. Compression does not guarantee that arbitrary data fits; an oversized item fails before the database write.
89
+
90
+ The host journal adapter encodes only the attempt checkpoint and decodes it when reading; successful acknowledgment removes that checkpoint payload. Journal-internal storage remains separate from conversation storage. Conversation writers default to `config.jsfe.sessionEncoding:'plain'`; only an explicit `'br-v1'` enables binary `engineSession` writes. This capability is independent of `durableActions`. The configuration group and both keys may be omitted; malformed values and unknown keys reject configuration. Conversation readers always decode supported envelopes before constructing a session, including when writers are disabled. Receipt versions are frozen at the actual save boundary, and other host fields retain their normal representation.
91
+
92
+ Historical plaintext remains readable. Unsupported envelopes, corrupt data, integrity failures and codec limits raise `CheckpointCodecError`; whole-item overflow raises `JSFEStorageSizeLimit`. A corrupt saved conversation must not become a fresh session or overwrite its stored row. Failed writes never acknowledge action receipts. Disabling encoding converts a decoded row to plaintext only on its next successful size-bounded save; it does not convert dormant rows, and oversized plaintext fails without trimming or modifying the old envelope.
93
+
94
+ Roll out decoder-capable readers before enabling any tenant’s binary writer. Old workers in a mixed fleet cannot read new envelopes. A pre-reader rollback requires lossless, revision-checked conversion and verification of every affected row; a flag toggle alone is insufficient. Existing native continuations and pending/unknown actions must remain on a compatible recovery service when native starts are disabled. Never hand them to legacy execution or reset them to manufacture rollback compatibility.
95
+
96
+ ## Explicit monetary grammar and compatibility
97
+
98
+ `amountInAnswer(text)` and `isAmountAnswer(text)` preserve the published JSFE 1.6 behavior. Installing native FORM support does not make existing production flows accept cardinal words or treat pesos as dollar markers.
99
+
100
+ Native inputs can declare enhanced grammar explicitly:
101
+
102
+ ```js
103
+ { name: 'amount', profile: { kind: 'amount', min: 0.50,
104
+ money: { currency: 'USD', cardinals: true, intent: 'payment' } },
105
+ validator: 'validate-payment-amount' }
106
+ ```
107
+
108
+ `readMoneyAmount(text, options)` returns a number or `NaN`; `isMoneyAmount` is true exactly for a finite result, including bare numeric input. Options require `currency: 'USD'`; `cardinals` defaults to false, and `intent` is optional. Currently USD is the only supported currency. Explicit pesos/MXN, euros and other named foreign currencies fail closed; no currency conversion or equivalence is inferred. The optional cardinal grammar supports complete English/Spanish dollar and cents phrases and preserves source spans. Unequal independent amounts, malformed cardinal phrases and signed negative amounts are rejected.
109
+
110
+ With `intent: 'payment'`, the shared `moneyIntentAllows` policy recognizes explicit English/Spanish payment-refusal, inability and deferral forms while allowing uncertainty followed by a positive correction (for example, “no entiendo, quiero pagar250”). The vocabulary is deterministic, not an exhaustive linguistic classifier. A recognized declined monetary instruction is not accepted as a numeric correction. Matching may pass an uncommittable `refused: true` candidate with raw text to a declared validator so that it can route to domain recovery. The common validation batch withholds that candidate even when a permissive validator returns success; activation and conversational paths use the same rule. Confirmation remains a separate, later action decision.
111
+
112
+ ## Acceptance
113
+
114
+ Map every original behavior to native definitions or an explicit consolidation. Runtime import graphs must exclude carried JSON, lifted steps, source-renaming adapters and the old generator. Historical fixtures can remain outside the runtime graph. Counts and valid contracts alone do not establish completion.
115
+
116
+ Run independent rule tests, real-engine journeys with JSON session round trips, original behavioral suites, anonymized production response-shape journeys and intent/parameter probes in English and Spanish. Include nested out-of-order input, optional-field recognition, multiple ambiguity groups, corrections, subject changes, provider questions during OTP, duplicate turns, refused APIs, unknown effects and process loss before host save. Preserve intentional interaction differences explicitly rather than weakening effects or consent assertions.
117
+
118
+ Generation failures, stale artifacts, empty suite selections, crashed suites and failed expectations must fail acceptance. Record engine/build identity, source revisions, model configuration and exact executed evidence. Production deployment still requires Ron’s explicit authorization.
119
+
120
+ ### Host capability admission
121
+
122
+ `definePerformer` declares `durableActions` in its contract requirements. A host must call `assertFlowHostCapabilities(flows, capabilities)` from `jsfe/host` before activation and supply `durableActions: true` explicitly for these flows. The check does not enable a journal or choose a conversation encoding. Missing requirements reject activation before customer input or effects. Other flow builders may declare additional boolean host requirements through `contract.requires`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsfe",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "TypeScript workflow engine for conversational/automation apps: multi-flow orchestration, interrupt/resume, stack-based control, robust errors, and flexible tool/REST integration.",
5
5
  "license": "MIT",
6
6
  "author": "Ron Pinkas",
@@ -19,9 +19,17 @@
19
19
  "types": "./dist/host/index.d.ts",
20
20
  "import": "./dist/host/index.js"
21
21
  },
22
+ "./host/node": {
23
+ "types": "./dist/host/node.d.ts",
24
+ "import": "./dist/host/node.js"
25
+ },
22
26
  "./helpers": {
23
27
  "types": "./dist/helpers/index.d.ts",
24
28
  "import": "./dist/helpers/index.js"
29
+ },
30
+ "./forms": {
31
+ "types": "./dist/forms/index.d.ts",
32
+ "import": "./dist/forms/index.js"
25
33
  }
26
34
  },
27
35
  "main": "./dist/index.js",
@@ -31,13 +39,16 @@
31
39
  "dist",
32
40
  "README.md",
33
41
  "LICENSE",
34
- "docs/copilot-reference.md"
42
+ "docs/copilot-reference.md",
43
+ "docs/forms.md",
44
+ "docs/development-release.md"
35
45
  ],
36
46
  "scripts": {
37
- "build": "tsc -p tsconfig.json",
47
+ "build": "tsc -p tsconfig.json && node scripts/stamp-runtime.mjs",
38
48
  "clean": "rm -rf dist",
39
49
  "test": "for t in tests/*.test.mjs; do node \"$t\" || exit 1; done",
40
- "prepublishOnly": "npm run clean && npm run build"
50
+ "prepublishOnly": "npm run clean && npm run build",
51
+ "verify:runtime": "node scripts/stamp-runtime.mjs --check"
41
52
  },
42
53
  "keywords": [
43
54
  "workflow",