@promptbook/cli 0.112.0-117 → 0.112.0-118

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 (41) hide show
  1. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatSidebarDefault.tsx +5 -6
  2. package/apps/agents-server/src/utils/externalChatRunner/processExternalUserChatJob.ts +17 -7
  3. package/apps/agents-server/src/utils/localChatRunner/processLocalUserChatJob.ts +17 -7
  4. package/apps/agents-server/src/utils/userChat/createImmediateUserChatAnswerModelRequirements.ts +11 -0
  5. package/apps/agents-server/src/utils/userChat/listUserChats.ts +5 -7
  6. package/esm/index.es.js +417 -64
  7. package/esm/index.es.js.map +1 -1
  8. package/esm/scripts/run-codex-prompts/common/parseDuration.d.ts +19 -0
  9. package/esm/src/_packages/node.index.d.ts +10 -0
  10. package/esm/src/book-3.0/BookNodeAgentSource.d.ts +1 -1
  11. package/esm/src/book-3.0/CliAgent.d.ts +7 -2
  12. package/esm/src/book-3.0/cliAgentEnv.d.ts +33 -0
  13. package/esm/src/cli/cli-commands/common/promptRunnerCliOptions.d.ts +2 -18
  14. package/esm/src/version.d.ts +1 -1
  15. package/package.json +1 -1
  16. package/src/_packages/node.index.ts +10 -0
  17. package/src/avatars/avatarAnimationScheduler.ts +33 -2
  18. package/src/avatars/visuals/fractalAvatarVisual.ts +5 -4
  19. package/src/avatars/visuals/minecraft2AvatarVisual.ts +16 -11
  20. package/src/avatars/visuals/minecraftAvatarVisual.ts +21 -7
  21. package/src/avatars/visuals/octopus3d2AvatarVisual.ts +69 -17
  22. package/src/avatars/visuals/octopus3d3AvatarVisual.ts +81 -18
  23. package/src/avatars/visuals/octopus3dAvatarVisual.ts +69 -17
  24. package/src/book-3.0/Book.ts +3 -1
  25. package/src/book-3.0/BookNodeAgentSource.ts +2 -2
  26. package/src/book-3.0/CliAgent.ts +84 -6
  27. package/src/book-3.0/cliAgentEnv.ts +46 -0
  28. package/src/cli/cli-commands/coder/run.ts +28 -3
  29. package/src/cli/cli-commands/common/promptRunnerCliOptions.ts +9 -29
  30. package/src/other/templates/getTemplatesPipelineCollection.ts +713 -735
  31. package/src/version.ts +2 -2
  32. package/src/versions.txt +1 -0
  33. package/umd/index.umd.js +417 -64
  34. package/umd/index.umd.js.map +1 -1
  35. package/umd/scripts/run-codex-prompts/common/parseDuration.d.ts +19 -0
  36. package/umd/src/_packages/node.index.d.ts +10 -0
  37. package/umd/src/book-3.0/BookNodeAgentSource.d.ts +1 -1
  38. package/umd/src/book-3.0/CliAgent.d.ts +7 -2
  39. package/umd/src/book-3.0/cliAgentEnv.d.ts +33 -0
  40. package/umd/src/cli/cli-commands/common/promptRunnerCliOptions.d.ts +2 -18
  41. package/umd/src/version.d.ts +1 -1
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Parses a human-readable duration string into milliseconds.
3
+ *
4
+ * Supported formats: `Xh`, `Xm`, `Xs`, and combinations like `1h30m`, `1h30m5s`.
5
+ *
6
+ * @returns Duration in milliseconds
7
+ * @throws When the string does not match any supported format
8
+ *
9
+ * @private internal utility of `ptbk coder run`
10
+ */
11
+ export declare function parseDuration(durationString: string): number;
12
+ /**
13
+ * Formats a duration in milliseconds into a compact human-readable string.
14
+ *
15
+ * Examples: `3600000` → `"1h"`, `90000` → `"1m 30s"`, `5000` → `"5s"`.
16
+ *
17
+ * @private internal utility of `ptbk coder run`
18
+ */
19
+ export declare function formatDurationMs(ms: number): string;
@@ -11,6 +11,11 @@ import type { CliAgentThinkingLevel } from '../book-3.0/CliAgent';
11
11
  import type { CliAgentRunOptions } from '../book-3.0/CliAgent';
12
12
  import type { CliAgentOptions } from '../book-3.0/CliAgent';
13
13
  import { CliAgent } from '../book-3.0/CliAgent';
14
+ import { CLI_AGENT_HARNESS_NAMES } from '../book-3.0/cliAgentEnv';
15
+ import { CLI_AGENT_THINKING_LEVEL_VALUES } from '../book-3.0/cliAgentEnv';
16
+ import { PTBK_HARNESS_ENV } from '../book-3.0/cliAgentEnv';
17
+ import { PTBK_MODEL_ENV } from '../book-3.0/cliAgentEnv';
18
+ import { PTBK_THINKING_LEVEL_ENV } from '../book-3.0/cliAgentEnv';
14
19
  import type { LiteAgentOptions } from '../book-3.0/LiteAgent';
15
20
  import type { LiteAgentRunOptions } from '../book-3.0/LiteAgent';
16
21
  import { LiteAgent } from '../book-3.0/LiteAgent';
@@ -39,6 +44,11 @@ export type { CliAgentThinkingLevel };
39
44
  export type { CliAgentRunOptions };
40
45
  export type { CliAgentOptions };
41
46
  export { CliAgent };
47
+ export { CLI_AGENT_HARNESS_NAMES };
48
+ export { CLI_AGENT_THINKING_LEVEL_VALUES };
49
+ export { PTBK_HARNESS_ENV };
50
+ export { PTBK_MODEL_ENV };
51
+ export { PTBK_THINKING_LEVEL_ENV };
42
52
  export type { LiteAgentOptions };
43
53
  export type { LiteAgentRunOptions };
44
54
  export { LiteAgent };
@@ -15,7 +15,7 @@ export type BookNodeAgentSource = Book | string_book;
15
15
  */
16
16
  export type BookNodeAgentSourceOptions = {
17
17
  readonly agentPath?: string;
18
- readonly book?: BookNodeAgentSource;
18
+ readonly book?: string | BookNodeAgentSource;
19
19
  readonly currentWorkingDirectory?: string;
20
20
  };
21
21
  /**
@@ -1,16 +1,17 @@
1
1
  import type { BookNodeAgentSourceOptions } from './BookNodeAgentSource';
2
+ import { CLI_AGENT_HARNESS_NAMES, CLI_AGENT_THINKING_LEVEL_VALUES } from './cliAgentEnv';
2
3
  /**
3
4
  * CLI harness names supported by `ptbk agent exec`.
4
5
  *
5
6
  * @public exported from `@promptbook/node`
6
7
  */
7
- export type CliAgentHarness = 'openai-codex' | 'github-copilot' | 'cline' | 'claude-code' | 'opencode' | 'gemini';
8
+ export type CliAgentHarness = (typeof CLI_AGENT_HARNESS_NAMES)[number];
8
9
  /**
9
10
  * Thinking levels supported by CLI coding harnesses.
10
11
  *
11
12
  * @public exported from `@promptbook/node`
12
13
  */
13
- export type CliAgentThinkingLevel = 'low' | 'medium' | 'high' | 'xhigh';
14
+ export type CliAgentThinkingLevel = (typeof CLI_AGENT_THINKING_LEVEL_VALUES)[number];
14
15
  /**
15
16
  * Per-run CLI options exposed by `CliAgent`.
16
17
  *
@@ -22,6 +23,7 @@ export type CliAgentRunOptions = {
22
23
  readonly allowCredits?: boolean;
23
24
  readonly context?: string;
24
25
  readonly harness?: CliAgentHarness;
26
+ readonly isVerbose?: boolean;
25
27
  readonly model?: string;
26
28
  readonly noUi?: boolean;
27
29
  readonly thinkingLevel?: CliAgentThinkingLevel;
@@ -38,6 +40,9 @@ export type CliAgentOptions = BookNodeAgentSourceOptions & CliAgentRunOptions;
38
40
  * It uses the same harnesses and execution path as `ptbk agent exec`, running the runner
39
41
  * in-process instead of spawning a separate CLI process.
40
42
  *
43
+ * When no `harness` is provided in the constructor or per-run options, `CliAgent` falls back
44
+ * to the `PTBK_HARNESS` environment variable, mirroring `ptbk agent exec` behavior.
45
+ *
41
46
  * @public exported from `@promptbook/node`
42
47
  */
43
48
  export declare class CliAgent {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * All CLI harness names supported by `CliAgent` and `ptbk agent exec`.
3
+ *
4
+ * @public exported from `@promptbook/node`
5
+ */
6
+ export declare const CLI_AGENT_HARNESS_NAMES: readonly ["openai-codex", "github-copilot", "cline", "claude-code", "opencode", "gemini"];
7
+ /**
8
+ * All supported thinking-level values for CLI coding-agent runners.
9
+ *
10
+ * @public exported from `@promptbook/node`
11
+ */
12
+ export declare const CLI_AGENT_THINKING_LEVEL_VALUES: readonly ["low", "medium", "high", "xhigh"];
13
+ /**
14
+ * Environment variable used as the default runner identifier when `--harness` is omitted or not set in `CliAgent`.
15
+ *
16
+ * Set this to one of the harness names (`openai-codex`, `github-copilot`, `cline`, `claude-code`, `opencode`, `gemini`)
17
+ * so that `CliAgent` and `ptbk agent exec` can run without an explicit `harness` option.
18
+ *
19
+ * @public exported from `@promptbook/node`
20
+ */
21
+ export declare const PTBK_HARNESS_ENV = "PTBK_HARNESS";
22
+ /**
23
+ * Environment variable used as the default runner model when `--model` is omitted or not set in `CliAgent`.
24
+ *
25
+ * @public exported from `@promptbook/node`
26
+ */
27
+ export declare const PTBK_MODEL_ENV = "PTBK_MODEL";
28
+ /**
29
+ * Environment variable used as the default thinking level when `--thinking-level` is omitted or not set in `CliAgent`.
30
+ *
31
+ * @public exported from `@promptbook/node`
32
+ */
33
+ export declare const PTBK_THINKING_LEVEL_ENV = "PTBK_THINKING_LEVEL";
@@ -1,29 +1,13 @@
1
1
  import { Command as Program } from 'commander';
2
2
  import type { ThinkingLevel } from '../coder/ThinkingLevel';
3
+ import { PTBK_HARNESS_ENV, PTBK_MODEL_ENV, PTBK_THINKING_LEVEL_ENV } from '../../../book-3.0/cliAgentEnv';
4
+ export { PTBK_HARNESS_ENV, PTBK_MODEL_ENV, PTBK_THINKING_LEVEL_ENV };
3
5
  /**
4
6
  * Runner identifiers supported by Promptbook CLI agent orchestration commands.
5
7
  *
6
8
  * @private internal utility of `promptbookCli`
7
9
  */
8
10
  export declare const PROMPT_RUNNER_HARNESS_NAMES: readonly ["openai-codex", "github-copilot", "cline", "claude-code", "opencode", "gemini"];
9
- /**
10
- * Environment variable used as the default runner identifier when `--harness` is omitted.
11
- *
12
- * @private internal utility of `promptbookCli`
13
- */
14
- export declare const PTBK_HARNESS_ENV = "PTBK_HARNESS";
15
- /**
16
- * Environment variable used as the default runner model when `--model` is omitted.
17
- *
18
- * @private internal utility of `promptbookCli`
19
- */
20
- export declare const PTBK_MODEL_ENV = "PTBK_MODEL";
21
- /**
22
- * Environment variable used as the default runner thinking level when `--thinking-level` is omitted.
23
- *
24
- * @private internal utility of `promptbookCli`
25
- */
26
- export declare const PTBK_THINKING_LEVEL_ENV = "PTBK_THINKING_LEVEL";
27
11
  /**
28
12
  * Runner identifier supported by Promptbook CLI agent orchestration commands.
29
13
  *
@@ -15,7 +15,7 @@ export declare const BOOK_LANGUAGE_VERSION: string_semantic_version;
15
15
  export declare const PROMPTBOOK_ENGINE_VERSION: string_promptbook_version;
16
16
  /**
17
17
  * Represents the version string of the Promptbook engine.
18
- * It follows semantic versioning (e.g., `0.112.0-116`).
18
+ * It follows semantic versioning (e.g., `0.112.0-117`).
19
19
  *
20
20
  * @generated
21
21
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptbook/cli",
3
- "version": "0.112.0-117",
3
+ "version": "0.112.0-118",
4
4
  "description": "Promptbook: Create persistent AI agents that turn your company's scattered knowledge into action",
5
5
  "private": false,
6
6
  "sideEffects": false,
@@ -14,6 +14,11 @@ import type { CliAgentThinkingLevel } from '../book-3.0/CliAgent';
14
14
  import type { CliAgentRunOptions } from '../book-3.0/CliAgent';
15
15
  import type { CliAgentOptions } from '../book-3.0/CliAgent';
16
16
  import { CliAgent } from '../book-3.0/CliAgent';
17
+ import { CLI_AGENT_HARNESS_NAMES } from '../book-3.0/cliAgentEnv';
18
+ import { CLI_AGENT_THINKING_LEVEL_VALUES } from '../book-3.0/cliAgentEnv';
19
+ import { PTBK_HARNESS_ENV } from '../book-3.0/cliAgentEnv';
20
+ import { PTBK_MODEL_ENV } from '../book-3.0/cliAgentEnv';
21
+ import { PTBK_THINKING_LEVEL_ENV } from '../book-3.0/cliAgentEnv';
17
22
  import type { LiteAgentOptions } from '../book-3.0/LiteAgent';
18
23
  import type { LiteAgentRunOptions } from '../book-3.0/LiteAgent';
19
24
  import { LiteAgent } from '../book-3.0/LiteAgent';
@@ -48,6 +53,11 @@ export type { CliAgentThinkingLevel };
48
53
  export type { CliAgentRunOptions };
49
54
  export type { CliAgentOptions };
50
55
  export { CliAgent };
56
+ export { CLI_AGENT_HARNESS_NAMES };
57
+ export { CLI_AGENT_THINKING_LEVEL_VALUES };
58
+ export { PTBK_HARNESS_ENV };
59
+ export { PTBK_MODEL_ENV };
60
+ export { PTBK_THINKING_LEVEL_ENV };
51
61
  export type { LiteAgentOptions };
52
62
  export type { LiteAgentRunOptions };
53
63
  export { LiteAgent };
@@ -7,6 +7,24 @@
7
7
  */
8
8
  type AvatarAnimationListener = (now: number) => void;
9
9
 
10
+ /**
11
+ * Target frames per second for the shared avatar animation loop.
12
+ *
13
+ * Animated octopus visuals change slowly enough that 24 fps is indistinguishable
14
+ * from 60 fps in practice, while cutting rendering work by ~60% when multiple
15
+ * avatars are on screen simultaneously.
16
+ *
17
+ * @private utility of the avatar rendering system
18
+ */
19
+ const AVATAR_TARGET_FPS = 24;
20
+
21
+ /**
22
+ * Minimum elapsed time in milliseconds required between avatar render passes.
23
+ *
24
+ * @private utility of the avatar rendering system
25
+ */
26
+ const AVATAR_TARGET_FRAME_INTERVAL_MS = 1000 / AVATAR_TARGET_FPS;
27
+
10
28
  /**
11
29
  * Next registration id used by the shared avatar animation scheduler.
12
30
  *
@@ -28,6 +46,15 @@ const avatarAnimationListeners = new Map<number, AvatarAnimationListener>();
28
46
  */
29
47
  let avatarAnimationFrameId: number | null = null;
30
48
 
49
+ /**
50
+ * Timestamp of the most recently rendered avatar frame.
51
+ *
52
+ * Used to throttle callbacks to `AVATAR_TARGET_FRAME_INTERVAL_MS`.
53
+ *
54
+ * @private utility of the avatar rendering system
55
+ */
56
+ let lastAvatarFrameTime = 0;
57
+
31
58
  /**
32
59
  * Registers one avatar animation callback in the shared animation loop.
33
60
  *
@@ -68,8 +95,12 @@ function ensureAvatarAnimationLoop(): void {
68
95
  const runFrame = (now: number) => {
69
96
  avatarAnimationFrameId = null;
70
97
 
71
- for (const avatarAnimationListener of [...avatarAnimationListeners.values()]) {
72
- avatarAnimationListener(now);
98
+ if (now - lastAvatarFrameTime >= AVATAR_TARGET_FRAME_INTERVAL_MS) {
99
+ lastAvatarFrameTime = now;
100
+
101
+ for (const avatarAnimationListener of [...avatarAnimationListeners.values()]) {
102
+ avatarAnimationListener(now);
103
+ }
73
104
  }
74
105
 
75
106
  ensureAvatarAnimationLoop();
@@ -290,7 +290,7 @@ function drawDragonCurveLayer(
290
290
  layerIndex: number;
291
291
  },
292
292
  ): void {
293
- const { size, primaryColor, secondaryColor, tertiaryColor, shadowColor, strokeWidth, timeMs, layerIndex } = options;
293
+ const { primaryColor, secondaryColor, tertiaryColor, shadowColor, strokeWidth, timeMs, layerIndex } = options;
294
294
  const firstPoint = points[0]!;
295
295
  const lastPoint = points[points.length - 1]!;
296
296
  const ribbonGradient = context.createLinearGradient(firstPoint.x, firstPoint.y, lastPoint.x, lastPoint.y);
@@ -298,14 +298,15 @@ function drawDragonCurveLayer(
298
298
  ribbonGradient.addColorStop(0.5, `${secondaryColor}e6`);
299
299
  ribbonGradient.addColorStop(1, `${tertiaryColor}f2`);
300
300
 
301
+ // Approximate the blurred shadow stroke with a wider semi-transparent stroke instead of
302
+ // context.filter blur, which triggers a costly software rasterization pass every frame.
301
303
  context.save();
302
304
  context.beginPath();
303
305
  tracePolyline(context, points);
304
- context.strokeStyle = `${shadowColor}82`;
305
- context.lineWidth = strokeWidth * 1.8;
306
+ context.strokeStyle = `${shadowColor}48`;
307
+ context.lineWidth = strokeWidth * 4.5;
306
308
  context.lineJoin = 'round';
307
309
  context.lineCap = 'round';
308
- context.filter = `blur(${size * 0.022}px)`;
309
310
  context.stroke();
310
311
  context.restore();
311
312
 
@@ -218,19 +218,24 @@ function drawMinecraftShadow(
218
218
  },
219
219
  timeMs: number,
220
220
  ): void {
221
+ const cx = size * 0.5 + interaction.gazeX * size * 0.03;
222
+ const cy = size * 0.85 + Math.sin(timeMs / 880) * size * 0.01;
223
+ const rx = size * (0.16 + interaction.intensity * 0.015);
224
+ const ry = size * 0.055;
225
+
226
+ // Radial gradient approximates the blurry ellipse shadow without context.filter blur.
221
227
  context.save();
222
- context.fillStyle = `${palette.shadow}66`;
223
- context.filter = `blur(${size * 0.02}px)`;
228
+ context.translate(cx, cy);
229
+ context.scale(1, ry / rx);
230
+ const blurRadius = rx * 1.4;
231
+ const shadowGradient = context.createRadialGradient(0, 0, 0, 0, 0, blurRadius);
232
+ shadowGradient.addColorStop(0, `${palette.shadow}7a`);
233
+ shadowGradient.addColorStop(0.45, `${palette.shadow}44`);
234
+ shadowGradient.addColorStop(0.8, `${palette.shadow}1a`);
235
+ shadowGradient.addColorStop(1, `${palette.shadow}00`);
236
+ context.fillStyle = shadowGradient;
224
237
  context.beginPath();
225
- context.ellipse(
226
- size * 0.5 + interaction.gazeX * size * 0.03,
227
- size * 0.85 + Math.sin(timeMs / 880) * size * 0.01,
228
- size * (0.16 + interaction.intensity * 0.015),
229
- size * 0.055,
230
- 0,
231
- 0,
232
- Math.PI * 2,
233
- );
238
+ context.arc(0, 0, blurRadius, 0, Math.PI * 2);
234
239
  context.fill();
235
240
  context.restore();
236
241
  }
@@ -45,13 +45,27 @@ export const minecraftAvatarVisual: AvatarVisualDefinition = {
45
45
  context.fillStyle = spotlight;
46
46
  context.fillRect(0, 0, size, size);
47
47
 
48
- context.save();
49
- context.fillStyle = 'rgba(0, 0, 0, 0.22)';
50
- context.filter = `blur(${size * 0.018}px)`;
51
- context.beginPath();
52
- context.ellipse(size * 0.5, size * 0.86, size * 0.2, size * 0.06, 0, 0, Math.PI * 2);
53
- context.fill();
54
- context.restore();
48
+ {
49
+ // Radial gradient approximates the blurry ellipse shadow without context.filter blur.
50
+ const cx = size * 0.5;
51
+ const cy = size * 0.86;
52
+ const rx = size * 0.2;
53
+ const ry = size * 0.06;
54
+ const blurRadius = rx * 1.4;
55
+ const shadowGradient = context.createRadialGradient(0, 0, 0, 0, 0, blurRadius);
56
+ shadowGradient.addColorStop(0, 'rgba(0,0,0,0.28)');
57
+ shadowGradient.addColorStop(0.45, 'rgba(0,0,0,0.14)');
58
+ shadowGradient.addColorStop(0.8, 'rgba(0,0,0,0.05)');
59
+ shadowGradient.addColorStop(1, 'rgba(0,0,0,0)');
60
+ context.save();
61
+ context.translate(cx, cy);
62
+ context.scale(1, ry / rx);
63
+ context.fillStyle = shadowGradient;
64
+ context.beginPath();
65
+ context.arc(0, 0, blurRadius, 0, Math.PI * 2);
66
+ context.fill();
67
+ context.restore();
68
+ }
55
69
 
56
70
  drawVoxelCuboid(context, {
57
71
  x: bodyX,
@@ -55,6 +55,53 @@ const LIGHT_DIRECTION: Point3D = normalizeVector3({
55
55
  z: 0.98,
56
56
  });
57
57
 
58
+ /**
59
+ * Per-avatar stable state derived once from the seeded random factory and reused across frames.
60
+ *
61
+ * @private helper of `octopus3d2AvatarVisual`
62
+ */
63
+ type Octopus3d2StableState = {
64
+ readonly morphologyProfile: Octopus3MorphologyProfile;
65
+ readonly animationPhase: number;
66
+ readonly leftEyePhaseOffset: number;
67
+ readonly rightEyePhaseOffset: number;
68
+ };
69
+
70
+ /**
71
+ * Cache keyed by the `createRandom` factory reference (stable per mounted `<Avatar/>`).
72
+ *
73
+ * @private helper of `octopus3d2AvatarVisual`
74
+ */
75
+ const octopus3d2StableStateCache = new WeakMap<(salt: string) => () => number, Octopus3d2StableState>();
76
+
77
+ /**
78
+ * Returns the stable per-avatar state, computing it on first access and caching for subsequent frames.
79
+ *
80
+ * @private helper of `octopus3d2AvatarVisual`
81
+ */
82
+ function getOctopus3d2StableState(createRandom: (salt: string) => () => number): Octopus3d2StableState {
83
+ const cached = octopus3d2StableStateCache.get(createRandom);
84
+
85
+ if (cached !== undefined) {
86
+ return cached;
87
+ }
88
+
89
+ const animationRandom = createRandom('octopus3d2-animation-profile');
90
+ const eyeRandom = createRandom('octopus3d2-eye-profile');
91
+ const leftEyePhaseOffset = eyeRandom() * 0.7;
92
+ const rightEyePhaseOffset = eyeRandom() * 0.7;
93
+ const state: Octopus3d2StableState = {
94
+ morphologyProfile: createOctopus3MorphologyProfile(createRandom),
95
+ animationPhase: animationRandom() * Math.PI * 2,
96
+ leftEyePhaseOffset,
97
+ rightEyePhaseOffset,
98
+ };
99
+
100
+ octopus3d2StableStateCache.set(createRandom, state);
101
+
102
+ return state;
103
+ }
104
+
58
105
  /**
59
106
  * Octopus 3D 2 avatar visual.
60
107
  *
@@ -67,10 +114,8 @@ export const octopus3d2AvatarVisual: AvatarVisualDefinition = {
67
114
  isAnimated: true,
68
115
  supportsPointerTracking: true,
69
116
  render({ context, size, palette, createRandom, timeMs, interaction }) {
70
- const morphologyProfile = createOctopus3MorphologyProfile(createRandom);
71
- const animationRandom = createRandom('octopus3d2-animation-profile');
72
- const eyeRandom = createRandom('octopus3d2-eye-profile');
73
- const animationPhase = animationRandom() * Math.PI * 2;
117
+ const { morphologyProfile, animationPhase, leftEyePhaseOffset, rightEyePhaseOffset } =
118
+ getOctopus3d2StableState(createRandom);
74
119
  const sceneCenterX = size * 0.5;
75
120
  const sceneCenterY = size * 0.575;
76
121
  const bob = Math.sin(timeMs / 940 + animationPhase) * size * 0.013;
@@ -152,7 +197,7 @@ export const octopus3d2AvatarVisual: AvatarVisualDefinition = {
152
197
  size,
153
198
  palette,
154
199
  timeMs,
155
- animationPhase + eyeRandom() * 0.7,
200
+ animationPhase + leftEyePhaseOffset,
156
201
  interaction,
157
202
  morphologyProfile.face.eyeStyle,
158
203
  );
@@ -169,7 +214,7 @@ export const octopus3d2AvatarVisual: AvatarVisualDefinition = {
169
214
  size,
170
215
  palette,
171
216
  timeMs,
172
- animationPhase + 0.9 + eyeRandom() * 0.7,
217
+ animationPhase + 0.9 + rightEyePhaseOffset,
173
218
  interaction,
174
219
  morphologyProfile.face.eyeStyle,
175
220
  );
@@ -249,6 +294,9 @@ function drawBlobbyOctopusAtmosphere(
249
294
  /**
250
295
  * Draws the soft floor shadow that anchors the single mesh in the frame.
251
296
  *
297
+ * Uses a scaled radial gradient instead of `context.filter = 'blur()'` to approximate the
298
+ * blurry ellipse without triggering a costly software rasterization pass on every frame.
299
+ *
252
300
  * @private helper of `octopus3d2AvatarVisual`
253
301
  */
254
302
  function drawBlobbyOctopusShadow(
@@ -262,19 +310,23 @@ function drawBlobbyOctopusShadow(
262
310
  timeMs: number,
263
311
  morphologyProfile: Octopus3MorphologyProfile,
264
312
  ): void {
313
+ const cx = size * 0.5 + interaction.gazeX * size * 0.045;
314
+ const cy = size * 0.88 + Math.sin(timeMs / 940) * size * 0.008;
315
+ const rx = size * (0.18 + (morphologyProfile.body.horizontalStretch - 1) * 0.04 + interaction.intensity * 0.018);
316
+ const ry = size * 0.062;
317
+
265
318
  context.save();
266
- context.fillStyle = `${palette.shadow}66`;
267
- context.filter = `blur(${size * 0.024}px)`;
319
+ context.translate(cx, cy);
320
+ context.scale(1, ry / rx);
321
+ const blurRadius = rx * 1.4;
322
+ const shadowGradient = context.createRadialGradient(0, 0, 0, 0, 0, blurRadius);
323
+ shadowGradient.addColorStop(0, `${palette.shadow}7a`);
324
+ shadowGradient.addColorStop(0.45, `${palette.shadow}44`);
325
+ shadowGradient.addColorStop(0.8, `${palette.shadow}1a`);
326
+ shadowGradient.addColorStop(1, `${palette.shadow}00`);
327
+ context.fillStyle = shadowGradient;
268
328
  context.beginPath();
269
- context.ellipse(
270
- size * 0.5 + interaction.gazeX * size * 0.045,
271
- size * 0.88 + Math.sin(timeMs / 940) * size * 0.008,
272
- size * (0.18 + (morphologyProfile.body.horizontalStretch - 1) * 0.04 + interaction.intensity * 0.018),
273
- size * 0.062,
274
- 0,
275
- 0,
276
- Math.PI * 2,
277
- );
329
+ context.arc(0, 0, blurRadius, 0, Math.PI * 2);
278
330
  context.fill();
279
331
  context.restore();
280
332
  }
@@ -93,6 +93,63 @@ const LIGHT_DIRECTION: Point3D = normalizeVector3({
93
93
  */
94
94
  const OCTOPUS_TENTACLE_COUNT = 8;
95
95
 
96
+ /**
97
+ * Per-avatar stable state derived once from the seeded random factory and reused across frames.
98
+ *
99
+ * These values depend only on the avatar definition (name + hash + colors) and never change
100
+ * while the avatar is mounted, so computing them once and caching eliminates the largest
101
+ * allocation/computation spike in the hot render path.
102
+ *
103
+ * @private helper of `octopus3d3AvatarVisual`
104
+ */
105
+ type Octopus3d3StableState = {
106
+ readonly morphologyProfile: Octopus3MorphologyProfile;
107
+ readonly animationPhase: number;
108
+ readonly leftEyePhaseOffset: number;
109
+ readonly rightEyePhaseOffset: number;
110
+ readonly tentacleProfiles: ReadonlyArray<ContinuousOctopusTentacleProfile>;
111
+ };
112
+
113
+ /**
114
+ * Cache keyed by the `createRandom` factory reference, which is stable for the lifetime of one
115
+ * mounted `<Avatar/>` component (created inside `resolveAvatarRenderDefinition` and held in a
116
+ * React `useMemo`). Using a `WeakMap` ensures the entry is collected when the component unmounts.
117
+ *
118
+ * @private helper of `octopus3d3AvatarVisual`
119
+ */
120
+ const stableStateCache = new WeakMap<(salt: string) => () => number, Octopus3d3StableState>();
121
+
122
+ /**
123
+ * Returns the stable per-avatar state, computing it on first access and returning the cached
124
+ * result on every subsequent call within the same `<Avatar/>` mount.
125
+ *
126
+ * @private helper of `octopus3d3AvatarVisual`
127
+ */
128
+ function getOctopus3d3StableState(createRandom: (salt: string) => () => number): Octopus3d3StableState {
129
+ const cached = stableStateCache.get(createRandom);
130
+
131
+ if (cached !== undefined) {
132
+ return cached;
133
+ }
134
+
135
+ const morphologyProfile = createOctopus3MorphologyProfile(createRandom);
136
+ const animationRandom = createRandom('octopus3d3-animation-profile');
137
+ const eyeRandom = createRandom('octopus3d3-eye-profile');
138
+ const leftEyePhaseOffset = eyeRandom() * 0.7;
139
+ const rightEyePhaseOffset = eyeRandom() * 0.7;
140
+ const state: Octopus3d3StableState = {
141
+ morphologyProfile,
142
+ animationPhase: animationRandom() * Math.PI * 2,
143
+ leftEyePhaseOffset,
144
+ rightEyePhaseOffset,
145
+ tentacleProfiles: createContinuousTentacleProfiles(createRandom, morphologyProfile),
146
+ };
147
+
148
+ stableStateCache.set(createRandom, state);
149
+
150
+ return state;
151
+ }
152
+
96
153
  /**
97
154
  * Octopus 3D 3 avatar visual.
98
155
  *
@@ -106,11 +163,8 @@ export const octopus3d3AvatarVisual: AvatarVisualDefinition = {
106
163
  isAnimated: true,
107
164
  supportsPointerTracking: true,
108
165
  render({ context, size, palette, createRandom, timeMs, interaction }) {
109
- const morphologyProfile = createOctopus3MorphologyProfile(createRandom);
110
- const animationRandom = createRandom('octopus3d3-animation-profile');
111
- const eyeRandom = createRandom('octopus3d3-eye-profile');
112
- const animationPhase = animationRandom() * Math.PI * 2;
113
- const tentacleProfiles = createContinuousTentacleProfiles(createRandom, morphologyProfile);
166
+ const { morphologyProfile, animationPhase, leftEyePhaseOffset, rightEyePhaseOffset, tentacleProfiles } =
167
+ getOctopus3d3StableState(createRandom);
114
168
  const sceneCenterX = size * 0.5;
115
169
  const sceneCenterY = size * 0.535;
116
170
  const bob = Math.sin(timeMs / 960 + animationPhase) * size * 0.012;
@@ -213,7 +267,7 @@ export const octopus3d3AvatarVisual: AvatarVisualDefinition = {
213
267
  size,
214
268
  palette,
215
269
  timeMs,
216
- animationPhase + eyeRandom() * 0.7,
270
+ animationPhase + leftEyePhaseOffset,
217
271
  interaction,
218
272
  morphologyProfile.face.eyeStyle,
219
273
  );
@@ -230,7 +284,7 @@ export const octopus3d3AvatarVisual: AvatarVisualDefinition = {
230
284
  size,
231
285
  palette,
232
286
  timeMs,
233
- animationPhase + 0.85 + eyeRandom() * 0.7,
287
+ animationPhase + 0.85 + rightEyePhaseOffset,
234
288
  interaction,
235
289
  morphologyProfile.face.eyeStyle,
236
290
  );
@@ -338,6 +392,9 @@ function drawContinuousOctopusAtmosphere(
338
392
  /**
339
393
  * Draws the soft lower shadow that anchors the octopus in the avatar frame.
340
394
  *
395
+ * Uses a scaled radial gradient instead of `context.filter = 'blur()'` to approximate the
396
+ * blurry ellipse without triggering a costly software rasterization pass on every frame.
397
+ *
341
398
  * @private helper of `octopus3d3AvatarVisual`
342
399
  */
343
400
  function drawContinuousOctopusShadow(
@@ -351,19 +408,25 @@ function drawContinuousOctopusShadow(
351
408
  timeMs: number,
352
409
  morphologyProfile: Octopus3MorphologyProfile,
353
410
  ): void {
411
+ const cx = size * 0.5 + interaction.gazeX * size * 0.045;
412
+ const cy = size * 0.9 + Math.sin(timeMs / 980) * size * 0.007;
413
+ const rx = size * (0.19 + morphologyProfile.tentacles.rootSpreadScale * 0.022 + interaction.intensity * 0.02);
414
+ const ry = size * 0.06;
415
+
416
+ // Scale the context so that drawing a circle produces the correct ellipse aspect ratio,
417
+ // then fill with a radial gradient that approximates the blurry edge without context.filter.
354
418
  context.save();
355
- context.fillStyle = `${palette.shadow}66`;
356
- context.filter = `blur(${size * 0.025}px)`;
419
+ context.translate(cx, cy);
420
+ context.scale(1, ry / rx);
421
+ const blurRadius = rx * 1.4;
422
+ const shadowGradient = context.createRadialGradient(0, 0, 0, 0, 0, blurRadius);
423
+ shadowGradient.addColorStop(0, `${palette.shadow}7a`);
424
+ shadowGradient.addColorStop(0.45, `${palette.shadow}44`);
425
+ shadowGradient.addColorStop(0.8, `${palette.shadow}1a`);
426
+ shadowGradient.addColorStop(1, `${palette.shadow}00`);
427
+ context.fillStyle = shadowGradient;
357
428
  context.beginPath();
358
- context.ellipse(
359
- size * 0.5 + interaction.gazeX * size * 0.045,
360
- size * 0.9 + Math.sin(timeMs / 980) * size * 0.007,
361
- size * (0.19 + morphologyProfile.tentacles.rootSpreadScale * 0.022 + interaction.intensity * 0.02),
362
- size * 0.06,
363
- 0,
364
- 0,
365
- Math.PI * 2,
366
- );
429
+ context.arc(0, 0, blurRadius, 0, Math.PI * 2);
367
430
  context.fill();
368
431
  context.restore();
369
432
  }