From 8228e6f447fd39ab18545910fe4aebf02d9de45a Mon Sep 17 00:00:00 2001 From: jakmro Date: Fri, 26 Jun 2026 07:20:24 +0200 Subject: [PATCH 1/3] Add cactus-code coding agent + integration Signed-off-by: jakmro --- .github/workflows/pypi.yml | 29 + .gitignore | 2 + README.md | 14 + cactus-code/.gitignore | 2 + cactus-code/LICENSE | 21 + cactus-code/README.md | 17 + cactus-code/package-lock.json | 1589 ++++++ cactus-code/package.json | 24 + cactus-code/packages/agent/package.json | 54 + cactus-code/packages/agent/src/agent-loop.ts | 748 +++ cactus-code/packages/agent/src/agent.ts | 557 ++ .../agent/src/harness/agent-harness.ts | 1029 ++++ .../compaction/branch-summarization.ts | 261 + .../src/harness/compaction/compaction.ts | 747 +++ .../agent/src/harness/compaction/utils.ts | 144 + .../packages/agent/src/harness/env/nodejs.ts | 550 ++ .../packages/agent/src/harness/messages.ts | 164 + .../agent/src/harness/prompt-templates.ts | 267 + .../agent/src/harness/session/jsonl-repo.ts | 177 + .../src/harness/session/jsonl-storage.ts | 293 + .../agent/src/harness/session/memory-repo.ts | 50 + .../src/harness/session/memory-storage.ts | 131 + .../agent/src/harness/session/repo-utils.ts | 51 + .../agent/src/harness/session/session.ts | 267 + .../agent/src/harness/session/uuid.ts | 54 + .../packages/agent/src/harness/skills.ts | 375 ++ .../agent/src/harness/system-prompt.ts | 34 + .../packages/agent/src/harness/types.ts | 836 +++ .../agent/src/harness/utils/shell-output.ts | 135 + .../agent/src/harness/utils/truncate.ts | 344 ++ cactus-code/packages/agent/src/index.ts | 44 + cactus-code/packages/agent/src/node.ts | 2 + cactus-code/packages/agent/src/proxy.ts | 367 ++ cactus-code/packages/agent/src/types.ts | 428 ++ .../packages/agent/tsconfig.build.json | 13 + cactus-code/packages/ai/package.json | 70 + .../ai/src/api/github-copilot-headers.ts | 37 + cactus-code/packages/ai/src/api/lazy.ts | 70 + .../ai/src/api/openai-completions.lazy.ts | 4 + .../packages/ai/src/api/openai-completions.ts | 1288 +++++ .../ai/src/api/openai-prompt-cache.ts | 8 + .../packages/ai/src/api/simple-options.ts | 54 + .../packages/ai/src/api/transform-messages.ts | 220 + cactus-code/packages/ai/src/auth/context.ts | 45 + .../packages/ai/src/auth/credential-store.ts | 47 + cactus-code/packages/ai/src/auth/helpers.ts | 46 + cactus-code/packages/ai/src/auth/resolve.ts | 141 + cactus-code/packages/ai/src/auth/types.ts | 182 + cactus-code/packages/ai/src/compat.ts | 246 + cactus-code/packages/ai/src/env-api-keys.ts | 177 + cactus-code/packages/ai/src/index.ts | 37 + .../packages/ai/src/models.generated.ts | 3 + cactus-code/packages/ai/src/models.ts | 441 ++ cactus-code/packages/ai/src/oauth.ts | 61 + cactus-code/packages/ai/src/providers/all.ts | 42 + .../packages/ai/src/providers/cactus.ts | 95 + cactus-code/packages/ai/src/providers/faux.ts | 538 ++ .../packages/ai/src/session-resources.ts | 24 + cactus-code/packages/ai/src/types.ts | 638 +++ .../packages/ai/src/utils/abort-signals.ts | 41 + .../packages/ai/src/utils/diagnostics.ts | 45 + .../packages/ai/src/utils/event-stream.ts | 88 + cactus-code/packages/ai/src/utils/hash.ts | 13 + cactus-code/packages/ai/src/utils/headers.ts | 18 + .../packages/ai/src/utils/json-parse.ts | 124 + .../packages/ai/src/utils/oauth/types.ts | 79 + cactus-code/packages/ai/src/utils/overflow.ts | 162 + .../packages/ai/src/utils/provider-env.ts | 52 + .../packages/ai/src/utils/sanitize-unicode.ts | 25 + .../packages/ai/src/utils/typebox-helpers.ts | 24 + .../packages/ai/src/utils/validation.ts | 324 ++ cactus-code/packages/ai/tsconfig.build.json | 9 + .../packages/coding-agent/package.json | 90 + cactus-code/packages/coding-agent/src/cli.ts | 20 + .../packages/coding-agent/src/cli/args.ts | 292 + .../coding-agent/src/cli/file-processor.ts | 99 + .../coding-agent/src/cli/initial-message.ts | 43 + .../coding-agent/src/cli/list-models.ts | 112 + .../coding-agent/src/cli/project-trust.ts | 62 + .../coding-agent/src/cli/session-picker.ts | 55 + .../coding-agent/src/cli/startup-ui.ts | 239 + .../packages/coding-agent/src/config.ts | 554 ++ .../src/core/agent-session-runtime.ts | 433 ++ .../src/core/agent-session-services.ts | 242 + .../coding-agent/src/core/agent-session.ts | 3149 +++++++++++ .../coding-agent/src/core/auth-guidance.ts | 25 + .../coding-agent/src/core/auth-storage.ts | 528 ++ .../coding-agent/src/core/bash-executor.ts | 156 + .../core/compaction/branch-summarization.ts | 371 ++ .../src/core/compaction/compaction.ts | 905 +++ .../coding-agent/src/core/compaction/index.ts | 7 + .../coding-agent/src/core/compaction/utils.ts | 170 + .../coding-agent/src/core/defaults.ts | 3 + .../coding-agent/src/core/diagnostics.ts | 15 + .../coding-agent/src/core/event-bus.ts | 33 + .../packages/coding-agent/src/core/exec.ts | 107 + .../coding-agent/src/core/experimental.ts | 3 + .../coding-agent/src/core/extensions/index.ts | 178 + .../src/core/extensions/loader.ts | 687 +++ .../src/core/extensions/runner.ts | 1135 ++++ .../coding-agent/src/core/extensions/types.ts | 1614 ++++++ .../src/core/extensions/wrapper.ts | 30 + .../src/core/footer-data-provider.ts | 388 ++ .../coding-agent/src/core/http-dispatcher.ts | 73 + .../packages/coding-agent/src/core/index.ts | 78 + .../coding-agent/src/core/keybindings.ts | 370 ++ .../coding-agent/src/core/messages.ts | 195 + .../coding-agent/src/core/model-registry.ts | 990 ++++ .../coding-agent/src/core/model-resolver.ts | 647 +++ .../coding-agent/src/core/output-guard.ts | 108 + .../coding-agent/src/core/package-manager.ts | 2561 +++++++++ .../coding-agent/src/core/project-trust.ts | 96 + .../coding-agent/src/core/prompt-templates.ts | 284 + .../src/core/provider-attribution.ts | 17 + .../src/core/provider-display-names.ts | 35 + .../src/core/resolve-config-value.ts | 287 + .../coding-agent/src/core/resource-loader.ts | 1037 ++++ .../packages/coding-agent/src/core/sdk.ts | 399 ++ .../coding-agent/src/core/session-cwd.ts | 59 + .../coding-agent/src/core/session-manager.ts | 1578 ++++++ .../coding-agent/src/core/settings-manager.ts | 1175 ++++ .../packages/coding-agent/src/core/skills.ts | 487 ++ .../coding-agent/src/core/slash-commands.ts | 37 + .../coding-agent/src/core/source-info.ts | 40 + .../coding-agent/src/core/system-prompt.ts | 158 + .../packages/coding-agent/src/core/timings.ts | 31 + .../coding-agent/src/core/tools/bash.ts | 453 ++ .../coding-agent/src/core/tools/edit-diff.ts | 560 ++ .../coding-agent/src/core/tools/edit.ts | 437 ++ .../src/core/tools/file-mutation-queue.ts | 61 + .../coding-agent/src/core/tools/find.ts | 374 ++ .../coding-agent/src/core/tools/grep.ts | 385 ++ .../coding-agent/src/core/tools/index.ts | 196 + .../coding-agent/src/core/tools/ls.ts | 225 + .../src/core/tools/output-accumulator.ts | 222 + .../coding-agent/src/core/tools/path-utils.ts | 118 + .../coding-agent/src/core/tools/read.ts | 362 ++ .../src/core/tools/render-utils.ts | 85 + .../src/core/tools/tool-definition-wrapper.ts | 45 + .../coding-agent/src/core/tools/truncate.ts | 276 + .../coding-agent/src/core/tools/write.ts | 267 + .../coding-agent/src/core/trust-manager.ts | 244 + .../packages/coding-agent/src/index.ts | 368 ++ cactus-code/packages/coding-agent/src/main.ts | 779 +++ .../packages/coding-agent/src/modes/index.ts | 6 + .../modes/interactive/assets/clankolas.png | Bin 0 -> 539053 bytes .../components/assistant-message.ts | 153 + .../interactive/components/bash-execution.ts | 220 + .../interactive/components/bordered-loader.ts | 68 + .../components/branch-summary-message.ts | 58 + .../components/compaction-summary-message.ts | 59 + .../interactive/components/countdown-timer.ts | 39 + .../interactive/components/custom-editor.ts | 80 + .../interactive/components/custom-message.ts | 99 + .../src/modes/interactive/components/diff.ts | 147 + .../interactive/components/dynamic-border.ts | 25 + .../components/extension-editor.ts | 156 + .../interactive/components/extension-input.ts | 87 + .../components/extension-selector.ts | 112 + .../components/first-time-setup.ts | 145 + .../modes/interactive/components/footer.ts | 247 + .../src/modes/interactive/components/index.ts | 33 + .../components/keybinding-hints.ts | 48 + .../interactive/components/model-selector.ts | 337 ++ .../components/scoped-models-selector.ts | 360 ++ .../components/session-selector-search.ts | 194 + .../components/session-selector.ts | 1031 ++++ .../components/settings-selector.ts | 786 +++ .../components/skill-invocation-message.ts | 55 + .../interactive/components/theme-selector.ts | 67 + .../components/thinking-selector.ts | 74 + .../interactive/components/tool-execution.ts | 377 ++ .../interactive/components/tree-selector.ts | 1386 +++++ .../interactive/components/trust-selector.ts | 134 + .../components/user-message-selector.ts | 155 + .../interactive/components/user-message.ts | 42 + .../interactive/components/visual-truncate.ts | 50 + .../src/modes/interactive/interactive-mode.ts | 4966 +++++++++++++++++ .../src/modes/interactive/model-search.ts | 21 + .../src/modes/interactive/theme/dark.json | 86 + .../src/modes/interactive/theme/light.json | 85 + .../interactive/theme/theme-controller.ts | 126 + .../modes/interactive/theme/theme-schema.json | 336 ++ .../src/modes/interactive/theme/theme.ts | 1284 +++++ .../coding-agent/src/modes/print-mode.ts | 159 + .../packages/coding-agent/src/utils/ansi.ts | 60 + .../coding-agent/src/utils/child-process.ts | 137 + .../coding-agent/src/utils/clipboard-image.ts | 300 + .../src/utils/clipboard-native.ts | 32 + .../coding-agent/src/utils/clipboard.ts | 127 + .../coding-agent/src/utils/deprecation.ts | 14 + .../src/utils/exif-orientation.ts | 183 + .../coding-agent/src/utils/frontmatter.ts | 39 + .../coding-agent/src/utils/fs-watch.ts | 30 + .../packages/coding-agent/src/utils/git.ts | 226 + .../src/utils/highlight-js-lib-index.d.ts | 19 + .../packages/coding-agent/src/utils/html.ts | 51 + .../coding-agent/src/utils/image-convert.ts | 41 + .../src/utils/image-resize-core.ts | 164 + .../src/utils/image-resize-worker.ts | 42 + .../coding-agent/src/utils/image-resize.ts | 123 + .../packages/coding-agent/src/utils/json.ts | 6 + .../packages/coding-agent/src/utils/mime.ts | 74 + .../coding-agent/src/utils/open-browser.ts | 24 + .../packages/coding-agent/src/utils/paths.ts | 118 + .../packages/coding-agent/src/utils/photon.ts | 139 + .../packages/coding-agent/src/utils/shell.ts | 225 + .../packages/coding-agent/src/utils/sleep.ts | 18 + .../src/utils/syntax-highlight.ts | 146 + .../coding-agent/src/utils/tools-manager.ts | 369 ++ .../packages/coding-agent/tsconfig.build.json | 17 + .../darwin-arm64/darwin-modifiers.node | Bin 0 -> 50200 bytes .../darwin-x64/darwin-modifiers.node | Bin 0 -> 12776 bytes .../tui/native/darwin/src/darwin-modifiers.c | 70 + .../win32-arm64/win32-console-mode.node | Bin 0 -> 3072 bytes .../win32-x64/win32-console-mode.node | Bin 0 -> 3072 bytes .../tui/native/win32/src/win32-console-mode.c | 53 + cactus-code/packages/tui/package.json | 45 + cactus-code/packages/tui/src/autocomplete.ts | 786 +++ .../packages/tui/src/components/box.ts | 137 + .../tui/src/components/cancellable-loader.ts | 40 + .../packages/tui/src/components/editor.ts | 2307 ++++++++ .../packages/tui/src/components/image.ts | 126 + .../packages/tui/src/components/input.ts | 447 ++ .../packages/tui/src/components/loader.ts | 92 + .../packages/tui/src/components/markdown.ts | 852 +++ .../tui/src/components/select-list.ts | 229 + .../tui/src/components/settings-list.ts | 250 + .../packages/tui/src/components/spacer.ts | 28 + .../packages/tui/src/components/text.ts | 106 + .../tui/src/components/truncated-text.ts | 65 + .../packages/tui/src/editor-component.ts | 74 + cactus-code/packages/tui/src/fuzzy.ts | 137 + cactus-code/packages/tui/src/index.ts | 114 + cactus-code/packages/tui/src/keybindings.ts | 244 + cactus-code/packages/tui/src/keys.ts | 1400 +++++ cactus-code/packages/tui/src/kill-ring.ts | 46 + .../packages/tui/src/native-modifiers.ts | 59 + cactus-code/packages/tui/src/stdin-buffer.ts | 434 ++ .../packages/tui/src/terminal-colors.ts | 73 + .../packages/tui/src/terminal-image.ts | 488 ++ cactus-code/packages/tui/src/terminal.ts | 531 ++ cactus-code/packages/tui/src/tui.ts | 1714 ++++++ cactus-code/packages/tui/src/undo-stack.ts | 28 + cactus-code/packages/tui/src/utils.ts | 1188 ++++ .../packages/tui/src/word-navigation.ts | 117 + cactus-code/packages/tui/tsconfig.build.json | 9 + cactus-code/scripts/bundle-for-pypi.sh | 67 + cactus-code/tsconfig.base.json | 25 + cactus-engine/src/cloud.cpp | 114 +- cactus-engine/src/complete.cpp | 70 +- cactus-engine/src/engine.h | 27 + cactus-engine/src/gemma_tools.h | 442 +- cactus-engine/src/utils.h | 234 +- python/cactus/cli/__init__.py | 26 + python/cactus/cli/clean.py | 134 +- python/cactus/cli/code.py | 187 + python/cactus/cli/convert.py | 11 +- python/cactus/cli/serve.py | 17 +- python/cactus/server.py | 201 +- python/pyproject.toml | 3 +- 261 files changed, 73526 insertions(+), 220 deletions(-) create mode 100644 cactus-code/.gitignore create mode 100644 cactus-code/LICENSE create mode 100644 cactus-code/README.md create mode 100644 cactus-code/package-lock.json create mode 100644 cactus-code/package.json create mode 100644 cactus-code/packages/agent/package.json create mode 100644 cactus-code/packages/agent/src/agent-loop.ts create mode 100644 cactus-code/packages/agent/src/agent.ts create mode 100644 cactus-code/packages/agent/src/harness/agent-harness.ts create mode 100644 cactus-code/packages/agent/src/harness/compaction/branch-summarization.ts create mode 100644 cactus-code/packages/agent/src/harness/compaction/compaction.ts create mode 100644 cactus-code/packages/agent/src/harness/compaction/utils.ts create mode 100644 cactus-code/packages/agent/src/harness/env/nodejs.ts create mode 100644 cactus-code/packages/agent/src/harness/messages.ts create mode 100644 cactus-code/packages/agent/src/harness/prompt-templates.ts create mode 100644 cactus-code/packages/agent/src/harness/session/jsonl-repo.ts create mode 100644 cactus-code/packages/agent/src/harness/session/jsonl-storage.ts create mode 100644 cactus-code/packages/agent/src/harness/session/memory-repo.ts create mode 100644 cactus-code/packages/agent/src/harness/session/memory-storage.ts create mode 100644 cactus-code/packages/agent/src/harness/session/repo-utils.ts create mode 100644 cactus-code/packages/agent/src/harness/session/session.ts create mode 100644 cactus-code/packages/agent/src/harness/session/uuid.ts create mode 100644 cactus-code/packages/agent/src/harness/skills.ts create mode 100644 cactus-code/packages/agent/src/harness/system-prompt.ts create mode 100644 cactus-code/packages/agent/src/harness/types.ts create mode 100644 cactus-code/packages/agent/src/harness/utils/shell-output.ts create mode 100644 cactus-code/packages/agent/src/harness/utils/truncate.ts create mode 100644 cactus-code/packages/agent/src/index.ts create mode 100644 cactus-code/packages/agent/src/node.ts create mode 100644 cactus-code/packages/agent/src/proxy.ts create mode 100644 cactus-code/packages/agent/src/types.ts create mode 100644 cactus-code/packages/agent/tsconfig.build.json create mode 100644 cactus-code/packages/ai/package.json create mode 100644 cactus-code/packages/ai/src/api/github-copilot-headers.ts create mode 100644 cactus-code/packages/ai/src/api/lazy.ts create mode 100644 cactus-code/packages/ai/src/api/openai-completions.lazy.ts create mode 100644 cactus-code/packages/ai/src/api/openai-completions.ts create mode 100644 cactus-code/packages/ai/src/api/openai-prompt-cache.ts create mode 100644 cactus-code/packages/ai/src/api/simple-options.ts create mode 100644 cactus-code/packages/ai/src/api/transform-messages.ts create mode 100644 cactus-code/packages/ai/src/auth/context.ts create mode 100644 cactus-code/packages/ai/src/auth/credential-store.ts create mode 100644 cactus-code/packages/ai/src/auth/helpers.ts create mode 100644 cactus-code/packages/ai/src/auth/resolve.ts create mode 100644 cactus-code/packages/ai/src/auth/types.ts create mode 100644 cactus-code/packages/ai/src/compat.ts create mode 100644 cactus-code/packages/ai/src/env-api-keys.ts create mode 100644 cactus-code/packages/ai/src/index.ts create mode 100644 cactus-code/packages/ai/src/models.generated.ts create mode 100644 cactus-code/packages/ai/src/models.ts create mode 100644 cactus-code/packages/ai/src/oauth.ts create mode 100644 cactus-code/packages/ai/src/providers/all.ts create mode 100644 cactus-code/packages/ai/src/providers/cactus.ts create mode 100644 cactus-code/packages/ai/src/providers/faux.ts create mode 100644 cactus-code/packages/ai/src/session-resources.ts create mode 100644 cactus-code/packages/ai/src/types.ts create mode 100644 cactus-code/packages/ai/src/utils/abort-signals.ts create mode 100644 cactus-code/packages/ai/src/utils/diagnostics.ts create mode 100644 cactus-code/packages/ai/src/utils/event-stream.ts create mode 100644 cactus-code/packages/ai/src/utils/hash.ts create mode 100644 cactus-code/packages/ai/src/utils/headers.ts create mode 100644 cactus-code/packages/ai/src/utils/json-parse.ts create mode 100644 cactus-code/packages/ai/src/utils/oauth/types.ts create mode 100644 cactus-code/packages/ai/src/utils/overflow.ts create mode 100644 cactus-code/packages/ai/src/utils/provider-env.ts create mode 100644 cactus-code/packages/ai/src/utils/sanitize-unicode.ts create mode 100644 cactus-code/packages/ai/src/utils/typebox-helpers.ts create mode 100644 cactus-code/packages/ai/src/utils/validation.ts create mode 100644 cactus-code/packages/ai/tsconfig.build.json create mode 100644 cactus-code/packages/coding-agent/package.json create mode 100644 cactus-code/packages/coding-agent/src/cli.ts create mode 100644 cactus-code/packages/coding-agent/src/cli/args.ts create mode 100644 cactus-code/packages/coding-agent/src/cli/file-processor.ts create mode 100644 cactus-code/packages/coding-agent/src/cli/initial-message.ts create mode 100644 cactus-code/packages/coding-agent/src/cli/list-models.ts create mode 100644 cactus-code/packages/coding-agent/src/cli/project-trust.ts create mode 100644 cactus-code/packages/coding-agent/src/cli/session-picker.ts create mode 100644 cactus-code/packages/coding-agent/src/cli/startup-ui.ts create mode 100644 cactus-code/packages/coding-agent/src/config.ts create mode 100644 cactus-code/packages/coding-agent/src/core/agent-session-runtime.ts create mode 100644 cactus-code/packages/coding-agent/src/core/agent-session-services.ts create mode 100644 cactus-code/packages/coding-agent/src/core/agent-session.ts create mode 100644 cactus-code/packages/coding-agent/src/core/auth-guidance.ts create mode 100644 cactus-code/packages/coding-agent/src/core/auth-storage.ts create mode 100644 cactus-code/packages/coding-agent/src/core/bash-executor.ts create mode 100644 cactus-code/packages/coding-agent/src/core/compaction/branch-summarization.ts create mode 100644 cactus-code/packages/coding-agent/src/core/compaction/compaction.ts create mode 100644 cactus-code/packages/coding-agent/src/core/compaction/index.ts create mode 100644 cactus-code/packages/coding-agent/src/core/compaction/utils.ts create mode 100644 cactus-code/packages/coding-agent/src/core/defaults.ts create mode 100644 cactus-code/packages/coding-agent/src/core/diagnostics.ts create mode 100644 cactus-code/packages/coding-agent/src/core/event-bus.ts create mode 100644 cactus-code/packages/coding-agent/src/core/exec.ts create mode 100644 cactus-code/packages/coding-agent/src/core/experimental.ts create mode 100644 cactus-code/packages/coding-agent/src/core/extensions/index.ts create mode 100644 cactus-code/packages/coding-agent/src/core/extensions/loader.ts create mode 100644 cactus-code/packages/coding-agent/src/core/extensions/runner.ts create mode 100644 cactus-code/packages/coding-agent/src/core/extensions/types.ts create mode 100644 cactus-code/packages/coding-agent/src/core/extensions/wrapper.ts create mode 100644 cactus-code/packages/coding-agent/src/core/footer-data-provider.ts create mode 100644 cactus-code/packages/coding-agent/src/core/http-dispatcher.ts create mode 100644 cactus-code/packages/coding-agent/src/core/index.ts create mode 100644 cactus-code/packages/coding-agent/src/core/keybindings.ts create mode 100644 cactus-code/packages/coding-agent/src/core/messages.ts create mode 100644 cactus-code/packages/coding-agent/src/core/model-registry.ts create mode 100644 cactus-code/packages/coding-agent/src/core/model-resolver.ts create mode 100644 cactus-code/packages/coding-agent/src/core/output-guard.ts create mode 100644 cactus-code/packages/coding-agent/src/core/package-manager.ts create mode 100644 cactus-code/packages/coding-agent/src/core/project-trust.ts create mode 100644 cactus-code/packages/coding-agent/src/core/prompt-templates.ts create mode 100644 cactus-code/packages/coding-agent/src/core/provider-attribution.ts create mode 100644 cactus-code/packages/coding-agent/src/core/provider-display-names.ts create mode 100644 cactus-code/packages/coding-agent/src/core/resolve-config-value.ts create mode 100644 cactus-code/packages/coding-agent/src/core/resource-loader.ts create mode 100644 cactus-code/packages/coding-agent/src/core/sdk.ts create mode 100644 cactus-code/packages/coding-agent/src/core/session-cwd.ts create mode 100644 cactus-code/packages/coding-agent/src/core/session-manager.ts create mode 100644 cactus-code/packages/coding-agent/src/core/settings-manager.ts create mode 100644 cactus-code/packages/coding-agent/src/core/skills.ts create mode 100644 cactus-code/packages/coding-agent/src/core/slash-commands.ts create mode 100644 cactus-code/packages/coding-agent/src/core/source-info.ts create mode 100644 cactus-code/packages/coding-agent/src/core/system-prompt.ts create mode 100644 cactus-code/packages/coding-agent/src/core/timings.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/bash.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/edit-diff.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/edit.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/file-mutation-queue.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/find.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/grep.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/index.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/ls.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/output-accumulator.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/path-utils.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/read.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/render-utils.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/truncate.ts create mode 100644 cactus-code/packages/coding-agent/src/core/tools/write.ts create mode 100644 cactus-code/packages/coding-agent/src/core/trust-manager.ts create mode 100644 cactus-code/packages/coding-agent/src/index.ts create mode 100644 cactus-code/packages/coding-agent/src/main.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/index.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/assets/clankolas.png create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/assistant-message.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/bash-execution.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/custom-editor.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/custom-message.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/diff.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/extension-editor.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/extension-input.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/extension-selector.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/footer.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/index.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/model-selector.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/session-selector.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/settings-selector.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/theme-selector.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/tool-execution.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/tree-selector.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/trust-selector.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/user-message.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/interactive-mode.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/model-search.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/theme/dark.json create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/theme/light.json create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/theme/theme-schema.json create mode 100644 cactus-code/packages/coding-agent/src/modes/interactive/theme/theme.ts create mode 100644 cactus-code/packages/coding-agent/src/modes/print-mode.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/ansi.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/child-process.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/clipboard-image.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/clipboard-native.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/clipboard.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/deprecation.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/exif-orientation.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/frontmatter.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/fs-watch.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/git.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/html.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/image-convert.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/image-resize-core.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/image-resize-worker.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/image-resize.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/json.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/mime.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/open-browser.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/paths.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/photon.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/shell.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/sleep.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/syntax-highlight.ts create mode 100644 cactus-code/packages/coding-agent/src/utils/tools-manager.ts create mode 100644 cactus-code/packages/coding-agent/tsconfig.build.json create mode 100755 cactus-code/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node create mode 100755 cactus-code/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node create mode 100644 cactus-code/packages/tui/native/darwin/src/darwin-modifiers.c create mode 100644 cactus-code/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node create mode 100644 cactus-code/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node create mode 100644 cactus-code/packages/tui/native/win32/src/win32-console-mode.c create mode 100644 cactus-code/packages/tui/package.json create mode 100644 cactus-code/packages/tui/src/autocomplete.ts create mode 100644 cactus-code/packages/tui/src/components/box.ts create mode 100644 cactus-code/packages/tui/src/components/cancellable-loader.ts create mode 100644 cactus-code/packages/tui/src/components/editor.ts create mode 100644 cactus-code/packages/tui/src/components/image.ts create mode 100644 cactus-code/packages/tui/src/components/input.ts create mode 100644 cactus-code/packages/tui/src/components/loader.ts create mode 100644 cactus-code/packages/tui/src/components/markdown.ts create mode 100644 cactus-code/packages/tui/src/components/select-list.ts create mode 100644 cactus-code/packages/tui/src/components/settings-list.ts create mode 100644 cactus-code/packages/tui/src/components/spacer.ts create mode 100644 cactus-code/packages/tui/src/components/text.ts create mode 100644 cactus-code/packages/tui/src/components/truncated-text.ts create mode 100644 cactus-code/packages/tui/src/editor-component.ts create mode 100644 cactus-code/packages/tui/src/fuzzy.ts create mode 100644 cactus-code/packages/tui/src/index.ts create mode 100644 cactus-code/packages/tui/src/keybindings.ts create mode 100644 cactus-code/packages/tui/src/keys.ts create mode 100644 cactus-code/packages/tui/src/kill-ring.ts create mode 100644 cactus-code/packages/tui/src/native-modifiers.ts create mode 100644 cactus-code/packages/tui/src/stdin-buffer.ts create mode 100644 cactus-code/packages/tui/src/terminal-colors.ts create mode 100644 cactus-code/packages/tui/src/terminal-image.ts create mode 100644 cactus-code/packages/tui/src/terminal.ts create mode 100644 cactus-code/packages/tui/src/tui.ts create mode 100644 cactus-code/packages/tui/src/undo-stack.ts create mode 100644 cactus-code/packages/tui/src/utils.ts create mode 100644 cactus-code/packages/tui/src/word-navigation.ts create mode 100644 cactus-code/packages/tui/tsconfig.build.json create mode 100644 cactus-code/scripts/bundle-for-pypi.sh create mode 100644 cactus-code/tsconfig.base.json create mode 100644 python/cactus/cli/code.py diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 5b62c06ee..eaffb7cc4 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -34,6 +34,15 @@ jobs: mkdir -p python/cactus/bindings/lib cp cactus-engine/build/libcactus_engine.dylib python/cactus/bindings/lib/ + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Bundle coding agent into the wheel + run: | + (cd cactus-code && npm install --no-audit --no-fund) + bash cactus-code/scripts/bundle-for-pypi.sh + - name: Build and repair wheel working-directory: python run: | @@ -68,6 +77,17 @@ jobs: mkdir -p python/cactus/bindings/lib cp cactus-engine/build/libcactus_engine.so python/cactus/bindings/lib/ + - name: Install Node.js 22 + run: | + curl -fsSL https://nodejs.org/dist/v22.21.0/node-v22.21.0-linux-arm64.tar.xz -o /tmp/node.tar.xz + tar -xJf /tmp/node.tar.xz -C /opt + echo "/opt/node-v22.21.0-linux-arm64/bin" >> "$GITHUB_PATH" + + - name: Bundle coding agent into the wheel + run: | + (cd cactus-code && npm install --no-audit --no-fund) + bash cactus-code/scripts/bundle-for-pypi.sh + - name: Build and repair wheel working-directory: python run: | @@ -92,6 +112,15 @@ jobs: - uses: ./.github/actions/stamp-version + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Bundle coding agent into the sdist + run: | + (cd cactus-code && npm install --no-audit --no-fund) + bash cactus-code/scripts/bundle-for-pypi.sh + - name: Build sdist working-directory: python run: | diff --git a/.gitignore b/.gitignore index 743d97d84..b2a93af05 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ python/cactus/bin/ python/cactus/bindings/lib/ site/ site_docs/ + +python/cactus/code/ diff --git a/README.md b/README.md index da0c317f2..327c8d5bc 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,20 @@ graph.hard_reset(); │ --confidence-threshold <0..1> handoff to cloud below this confidence │ │ --cloud-timeout-ms max wait for cloud handoff │ │ │ +│ cactus code run the AI coding agent (TUI / print) │ +│ --serve-model auto-start a server with this model │ +│ --bits 1|2|3|4 CQ quantization (default: 4) │ +│ --platform auto|cpu|apple target platform (default: auto) │ +│ --token HuggingFace token (gated models) │ +│ --reconvert force local rebuild from source │ +│ --host server address (default: 127.0.0.1) │ +│ --port server port (default: 8080) │ +│ --no-serve require a running server (no auto-start) │ +│ --no-cloud-handoff disable automatic cloud handoff │ +│ --confidence-threshold <0..1> handoff to cloud below this confidence │ +│ --cloud-timeout-ms max wait for cloud handoff │ +│ -- pass remaining args to the agent │ +│ │ │ cactus list list downloaded models │ │ │ │ cactus build build cactus libraries │ diff --git a/cactus-code/.gitignore b/cactus-code/.gitignore new file mode 100644 index 000000000..76add878f --- /dev/null +++ b/cactus-code/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/cactus-code/LICENSE b/cactus-code/LICENSE new file mode 100644 index 000000000..b0a8e9b81 --- /dev/null +++ b/cactus-code/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Mario Zechner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/cactus-code/README.md b/cactus-code/README.md new file mode 100644 index 000000000..7aa51eca0 --- /dev/null +++ b/cactus-code/README.md @@ -0,0 +1,17 @@ +# cactus-code + +The terminal coding agent behind `cactus code` — an interactive AI assistant +for a local [Cactus](https://github.com/cactus-compute/cactus) server. + +## Credits + +Based on **[pi](https://github.com/earendil-works/pi)** by **Mario Zechner** +and contributors. The agent — its TUI, sessions, tools, and compaction — is +their work. Thank you. + +Want the original, full-featured agent? Use upstream: +https://github.com/earendil-works/pi + +## License + +MIT — see [LICENSE](./LICENSE), © 2025 Mario Zechner. Same license as upstream. diff --git a/cactus-code/package-lock.json b/cactus-code/package-lock.json new file mode 100644 index 000000000..87f1343bb --- /dev/null +++ b/cactus-code/package-lock.json @@ -0,0 +1,1589 @@ +{ + "name": "pi-monorepo", + "version": "0.0.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-monorepo", + "version": "0.0.3", + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "@types/node": "22.19.19", + "@typescript/native-preview": "7.0.0-dev.20260120.1", + "shx": "0.4.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "resolved": "packages/agent", + "link": true + }, + "node_modules/@earendil-works/pi-ai": { + "resolved": "packages/ai", + "link": true + }, + "node_modules/@earendil-works/pi-coding-agent": { + "resolved": "packages/coding-agent", + "link": true + }, + "node_modules/@earendil-works/pi-tui": { + "resolved": "packages/tui", + "link": true + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@types/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hosted-git-info": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hosted-git-info/-/hosted-git-info-3.0.5.tgz", + "integrity": "sha512-Dmngh7U003cOHPhKGyA7LWqrnvcTyILNgNPmNCxlx7j8MIi54iBliiT8XqVLIQ3GchoOjVAyBzNJVyuaJjqokg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/proper-lockfile": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", + "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/native-preview": { + "version": "7.0.0-dev.20260120.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260120.1.tgz", + "integrity": "sha512-nnEf37C9ue7OBRnF2zmV/OCBmV5Y7T/K4mCHa+nxgiXcF/1w8sA0cgdFl+gHQ0mysqUJ+Bu5btAMeWgpLyjrgg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsgo": "bin/tsgo.js" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260120.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260120.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260120.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260120.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260120.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260120.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260120.1" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20260120.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260120.1.tgz", + "integrity": "sha512-r3pWFuR2H7mn6ScwpH5jJljKQqKto0npVuJSk6pRwFwexpTyxOGmJTZJ1V0AWiisaNxU2+CNAqWFJSJYIE/QTg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20260120.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260120.1.tgz", + "integrity": "sha512-cuC1+wLbUP+Ip2UT94G134fqRdp5w3b3dhcCO6/FQ4yXxvRNyv/WK+upHBUFDaeSOeHgDTyO9/QFYUWwC4If1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20260120.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260120.1.tgz", + "integrity": "sha512-vN6OYVySol/kQZjJGmAzd6L30SyVlCgmCXS8WjUYtE5clN0YrzQHop16RK29fYZHMxpkOniVBtRPxUYQANZBlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-linux-arm64": { + "version": "7.0.0-dev.20260120.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260120.1.tgz", + "integrity": "sha512-zZGvEGY7wcHYefMZ87KNmvjN3NLIhsCMHEpHZiGCS3khKf+8z6ZsanrzCjOTodvL01VPyBzHxV1EtkSxAcLiQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-linux-x64": { + "version": "7.0.0-dev.20260120.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260120.1.tgz", + "integrity": "sha512-JBfNhWd/asd5MDeS3VgRvE24pGKBkmvLub6tsux6ypr+Yhy+o0WaAEzVpmlRYZUqss2ai5tvOu4dzPBXzZAtFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@typescript/native-preview-win32-arm64": { + "version": "7.0.0-dev.20260120.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260120.1.tgz", + "integrity": "sha512-tTndRtYCq2xwgE0VkTi9ACNiJaV43+PqvBqCxk8ceYi3X36Ve+CCnwlZfZJ4k9NxZthtrAwF/kUmpC9iIYbq1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@typescript/native-preview-win32-x64": { + "version": "7.0.0-dev.20260120.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260120.1.tgz", + "integrity": "sha512-oZia7hFL6k9pVepfonuPI86Jmyz6WlJKR57tWCDwRNmpA7odxuTq1PbvcYgy1z4+wHF1nnKKJY0PMAiq6ac18w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/execa/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dev": true, + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shelljs": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.9.2.tgz", + "integrity": "sha512-S3I64fEiKgTZzKCC46zT/Ib9meqofLrQVbpSswtjFfAVDW+AZ54WTnAM/3/yENoxz/V1Cy6u3kiiEbQ4DNphvw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "execa": "^1.0.0", + "fast-glob": "^3.3.2", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/shx": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/shx/-/shx-0.4.0.tgz", + "integrity": "sha512-Z0KixSIlGPpijKgcH6oCMCbltPImvaKy0sGH8AkLRXw1KyzpKtaCTizP2xen+hNDqVF4xxgvA0KXSb9o4Q6hnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.8", + "shelljs": "^0.9.2" + }, + "bin": { + "shx": "lib/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/agent": { + "name": "@earendil-works/pi-agent-core", + "version": "0.80.2", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.80.2", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "devDependencies": { + "@types/node": "24.12.4" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "packages/agent/node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "packages/agent/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "packages/ai": { + "name": "@earendil-works/pi-ai", + "version": "0.80.2", + "license": "MIT", + "dependencies": { + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "devDependencies": { + "@types/node": "24.12.4" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "packages/ai/node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "packages/ai/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "packages/coding-agent": { + "name": "@earendil-works/pi-coding-agent", + "version": "0.80.2", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.80.2", + "@earendil-works/pi-ai": "^0.80.2", + "@earendil-works/pi-tui": "^0.80.2", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.1.38", + "undici": "8.5.0", + "yaml": "2.9.0" + }, + "bin": { + "cactus": "dist/cli.js" + }, + "devDependencies": { + "@types/cross-spawn": "6.0.6", + "@types/diff": "7.0.2", + "@types/hosted-git-info": "3.0.5", + "@types/ms": "2.1.0", + "@types/node": "24.12.4", + "@types/proper-lockfile": "4.1.4", + "@types/semver": "7.7.1", + "shx": "0.4.0" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "packages/coding-agent/examples/extensions/custom-provider-anthropic": { + "name": "pi-extension-custom-provider-anthropic", + "version": "0.80.2", + "extraneous": true, + "dependencies": { + "@anthropic-ai/sdk": "0.52.0" + } + }, + "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { + "name": "pi-extension-custom-provider-gitlab-duo", + "version": "0.80.2", + "extraneous": true + }, + "packages/coding-agent/examples/extensions/gondolin": { + "name": "pi-extension-gondolin", + "version": "0.80.2", + "extraneous": true, + "dependencies": { + "@earendil-works/gondolin": "0.12.0" + } + }, + "packages/coding-agent/examples/extensions/sandbox": { + "name": "pi-extension-sandbox", + "version": "1.10.2", + "extraneous": true, + "dependencies": { + "@anthropic-ai/sandbox-runtime": "0.0.26" + } + }, + "packages/coding-agent/examples/extensions/with-deps": { + "name": "pi-extension-with-deps", + "version": "0.80.2", + "extraneous": true, + "dependencies": { + "ms": "2.1.3" + }, + "devDependencies": { + "@types/ms": "2.1.0" + } + }, + "packages/coding-agent/node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "packages/coding-agent/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "packages/tui": { + "name": "@earendil-works/pi-tui", + "version": "0.80.2", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "devDependencies": { + "@types/node": "24.12.4" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "packages/tui/node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "packages/tui/node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/cactus-code/package.json b/cactus-code/package.json new file mode 100644 index 000000000..0b70eeaab --- /dev/null +++ b/cactus-code/package.json @@ -0,0 +1,24 @@ +{ + "name": "pi-monorepo", + "private": true, + "type": "module", + "workspaces": [ + "packages/*" + ], + "scripts": { + "clean": "npm run clean --workspaces", + "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build" + }, + "devDependencies": { + "@types/node": "22.19.19", + "@typescript/native-preview": "7.0.0-dev.20260120.1", + "shx": "0.4.0" + }, + "engines": { + "node": ">=22.19.0" + }, + "version": "0.0.3", + "overrides": { + "rimraf": "6.1.2" + } +} diff --git a/cactus-code/packages/agent/package.json b/cactus-code/packages/agent/package.json new file mode 100644 index 000000000..82003a79d --- /dev/null +++ b/cactus-code/packages/agent/package.json @@ -0,0 +1,54 @@ +{ + "name": "@earendil-works/pi-agent-core", + "version": "0.80.2", + "description": "General-purpose agent with transport abstraction, state management, and attachment support", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "clean": "shx rm -rf dist", + "build": "tsgo -p tsconfig.build.json", + "prepublishOnly": "npm run clean && npm run build" + }, + "dependencies": { + "@earendil-works/pi-ai": "^0.80.2", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "keywords": [ + "ai", + "agent", + "llm", + "transport", + "state-management" + ], + "author": "Mario Zechner", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/earendil-works/pi.git", + "directory": "packages/agent" + }, + "engines": { + "node": ">=22.19.0" + }, + "devDependencies": { + "@types/node": "24.12.4" + } +} diff --git a/cactus-code/packages/agent/src/agent-loop.ts b/cactus-code/packages/agent/src/agent-loop.ts new file mode 100644 index 000000000..d93458d07 --- /dev/null +++ b/cactus-code/packages/agent/src/agent-loop.ts @@ -0,0 +1,748 @@ +/** + * Agent loop that works with AgentMessage throughout. + * Transforms to Message[] only at the LLM call boundary. + */ + +import { + type AssistantMessage, + type Context, + EventStream, + streamSimple, + type ToolResultMessage, + validateToolArguments, +} from "@earendil-works/pi-ai/compat"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentTool, + AgentToolCall, + AgentToolResult, + StreamFn, +} from "./types.ts"; + +export type AgentEventSink = (event: AgentEvent) => Promise | void; + +/** + * Start an agent loop with a new prompt message. + * The prompt is added to the context and events are emitted for it. + */ +export function agentLoop( + prompts: AgentMessage[], + context: AgentContext, + config: AgentLoopConfig, + signal?: AbortSignal, + streamFn?: StreamFn, +): EventStream { + const stream = createAgentStream(); + + void runAgentLoop( + prompts, + context, + config, + async (event) => { + stream.push(event); + }, + signal, + streamFn, + ).then((messages) => { + stream.end(messages); + }); + + return stream; +} + +/** + * Continue an agent loop from the current context without adding a new message. + * Used for retries - context already has user message or tool results. + * + * **Important:** The last message in context must convert to a `user` or `toolResult` message + * via `convertToLlm`. If it doesn't, the LLM provider will reject the request. + * This cannot be validated here since `convertToLlm` is only called once per turn. + */ +export function agentLoopContinue( + context: AgentContext, + config: AgentLoopConfig, + signal?: AbortSignal, + streamFn?: StreamFn, +): EventStream { + if (context.messages.length === 0) { + throw new Error("Cannot continue: no messages in context"); + } + + if (context.messages[context.messages.length - 1].role === "assistant") { + throw new Error("Cannot continue from message role: assistant"); + } + + const stream = createAgentStream(); + + void runAgentLoopContinue( + context, + config, + async (event) => { + stream.push(event); + }, + signal, + streamFn, + ).then((messages) => { + stream.end(messages); + }); + + return stream; +} + +export async function runAgentLoop( + prompts: AgentMessage[], + context: AgentContext, + config: AgentLoopConfig, + emit: AgentEventSink, + signal?: AbortSignal, + streamFn?: StreamFn, +): Promise { + const newMessages: AgentMessage[] = [...prompts]; + const currentContext: AgentContext = { + ...context, + messages: [...context.messages, ...prompts], + }; + + await emit({ type: "agent_start" }); + await emit({ type: "turn_start" }); + for (const prompt of prompts) { + await emit({ type: "message_start", message: prompt }); + await emit({ type: "message_end", message: prompt }); + } + + await runLoop(currentContext, newMessages, config, signal, emit, streamFn); + return newMessages; +} + +export async function runAgentLoopContinue( + context: AgentContext, + config: AgentLoopConfig, + emit: AgentEventSink, + signal?: AbortSignal, + streamFn?: StreamFn, +): Promise { + if (context.messages.length === 0) { + throw new Error("Cannot continue: no messages in context"); + } + + if (context.messages[context.messages.length - 1].role === "assistant") { + throw new Error("Cannot continue from message role: assistant"); + } + + const newMessages: AgentMessage[] = []; + const currentContext: AgentContext = { ...context }; + + await emit({ type: "agent_start" }); + await emit({ type: "turn_start" }); + + await runLoop(currentContext, newMessages, config, signal, emit, streamFn); + return newMessages; +} + +function createAgentStream(): EventStream { + return new EventStream( + (event: AgentEvent) => event.type === "agent_end", + (event: AgentEvent) => (event.type === "agent_end" ? event.messages : []), + ); +} + +/** + * Main loop logic shared by agentLoop and agentLoopContinue. + */ +async function runLoop( + initialContext: AgentContext, + newMessages: AgentMessage[], + initialConfig: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFn?: StreamFn, +): Promise { + let currentContext = initialContext; + let config = initialConfig; + let firstTurn = true; + // Check for steering messages at start (user may have typed while waiting) + let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || []; + + // Outer loop: continues when queued follow-up messages arrive after agent would stop + while (true) { + let hasMoreToolCalls = true; + + // Inner loop: process tool calls and steering messages + while (hasMoreToolCalls || pendingMessages.length > 0) { + if (!firstTurn) { + await emit({ type: "turn_start" }); + } else { + firstTurn = false; + } + + // Process pending messages (inject before next assistant response) + if (pendingMessages.length > 0) { + for (const message of pendingMessages) { + await emit({ type: "message_start", message }); + await emit({ type: "message_end", message }); + currentContext.messages.push(message); + newMessages.push(message); + } + pendingMessages = []; + } + + // Stream assistant response + const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn); + newMessages.push(message); + + if (message.stopReason === "error" || message.stopReason === "aborted") { + await emit({ type: "turn_end", message, toolResults: [] }); + await emit({ type: "agent_end", messages: newMessages }); + return; + } + + // Check for tool calls + const toolCalls = message.content.filter((c) => c.type === "toolCall"); + + const toolResults: ToolResultMessage[] = []; + hasMoreToolCalls = false; + if (toolCalls.length > 0) { + const executedToolBatch = await executeToolCalls(currentContext, message, config, signal, emit); + toolResults.push(...executedToolBatch.messages); + hasMoreToolCalls = !executedToolBatch.terminate; + + for (const result of toolResults) { + currentContext.messages.push(result); + newMessages.push(result); + } + } + + await emit({ type: "turn_end", message, toolResults }); + + const nextTurnContext = { + message, + toolResults, + context: currentContext, + newMessages, + }; + const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext); + if (nextTurnSnapshot) { + currentContext = nextTurnSnapshot.context ?? currentContext; + config = { + ...config, + model: nextTurnSnapshot.model ?? config.model, + reasoning: + nextTurnSnapshot.thinkingLevel === undefined + ? config.reasoning + : nextTurnSnapshot.thinkingLevel === "off" + ? undefined + : nextTurnSnapshot.thinkingLevel, + }; + } + + if ( + await config.shouldStopAfterTurn?.({ + message, + toolResults, + context: currentContext, + newMessages, + }) + ) { + await emit({ type: "agent_end", messages: newMessages }); + return; + } + + pendingMessages = (await config.getSteeringMessages?.()) || []; + } + + // Agent would stop here. Check for follow-up messages. + const followUpMessages = (await config.getFollowUpMessages?.()) || []; + if (followUpMessages.length > 0) { + // Set as pending so inner loop processes them + pendingMessages = followUpMessages; + continue; + } + + // No more messages, exit + break; + } + + await emit({ type: "agent_end", messages: newMessages }); +} + +/** + * Stream an assistant response from the LLM. + * This is where AgentMessage[] gets transformed to Message[] for the LLM. + */ +async function streamAssistantResponse( + context: AgentContext, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFn?: StreamFn, +): Promise { + // Apply context transform if configured (AgentMessage[] → AgentMessage[]) + let messages = context.messages; + if (config.transformContext) { + messages = await config.transformContext(messages, signal); + } + + // Convert to LLM-compatible messages (AgentMessage[] → Message[]) + const llmMessages = await config.convertToLlm(messages); + + // Build LLM context + const llmContext: Context = { + systemPrompt: context.systemPrompt, + messages: llmMessages, + tools: context.tools, + }; + + const streamFunction = streamFn || streamSimple; + + // Resolve API key (important for expiring tokens) + const resolvedApiKey = + (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey; + + const response = await streamFunction(config.model, llmContext, { + ...config, + apiKey: resolvedApiKey, + signal, + }); + + let partialMessage: AssistantMessage | null = null; + let addedPartial = false; + + for await (const event of response) { + switch (event.type) { + case "start": + partialMessage = event.partial; + context.messages.push(partialMessage); + addedPartial = true; + await emit({ type: "message_start", message: { ...partialMessage } }); + break; + + case "text_start": + case "text_delta": + case "text_end": + case "thinking_start": + case "thinking_delta": + case "thinking_end": + case "toolcall_start": + case "toolcall_delta": + case "toolcall_end": + if (partialMessage) { + partialMessage = event.partial; + context.messages[context.messages.length - 1] = partialMessage; + await emit({ + type: "message_update", + assistantMessageEvent: event, + message: { ...partialMessage }, + }); + } + break; + + case "done": + case "error": { + const finalMessage = await response.result(); + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage; + } else { + context.messages.push(finalMessage); + } + if (!addedPartial) { + await emit({ type: "message_start", message: { ...finalMessage } }); + } + await emit({ type: "message_end", message: finalMessage }); + return finalMessage; + } + } + } + + const finalMessage = await response.result(); + if (addedPartial) { + context.messages[context.messages.length - 1] = finalMessage; + } else { + context.messages.push(finalMessage); + await emit({ type: "message_start", message: { ...finalMessage } }); + } + await emit({ type: "message_end", message: finalMessage }); + return finalMessage; +} + +/** + * Execute tool calls from an assistant message. + */ +async function executeToolCalls( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall"); + const hasSequentialToolCall = toolCalls.some( + (tc) => currentContext.tools?.find((t) => t.name === tc.name)?.executionMode === "sequential", + ); + if (config.toolExecution === "sequential" || hasSequentialToolCall) { + return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit); + } + return executeToolCallsParallel(currentContext, assistantMessage, toolCalls, config, signal, emit); +} + +type ExecutedToolCallBatch = { + messages: ToolResultMessage[]; + terminate: boolean; +}; + +async function executeToolCallsSequential( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCalls: AgentToolCall[], + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const finalizedCalls: FinalizedToolCallOutcome[] = []; + const messages: ToolResultMessage[] = []; + + for (const toolCall of toolCalls) { + await emit({ + type: "tool_execution_start", + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.arguments, + }); + + const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal); + let finalized: FinalizedToolCallOutcome; + if (preparation.kind === "immediate") { + finalized = { + toolCall, + result: preparation.result, + isError: preparation.isError, + }; + } else { + const executed = await executePreparedToolCall(preparation, signal, emit); + finalized = await finalizeExecutedToolCall( + currentContext, + assistantMessage, + preparation, + executed, + config, + signal, + ); + } + + await emitToolExecutionEnd(finalized, emit); + const toolResultMessage = createToolResultMessage(finalized); + await emitToolResultMessage(toolResultMessage, emit); + finalizedCalls.push(finalized); + messages.push(toolResultMessage); + + if (signal?.aborted) { + break; + } + } + + return { + messages, + terminate: shouldTerminateToolBatch(finalizedCalls), + }; +} + +async function executeToolCallsParallel( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCalls: AgentToolCall[], + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const finalizedCalls: FinalizedToolCallEntry[] = []; + + for (const toolCall of toolCalls) { + await emit({ + type: "tool_execution_start", + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.arguments, + }); + + const preparation = await prepareToolCall(currentContext, assistantMessage, toolCall, config, signal); + if (preparation.kind === "immediate") { + const finalized = { + toolCall, + result: preparation.result, + isError: preparation.isError, + } satisfies FinalizedToolCallOutcome; + await emitToolExecutionEnd(finalized, emit); + finalizedCalls.push(finalized); + if (signal?.aborted) { + break; + } + continue; + } + + finalizedCalls.push(async () => { + const executed = await executePreparedToolCall(preparation, signal, emit); + const finalized = await finalizeExecutedToolCall( + currentContext, + assistantMessage, + preparation, + executed, + config, + signal, + ); + await emitToolExecutionEnd(finalized, emit); + return finalized; + }); + if (signal?.aborted) { + break; + } + } + + const orderedFinalizedCalls = await Promise.all( + finalizedCalls.map((entry) => (typeof entry === "function" ? entry() : Promise.resolve(entry))), + ); + const messages: ToolResultMessage[] = []; + for (const finalized of orderedFinalizedCalls) { + const toolResultMessage = createToolResultMessage(finalized); + await emitToolResultMessage(toolResultMessage, emit); + messages.push(toolResultMessage); + } + + return { + messages, + terminate: shouldTerminateToolBatch(orderedFinalizedCalls), + }; +} + +type PreparedToolCall = { + kind: "prepared"; + toolCall: AgentToolCall; + tool: AgentTool; + args: unknown; +}; + +type ImmediateToolCallOutcome = { + kind: "immediate"; + result: AgentToolResult; + isError: boolean; +}; + +type ExecutedToolCallOutcome = { + result: AgentToolResult; + isError: boolean; +}; + +type FinalizedToolCallOutcome = { + toolCall: AgentToolCall; + result: AgentToolResult; + isError: boolean; +}; + +type FinalizedToolCallEntry = FinalizedToolCallOutcome | (() => Promise); + +function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean { + return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true); +} + +function prepareToolCallArguments(tool: AgentTool, toolCall: AgentToolCall): AgentToolCall { + if (!tool.prepareArguments) { + return toolCall; + } + const preparedArguments = tool.prepareArguments(toolCall.arguments); + if (preparedArguments === toolCall.arguments) { + return toolCall; + } + return { + ...toolCall, + arguments: preparedArguments as Record, + }; +} + +async function prepareToolCall( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + toolCall: AgentToolCall, + config: AgentLoopConfig, + signal: AbortSignal | undefined, +): Promise { + const tool = currentContext.tools?.find((t) => t.name === toolCall.name); + if (!tool) { + return { + kind: "immediate", + result: createErrorToolResult(`Tool ${toolCall.name} not found`), + isError: true, + }; + } + + try { + const preparedToolCall = prepareToolCallArguments(tool, toolCall); + const validatedArgs = validateToolArguments(tool, preparedToolCall); + if (config.beforeToolCall) { + const beforeResult = await config.beforeToolCall( + { + assistantMessage, + toolCall, + args: validatedArgs, + context: currentContext, + }, + signal, + ); + if (signal?.aborted) { + return { + kind: "immediate", + result: createErrorToolResult("Operation aborted"), + isError: true, + }; + } + if (beforeResult?.block) { + return { + kind: "immediate", + result: createErrorToolResult(beforeResult.reason || "Tool execution was blocked"), + isError: true, + }; + } + } + if (signal?.aborted) { + return { + kind: "immediate", + result: createErrorToolResult("Operation aborted"), + isError: true, + }; + } + return { + kind: "prepared", + toolCall, + tool, + args: validatedArgs, + }; + } catch (error) { + return { + kind: "immediate", + result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + isError: true, + }; + } +} + +async function executePreparedToolCall( + prepared: PreparedToolCall, + signal: AbortSignal | undefined, + emit: AgentEventSink, +): Promise { + const updateEvents: Promise[] = []; + let acceptingUpdates = true; + + try { + const result = await prepared.tool.execute( + prepared.toolCall.id, + prepared.args as never, + signal, + (partialResult) => { + if (!acceptingUpdates) return; + updateEvents.push( + Promise.resolve( + emit({ + type: "tool_execution_update", + toolCallId: prepared.toolCall.id, + toolName: prepared.toolCall.name, + args: prepared.toolCall.arguments, + partialResult, + }), + ), + ); + }, + ); + acceptingUpdates = false; + await Promise.all(updateEvents); + return { result, isError: false }; + } catch (error) { + acceptingUpdates = false; + await Promise.all(updateEvents); + return { + result: createErrorToolResult(error instanceof Error ? error.message : String(error)), + isError: true, + }; + } finally { + acceptingUpdates = false; + } +} + +async function finalizeExecutedToolCall( + currentContext: AgentContext, + assistantMessage: AssistantMessage, + prepared: PreparedToolCall, + executed: ExecutedToolCallOutcome, + config: AgentLoopConfig, + signal: AbortSignal | undefined, +): Promise { + let result = executed.result; + let isError = executed.isError; + + if (config.afterToolCall) { + try { + const afterResult = await config.afterToolCall( + { + assistantMessage, + toolCall: prepared.toolCall, + args: prepared.args, + result, + isError, + context: currentContext, + }, + signal, + ); + if (afterResult) { + result = { + content: afterResult.content ?? result.content, + details: afterResult.details ?? result.details, + terminate: afterResult.terminate ?? result.terminate, + }; + isError = afterResult.isError ?? isError; + } + } catch (error) { + result = createErrorToolResult(error instanceof Error ? error.message : String(error)); + isError = true; + } + } + + return { + toolCall: prepared.toolCall, + result, + isError, + }; +} + +function createErrorToolResult(message: string): AgentToolResult { + return { + content: [{ type: "text", text: message }], + details: {}, + }; +} + +async function emitToolExecutionEnd(finalized: FinalizedToolCallOutcome, emit: AgentEventSink): Promise { + await emit({ + type: "tool_execution_end", + toolCallId: finalized.toolCall.id, + toolName: finalized.toolCall.name, + result: finalized.result, + isError: finalized.isError, + }); +} + +function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResultMessage { + return { + role: "toolResult", + toolCallId: finalized.toolCall.id, + toolName: finalized.toolCall.name, + content: finalized.result.content, + details: finalized.result.details, + isError: finalized.isError, + timestamp: Date.now(), + }; +} + +async function emitToolResultMessage(toolResultMessage: ToolResultMessage, emit: AgentEventSink): Promise { + await emit({ type: "message_start", message: toolResultMessage }); + await emit({ type: "message_end", message: toolResultMessage }); +} diff --git a/cactus-code/packages/agent/src/agent.ts b/cactus-code/packages/agent/src/agent.ts new file mode 100644 index 000000000..54020435e --- /dev/null +++ b/cactus-code/packages/agent/src/agent.ts @@ -0,0 +1,557 @@ +import { + type ImageContent, + type Message, + type Model, + type SimpleStreamOptions, + streamSimple, + type TextContent, + type ThinkingBudgets, + type Transport, +} from "@earendil-works/pi-ai/compat"; +import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; +import type { + AfterToolCallContext, + AfterToolCallResult, + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentLoopTurnUpdate, + AgentMessage, + AgentState, + AgentTool, + BeforeToolCallContext, + BeforeToolCallResult, + QueueMode, + StreamFn, + ToolExecutionMode, +} from "./types.ts"; + +export type { QueueMode } from "./types.ts"; + +function defaultConvertToLlm(messages: AgentMessage[]): Message[] { + return messages.filter( + (message) => message.role === "user" || message.role === "assistant" || message.role === "toolResult", + ); +} + +const EMPTY_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +const DEFAULT_MODEL = { + id: "unknown", + name: "unknown", + api: "unknown", + provider: "unknown", + baseUrl: "", + reasoning: false, + input: [], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 0, + maxTokens: 0, +} satisfies Model; + +type MutableAgentState = Omit & { + isStreaming: boolean; + streamingMessage?: AgentMessage; + pendingToolCalls: Set; + errorMessage?: string; +}; + +function createMutableAgentState( + initialState?: Partial>, +): MutableAgentState { + let tools = initialState?.tools?.slice() ?? []; + let messages = initialState?.messages?.slice() ?? []; + + return { + systemPrompt: initialState?.systemPrompt ?? "", + model: initialState?.model ?? DEFAULT_MODEL, + thinkingLevel: initialState?.thinkingLevel ?? "off", + get tools() { + return tools; + }, + set tools(nextTools: AgentTool[]) { + tools = nextTools.slice(); + }, + get messages() { + return messages; + }, + set messages(nextMessages: AgentMessage[]) { + messages = nextMessages.slice(); + }, + isStreaming: false, + streamingMessage: undefined, + pendingToolCalls: new Set(), + errorMessage: undefined, + }; +} + +/** Options for constructing an {@link Agent}. */ +export interface AgentOptions { + initialState?: Partial>; + convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + streamFn?: StreamFn; + getApiKey?: (provider: string) => Promise | string | undefined; + onPayload?: SimpleStreamOptions["onPayload"]; + onResponse?: SimpleStreamOptions["onResponse"]; + beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise; + afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise; + prepareNextTurn?: ( + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; + steeringMode?: QueueMode; + followUpMode?: QueueMode; + sessionId?: string; + thinkingBudgets?: ThinkingBudgets; + transport?: Transport; + maxRetryDelayMs?: number; + toolExecution?: ToolExecutionMode; +} + +class PendingMessageQueue { + private messages: AgentMessage[] = []; + public mode: QueueMode; + + constructor(mode: QueueMode) { + this.mode = mode; + } + + enqueue(message: AgentMessage): void { + this.messages.push(message); + } + + hasItems(): boolean { + return this.messages.length > 0; + } + + drain(): AgentMessage[] { + if (this.mode === "all") { + const drained = this.messages.slice(); + this.messages = []; + return drained; + } + + const first = this.messages[0]; + if (!first) { + return []; + } + this.messages = this.messages.slice(1); + return [first]; + } + + clear(): void { + this.messages = []; + } +} + +type ActiveRun = { + promise: Promise; + resolve: () => void; + abortController: AbortController; +}; + +/** + * Stateful wrapper around the low-level agent loop. + * + * `Agent` owns the current transcript, emits lifecycle events, executes tools, + * and exposes queueing APIs for steering and follow-up messages. + */ +export class Agent { + private _state: MutableAgentState; + private readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise | void>(); + private readonly steeringQueue: PendingMessageQueue; + private readonly followUpQueue: PendingMessageQueue; + + public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; + public transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + public streamFn: StreamFn; + public getApiKey?: (provider: string) => Promise | string | undefined; + public onPayload?: SimpleStreamOptions["onPayload"]; + public onResponse?: SimpleStreamOptions["onResponse"]; + public beforeToolCall?: ( + context: BeforeToolCallContext, + signal?: AbortSignal, + ) => Promise; + public afterToolCall?: ( + context: AfterToolCallContext, + signal?: AbortSignal, + ) => Promise; + public prepareNextTurn?: ( + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; + private activeRun?: ActiveRun; + /** Session identifier forwarded to providers for cache-aware backends. */ + public sessionId?: string; + /** Optional per-level thinking token budgets forwarded to the stream function. */ + public thinkingBudgets?: ThinkingBudgets; + /** Preferred transport forwarded to the stream function. */ + public transport: Transport; + /** Optional cap for provider-requested retry delays. */ + public maxRetryDelayMs?: number; + /** Tool execution strategy for assistant messages that contain multiple tool calls. */ + public toolExecution: ToolExecutionMode; + + constructor(options: AgentOptions = {}) { + this._state = createMutableAgentState(options.initialState); + this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm; + this.transformContext = options.transformContext; + this.streamFn = options.streamFn ?? streamSimple; + this.getApiKey = options.getApiKey; + this.onPayload = options.onPayload; + this.onResponse = options.onResponse; + this.beforeToolCall = options.beforeToolCall; + this.afterToolCall = options.afterToolCall; + this.prepareNextTurn = options.prepareNextTurn; + this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time"); + this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time"); + this.sessionId = options.sessionId; + this.thinkingBudgets = options.thinkingBudgets; + this.transport = options.transport ?? "auto"; + this.maxRetryDelayMs = options.maxRetryDelayMs; + this.toolExecution = options.toolExecution ?? "parallel"; + } + + /** + * Subscribe to agent lifecycle events. + * + * Listener promises are awaited in subscription order and are included in + * the current run's settlement. Listeners also receive the active abort + * signal for the current run. + * + * `agent_end` is the final emitted event for a run, but the agent does not + * become idle until all awaited listeners for that event have settled. + */ + subscribe(listener: (event: AgentEvent, signal: AbortSignal) => Promise | void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Current agent state. + * + * Assigning `state.tools` or `state.messages` copies the provided top-level array. + */ + get state(): AgentState { + return this._state; + } + + /** Controls how queued steering messages are drained. */ + set steeringMode(mode: QueueMode) { + this.steeringQueue.mode = mode; + } + + get steeringMode(): QueueMode { + return this.steeringQueue.mode; + } + + /** Controls how queued follow-up messages are drained. */ + set followUpMode(mode: QueueMode) { + this.followUpQueue.mode = mode; + } + + get followUpMode(): QueueMode { + return this.followUpQueue.mode; + } + + /** Queue a message to be injected after the current assistant turn finishes. */ + steer(message: AgentMessage): void { + this.steeringQueue.enqueue(message); + } + + /** Queue a message to run only after the agent would otherwise stop. */ + followUp(message: AgentMessage): void { + this.followUpQueue.enqueue(message); + } + + /** Remove all queued steering messages. */ + clearSteeringQueue(): void { + this.steeringQueue.clear(); + } + + /** Remove all queued follow-up messages. */ + clearFollowUpQueue(): void { + this.followUpQueue.clear(); + } + + /** Remove all queued steering and follow-up messages. */ + clearAllQueues(): void { + this.clearSteeringQueue(); + this.clearFollowUpQueue(); + } + + /** Returns true when either queue still contains pending messages. */ + hasQueuedMessages(): boolean { + return this.steeringQueue.hasItems() || this.followUpQueue.hasItems(); + } + + /** Active abort signal for the current run, if any. */ + get signal(): AbortSignal | undefined { + return this.activeRun?.abortController.signal; + } + + /** Abort the current run, if one is active. */ + abort(): void { + this.activeRun?.abortController.abort(); + } + + /** + * Resolve when the current run and all awaited event listeners have finished. + * + * This resolves after `agent_end` listeners settle. + */ + waitForIdle(): Promise { + return this.activeRun?.promise ?? Promise.resolve(); + } + + /** Clear transcript state, runtime state, and queued messages. */ + reset(): void { + this._state.messages = []; + this._state.isStreaming = false; + this._state.streamingMessage = undefined; + this._state.pendingToolCalls = new Set(); + this._state.errorMessage = undefined; + this.clearFollowUpQueue(); + this.clearSteeringQueue(); + } + + /** Start a new prompt from text, a single message, or a batch of messages. */ + async prompt(message: AgentMessage | AgentMessage[]): Promise; + async prompt(input: string, images?: ImageContent[]): Promise; + async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]): Promise { + if (this.activeRun) { + throw new Error( + "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", + ); + } + const messages = this.normalizePromptInput(input, images); + await this.runPromptMessages(messages); + } + + /** Continue from the current transcript. The last message must be a user or tool-result message. */ + async continue(): Promise { + if (this.activeRun) { + throw new Error("Agent is already processing. Wait for completion before continuing."); + } + + const lastMessage = this._state.messages[this._state.messages.length - 1]; + if (!lastMessage) { + throw new Error("No messages to continue from"); + } + + if (lastMessage.role === "assistant") { + const queuedSteering = this.steeringQueue.drain(); + if (queuedSteering.length > 0) { + await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true }); + return; + } + + const queuedFollowUps = this.followUpQueue.drain(); + if (queuedFollowUps.length > 0) { + await this.runPromptMessages(queuedFollowUps); + return; + } + + throw new Error("Cannot continue from message role: assistant"); + } + + await this.runContinuation(); + } + + private normalizePromptInput( + input: string | AgentMessage | AgentMessage[], + images?: ImageContent[], + ): AgentMessage[] { + if (Array.isArray(input)) { + return input; + } + + if (typeof input !== "string") { + return [input]; + } + + const content: Array = [{ type: "text", text: input }]; + if (images && images.length > 0) { + content.push(...images); + } + return [{ role: "user", content, timestamp: Date.now() }]; + } + + private async runPromptMessages( + messages: AgentMessage[], + options: { skipInitialSteeringPoll?: boolean } = {}, + ): Promise { + await this.runWithLifecycle(async (signal) => { + await runAgentLoop( + messages, + this.createContextSnapshot(), + this.createLoopConfig(options), + (event) => this.processEvents(event), + signal, + this.streamFn, + ); + }); + } + + private async runContinuation(): Promise { + await this.runWithLifecycle(async (signal) => { + await runAgentLoopContinue( + this.createContextSnapshot(), + this.createLoopConfig(), + (event) => this.processEvents(event), + signal, + this.streamFn, + ); + }); + } + + private createContextSnapshot(): AgentContext { + return { + systemPrompt: this._state.systemPrompt, + messages: this._state.messages.slice(), + tools: this._state.tools.slice(), + }; + } + + private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig { + let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true; + return { + model: this._state.model, + reasoning: this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel, + sessionId: this.sessionId, + onPayload: this.onPayload, + onResponse: this.onResponse, + transport: this.transport, + thinkingBudgets: this.thinkingBudgets, + maxRetryDelayMs: this.maxRetryDelayMs, + toolExecution: this.toolExecution, + beforeToolCall: this.beforeToolCall, + afterToolCall: this.afterToolCall, + prepareNextTurn: this.prepareNextTurn ? async () => await this.prepareNextTurn?.(this.signal) : undefined, + convertToLlm: this.convertToLlm, + transformContext: this.transformContext, + getApiKey: this.getApiKey, + getSteeringMessages: async () => { + if (skipInitialSteeringPoll) { + skipInitialSteeringPoll = false; + return []; + } + return this.steeringQueue.drain(); + }, + getFollowUpMessages: async () => this.followUpQueue.drain(), + }; + } + + private async runWithLifecycle(executor: (signal: AbortSignal) => Promise): Promise { + if (this.activeRun) { + throw new Error("Agent is already processing."); + } + + const abortController = new AbortController(); + let resolvePromise = () => {}; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + this.activeRun = { promise, resolve: resolvePromise, abortController }; + + this._state.isStreaming = true; + this._state.streamingMessage = undefined; + this._state.errorMessage = undefined; + + try { + await executor(abortController.signal); + } catch (error) { + await this.handleRunFailure(error, abortController.signal.aborted); + } finally { + this.finishRun(); + } + } + + private async handleRunFailure(error: unknown, aborted: boolean): Promise { + const failureMessage = { + role: "assistant", + content: [{ type: "text", text: "" }], + api: this._state.model.api, + provider: this._state.model.provider, + model: this._state.model.id, + usage: EMPTY_USAGE, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + } satisfies AgentMessage; + await this.processEvents({ type: "message_start", message: failureMessage }); + await this.processEvents({ type: "message_end", message: failureMessage }); + await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] }); + await this.processEvents({ type: "agent_end", messages: [failureMessage] }); + } + + private finishRun(): void { + this._state.isStreaming = false; + this._state.streamingMessage = undefined; + this._state.pendingToolCalls = new Set(); + this.activeRun?.resolve(); + this.activeRun = undefined; + } + + /** + * Reduce internal state for a loop event, then await listeners. + * + * `agent_end` only means no further loop events will be emitted. The run is + * considered idle later, after all awaited listeners for `agent_end` finish + * and `finishRun()` clears runtime-owned state. + */ + private async processEvents(event: AgentEvent): Promise { + switch (event.type) { + case "message_start": + this._state.streamingMessage = event.message; + break; + + case "message_update": + this._state.streamingMessage = event.message; + break; + + case "message_end": + this._state.streamingMessage = undefined; + this._state.messages.push(event.message); + break; + + case "tool_execution_start": { + const pendingToolCalls = new Set(this._state.pendingToolCalls); + pendingToolCalls.add(event.toolCallId); + this._state.pendingToolCalls = pendingToolCalls; + break; + } + + case "tool_execution_end": { + const pendingToolCalls = new Set(this._state.pendingToolCalls); + pendingToolCalls.delete(event.toolCallId); + this._state.pendingToolCalls = pendingToolCalls; + break; + } + + case "turn_end": + if (event.message.role === "assistant" && event.message.errorMessage) { + this._state.errorMessage = event.message.errorMessage; + } + break; + + case "agent_end": + this._state.streamingMessage = undefined; + break; + } + + const signal = this.activeRun?.abortController.signal; + if (!signal) { + throw new Error("Agent listener invoked outside active run"); + } + for (const listener of this.listeners) { + await listener(event, signal); + } + } +} diff --git a/cactus-code/packages/agent/src/harness/agent-harness.ts b/cactus-code/packages/agent/src/harness/agent-harness.ts new file mode 100644 index 000000000..1d09b054e --- /dev/null +++ b/cactus-code/packages/agent/src/harness/agent-harness.ts @@ -0,0 +1,1029 @@ +import type { AssistantMessage, ImageContent, Model, Models, UserMessage } from "@earendil-works/pi-ai"; +import { runAgentLoop } from "../agent-loop.ts"; +import type { + AgentContext, + AgentEvent, + AgentLoopConfig, + AgentMessage, + AgentTool, + QueueMode, + StreamFn, + ThinkingLevel, +} from "../types.ts"; +import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction/branch-summarization.ts"; +import { compact, DEFAULT_COMPACTION_SETTINGS, prepareCompaction } from "./compaction/compaction.ts"; +import { convertToLlm } from "./messages.ts"; +import { formatPromptTemplateInvocation } from "./prompt-templates.ts"; +import { formatSkillInvocation } from "./skills.ts"; +import type { + AbortResult, + AgentHarnessEvent, + AgentHarnessEventResultMap, + AgentHarnessOptions, + AgentHarnessOwnEvent, + AgentHarnessPhase, + AgentHarnessResources, + AgentHarnessStreamOptions, + AgentHarnessStreamOptionsPatch, + ExecutionEnv, + NavigateTreeResult, + PendingSessionWrite, + PromptTemplate, + Session, + Skill, +} from "./types.ts"; +import { AgentHarnessError, BranchSummaryError, CompactionError, SessionError, toError } from "./types.ts"; + +function createUserMessage(text: string, images?: ImageContent[]): UserMessage { + const content: Array<{ type: "text"; text: string } | ImageContent> = [{ type: "text", text }]; + if (images) content.push(...images); + return { role: "user", content, timestamp: Date.now() }; +} + +function createFailureMessage(model: Model, error: unknown, aborted: boolean): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: "" }], + api: model.api, + provider: model.provider, + model: model.id, + stopReason: aborted ? "aborted" : "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +function cloneStreamOptions(streamOptions?: AgentHarnessStreamOptions): AgentHarnessStreamOptions { + return { + ...streamOptions, + headers: streamOptions?.headers ? { ...streamOptions.headers } : undefined, + metadata: streamOptions?.metadata ? { ...streamOptions.metadata } : undefined, + }; +} + +function findDuplicateNames(names: string[]): string[] { + const seen = new Set(); + const duplicates = new Set(); + for (const name of names) { + if (seen.has(name)) duplicates.add(name); + seen.add(name); + } + return [...duplicates]; +} + +function applyStreamOptionsPatch( + base: AgentHarnessStreamOptions, + patch?: AgentHarnessStreamOptionsPatch, +): AgentHarnessStreamOptions { + const result = cloneStreamOptions(base); + if (!patch) return result; + + if (Object.hasOwn(patch, "transport")) result.transport = patch.transport; + if (Object.hasOwn(patch, "timeoutMs")) result.timeoutMs = patch.timeoutMs; + if (Object.hasOwn(patch, "maxRetries")) result.maxRetries = patch.maxRetries; + if (Object.hasOwn(patch, "maxRetryDelayMs")) result.maxRetryDelayMs = patch.maxRetryDelayMs; + if (Object.hasOwn(patch, "cacheRetention")) result.cacheRetention = patch.cacheRetention; + + if (Object.hasOwn(patch, "headers")) { + if (patch.headers === undefined) { + result.headers = undefined; + } else { + const headers = { ...(result.headers ?? {}) }; + for (const [key, value] of Object.entries(patch.headers)) { + if (value === undefined) delete headers[key]; + else headers[key] = value; + } + result.headers = Object.keys(headers).length > 0 ? headers : undefined; + } + } + + if (Object.hasOwn(patch, "metadata")) { + if (patch.metadata === undefined) { + result.metadata = undefined; + } else { + const metadata = { ...(result.metadata ?? {}) }; + for (const [key, value] of Object.entries(patch.metadata)) { + if (value === undefined) delete metadata[key]; + else metadata[key] = value; + } + result.metadata = Object.keys(metadata).length > 0 ? metadata : undefined; + } + } + + return result; +} + +const SUBSCRIBER_EVENT_TYPE = "*"; + +type AgentHarnessHandler = (event: any, signal?: AbortSignal) => Promise | any; + +function normalizeHarnessError(error: unknown, fallbackCode: AgentHarnessError["code"]): AgentHarnessError { + if (error instanceof AgentHarnessError) return error; + const cause = toError(error); + if (cause instanceof SessionError) return new AgentHarnessError("session", cause.message, cause); + if (cause instanceof CompactionError) return new AgentHarnessError("compaction", cause.message, cause); + if (cause instanceof BranchSummaryError) return new AgentHarnessError("branch_summary", cause.message, cause); + return new AgentHarnessError(fallbackCode, cause.message, cause); +} + +function normalizeHookError(error: unknown): AgentHarnessError { + return normalizeHarnessError(error, "hook"); +} + +interface AgentHarnessTurnState< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + messages: AgentMessage[]; + resources: AgentHarnessResources; + streamOptions: AgentHarnessStreamOptions; + sessionId: string; + systemPrompt: string; + model: Model; + thinkingLevel: ThinkingLevel; + tools: TTool[]; + activeTools: TTool[]; +} + +export class AgentHarness< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + readonly env: ExecutionEnv; + private session: Session; + readonly models: Models; + private phase: AgentHarnessPhase = "idle"; + private runAbortController?: AbortController; + private runPromise?: Promise; + private pendingSessionWrites: PendingSessionWrite[] = []; + private model: Model; + private thinkingLevel: ThinkingLevel; + private systemPrompt: AgentHarnessOptions["systemPrompt"]; + private streamOptions: AgentHarnessStreamOptions; + private resources: AgentHarnessResources; + private tools = new Map(); + private activeToolNames: string[]; + private steerQueue: UserMessage[] = []; + private steeringQueueMode: QueueMode; + private followUpQueue: UserMessage[] = []; + private followUpQueueMode: QueueMode; + private nextTurnQueue: AgentMessage[] = []; + private handlers = new Map>(); + + constructor(options: AgentHarnessOptions) { + this.env = options.env; + this.session = options.session; + this.models = options.models; + this.resources = options.resources ?? {}; + this.streamOptions = cloneStreamOptions(options.streamOptions); + this.systemPrompt = options.systemPrompt; + this.validateUniqueNames( + (options.tools ?? []).map((tool) => tool.name), + "Duplicate tool name(s)", + ); + for (const tool of options.tools ?? []) { + this.tools.set(tool.name, tool); + } + this.model = options.model; + this.thinkingLevel = options.thinkingLevel ?? "off"; + this.activeToolNames = options.activeToolNames + ? [...options.activeToolNames] + : (options.tools ?? []).map((tool) => tool.name); + this.validateUniqueNames(this.activeToolNames, "Duplicate active tool name(s)"); + this.validateToolNames(this.activeToolNames); + this.steeringQueueMode = options.steeringMode ?? "one-at-a-time"; + this.followUpQueueMode = options.followUpMode ?? "one-at-a-time"; + } + + private getHandlers(type: string): Set | undefined { + return this.handlers.get(type); + } + + private async emitOwn(event: AgentHarnessOwnEvent, signal?: AbortSignal): Promise { + for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { + try { + await listener(event, signal); + } catch (error) { + throw normalizeHookError(error); + } + } + } + + private async emitAny(event: AgentHarnessEvent, signal?: AbortSignal): Promise { + for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) { + try { + await listener(event, signal); + } catch (error) { + throw normalizeHookError(error); + } + } + } + + private async emitHook( + event: Extract, + ): Promise { + const handlers = this.getHandlers(event.type as TType); + if (!handlers || handlers.size === 0) return undefined; + let lastResult: AgentHarnessEventResultMap[TType] | undefined; + for (const handler of handlers) { + try { + const result = await handler(event); + if (result !== undefined) { + lastResult = result; + } + } catch (error) { + throw normalizeHookError(error); + } + } + return lastResult; + } + + private async emitBeforeProviderRequest( + model: Model, + sessionId: string, + streamOptions: AgentHarnessStreamOptions, + ): Promise { + const handlers = this.getHandlers("before_provider_request"); + let current = cloneStreamOptions(streamOptions); + if (!handlers || handlers.size === 0) return current; + for (const handler of handlers) { + try { + const result = await handler({ + type: "before_provider_request", + model, + sessionId, + streamOptions: cloneStreamOptions(current), + }); + if (result?.streamOptions) { + current = applyStreamOptionsPatch(current, result.streamOptions); + } + } catch (error) { + throw normalizeHookError(error); + } + } + return current; + } + + private async emitBeforeProviderPayload(model: Model, payload: unknown): Promise { + const handlers = this.getHandlers("before_provider_payload"); + let current = payload; + if (!handlers || handlers.size === 0) return current; + for (const handler of handlers) { + try { + const result = await handler({ type: "before_provider_payload", model, payload: current }); + if (result !== undefined) { + current = result.payload; + } + } catch (error) { + throw normalizeHookError(error); + } + } + return current; + } + + private async emitQueueUpdate(): Promise { + await this.emitOwn({ + type: "queue_update", + steer: [...this.steerQueue], + followUp: [...this.followUpQueue], + nextTurn: [...this.nextTurnQueue], + }); + } + + private startRunPromise(): () => void { + let finish = () => {}; + this.runPromise = new Promise((resolve) => { + finish = resolve; + }); + return () => { + this.runPromise = undefined; + finish(); + }; + } + + private async createTurnState(): Promise> { + const context = await this.session.buildContext(); + const resources = this.getResources(); + const sessionMetadata = await this.session.getMetadata(); + const tools = [...this.tools.values()]; + const activeTools = this.activeToolNames + .map((name) => this.tools.get(name)) + .filter((tool): tool is TTool => tool !== undefined); + let systemPrompt = "You are a helpful assistant."; + if (typeof this.systemPrompt === "string") { + systemPrompt = this.systemPrompt; + } else if (this.systemPrompt) { + systemPrompt = await this.systemPrompt({ + env: this.env, + session: this.session, + model: this.model, + thinkingLevel: this.thinkingLevel, + activeTools, + resources, + }); + } + return { + messages: context.messages, + resources, + streamOptions: cloneStreamOptions(this.streamOptions), + sessionId: sessionMetadata.id, + systemPrompt, + model: this.model, + thinkingLevel: this.thinkingLevel, + tools, + activeTools, + }; + } + + private createContext( + turnState: AgentHarnessTurnState, + systemPrompt?: string, + ): AgentContext { + return { + systemPrompt: systemPrompt ?? turnState.systemPrompt, + messages: turnState.messages.slice(), + tools: turnState.activeTools.slice(), + }; + } + + private createStreamFn(getTurnState: () => AgentHarnessTurnState): StreamFn { + return async (model, context, streamOptions) => { + const turnState = getTurnState(); + const snapshotOptions: AgentHarnessStreamOptions = { ...turnState.streamOptions }; + const requestOptions = await this.emitBeforeProviderRequest(model, turnState.sessionId, snapshotOptions); + return this.models.streamSimple(model, context, { + cacheRetention: requestOptions.cacheRetention, + headers: requestOptions.headers, + maxRetries: requestOptions.maxRetries, + maxRetryDelayMs: requestOptions.maxRetryDelayMs, + metadata: requestOptions.metadata, + onPayload: async (payload) => await this.emitBeforeProviderPayload(model, payload), + onResponse: async (response) => { + const headers = { ...(response.headers as Record) }; + await this.emitOwn( + { type: "after_provider_response", status: response.status, headers }, + streamOptions?.signal, + ); + }, + reasoning: streamOptions?.reasoning, + signal: streamOptions?.signal, + sessionId: turnState.sessionId, + timeoutMs: requestOptions.timeoutMs, + transport: requestOptions.transport, + }); + }; + } + + private async drainQueuedMessages(queue: AgentMessage[], mode: QueueMode): Promise { + const messages = mode === "all" ? queue.splice(0) : queue.splice(0, 1); + if (messages.length === 0) return messages; + try { + await this.emitQueueUpdate(); + return messages; + } catch (error) { + queue.unshift(...messages); + throw normalizeHookError(error); + } + } + + private createLoopConfig( + getTurnState: () => AgentHarnessTurnState, + setTurnState: (turnState: AgentHarnessTurnState) => void, + ): AgentLoopConfig { + const turnState = getTurnState(); + return { + model: turnState.model, + reasoning: turnState.thinkingLevel === "off" ? undefined : turnState.thinkingLevel, + convertToLlm, + transformContext: async (messages) => { + const result = await this.emitHook({ type: "context", messages: [...messages] }); + return result?.messages ?? messages; + }, + beforeToolCall: async ({ toolCall, args }) => { + const result = await this.emitHook({ + type: "tool_call", + toolCallId: toolCall.id, + toolName: toolCall.name, + input: args as Record, + }); + return result ? { block: result.block, reason: result.reason } : undefined; + }, + afterToolCall: async ({ toolCall, args, result, isError }) => { + const patch = await this.emitHook({ + type: "tool_result", + toolCallId: toolCall.id, + toolName: toolCall.name, + input: args as Record, + content: result.content, + details: result.details, + isError, + }); + return patch + ? { content: patch.content, details: patch.details, isError: patch.isError, terminate: patch.terminate } + : undefined; + }, + prepareNextTurn: async () => { + await this.flushPendingSessionWrites(); + const nextTurnState = await this.createTurnState(); + setTurnState(nextTurnState); + return { + context: this.createContext(nextTurnState), + model: nextTurnState.model, + thinkingLevel: nextTurnState.thinkingLevel, + }; + }, + getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode), + getFollowUpMessages: async () => this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode), + }; + } + + private validateUniqueNames(names: string[], message: string): void { + const duplicates = findDuplicateNames(names); + if (duplicates.length > 0) + throw new AgentHarnessError("invalid_argument", `${message}: ${duplicates.join(", ")}`); + } + + private validateToolNames(toolNames: string[], tools: Map = this.tools): void { + this.validateUniqueNames(toolNames, "Duplicate active tool name(s)"); + const missing = toolNames.filter((name) => !tools.has(name)); + if (missing.length > 0) throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`); + } + + private async flushPendingSessionWrites(): Promise { + while (this.pendingSessionWrites.length > 0) { + const write = this.pendingSessionWrites[0]!; + if (write.type === "message") { + await this.session.appendMessage(write.message); + } else if (write.type === "model_change") { + await this.session.appendModelChange(write.provider, write.modelId); + } else if (write.type === "thinking_level_change") { + await this.session.appendThinkingLevelChange(write.thinkingLevel); + } else if (write.type === "active_tools_change") { + await this.session.appendActiveToolsChange(write.activeToolNames); + } else if (write.type === "custom") { + await this.session.appendCustomEntry(write.customType, write.data); + } else if (write.type === "custom_message") { + await this.session.appendCustomMessageEntry(write.customType, write.content, write.display, write.details); + } else if (write.type === "label") { + await this.session.appendLabel(write.targetId, write.label); + } else if (write.type === "session_info") { + await this.session.appendSessionName(write.name ?? ""); + } else if (write.type === "leaf") { + await this.session.getStorage().setLeafId(write.targetId); + } + this.pendingSessionWrites.shift(); + } + } + + private async handleAgentEvent(event: AgentEvent, signal?: AbortSignal): Promise { + if (event.type === "message_end") { + await this.session.appendMessage(event.message); + await this.emitAny(event, signal); + return; + } + if (event.type === "turn_end") { + let eventError: unknown; + try { + await this.emitAny(event, signal); + } catch (error) { + eventError = error; + } + const hadPendingMutations = this.pendingSessionWrites.length > 0; + await this.flushPendingSessionWrites(); + if (eventError) throw eventError; + await this.emitOwn({ type: "save_point", hadPendingMutations }); + return; + } + if (event.type === "agent_end") { + await this.flushPendingSessionWrites(); + this.phase = "idle"; + await this.emitAny(event, signal); + await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal); + return; + } + await this.emitAny(event, signal); + } + + private async emitRunFailure( + model: Model, + error: unknown, + aborted: boolean, + signal: AbortSignal, + ): Promise { + const failureMessage = createFailureMessage(model, error, aborted); + await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal); + await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal); + await this.handleAgentEvent({ type: "turn_end", message: failureMessage, toolResults: [] }, signal); + await this.handleAgentEvent({ type: "agent_end", messages: [failureMessage] }, signal); + return [failureMessage]; + } + + private async executeTurn( + turnState: AgentHarnessTurnState, + text: string, + options?: { images?: ImageContent[] }, + ): Promise { + let activeTurnState = turnState; + let messages: AgentMessage[] = [createUserMessage(text, options?.images)]; + if (this.nextTurnQueue.length > 0) { + const queuedMessages = this.nextTurnQueue.splice(0); + try { + await this.emitQueueUpdate(); + } catch (error) { + this.nextTurnQueue.unshift(...queuedMessages); + throw normalizeHookError(error); + } + messages = [...queuedMessages, messages[0]!]; + } + const beforeResult = await this.emitHook({ + type: "before_agent_start", + prompt: text, + images: options?.images, + systemPrompt: turnState.systemPrompt, + resources: turnState.resources, + }); + if (beforeResult?.messages) messages = [...messages, ...beforeResult.messages]; + + const abortController = new AbortController(); + const getTurnState = () => activeTurnState; + const setTurnState = (nextTurnState: AgentHarnessTurnState) => { + activeTurnState = nextTurnState; + }; + this.runAbortController = abortController; + const runResultPromise = (async () => { + try { + return await runAgentLoop( + messages, + this.createContext(turnState, beforeResult?.systemPrompt), + this.createLoopConfig(getTurnState, setTurnState), + (event) => this.handleAgentEvent(event, abortController.signal), + abortController.signal, + this.createStreamFn(getTurnState), + ); + } catch (error) { + try { + return await this.emitRunFailure( + activeTurnState.model, + error, + abortController.signal.aborted, + abortController.signal, + ); + } catch (failureError) { + const cause = new AggregateError( + [toError(error), toError(failureError)], + "Agent run failed and failure reporting failed", + ); + throw new AgentHarnessError("unknown", cause.message, cause); + } + } + })(); + try { + const newMessages = await runResultPromise; + for (let i = newMessages.length - 1; i >= 0; i--) { + const message = newMessages[i]!; + if (message.role === "assistant") { + return message; + } + } + throw new AgentHarnessError("invalid_state", "AgentHarness prompt completed without an assistant message"); + } finally { + try { + await this.flushPendingSessionWrites(); + } finally { + this.runAbortController = undefined; + } + } + } + + async prompt(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + return await this.executeTurn(turnState, text, options); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async skill(name: string, additionalInstructions?: string): Promise { + if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name); + if (!skill) throw new AgentHarnessError("invalid_argument", `Unknown skill: ${name}`); + return await this.executeTurn(turnState, formatSkillInvocation(skill, additionalInstructions)); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async promptFromTemplate(name: string, args: string[] = []): Promise { + if (this.phase !== "idle") throw new AgentHarnessError("busy", "AgentHarness is busy"); + this.phase = "turn"; + const finishRunPromise = this.startRunPromise(); + try { + const turnState = await this.createTurnState(); + const template = (turnState.resources.promptTemplates ?? []).find((candidate) => candidate.name === name); + if (!template) throw new AgentHarnessError("invalid_argument", `Unknown prompt template: ${name}`); + return await this.executeTurn(turnState, formatPromptTemplateInvocation(template, args)); + } catch (error) { + this.phase = "idle"; + throw normalizeHarnessError(error, "unknown"); + } finally { + finishRunPromise(); + } + } + + async steer(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase === "idle") throw new AgentHarnessError("invalid_state", "Cannot steer while idle"); + this.steerQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async followUp(text: string, options?: { images?: ImageContent[] }): Promise { + if (this.phase === "idle") throw new AgentHarnessError("invalid_state", "Cannot follow up while idle"); + this.followUpQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async nextTurn(text: string, options?: { images?: ImageContent[] }): Promise { + this.nextTurnQueue.push(createUserMessage(text, options?.images)); + await this.emitQueueUpdate(); + } + + async appendMessage(message: AgentMessage): Promise { + try { + if (this.phase === "idle") { + await this.session.appendMessage(message); + } else { + this.pendingSessionWrites.push({ type: "message", message }); + } + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + async compact( + customInstructions?: string, + ): Promise<{ summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown }> { + if (this.phase !== "idle") throw new AgentHarnessError("busy", "compact() requires idle harness"); + this.phase = "compaction"; + try { + const model = this.model; + if (!model) throw new AgentHarnessError("invalid_state", "No model set for compaction"); + const branchEntries = await this.session.getBranch(); + const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS); + if (!preparationResult.ok) throw preparationResult.error; + const preparation = preparationResult.value; + if (!preparation) throw new AgentHarnessError("compaction", "Nothing to compact"); + const hookResult = await this.emitHook({ + type: "session_before_compact", + preparation, + branchEntries, + customInstructions, + signal: new AbortController().signal, + }); + if (hookResult?.cancel) throw new AgentHarnessError("compaction", "Compaction cancelled"); + const provided = hookResult?.compaction; + const compactResult = provided + ? { ok: true as const, value: provided } + : await compact(preparation, this.models, model, customInstructions, undefined, this.thinkingLevel); + if (!compactResult.ok) throw compactResult.error; + const result = compactResult.value; + const entryId = await this.session.appendCompaction( + result.summary, + result.firstKeptEntryId, + result.tokensBefore, + result.details, + provided !== undefined, + ); + const entry = await this.session.getEntry(entryId); + if (entry?.type === "compaction") { + await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined }); + } + return result; + } catch (error) { + throw normalizeHarnessError(error, "compaction"); + } finally { + this.phase = "idle"; + } + } + + async navigateTree( + targetId: string, + options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, + ): Promise { + if (this.phase !== "idle") throw new AgentHarnessError("busy", "navigateTree() requires idle harness"); + this.phase = "branch_summary"; + try { + const oldLeafId = await this.session.getLeafId(); + if (oldLeafId === targetId) return { cancelled: false }; + const targetEntry = await this.session.getEntry(targetId); + if (!targetEntry) throw new AgentHarnessError("invalid_argument", `Entry ${targetId} not found`); + const { entries, commonAncestorId } = await collectEntriesForBranchSummary(this.session, oldLeafId, targetId); + const preparation = { + targetId, + oldLeafId, + commonAncestorId, + entriesToSummarize: entries, + userWantsSummary: options?.summarize ?? false, + customInstructions: options?.customInstructions, + replaceInstructions: options?.replaceInstructions, + label: options?.label, + }; + const signal = new AbortController().signal; + const hookResult = await this.emitHook({ type: "session_before_tree", preparation, signal }); + if (hookResult?.cancel) return { cancelled: true }; + let summaryEntry: NavigateTreeResult["summaryEntry"]; + let summaryText: string | undefined = hookResult?.summary?.summary; + let summaryDetails: unknown = hookResult?.summary?.details; + if (!summaryText && options?.summarize && entries.length > 0) { + const model = this.model; + if (!model) throw new AgentHarnessError("invalid_state", "No model set for branch summary"); + const branchSummary = await generateBranchSummary(entries, { + models: this.models, + model, + signal: new AbortController().signal, + customInstructions: hookResult?.customInstructions ?? options?.customInstructions, + replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions, + }); + if (!branchSummary.ok) { + if (branchSummary.error.code === "aborted") return { cancelled: true }; + throw new AgentHarnessError("branch_summary", branchSummary.error.message, branchSummary.error); + } + summaryText = branchSummary.value.summary; + summaryDetails = { + readFiles: branchSummary.value.readFiles, + modifiedFiles: branchSummary.value.modifiedFiles, + }; + } + let editorText: string | undefined; + let newLeafId: string | null; + if (targetEntry.type === "message" && targetEntry.message.role === "user") { + newLeafId = targetEntry.parentId; + const content = targetEntry.message.content; + editorText = + typeof content === "string" + ? content + : content + .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else if (targetEntry.type === "custom_message") { + newLeafId = targetEntry.parentId; + editorText = + typeof targetEntry.content === "string" + ? targetEntry.content + : targetEntry.content + .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else { + newLeafId = targetId; + } + const summaryId = await this.session.moveTo( + newLeafId, + summaryText + ? { summary: summaryText, details: summaryDetails, fromHook: hookResult?.summary !== undefined } + : undefined, + ); + if (summaryId) { + const entry = await this.session.getEntry(summaryId); + if (entry?.type === "branch_summary") summaryEntry = entry; + } + await this.emitOwn({ + type: "session_tree", + newLeafId: await this.session.getLeafId(), + oldLeafId, + summaryEntry, + fromHook: hookResult?.summary !== undefined, + }); + return { cancelled: false, editorText, summaryEntry }; + } catch (error) { + throw normalizeHarnessError(error, "branch_summary"); + } finally { + this.phase = "idle"; + } + } + + getModel(): Model { + return this.model; + } + + async setModel(model: Model): Promise { + try { + const previousModel = this.model; + if (this.phase === "idle") { + await this.session.appendModelChange(model.provider, model.id); + } else { + this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id }); + } + this.model = model; + await this.emitOwn({ type: "model_update", model, previousModel, source: "set" }); + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + getThinkingLevel(): ThinkingLevel { + return this.thinkingLevel; + } + + async setThinkingLevel(level: ThinkingLevel): Promise { + try { + const previousLevel = this.thinkingLevel; + if (this.phase === "idle") { + await this.session.appendThinkingLevelChange(level); + } else { + this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level }); + } + this.thinkingLevel = level; + await this.emitOwn({ type: "thinking_level_update", level, previousLevel }); + } catch (error) { + throw normalizeHarnessError(error, "session"); + } + } + + getTools(): TTool[] { + return [...this.tools.values()]; + } + + async setTools(tools: TTool[], activeToolNames?: string[]): Promise { + try { + this.validateUniqueNames( + tools.map((tool) => tool.name), + "Duplicate tool name(s)", + ); + const nextTools = new Map(tools.map((tool) => [tool.name, tool])); + const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames; + this.validateToolNames(nextActiveToolNames, nextTools); + const previousToolNames = [...this.tools.keys()]; + const previousActiveToolNames = [...this.activeToolNames]; + if (this.phase === "idle") { + await this.session.appendActiveToolsChange(nextActiveToolNames); + } else { + this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] }); + } + this.tools = nextTools; + this.activeToolNames = [...nextActiveToolNames]; + await this.emitOwn({ + type: "tools_update", + toolNames: [...this.tools.keys()], + previousToolNames, + activeToolNames: [...this.activeToolNames], + previousActiveToolNames, + source: "set", + }); + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); + } + } + + getActiveTools(): TTool[] { + return this.activeToolNames.map((name) => this.tools.get(name)!); + } + + async setActiveTools(toolNames: string[]): Promise { + try { + this.validateToolNames(toolNames); + const previousToolNames = [...this.tools.keys()]; + const previousActiveToolNames = [...this.activeToolNames]; + if (this.phase === "idle") { + await this.session.appendActiveToolsChange(toolNames); + } else { + this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] }); + } + this.activeToolNames = [...toolNames]; + await this.emitOwn({ + type: "tools_update", + toolNames: [...this.tools.keys()], + previousToolNames, + activeToolNames: [...this.activeToolNames], + previousActiveToolNames, + source: "set", + }); + } catch (error) { + throw normalizeHarnessError(error, "invalid_argument"); + } + } + + getSteeringMode(): QueueMode { + return this.steeringQueueMode; + } + + async setSteeringMode(mode: QueueMode): Promise { + this.steeringQueueMode = mode; + } + + getFollowUpMode(): QueueMode { + return this.followUpQueueMode; + } + + async setFollowUpMode(mode: QueueMode): Promise { + this.followUpQueueMode = mode; + } + + getResources(): AgentHarnessResources { + return { + skills: this.resources.skills?.slice(), + promptTemplates: this.resources.promptTemplates?.slice(), + }; + } + + async setResources(resources: AgentHarnessResources): Promise { + const previousResources = this.getResources(); + this.resources = { + skills: resources.skills?.slice(), + promptTemplates: resources.promptTemplates?.slice(), + }; + await this.emitOwn({ type: "resources_update", resources: this.getResources(), previousResources }); + } + + getStreamOptions(): AgentHarnessStreamOptions { + return cloneStreamOptions(this.streamOptions); + } + + async setStreamOptions(streamOptions: AgentHarnessStreamOptions): Promise { + this.streamOptions = cloneStreamOptions(streamOptions); + } + + async abort(): Promise { + const clearedSteer = [...this.steerQueue]; + const clearedFollowUp = [...this.followUpQueue]; + this.steerQueue = []; + this.followUpQueue = []; + this.runAbortController?.abort(); + const errors: Error[] = []; + try { + await this.emitQueueUpdate(); + } catch (error) { + errors.push(toError(error)); + } + try { + await this.waitForIdle(); + } catch (error) { + errors.push(toError(error)); + } + try { + await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp }); + } catch (error) { + errors.push(toError(error)); + } + if (errors.length > 0) { + const cause = errors.length === 1 ? errors[0]! : new AggregateError(errors, "Abort completed with errors"); + throw normalizeHarnessError(cause, "hook"); + } + return { clearedSteer, clearedFollowUp }; + } + + async waitForIdle(): Promise { + await this.runPromise; + } + + subscribe( + listener: (event: AgentHarnessEvent, signal?: AbortSignal) => Promise | void, + ): () => void { + let handlers = this.handlers.get(SUBSCRIBER_EVENT_TYPE); + if (!handlers) { + handlers = new Set(); + this.handlers.set(SUBSCRIBER_EVENT_TYPE, handlers); + } + handlers.add(listener as AgentHarnessHandler); + return () => handlers!.delete(listener as AgentHarnessHandler); + } + + on( + type: TType, + handler: ( + event: Extract, + ) => Promise | AgentHarnessEventResultMap[TType], + ): () => void { + let handlers = this.handlers.get(type); + if (!handlers) { + handlers = new Set(); + this.handlers.set(type, handlers); + } + handlers.add(handler as AgentHarnessHandler); + return () => handlers!.delete(handler as AgentHarnessHandler); + } +} diff --git a/cactus-code/packages/agent/src/harness/compaction/branch-summarization.ts b/cactus-code/packages/agent/src/harness/compaction/branch-summarization.ts new file mode 100644 index 000000000..fdf1df498 --- /dev/null +++ b/cactus-code/packages/agent/src/harness/compaction/branch-summarization.ts @@ -0,0 +1,261 @@ +import type { Model, Models } from "@earendil-works/pi-ai"; + +import type { AgentMessage } from "../../types.ts"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages.ts"; +import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts"; +import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts"; +import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + serializeConversation, +} from "./utils.ts"; + +/** File-operation details stored on generated branch summary entries. */ +export interface BranchSummaryDetails { + /** Files read while exploring the summarized branch. */ + readFiles: string[]; + /** Files modified while exploring the summarized branch. */ + modifiedFiles: string[]; +} + +export type { FileOperations } from "./utils.ts"; + +/** Prepared branch content for summarization. */ +export interface BranchPreparation { + /** Messages selected for the branch summary. */ + messages: AgentMessage[]; + /** File operations extracted from the branch. */ + fileOps: FileOperations; + /** Estimated token count for selected messages. */ + totalTokens: number; +} + +/** Entries selected for branch summarization. */ +export interface CollectEntriesResult { + /** Entries to summarize in chronological order. */ + entries: SessionTreeEntry[]; + /** Deepest common ancestor between the previous leaf and target entry. */ + commonAncestorId: string | null; +} + +/** Options for generating a branch summary. */ +export interface GenerateBranchSummaryOptions { + /** Provider collection the summarization request goes through; owns auth resolution. */ + models: Models; + /** Model used for summarization. */ + model: Model; + /** Abort signal for the summarization request. */ + signal: AbortSignal; + /** Optional instructions appended to or replacing the default prompt. */ + customInstructions?: string; + /** Replace the default prompt with custom instructions instead of appending them. */ + replaceInstructions?: boolean; + /** Tokens reserved for prompt and model output. Defaults to 16384. */ + reserveTokens?: number; +} + +/** Collect entries that should be summarized before navigating to a different session tree entry. */ +export async function collectEntriesForBranchSummary( + session: Session, + oldLeafId: string | null, + targetId: string, +): Promise { + if (!oldLeafId) { + return { entries: [], commonAncestorId: null }; + } + const oldPath = new Set((await session.getBranch(oldLeafId)).map((e) => e.id)); + const targetPath = await session.getBranch(targetId); + let commonAncestorId: string | null = null; + for (let i = targetPath.length - 1; i >= 0; i--) { + if (oldPath.has(targetPath[i].id)) { + commonAncestorId = targetPath[i].id; + break; + } + } + const entries: SessionTreeEntry[] = []; + let current: string | null = oldLeafId; + + while (current && current !== commonAncestorId) { + const entry = await session.getEntry(current); + if (!entry) throw new SessionError("invalid_session", `Entry ${current} not found`); + entries.push(entry as SessionTreeEntry); + current = entry.parentId; + } + entries.reverse(); + + return { entries, commonAncestorId }; +} +function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { + switch (entry.type) { + case "message": + if (entry.message.role === "toolResult") return undefined; + return entry.message; + + case "custom_message": + return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); + + case "branch_summary": + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + + case "compaction": + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + case "thinking_level_change": + case "model_change": + case "active_tools_change": + case "custom": + case "label": + case "session_info": + case "leaf": + return undefined; + } +} + +/** Prepare branch entries for summarization within an optional token budget. */ +export function prepareBranchEntries(entries: SessionTreeEntry[], tokenBudget: number = 0): BranchPreparation { + const messages: AgentMessage[] = []; + const fileOps = createFileOps(); + let totalTokens = 0; + for (const entry of entries) { + if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { + const details = entry.details as BranchSummaryDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) fileOps.read.add(f); + } + if (Array.isArray(details.modifiedFiles)) { + for (const f of details.modifiedFiles) { + fileOps.edited.add(f); + } + } + } + } + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + const message = getMessageFromEntry(entry); + if (!message) continue; + extractFileOpsFromMessage(message, fileOps); + + const tokens = estimateTokens(message); + if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { + if (entry.type === "compaction" || entry.type === "branch_summary") { + if (totalTokens < tokenBudget * 0.9) { + messages.unshift(message); + totalTokens += tokens; + } + } + break; + } + + messages.unshift(message); + totalTokens += tokens; + } + + return { messages, fileOps, totalTokens }; +} + +const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here. +Summary of that exploration: + +`; + +const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later. + +Use this EXACT format: + +## Goal +[What was the user trying to accomplish in this branch?] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Work that was started but not finished] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [What should happen next to continue this work] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +/** Generate a summary for abandoned branch entries. */ +export async function generateBranchSummary( + entries: SessionTreeEntry[], + options: GenerateBranchSummaryOptions, +): Promise> { + const { models, model, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options; + const contextWindow = model.contextWindow || 128000; + const tokenBudget = contextWindow - reserveTokens; + + const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget); + + if (messages.length === 0) { + return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] }); + } + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + let instructions: string; + if (replaceInstructions && customInstructions) { + instructions = customInstructions; + } else if (customInstructions) { + instructions = `${BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: ${customInstructions}`; + } else { + instructions = BRANCH_SUMMARY_PROMPT; + } + const promptText = `\n${conversationText}\n\n\n${instructions}`; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + const response = await models.completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + { signal, maxTokens: 2048 }, + ); + if (response.stopReason === "aborted") { + return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted")); + } + if (response.stopReason === "error") { + return err( + new BranchSummaryError( + "summarization_failed", + `Branch summary failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + let summary = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + summary = BRANCH_SUMMARY_PREAMBLE + summary; + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + return ok({ + summary: summary || "No summary generated", + readFiles, + modifiedFiles, + }); +} diff --git a/cactus-code/packages/agent/src/harness/compaction/compaction.ts b/cactus-code/packages/agent/src/harness/compaction/compaction.ts new file mode 100644 index 000000000..2d6d55837 --- /dev/null +++ b/cactus-code/packages/agent/src/harness/compaction/compaction.ts @@ -0,0 +1,747 @@ +import type { AssistantMessage, ImageContent, Model, Models, TextContent, Usage } from "@earendil-works/pi-ai"; +import type { AgentMessage, ThinkingLevel } from "../../types.ts"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages.ts"; +import { buildSessionContext } from "../session/session.ts"; +import { type CompactionEntry, CompactionError, err, ok, type Result, type SessionTreeEntry } from "../types.ts"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + serializeConversation, +} from "./utils.ts"; + +/** File-operation details stored on generated compaction entries. */ +export interface CompactionDetails { + /** Files read in the compacted history. */ + readFiles: string[]; + /** Files modified in the compacted history. */ + modifiedFiles: string[]; +} +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} + +function extractFileOperations( + messages: AgentMessage[], + entries: SessionTreeEntry[], + prevCompactionIndex: number, +): FileOperations { + const fileOps = createFileOps(); + if (prevCompactionIndex >= 0) { + const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; + if (!prevCompaction.fromHook && prevCompaction.details) { + const details = prevCompaction.details as CompactionDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) fileOps.read.add(f); + } + if (Array.isArray(details.modifiedFiles)) { + for (const f of details.modifiedFiles) fileOps.edited.add(f); + } + } + } + for (const msg of messages) { + extractFileOpsFromMessage(msg, fileOps); + } + + return fileOps; +} +function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined { + if (entry.type === "message") { + return entry.message as AgentMessage; + } + if (entry.type === "custom_message") { + return createCustomMessage( + entry.customType, + entry.content as string | (TextContent | ImageContent)[], + entry.display, + entry.details, + entry.timestamp, + ); + } + if (entry.type === "branch_summary") { + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + } + if (entry.type === "compaction") { + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + } + return undefined; +} + +function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage | undefined { + if (entry.type === "compaction") { + return undefined; + } + return getMessageFromEntry(entry); +} + +/** Generated compaction data ready to be persisted as a compaction entry. */ +export interface CompactionResult { + /** Summary text that replaces compacted history in future context. */ + summary: string; + /** Entry id where retained history starts. */ + firstKeptEntryId: string; + /** Estimated context tokens before compaction. */ + tokensBefore: number; + /** Optional implementation-specific details stored with the compaction entry. */ + details?: T; +} + +/** Compaction thresholds and retention settings. */ +export interface CompactionSettings { + /** Enable automatic compaction decisions. */ + enabled: boolean; + /** Tokens reserved for summary prompt and output. */ + reserveTokens: number; + /** Approximate recent-context tokens to keep after compaction. */ + keepRecentTokens: number; +} + +/** Default compaction settings used by the harness. */ +export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, +}; + +/** Calculate total context tokens from provider usage. */ +export function calculateContextTokens(usage: Usage): number { + return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; +} +function getAssistantUsage(msg: AgentMessage): Usage | undefined { + if (msg.role === "assistant" && "usage" in msg) { + const assistantMsg = msg as AssistantMessage; + if ( + assistantMsg.stopReason !== "aborted" && + assistantMsg.stopReason !== "error" && + assistantMsg.usage && + calculateContextTokens(assistantMsg.usage) > 0 + ) { + return assistantMsg.usage; + } + } + return undefined; +} + +/** Return usage from the last valid assistant message in session entries. */ +export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "message") { + const usage = getAssistantUsage(entry.message as AgentMessage); + if (usage) return usage; + } + } + return undefined; +} + +/** Estimated context-token usage for a message list. */ +export interface ContextUsageEstimate { + /** Estimated total context tokens. */ + tokens: number; + /** Tokens reported by the most recent assistant usage block. */ + usageTokens: number; + /** Estimated tokens after the most recent assistant usage block. */ + trailingTokens: number; + /** Index of the message that provided usage, or null when none exists. */ + lastUsageIndex: number | null; +} + +function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; index: number } | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const usage = getAssistantUsage(messages[i]); + if (usage) return { usage, index: i }; + } + return undefined; +} + +/** Estimate context tokens for messages using provider usage when available. */ +export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate { + const usageInfo = getLastAssistantUsageInfo(messages); + + if (!usageInfo) { + let estimated = 0; + for (const message of messages) { + estimated += estimateTokens(message); + } + return { + tokens: estimated, + usageTokens: 0, + trailingTokens: estimated, + lastUsageIndex: null, + }; + } + + const usageTokens = calculateContextTokens(usageInfo.usage); + let trailingTokens = 0; + for (let i = usageInfo.index + 1; i < messages.length; i++) { + trailingTokens += estimateTokens(messages[i]); + } + + return { + tokens: usageTokens + trailingTokens, + usageTokens, + trailingTokens, + lastUsageIndex: usageInfo.index, + }; +} + +/** Return whether context usage exceeds the configured compaction threshold. */ +export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean { + if (!settings.enabled) return false; + return contextTokens > contextWindow - settings.reserveTokens; +} + +const ESTIMATED_IMAGE_CHARS = 4800; + +function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number { + if (typeof content === "string") { + return content.length; + } + + let chars = 0; + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } else if (block.type === "image") { + chars += ESTIMATED_IMAGE_CHARS; + } + } + return chars; +} + +/** Estimate token count for one message using a conservative character heuristic. */ +export function estimateTokens(message: AgentMessage): number { + let chars = 0; + + switch (message.role) { + case "user": { + chars = estimateTextAndImageContentChars( + (message as { content: string | Array<{ type: string; text?: string }> }).content, + ); + return Math.ceil(chars / 4); + } + case "assistant": { + const assistant = message as AssistantMessage; + for (const block of assistant.content) { + if (block.type === "text") { + chars += block.text.length; + } else if (block.type === "thinking") { + chars += block.thinking.length; + } else if (block.type === "toolCall") { + chars += block.name.length + safeJsonStringify(block.arguments).length; + } + } + return Math.ceil(chars / 4); + } + case "custom": + case "toolResult": { + chars = estimateTextAndImageContentChars(message.content); + return Math.ceil(chars / 4); + } + case "bashExecution": { + chars = message.command.length + message.output.length; + return Math.ceil(chars / 4); + } + case "branchSummary": + case "compactionSummary": { + chars = message.summary.length; + return Math.ceil(chars / 4); + } + } + + return 0; +} +function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, endIndex: number): number[] { + const cutPoints: number[] = []; + for (let i = startIndex; i < endIndex; i++) { + const entry = entries[i]; + switch (entry.type) { + case "message": { + const role = entry.message.role; + switch (role) { + case "bashExecution": + case "custom": + case "branchSummary": + case "compactionSummary": + case "user": + case "assistant": + cutPoints.push(i); + break; + case "toolResult": + break; + } + break; + } + case "thinking_level_change": + case "model_change": + case "active_tools_change": + case "compaction": + case "branch_summary": + case "custom": + case "custom_message": + case "label": + case "session_info": + case "leaf": + break; + } + if (entry.type === "branch_summary" || entry.type === "custom_message") { + cutPoints.push(i); + } + } + return cutPoints; +} + +/** Find the user-visible message that starts the turn containing an entry. */ +export function findTurnStartIndex(entries: SessionTreeEntry[], entryIndex: number, startIndex: number): number { + for (let i = entryIndex; i >= startIndex; i--) { + const entry = entries[i]; + if (entry.type === "branch_summary" || entry.type === "custom_message") { + return i; + } + if (entry.type === "message") { + const role = entry.message.role; + if (role === "user" || role === "bashExecution") { + return i; + } + } + } + return -1; +} + +/** Cut point selected for compaction. */ +export interface CutPointResult { + /** Index of the first entry retained after compaction. */ + firstKeptEntryIndex: number; + /** Index of the turn-start entry when the cut splits a turn, otherwise -1. */ + turnStartIndex: number; + /** Whether the selected cut point splits an in-progress turn. */ + isSplitTurn: boolean; +} + +/** Find the compaction cut point that keeps approximately the requested recent-token budget. */ +export function findCutPoint( + entries: SessionTreeEntry[], + startIndex: number, + endIndex: number, + keepRecentTokens: number, +): CutPointResult { + const cutPoints = findValidCutPoints(entries, startIndex, endIndex); + + if (cutPoints.length === 0) { + return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false }; + } + let accumulatedTokens = 0; + let cutIndex = cutPoints[0]; + + for (let i = endIndex - 1; i >= startIndex; i--) { + const entry = entries[i]; + if (entry.type !== "message") continue; + const messageTokens = estimateTokens(entry.message as AgentMessage); + accumulatedTokens += messageTokens; + if (accumulatedTokens >= keepRecentTokens) { + for (let c = 0; c < cutPoints.length; c++) { + if (cutPoints[c] >= i) { + cutIndex = cutPoints[c]; + break; + } + } + break; + } + } + while (cutIndex > startIndex) { + const prevEntry = entries[cutIndex - 1]; + if (prevEntry.type === "compaction") { + break; + } + if (prevEntry.type === "message") { + break; + } + cutIndex--; + } + const cutEntry = entries[cutIndex]; + const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user"; + const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex); + + return { + firstKeptEntryIndex: cutIndex, + turnStartIndex, + isSplitTurn: !isUserMessage && turnStartIndex !== -1, + }; +} + +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; + +const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. + +Use this EXACT format: + +## Goal +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. + +Update the existing structured summary with new information. RULES: +- PRESERVE all existing information from the previous summary +- ADD new progress, decisions, and context from the new messages +- UPDATE the Progress section: move items from "In Progress" to "Done" when completed +- UPDATE "Next Steps" based on what was accomplished +- PRESERVE exact file paths, function names, and error messages +- If something is no longer relevant, you may remove it + +Use this EXACT format: + +## Goal +[Preserve existing goals, add new ones if the task expanded] + +## Constraints & Preferences +- [Preserve existing, add new ones discovered] + +## Progress +### Done +- [x] [Include previously done items AND newly completed items] + +### In Progress +- [ ] [Current work - update based on progress] + +### Blocked +- [Current blockers - remove if resolved] + +## Key Decisions +- **[Decision]**: [Brief rationale] (preserve all previous, add new) + +## Next Steps +1. [Update based on current state] + +## Critical Context +- [Preserve important context, add new if needed] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +/** Generate or update a conversation summary for compaction. */ +export async function generateSummary( + currentMessages: AgentMessage[], + models: Models, + model: Model, + reserveTokens: number, + signal?: AbortSignal, + customInstructions?: string, + previousSummary?: string, + thinkingLevel?: ThinkingLevel, +): Promise> { + const maxTokens = Math.min( + Math.floor(0.8 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); + let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; + if (customInstructions) { + basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; + } + const llmMessages = convertToLlm(currentMessages); + const conversationText = serializeConversation(llmMessages); + let promptText = `\n${conversationText}\n\n\n`; + if (previousSummary) { + promptText += `\n${previousSummary}\n\n\n`; + } + promptText += basePrompt; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const completionOptions = + model.reasoning && thinkingLevel && thinkingLevel !== "off" + ? { maxTokens, signal, reasoning: thinkingLevel } + : { maxTokens, signal }; + + const response = await models.completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + completionOptions, + ); + if (response.stopReason === "aborted") { + return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted")); + } + if (response.stopReason === "error") { + return err( + new CompactionError( + "summarization_failed", + `Summarization failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + const textContent = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return ok(textContent); +} + +/** Prepared inputs for a compaction run. */ +export interface CompactionPreparation { + /** Entry id where retained history starts. */ + firstKeptEntryId: string; + /** Messages summarized into the history summary. */ + messagesToSummarize: AgentMessage[]; + /** Prefix messages summarized separately when compaction splits a turn. */ + turnPrefixMessages: AgentMessage[]; + /** Whether compaction splits a turn. */ + isSplitTurn: boolean; + /** Estimated context tokens before compaction. */ + tokensBefore: number; + /** Previous compaction summary used for iterative updates. */ + previousSummary?: string; + /** File operations extracted from summarized history. */ + fileOps: FileOperations; + /** Settings used to prepare compaction. */ + settings: CompactionSettings; +} + +/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */ +export function prepareCompaction( + pathEntries: SessionTreeEntry[], + settings: CompactionSettings, +): Result { + if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") { + return ok(undefined); + } + + let prevCompactionIndex = -1; + for (let i = pathEntries.length - 1; i >= 0; i--) { + if (pathEntries[i].type === "compaction") { + prevCompactionIndex = i; + break; + } + } + + let previousSummary: string | undefined; + let boundaryStart = 0; + if (prevCompactionIndex >= 0) { + const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry; + previousSummary = prevCompaction.summary; + const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId); + boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1; + } + const boundaryEnd = pathEntries.length; + + const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens; + + const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); + const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; + if (!firstKeptEntry?.id) { + return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration")); + } + const firstKeptEntryId = firstKeptEntry.id; + + const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; + const messagesToSummarize: AgentMessage[] = []; + for (let i = boundaryStart; i < historyEnd; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) messagesToSummarize.push(msg); + } + const turnPrefixMessages: AgentMessage[] = []; + if (cutPoint.isSplitTurn) { + for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) turnPrefixMessages.push(msg); + } + } + const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); + if (cutPoint.isSplitTurn) { + for (const msg of turnPrefixMessages) { + extractFileOpsFromMessage(msg, fileOps); + } + } + + return ok({ + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn: cutPoint.isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + }); +} + +const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. + +Summarize the prefix to provide context for the retained suffix: + +## Original Request +[What did the user ask for in this turn?] + +## Early Progress +- [Key decisions and work done in the prefix] + +## Context for Suffix +- [Information needed to understand the retained recent work] + +Be concise. Focus on what's needed to understand the kept suffix.`; + +export { serializeConversation } from "./utils.ts"; + +/** Generate compaction summary data from prepared session history. */ +export async function compact( + preparation: CompactionPreparation, + models: Models, + model: Model, + customInstructions?: string, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, +): Promise> { + const { + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + } = preparation; + + if (!firstKeptEntryId) { + return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration")); + } + + let summary: string; + + if (isSplitTurn && turnPrefixMessages.length > 0) { + const [historyResult, turnPrefixResult] = await Promise.all([ + messagesToSummarize.length > 0 + ? generateSummary( + messagesToSummarize, + models, + model, + settings.reserveTokens, + signal, + customInstructions, + previousSummary, + thinkingLevel, + ) + : Promise.resolve(ok("No prior history.")), + generateTurnPrefixSummary(turnPrefixMessages, models, model, settings.reserveTokens, signal, thinkingLevel), + ]); + if (!historyResult.ok) return err(historyResult.error); + if (!turnPrefixResult.ok) return err(turnPrefixResult.error); + summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`; + } else { + const summaryResult = await generateSummary( + messagesToSummarize, + models, + model, + settings.reserveTokens, + signal, + customInstructions, + previousSummary, + thinkingLevel, + ); + if (!summaryResult.ok) return err(summaryResult.error); + summary = summaryResult.value; + } + + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + return ok({ + summary, + firstKeptEntryId, + tokensBefore, + details: { readFiles, modifiedFiles } as CompactionDetails, + }); +} +async function generateTurnPrefixSummary( + messages: AgentMessage[], + models: Models, + model: Model, + reserveTokens: number, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, +): Promise> { + const maxTokens = Math.min( + Math.floor(0.5 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const response = await models.completeSimple( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + model.reasoning && thinkingLevel && thinkingLevel !== "off" + ? { maxTokens, signal, reasoning: thinkingLevel } + : { maxTokens, signal }, + ); + if (response.stopReason === "aborted") { + return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted")); + } + if (response.stopReason === "error") { + return err( + new CompactionError( + "summarization_failed", + `Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`, + ), + ); + } + + return ok( + response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"), + ); +} diff --git a/cactus-code/packages/agent/src/harness/compaction/utils.ts b/cactus-code/packages/agent/src/harness/compaction/utils.ts new file mode 100644 index 000000000..07535b79b --- /dev/null +++ b/cactus-code/packages/agent/src/harness/compaction/utils.ts @@ -0,0 +1,144 @@ +import type { Message } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "../../types.ts"; + +/** File paths touched by a session branch or compaction range. */ +export interface FileOperations { + /** Files read but not necessarily modified. */ + read: Set; + /** Files written by full-file write operations. */ + written: Set; + /** Files modified by edit operations. */ + edited: Set; +} + +/** Create an empty file-operation accumulator. */ +export function createFileOps(): FileOperations { + return { + read: new Set(), + written: new Set(), + edited: new Set(), + }; +} + +/** Add file operations from assistant tool calls to an accumulator. */ +export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { + if (message.role !== "assistant") return; + if (!("content" in message) || !Array.isArray(message.content)) return; + + for (const block of message.content) { + if (typeof block !== "object" || block === null) continue; + if (!("type" in block) || block.type !== "toolCall") continue; + if (!("arguments" in block) || !("name" in block)) continue; + + const args = block.arguments as Record | undefined; + if (!args) continue; + + const path = typeof args.path === "string" ? args.path : undefined; + if (!path) continue; + + switch (block.name) { + case "read": + fileOps.read.add(path); + break; + case "write": + fileOps.written.add(path); + break; + case "edit": + fileOps.edited.add(path); + break; + } + } +} + +/** Compute sorted read-only and modified file lists from accumulated operations. */ +export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } { + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); + const modifiedFiles = [...modified].sort(); + return { readFiles: readOnly, modifiedFiles }; +} + +/** Format file lists as summary metadata tags. */ +export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { + const sections: string[] = []; + if (readFiles.length > 0) { + sections.push(`\n${readFiles.join("\n")}\n`); + } + if (modifiedFiles.length > 0) { + sections.push(`\n${modifiedFiles.join("\n")}\n`); + } + if (sections.length === 0) return ""; + return `\n\n${sections.join("\n\n")}`; +} + +const TOOL_RESULT_MAX_CHARS = 2000; + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? "undefined"; + } catch { + return "[unserializable]"; + } +} + +function truncateForSummary(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + const truncatedChars = text.length - maxChars; + return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`; +} + +/** Serialize LLM messages to plain text for summarization prompts. */ +export function serializeConversation(messages: Message[]): string { + const parts: string[] = []; + + for (const msg of messages) { + if (msg.role === "user") { + const content = + typeof msg.content === "string" + ? msg.content + : msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) parts.push(`[User]: ${content}`); + } else if (msg.role === "assistant") { + const textParts: string[] = []; + const thinkingParts: string[] = []; + const toolCalls: string[] = []; + + for (const block of msg.content) { + if (block.type === "text") { + textParts.push(block.text); + } else if (block.type === "thinking") { + thinkingParts.push(block.thinking); + } else if (block.type === "toolCall") { + const args = block.arguments as Record; + const argsStr = Object.entries(args) + .map(([k, v]) => `${k}=${safeJsonStringify(v)}`) + .join(", "); + toolCalls.push(`${block.name}(${argsStr})`); + } + } + + if (thinkingParts.length > 0) { + parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); + } + if (textParts.length > 0) { + parts.push(`[Assistant]: ${textParts.join("\n")}`); + } + if (toolCalls.length > 0) { + parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); + } + } else if (msg.role === "toolResult") { + const content = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) { + parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); + } + } + } + + return parts.join("\n\n"); +} diff --git a/cactus-code/packages/agent/src/harness/env/nodejs.ts b/cactus-code/packages/agent/src/harness/env/nodejs.ts new file mode 100644 index 000000000..3d929c824 --- /dev/null +++ b/cactus-code/packages/agent/src/harness/env/nodejs.ts @@ -0,0 +1,550 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { constants, createReadStream } from "node:fs"; +import { + access, + appendFile, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { createInterface } from "node:readline"; +import { + type ExecutionEnv, + ExecutionError, + err, + FileError, + type FileInfo, + type FileKind, + ok, + type Result, + toError, +} from "../types.ts"; + +function resolvePath(cwd: string, path: string): string { + return isAbsolute(path) ? path : resolve(cwd, path); +} + +function fileKindFromStats(stats: { + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +}): FileKind | undefined { + if (stats.isFile()) return "file"; + if (stats.isDirectory()) return "directory"; + if (stats.isSymbolicLink()) return "symlink"; + return undefined; +} + +function fileInfoFromStats( + path: string, + stats: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean; size: number; mtimeMs: number }, +): Result { + const kind = fileKindFromStats(stats); + if (!kind) return err(new FileError("invalid", "Unsupported file type", path)); + return ok({ + name: path.replace(/\/+$/, "").split("/").pop() ?? path, + path, + kind, + size: stats.size, + mtimeMs: stats.mtimeMs, + }); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function toFileError(error: unknown, path?: string): FileError { + if (error instanceof FileError) return error; + const cause = toError(error); + if (isNodeError(error)) { + const message = error.message; + switch (error.code) { + case "ABORT_ERR": + return new FileError("aborted", message, path, cause); + case "ENOENT": + return new FileError("not_found", message, path, cause); + case "EACCES": + case "EPERM": + return new FileError("permission_denied", message, path, cause); + case "ENOTDIR": + return new FileError("not_directory", message, path, cause); + case "EISDIR": + return new FileError("is_directory", message, path, cause); + case "EINVAL": + return new FileError("invalid", message, path, cause); + } + } + return new FileError("unknown", cause.message, path, cause); +} + +function abortResult(signal: AbortSignal | undefined, path?: string): Result | undefined { + return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined; +} + +async function pathExists(path: string): Promise { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +async function runCommand( + command: string, + args: string[], + timeoutMs: number, +): Promise<{ stdout: string; status: number | null }> { + return await new Promise((resolve) => { + let stdout = ""; + let child: ReturnType; + try { + child = spawn(command, args, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + } catch { + resolve({ stdout: "", status: null }); + return; + } + const timeout = setTimeout(() => { + if (child.pid) killProcessTree(child.pid); + }, timeoutMs); + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.on("error", () => { + clearTimeout(timeout); + resolve({ stdout: "", status: null }); + }); + child.on("close", (status) => { + clearTimeout(timeout); + resolve({ stdout, status }); + }); + }); +} + +async function findBashOnPath(): Promise { + const result = + process.platform === "win32" + ? await runCommand("where", ["bash.exe"], 5000) + : await runCommand("which", ["bash"], 5000); + if (result.status !== 0 || !result.stdout) return null; + const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; + return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null; +} + +interface ShellConfig { + shell: string; + args: string[]; + commandTransport?: "argv" | "stdin"; +} + +function isLegacyWslBashPath(path: string): boolean { + const normalized = path.replace(/\//g, "\\").toLowerCase(); + return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized); +} + +function getBashShellConfig(shell: string): ShellConfig { + return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] }; +} + +async function getShellConfig(customShellPath?: string): Promise> { + if (customShellPath) { + if (await pathExists(customShellPath)) { + return ok(getBashShellConfig(customShellPath)); + } + return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`)); + } + if (process.platform === "win32") { + const candidates: string[] = []; + const programFiles = process.env.ProgramFiles; + if (programFiles) candidates.push(`${programFiles}\\Git\\bin\\bash.exe`); + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`); + for (const candidate of candidates) { + if (await pathExists(candidate)) { + return ok(getBashShellConfig(candidate)); + } + } + const bashOnPath = await findBashOnPath(); + if (bashOnPath) { + return ok(getBashShellConfig(bashOnPath)); + } + return err(new ExecutionError("shell_unavailable", "No bash shell found")); + } + + if (await pathExists("/bin/bash")) { + return ok(getBashShellConfig("/bin/bash")); + } + const bashOnPath = await findBashOnPath(); + if (bashOnPath) { + return ok(getBashShellConfig(bashOnPath)); + } + return ok({ shell: "sh", args: ["-c"] }); +} + +function getShellEnv(baseEnv?: NodeJS.ProcessEnv, extraEnv?: Record): NodeJS.ProcessEnv { + return { + ...process.env, + ...baseEnv, + ...extraEnv, + }; +} + +function killProcessTree(pid: number): void { + if (process.platform === "win32") { + try { + spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { + stdio: "ignore", + detached: true, + windowsHide: true, + }); + } catch { + // Ignore errors. + } + return; + } + + try { + process.kill(-pid, "SIGKILL"); + } catch { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process already dead. + } + } +} + +export class NodeExecutionEnv implements ExecutionEnv { + cwd: string; + private shellPath?: string; + private shellEnv?: NodeJS.ProcessEnv; + + constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) { + this.cwd = options.cwd; + this.shellPath = options.shellPath; + this.shellEnv = options.shellEnv; + } + + async absolutePath(path: string): Promise> { + return ok(resolvePath(this.cwd, path)); + } + + async joinPath(parts: string[]): Promise> { + return ok(join(...parts)); + } + + async exec( + command: string, + options?: { + cwd?: string; + env?: Record; + timeout?: number; + abortSignal?: AbortSignal; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; + }, + ): Promise> { + if (options?.abortSignal?.aborted) return err(new ExecutionError("aborted", "aborted")); + + const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd; + const shellConfig = await getShellConfig(this.shellPath); + if (!shellConfig.ok) return shellConfig; + + return await new Promise((resolvePromise) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + let callbackError: ExecutionError | undefined; + let child: ReturnType | undefined; + let timeoutId: ReturnType | undefined; + + const onAbort = () => { + if (child?.pid) { + killProcessTree(child.pid); + } + }; + + const settle = (result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>) => { + if (timeoutId) clearTimeout(timeoutId); + if (options?.abortSignal) options.abortSignal.removeEventListener("abort", onAbort); + if (settled) return; + settled = true; + resolvePromise(result); + }; + + try { + const commandFromStdin = shellConfig.value.commandTransport === "stdin"; + child = spawn( + shellConfig.value.shell, + commandFromStdin ? shellConfig.value.args : [...shellConfig.value.args, command], + { + cwd, + detached: process.platform !== "win32", + env: getShellEnv(this.shellEnv, options?.env), + stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], + windowsHide: true, + }, + ); + if (commandFromStdin) { + child.stdin?.on("error", () => {}); + child.stdin?.end(command); + } + } catch (error) { + const cause = toError(error); + settle(err(new ExecutionError("spawn_error", cause.message, cause))); + return; + } + + timeoutId = + typeof options?.timeout === "number" + ? setTimeout(() => { + timedOut = true; + if (child?.pid) { + killProcessTree(child.pid); + } + }, options.timeout * 1000) + : undefined; + + if (options?.abortSignal) { + if (options.abortSignal.aborted) { + onAbort(); + } else { + options.abortSignal.addEventListener("abort", onAbort, { once: true }); + } + } + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + try { + options?.onStdout?.(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } + }); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + try { + options?.onStderr?.(chunk); + } catch (error) { + const cause = toError(error); + callbackError = new ExecutionError("callback_error", cause.message, cause); + onAbort(); + } + }); + + child.on("error", (error) => { + settle(err(new ExecutionError("spawn_error", error.message, error))); + }); + + child.on("close", (code) => { + if (callbackError) { + settle(err(callbackError)); + return; + } + if (timedOut) { + settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`))); + return; + } + if (options?.abortSignal?.aborted) { + settle(err(new ExecutionError("aborted", "aborted"))); + return; + } + settle(ok({ stdout, stderr, exitCode: code ?? 0 })); + }); + }); + } + + async readTextFile(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) return aborted; + try { + return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal })); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(options?.abortSignal, resolved); + if (aborted) return aborted; + if (options?.maxLines !== undefined && options.maxLines <= 0) return ok([]); + let stream: ReturnType | undefined; + let lineReader: ReturnType | undefined; + try { + stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal }); + lineReader = createInterface({ input: stream, crlfDelay: Infinity }); + const lines: string[] = []; + for await (const line of lineReader) { + const loopAbort = abortResult(options?.abortSignal, resolved); + if (loopAbort) return loopAbort; + lines.push(line); + if (options?.maxLines !== undefined && lines.length >= options.maxLines) break; + } + const afterReadAbort = abortResult(options?.abortSignal, resolved); + if (afterReadAbort) return afterReadAbort; + return ok(lines); + } catch (error) { + return err(toFileError(error, resolved)); + } finally { + lineReader?.close(); + stream?.destroy(); + } + } + + async readBinaryFile(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) return aborted; + try { + return ok(await readFile(resolved, { signal: abortSignal })); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async writeFile( + path: string, + content: string | Uint8Array, + abortSignal?: AbortSignal, + ): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) return aborted; + try { + await mkdir(resolve(resolved, ".."), { recursive: true }); + const afterMkdirAbort = abortResult(abortSignal, resolved); + if (afterMkdirAbort) return afterMkdirAbort; + await writeFile(resolved, content, { signal: abortSignal }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async appendFile(path: string, content: string | Uint8Array): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + await mkdir(resolve(resolved, ".."), { recursive: true }); + await appendFile(resolved, content); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async fileInfo(path: string): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + return fileInfoFromStats(resolved, await lstat(resolved)); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async listDir(path: string, abortSignal?: AbortSignal): Promise> { + const resolved = resolvePath(this.cwd, path); + const aborted = abortResult(abortSignal, resolved); + if (aborted) return aborted; + try { + const entries = await readdir(resolved, { withFileTypes: true }); + const infos: FileInfo[] = []; + for (const entry of entries) { + const loopAbort = abortResult(abortSignal, resolved); + if (loopAbort) return loopAbort; + const entryPath = resolve(resolved, entry.name); + try { + const info = fileInfoFromStats(entryPath, await lstat(entryPath)); + if (info.ok) infos.push(info.value); + } catch (error) { + return err(toFileError(error, entryPath)); + } + } + return ok(infos); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async canonicalPath(path: string): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + return ok(await realpath(resolved)); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async exists(path: string): Promise> { + const result = await this.fileInfo(path); + if (result.ok) return ok(true); + if (result.error.code === "not_found") return ok(false); + return err(result.error); + } + + async createDir(path: string, options?: { recursive?: boolean }): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + await mkdir(resolved, { recursive: options?.recursive ?? true }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async remove(path: string, options?: { recursive?: boolean; force?: boolean }): Promise> { + const resolved = resolvePath(this.cwd, path); + try { + await rm(resolved, { recursive: options?.recursive ?? false, force: options?.force ?? false }); + return ok(undefined); + } catch (error) { + return err(toFileError(error, resolved)); + } + } + + async createTempDir(prefix: string = "tmp-"): Promise> { + try { + return ok(await mkdtemp(join(tmpdir(), prefix))); + } catch (error) { + return err(toFileError(error)); + } + } + + async createTempFile(options?: { prefix?: string; suffix?: string }): Promise> { + const dir = await this.createTempDir("tmp-"); + if (!dir.ok) return dir; + const filePath = join(dir.value, `${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`); + try { + await writeFile(filePath, ""); + return ok(filePath); + } catch (error) { + return err(toFileError(error, filePath)); + } + } + + async cleanup(): Promise { + // nothing to clean up for the local node implementation + } +} diff --git a/cactus-code/packages/agent/src/harness/messages.ts b/cactus-code/packages/agent/src/harness/messages.ts new file mode 100644 index 000000000..36ce96a1e --- /dev/null +++ b/cactus-code/packages/agent/src/harness/messages.ts @@ -0,0 +1,164 @@ +import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "../types.ts"; + +export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: + + +`; + +export const COMPACTION_SUMMARY_SUFFIX = ` +`; + +export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from: + + +`; + +export const BRANCH_SUMMARY_SUFFIX = ``; + +export interface BashExecutionMessage { + role: "bashExecution"; + command: string; + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; + timestamp: number; + excludeFromContext?: boolean; +} + +export interface CustomMessage { + role: "custom"; + customType: string; + content: string | (TextContent | ImageContent)[]; + display: boolean; + details?: T; + timestamp: number; +} + +export interface BranchSummaryMessage { + role: "branchSummary"; + summary: string; + fromId: string; + timestamp: number; +} + +export interface CompactionSummaryMessage { + role: "compactionSummary"; + summary: string; + tokensBefore: number; + timestamp: number; +} + +declare module "../types.ts" { + interface CustomAgentMessages { + bashExecution: BashExecutionMessage; + custom: CustomMessage; + branchSummary: BranchSummaryMessage; + compactionSummary: CompactionSummaryMessage; + } +} + +export function bashExecutionToText(msg: BashExecutionMessage): string { + let text = `Ran \`${msg.command}\`\n`; + if (msg.output) { + text += `\`\`\`\n${msg.output}\n\`\`\``; + } else { + text += "(no output)"; + } + if (msg.cancelled) { + text += "\n\n(command cancelled)"; + } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) { + text += `\n\nCommand exited with code ${msg.exitCode}`; + } + if (msg.truncated && msg.fullOutputPath) { + text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`; + } + return text; +} + +export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage { + return { + role: "branchSummary", + summary, + fromId, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCompactionSummaryMessage( + summary: string, + tokensBefore: number, + timestamp: string, +): CompactionSummaryMessage { + return { + role: "compactionSummary", + summary, + tokensBefore, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCustomMessage( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details: unknown | undefined, + timestamp: string, +): CustomMessage { + return { + role: "custom", + customType, + content, + display, + details, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function convertToLlm(messages: AgentMessage[]): Message[] { + return messages + .map((m): Message | undefined => { + switch (m.role) { + case "bashExecution": + if (m.excludeFromContext) { + return undefined; + } + return { + role: "user", + content: [{ type: "text", text: bashExecutionToText(m) }], + timestamp: m.timestamp, + }; + case "custom": { + const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; + return { + role: "user", + content, + timestamp: m.timestamp, + }; + } + case "branchSummary": + return { + role: "user", + content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }], + timestamp: m.timestamp, + }; + case "compactionSummary": + return { + role: "user", + content: [ + { type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX }, + ], + timestamp: m.timestamp, + }; + case "user": + case "assistant": + case "toolResult": + return m; + default: + return undefined; + } + }) + .filter((m): m is Message => m !== undefined); +} diff --git a/cactus-code/packages/agent/src/harness/prompt-templates.ts b/cactus-code/packages/agent/src/harness/prompt-templates.ts new file mode 100644 index 000000000..6c1b6be0b --- /dev/null +++ b/cactus-code/packages/agent/src/harness/prompt-templates.ts @@ -0,0 +1,267 @@ +import { parse } from "yaml"; +import { type ExecutionEnv, type FileInfo, type PromptTemplate, type Result, toError } from "./types.ts"; + +export type PromptTemplateDiagnosticCode = "file_info_failed" | "list_failed" | "read_failed" | "parse_failed"; + +/** Warning produced while loading prompt templates. */ +export interface PromptTemplateDiagnostic { + /** Diagnostic severity. Currently only warnings are emitted. */ + type: "warning"; + /** Stable diagnostic code. */ + code: PromptTemplateDiagnosticCode; + /** Human-readable diagnostic message. */ + message: string; + /** Path associated with the diagnostic. */ + path: string; +} + +interface PromptTemplateFrontmatter { + description?: string; + "argument-hint"?: string; + [key: string]: unknown; +} + +/** + * Load prompt templates from one or more paths. + * + * Directory inputs load direct `.md` children non-recursively. File inputs load explicit `.md` files. Missing paths and + * non-markdown files are skipped. Read and parse failures are returned as diagnostics. + */ +export async function loadPromptTemplates( + env: ExecutionEnv, + paths: string | string[], +): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> { + const promptTemplates: PromptTemplate[] = []; + const diagnostics: PromptTemplateDiagnostic[] = []; + for (const path of Array.isArray(paths) ? paths : [paths]) { + const infoResult = await env.fileInfo(path); + if (!infoResult.ok) { + if (infoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: infoResult.error.message, + path, + }); + } + continue; + } + const info = infoResult.value; + const kind = await resolveKind(env, info, diagnostics); + if (kind === "directory") { + const result = await loadTemplatesFromDir(env, info.path); + promptTemplates.push(...result.promptTemplates); + diagnostics.push(...result.diagnostics); + } else if (kind === "file" && info.name.endsWith(".md")) { + const result = await loadTemplateFromFile(env, info.path); + if (result.promptTemplate) promptTemplates.push(result.promptTemplate); + diagnostics.push(...result.diagnostics); + } + } + return { promptTemplates, diagnostics }; +} + +/** + * Load prompt templates from source-tagged paths. + * + * Source values are preserved exactly and attached to every loaded prompt template and diagnostic. The agent package does + * not interpret source values; applications define their own provenance shape. + */ +export async function loadSourcedPromptTemplates( + env: ExecutionEnv, + inputs: Array<{ path: string; source: TSource }>, + mapPromptTemplate?: (promptTemplate: PromptTemplate, source: TSource) => TPromptTemplate, +): Promise<{ + promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }>; + diagnostics: Array; +}> { + const promptTemplates: Array<{ promptTemplate: TPromptTemplate; source: TSource }> = []; + const diagnostics: Array = []; + for (const input of inputs) { + const result = await loadPromptTemplates(env, input.path); + for (const promptTemplate of result.promptTemplates) { + promptTemplates.push({ + promptTemplate: mapPromptTemplate + ? mapPromptTemplate(promptTemplate, input.source) + : (promptTemplate as TPromptTemplate), + source: input.source, + }); + } + for (const diagnostic of result.diagnostics) diagnostics.push({ ...diagnostic, source: input.source }); + } + return { promptTemplates, diagnostics }; +} + +async function loadTemplatesFromDir( + env: ExecutionEnv, + dir: string, +): Promise<{ promptTemplates: PromptTemplate[]; diagnostics: PromptTemplateDiagnostic[] }> { + const promptTemplates: PromptTemplate[] = []; + const diagnostics: PromptTemplateDiagnostic[] = []; + const entriesResult = await env.listDir(dir); + if (!entriesResult.ok) { + diagnostics.push({ + type: "warning", + code: "list_failed", + message: entriesResult.error.message, + path: dir, + }); + return { promptTemplates, diagnostics }; + } + const entries = entriesResult.value; + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const kind = await resolveKind(env, entry, diagnostics); + if (kind !== "file" || !entry.name.endsWith(".md")) continue; + const result = await loadTemplateFromFile(env, entry.path); + if (result.promptTemplate) promptTemplates.push(result.promptTemplate); + diagnostics.push(...result.diagnostics); + } + return { promptTemplates, diagnostics }; +} + +async function loadTemplateFromFile( + env: ExecutionEnv, + filePath: string, +): Promise<{ promptTemplate: PromptTemplate | null; diagnostics: PromptTemplateDiagnostic[] }> { + const diagnostics: PromptTemplateDiagnostic[] = []; + const rawContent = await env.readTextFile(filePath); + if (!rawContent.ok) { + diagnostics.push({ + type: "warning", + code: "read_failed", + message: rawContent.error.message, + path: filePath, + }); + return { promptTemplate: null, diagnostics }; + } + + const parsed = parseFrontmatter(rawContent.value); + if (!parsed.ok) { + diagnostics.push({ + type: "warning", + code: "parse_failed", + message: parsed.error.message, + path: filePath, + }); + return { promptTemplate: null, diagnostics }; + } + + const { frontmatter, body } = parsed.value; + const firstLine = body.split("\n").find((line) => line.trim()); + let description = typeof frontmatter.description === "string" ? frontmatter.description : ""; + if (!description && firstLine) { + description = firstLine.slice(0, 60); + if (firstLine.length > 60) description += "..."; + } + return { + promptTemplate: { + name: basenameEnvPath(filePath).replace(/\.md$/i, ""), + description, + content: body, + }, + diagnostics, + }; +} + +async function resolveKind( + env: ExecutionEnv, + info: FileInfo, + diagnostics: PromptTemplateDiagnostic[], +): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") return info.kind; + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } + return undefined; + } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined; +} + +function parseFrontmatter>( + content: string, +): Result<{ frontmatter: T; body: string }, Error> { + try { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } }; + } catch (error) { + return { ok: false, error: toError(error) }; + } +} + +function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} + +/** Parse an argument string using simple shell-style single and double quotes. */ +export function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]!; + if (inQuote) { + if (char === inQuote) inQuote = null; + else current += char; + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (char === " " || char === "\t") { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + if (current) args.push(current); + return args; +} + +/** Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments. */ +export function substituteArgs(content: string, args: string[]): string { + let result = content; + result = result.replace(/\$(\d+)/g, (_, num: string) => args[parseInt(num, 10) - 1] ?? ""); + result = result.replace(/\$\{@:(\d+)(?::(\d+))?\}/g, (_, startStr: string, lengthStr?: string) => { + let start = parseInt(startStr, 10) - 1; + if (start < 0) start = 0; + if (lengthStr) return args.slice(start, start + parseInt(lengthStr, 10)).join(" "); + return args.slice(start).join(" "); + }); + const allArgs = args.join(" "); + result = result.replace(/\$ARGUMENTS/g, allArgs); + result = result.replace(/\$@/g, allArgs); + return result; +} + +/** Format a prompt template invocation with positional arguments. */ +export function formatPromptTemplateInvocation(template: PromptTemplate, args: string[] = []): string { + return substituteArgs(template.content, args); +} diff --git a/cactus-code/packages/agent/src/harness/session/jsonl-repo.ts b/cactus-code/packages/agent/src/harness/session/jsonl-repo.ts new file mode 100644 index 000000000..1a08d4d35 --- /dev/null +++ b/cactus-code/packages/agent/src/harness/session/jsonl-repo.ts @@ -0,0 +1,177 @@ +import type { + FileSystem, + JsonlSessionCreateOptions, + JsonlSessionListOptions, + JsonlSessionMetadata, + JsonlSessionRepoApi, + Session, +} from "../types.ts"; +import { SessionError, toError } from "../types.ts"; +import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.ts"; +import { + createSessionId, + createTimestamp, + getEntriesToFork, + getFileSystemResultOrThrow, + toSession, +} from "./repo-utils.ts"; + +type JsonlSessionRepoFileSystem = Pick< + FileSystem, + | "cwd" + | "absolutePath" + | "joinPath" + | "readTextFile" + | "readTextLines" + | "writeFile" + | "appendFile" + | "listDir" + | "exists" + | "createDir" + | "remove" +>; + +function encodeCwd(cwd: string): string { + return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; +} + +export class JsonlSessionRepo implements JsonlSessionRepoApi { + private readonly fs: JsonlSessionRepoFileSystem; + private readonly sessionsRootInput: string; + private sessionsRoot: string | undefined; + + constructor(options: { fs: JsonlSessionRepoFileSystem; sessionsRoot: string }) { + this.fs = options.fs; + this.sessionsRootInput = options.sessionsRoot; + } + + private async getSessionsRoot(): Promise { + if (!this.sessionsRoot) { + this.sessionsRoot = getFileSystemResultOrThrow( + await this.fs.absolutePath(this.sessionsRootInput), + `Failed to resolve sessions root ${this.sessionsRootInput}`, + ); + } + return this.sessionsRoot; + } + + private async getSessionDir(cwd: string): Promise { + return getFileSystemResultOrThrow( + await this.fs.joinPath([await this.getSessionsRoot(), encodeCwd(cwd)]), + `Failed to resolve session directory for ${cwd}`, + ); + } + + private async createSessionFilePath(cwd: string, sessionId: string, timestamp: string): Promise { + return getFileSystemResultOrThrow( + await this.fs.joinPath([ + await this.getSessionDir(cwd), + `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`, + ]), + `Failed to resolve session file path for ${sessionId}`, + ); + } + + async create(options: JsonlSessionCreateOptions): Promise> { + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getFileSystemResultOrThrow( + await this.fs.createDir(sessionDir, { recursive: true }), + `Failed to create session directory ${sessionDir}`, + ); + const filePath = await this.createSessionFilePath(options.cwd, id, createdAt); + const storage = await JsonlSessionStorage.create(this.fs, filePath, { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath, + }); + return toSession(storage); + } + + async open(metadata: JsonlSessionMetadata): Promise> { + if ( + !getFileSystemResultOrThrow(await this.fs.exists(metadata.path), `Failed to check session ${metadata.path}`) + ) { + throw new SessionError("not_found", `Session not found: ${metadata.path}`); + } + const storage = await JsonlSessionStorage.open(this.fs, metadata.path); + return toSession(storage); + } + + async list(options: JsonlSessionListOptions = {}): Promise { + const dirs = options.cwd ? [await this.getSessionDir(options.cwd)] : await this.listSessionDirs(); + const sessions: JsonlSessionMetadata[] = []; + for (const dir of dirs) { + if (!getFileSystemResultOrThrow(await this.fs.exists(dir), `Failed to check session directory ${dir}`)) { + continue; + } + const files = getFileSystemResultOrThrow( + await this.fs.listDir(dir), + `Failed to list sessions in ${dir}`, + ).filter((file) => file.kind !== "directory" && file.name.endsWith(".jsonl")); + for (const file of files) { + try { + sessions.push(await loadJsonlSessionMetadata(this.fs, file.path)); + } catch (error) { + const cause = toError(error); + if (!(cause instanceof SessionError) || cause.code !== "invalid_session") throw cause; + } + } + } + sessions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + return sessions; + } + + async delete(metadata: JsonlSessionMetadata): Promise { + getFileSystemResultOrThrow( + await this.fs.remove(metadata.path, { force: true }), + `Failed to delete session ${metadata.path}`, + ); + } + + async fork( + sourceMetadata: JsonlSessionMetadata, + options: JsonlSessionCreateOptions & { entryId?: string; position?: "before" | "at"; id?: string }, + ): Promise> { + const source = await this.open(sourceMetadata); + const forkedEntries = await getEntriesToFork(source.getStorage(), options); + const id = options.id ?? createSessionId(); + const createdAt = createTimestamp(); + const sessionDir = await this.getSessionDir(options.cwd); + getFileSystemResultOrThrow( + await this.fs.createDir(sessionDir, { recursive: true }), + `Failed to create session directory ${sessionDir}`, + ); + const storage = await JsonlSessionStorage.create( + this.fs, + await this.createSessionFilePath(options.cwd, id, createdAt), + { + cwd: options.cwd, + sessionId: id, + parentSessionPath: options.parentSessionPath ?? sourceMetadata.path, + }, + ); + for (const entry of forkedEntries) { + await storage.appendEntry(entry); + } + return toSession(storage); + } + + private async listSessionDirs(): Promise { + const sessionsRoot = await this.getSessionsRoot(); + if ( + !getFileSystemResultOrThrow( + await this.fs.exists(sessionsRoot), + `Failed to check sessions root ${sessionsRoot}`, + ) + ) { + return []; + } + const entries = getFileSystemResultOrThrow( + await this.fs.listDir(sessionsRoot), + `Failed to list sessions root ${sessionsRoot}`, + ); + return entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path); + } +} diff --git a/cactus-code/packages/agent/src/harness/session/jsonl-storage.ts b/cactus-code/packages/agent/src/harness/session/jsonl-storage.ts new file mode 100644 index 000000000..54ad9eb03 --- /dev/null +++ b/cactus-code/packages/agent/src/harness/session/jsonl-storage.ts @@ -0,0 +1,293 @@ +import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.ts"; +import { SessionError, toError } from "../types.ts"; +import { getFileSystemResultOrThrow } from "./repo-utils.ts"; +import { uuidv7 } from "./uuid.ts"; + +type JsonlSessionStorageFileSystem = Pick; + +interface SessionHeader { + type: "session"; + version: 3; + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { + if (entry.type !== "label") return; + const label = entry.label?.trim(); + if (label) { + labelsById.set(entry.targetId, label); + } else { + labelsById.delete(entry.targetId); + } +} + +function buildLabelsById(entries: SessionTreeEntry[]): Map { + const labelsById = new Map(); + for (const entry of entries) { + updateLabelCache(labelsById, entry); + } + return labelsById; +} + +function generateEntryId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = uuidv7().slice(0, 8); + if (!byId.has(id)) return id; + } + return uuidv7(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function invalidSession(filePath: string, message: string, cause?: Error): SessionError { + return new SessionError("invalid_session", `Invalid JSONL session file ${filePath}: ${message}`, cause); +} + +function invalidEntry(filePath: string, lineNumber: number, message: string, cause?: Error): SessionError { + return new SessionError( + "invalid_entry", + `Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`, + cause, + ); +} + +function parseHeaderLine(line: string, filePath: string): SessionHeader { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + throw invalidSession(filePath, "first line is not a valid session header", toError(error)); + } + if (!isRecord(parsed)) throw invalidSession(filePath, "first line is not a valid session header"); + if (parsed.type !== "session") throw invalidSession(filePath, "first line is not a valid session header"); + if (parsed.version !== 3) throw invalidSession(filePath, "unsupported session version"); + if (typeof parsed.id !== "string" || !parsed.id) throw invalidSession(filePath, "session header is missing id"); + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw invalidSession(filePath, "session header is missing timestamp"); + } + if (typeof parsed.cwd !== "string" || !parsed.cwd) throw invalidSession(filePath, "session header is missing cwd"); + if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") { + throw invalidSession(filePath, "session header parentSession must be a string"); + } + return { + type: "session", + version: 3, + id: parsed.id, + timestamp: parsed.timestamp, + cwd: parsed.cwd, + parentSession: parsed.parentSession, + }; +} + +function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error)); + } + if (!isRecord(parsed)) throw invalidEntry(filePath, lineNumber, "is not a valid session entry"); + if (typeof parsed.type !== "string") throw invalidEntry(filePath, lineNumber, "is missing entry type"); + if (typeof parsed.id !== "string" || !parsed.id) throw invalidEntry(filePath, lineNumber, "is missing entry id"); + if (parsed.parentId !== null && typeof parsed.parentId !== "string") { + throw invalidEntry(filePath, lineNumber, "has invalid parentId"); + } + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw invalidEntry(filePath, lineNumber, "is missing timestamp"); + } + if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") { + throw invalidEntry(filePath, lineNumber, "has invalid targetId"); + } + return parsed as unknown as SessionTreeEntry; +} + +function leafIdAfterEntry(entry: SessionTreeEntry): string | null { + return entry.type === "leaf" ? entry.targetId : entry.id; +} + +function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata { + return { + id: header.id, + createdAt: header.timestamp, + cwd: header.cwd, + path, + parentSessionPath: header.parentSession, + }; +} + +export async function loadJsonlSessionMetadata( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise { + const lines = getFileSystemResultOrThrow( + await fs.readTextLines(filePath, { maxLines: 1 }), + `Failed to read session header ${filePath}`, + ); + const line = lines[0]; + if (line?.trim()) return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath); + throw invalidSession(filePath, "missing session header"); +} + +async function loadJsonlStorage( + fs: JsonlSessionStorageFileSystem, + filePath: string, +): Promise<{ + header: SessionHeader; + entries: SessionTreeEntry[]; + leafId: string | null; +}> { + const content = getFileSystemResultOrThrow(await fs.readTextFile(filePath), `Failed to read session ${filePath}`); + const lines = content.split("\n").filter((line) => line.trim()); + if (lines.length === 0) { + throw invalidSession(filePath, "missing session header"); + } + + const header = parseHeaderLine(lines[0]!, filePath); + const entries: SessionTreeEntry[] = []; + let leafId: string | null = null; + for (let i = 1; i < lines.length; i++) { + const entry = parseEntryLine(lines[i]!, filePath, i + 1); + entries.push(entry); + leafId = leafIdAfterEntry(entry); + } + return { header, entries, leafId }; +} + +export class JsonlSessionStorage implements SessionStorage { + private readonly fs: JsonlSessionStorageFileSystem; + private readonly filePath: string; + private readonly metadata: JsonlSessionMetadata; + private entries: SessionTreeEntry[]; + private byId: Map; + private labelsById: Map; + private currentLeafId: string | null; + + private constructor( + fs: JsonlSessionStorageFileSystem, + filePath: string, + header: SessionHeader, + entries: SessionTreeEntry[], + leafId: string | null, + ) { + this.fs = fs; + this.filePath = filePath; + this.metadata = headerToSessionMetadata(header, this.filePath); + this.entries = entries; + this.byId = new Map(entries.map((entry) => [entry.id, entry])); + this.labelsById = buildLabelsById(entries); + this.currentLeafId = leafId; + } + + static async open(fs: JsonlSessionStorageFileSystem, filePath: string): Promise { + const loaded = await loadJsonlStorage(fs, filePath); + return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId); + } + + static async create( + fs: JsonlSessionStorageFileSystem, + filePath: string, + options: { + cwd: string; + sessionId: string; + parentSessionPath?: string; + }, + ): Promise { + const header: SessionHeader = { + type: "session", + version: 3, + id: options.sessionId, + timestamp: new Date().toISOString(), + cwd: options.cwd, + parentSession: options.parentSessionPath, + }; + getFileSystemResultOrThrow( + await fs.writeFile(filePath, `${JSON.stringify(header)}\n`), + `Failed to create session ${filePath}`, + ); + return new JsonlSessionStorage(fs, filePath, header, [], null); + } + + async getMetadata(): Promise { + return this.metadata; + } + + async getLeafId(): Promise { + if (this.currentLeafId !== null && !this.byId.has(this.currentLeafId)) { + throw new SessionError("invalid_session", `Entry ${this.currentLeafId} not found`); + } + return this.currentLeafId; + } + + async setLeafId(leafId: string | null): Promise { + if (leafId !== null && !this.byId.has(leafId)) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + const entry: LeafEntry = { + type: "leaf", + id: generateEntryId(this.byId), + parentId: this.currentLeafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + getFileSystemResultOrThrow( + await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), + `Failed to append session leaf ${entry.id}`, + ); + this.entries.push(entry); + this.byId.set(entry.id, entry); + this.currentLeafId = leafId; + } + + async createEntryId(): Promise { + return generateEntryId(this.byId); + } + + async appendEntry(entry: SessionTreeEntry): Promise { + getFileSystemResultOrThrow( + await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`), + `Failed to append session entry ${entry.id}`, + ); + this.entries.push(entry); + this.byId.set(entry.id, entry); + updateLabelCache(this.labelsById, entry); + this.currentLeafId = leafIdAfterEntry(entry); + } + + async getEntry(id: string): Promise { + return this.byId.get(id); + } + + async findEntries( + type: TType, + ): Promise>> { + return this.entries.filter((entry): entry is Extract => entry.type === type); + } + + async getLabel(id: string): Promise { + return this.labelsById.get(id); + } + + async getPathToRoot(leafId: string | null): Promise { + if (leafId === null) return []; + const path: SessionTreeEntry[] = []; + let current = this.byId.get(leafId); + if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`); + while (current) { + path.unshift(current); + if (!current.parentId) break; + const parent = this.byId.get(current.parentId); + if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); + current = parent; + } + return path; + } + + async getEntries(): Promise { + return [...this.entries]; + } +} diff --git a/cactus-code/packages/agent/src/harness/session/memory-repo.ts b/cactus-code/packages/agent/src/harness/session/memory-repo.ts new file mode 100644 index 000000000..145bc2b7c --- /dev/null +++ b/cactus-code/packages/agent/src/harness/session/memory-repo.ts @@ -0,0 +1,50 @@ +import { type Session, SessionError, type SessionMetadata, type SessionRepo } from "../types.ts"; +import { InMemorySessionStorage } from "./memory-storage.ts"; +import { createSessionId, createTimestamp, getEntriesToFork, toSession } from "./repo-utils.ts"; + +export class InMemorySessionRepo implements SessionRepo { + private sessions = new Map>(); + + async create(options: { id?: string } = {}): Promise> { + const metadata: SessionMetadata = { + id: options.id ?? createSessionId(), + createdAt: createTimestamp(), + }; + const storage = new InMemorySessionStorage({ metadata }); + const session = toSession(storage); + this.sessions.set(metadata.id, session); + return session; + } + + async open(metadata: SessionMetadata): Promise> { + const session = this.sessions.get(metadata.id); + if (!session) { + throw new SessionError("not_found", `Session not found: ${metadata.id}`); + } + return session; + } + + async list(): Promise { + return Promise.all([...this.sessions.values()].map((session) => session.getMetadata())); + } + + async delete(metadata: SessionMetadata): Promise { + this.sessions.delete(metadata.id); + } + + async fork( + sourceMetadata: SessionMetadata, + options: { entryId?: string; position?: "before" | "at"; id?: string }, + ): Promise> { + const source = await this.open(sourceMetadata); + const forkedEntries = await getEntriesToFork(source.getStorage(), options); + const metadata: SessionMetadata = { + id: options.id ?? createSessionId(), + createdAt: createTimestamp(), + }; + const storage = new InMemorySessionStorage({ metadata, entries: forkedEntries }); + const session = toSession(storage); + this.sessions.set(metadata.id, session); + return session; + } +} diff --git a/cactus-code/packages/agent/src/harness/session/memory-storage.ts b/cactus-code/packages/agent/src/harness/session/memory-storage.ts new file mode 100644 index 000000000..c6428cac6 --- /dev/null +++ b/cactus-code/packages/agent/src/harness/session/memory-storage.ts @@ -0,0 +1,131 @@ +import { + type LeafEntry, + SessionError, + type SessionMetadata, + type SessionStorage, + type SessionTreeEntry, +} from "../types.ts"; +import { uuidv7 } from "./uuid.ts"; + +function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { + if (entry.type !== "label") return; + const label = entry.label?.trim(); + if (label) { + labelsById.set(entry.targetId, label); + } else { + labelsById.delete(entry.targetId); + } +} + +function buildLabelsById(entries: SessionTreeEntry[]): Map { + const labelsById = new Map(); + for (const entry of entries) { + updateLabelCache(labelsById, entry); + } + return labelsById; +} + +function generateEntryId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = uuidv7().slice(0, 8); + if (!byId.has(id)) return id; + } + return uuidv7(); +} + +function leafIdAfterEntry(entry: SessionTreeEntry): string | null { + return entry.type === "leaf" ? entry.targetId : entry.id; +} + +export class InMemorySessionStorage + implements SessionStorage +{ + private readonly metadata: TMetadata; + private entries: SessionTreeEntry[]; + private byId: Map; + private labelsById: Map; + private leafId: string | null; + + constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) { + this.entries = options?.entries ? [...options.entries] : []; + this.byId = new Map(this.entries.map((entry) => [entry.id, entry])); + this.labelsById = buildLabelsById(this.entries); + this.leafId = null; + for (const entry of this.entries) this.leafId = leafIdAfterEntry(entry); + if (this.leafId !== null && !this.byId.has(this.leafId)) { + throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); + } + this.metadata = options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata); + } + + async getMetadata(): Promise { + return this.metadata; + } + + async getLeafId(): Promise { + if (this.leafId !== null && !this.byId.has(this.leafId)) { + throw new SessionError("invalid_session", `Entry ${this.leafId} not found`); + } + return this.leafId; + } + + async setLeafId(leafId: string | null): Promise { + if (leafId !== null && !this.byId.has(leafId)) { + throw new SessionError("not_found", `Entry ${leafId} not found`); + } + const entry: LeafEntry = { + type: "leaf", + id: generateEntryId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + targetId: leafId, + }; + this.entries.push(entry); + this.byId.set(entry.id, entry); + this.leafId = leafId; + } + + async createEntryId(): Promise { + return generateEntryId(this.byId); + } + + async appendEntry(entry: SessionTreeEntry): Promise { + this.entries.push(entry); + this.byId.set(entry.id, entry); + updateLabelCache(this.labelsById, entry); + this.leafId = leafIdAfterEntry(entry); + } + + async getEntry(id: string): Promise { + return this.byId.get(id); + } + + async findEntries( + type: TType, + ): Promise>> { + return this.entries.filter((entry): entry is Extract => entry.type === type); + } + + async getLabel(id: string): Promise { + return this.labelsById.get(id); + } + + async getPathToRoot(leafId: string | null): Promise { + if (leafId === null) return []; + const path: SessionTreeEntry[] = []; + let current = this.byId.get(leafId); + if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`); + while (current) { + path.unshift(current); + if (!current.parentId) break; + const parent = this.byId.get(current.parentId); + if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); + current = parent; + } + return path; + } + + async getEntries(): Promise { + return [...this.entries]; + } +} diff --git a/cactus-code/packages/agent/src/harness/session/repo-utils.ts b/cactus-code/packages/agent/src/harness/session/repo-utils.ts new file mode 100644 index 000000000..a25b62def --- /dev/null +++ b/cactus-code/packages/agent/src/harness/session/repo-utils.ts @@ -0,0 +1,51 @@ +import { + type FileError, + type Result, + SessionError, + type SessionMetadata, + type SessionStorage, + type SessionTreeEntry, +} from "../types.ts"; +import { Session } from "./session.ts"; +import { uuidv7 } from "./uuid.ts"; + +export function createSessionId(): string { + return uuidv7(); +} + +export function createTimestamp(): string { + return new Date().toISOString(); +} + +export function toSession(storage: SessionStorage): Session { + return new Session(storage); +} + +export function getFileSystemResultOrThrow(result: Result, message: string): TValue { + if (!result.ok) { + const code = result.error.code === "not_found" ? "not_found" : "storage"; + throw new SessionError(code, `${message}: ${result.error.message}`, result.error); + } + return result.value; +} + +export async function getEntriesToFork( + storage: SessionStorage, + options: { entryId?: string; position?: "before" | "at" }, +): Promise { + if (!options.entryId) return storage.getEntries(); + const target = await storage.getEntry(options.entryId); + if (!target) { + throw new SessionError("invalid_fork_target", `Entry ${options.entryId} not found`); + } + let effectiveLeafId: string | null; + if ((options.position ?? "before") === "at") { + effectiveLeafId = target.id; + } else { + if (target.type !== "message" || target.message.role !== "user") { + throw new SessionError("invalid_fork_target", `Entry ${options.entryId} is not a user message`); + } + effectiveLeafId = target.parentId; + } + return storage.getPathToRoot(effectiveLeafId); +} diff --git a/cactus-code/packages/agent/src/harness/session/session.ts b/cactus-code/packages/agent/src/harness/session/session.ts new file mode 100644 index 000000000..ce369dfff --- /dev/null +++ b/cactus-code/packages/agent/src/harness/session/session.ts @@ -0,0 +1,267 @@ +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "../../types.ts"; +import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts"; +import type { + ActiveToolsChangeEntry, + BranchSummaryEntry, + CompactionEntry, + CustomEntry, + CustomMessageEntry, + LabelEntry, + MessageEntry, + ModelChangeEntry, + SessionContext, + SessionInfoEntry, + SessionMetadata, + SessionStorage, + SessionTreeEntry, + ThinkingLevelChangeEntry, +} from "../types.ts"; +import { SessionError } from "../types.ts"; + +export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext { + let thinkingLevel = "off"; + let model: { provider: string; modelId: string } | null = null; + let activeToolNames: string[] | null = null; + let compaction: CompactionEntry | null = null; + + for (const entry of pathEntries) { + if (entry.type === "thinking_level_change") { + thinkingLevel = entry.thinkingLevel; + } else if (entry.type === "model_change") { + model = { provider: entry.provider, modelId: entry.modelId }; + } else if (entry.type === "message" && entry.message.role === "assistant") { + model = { provider: entry.message.provider, modelId: entry.message.model }; + } else if (entry.type === "active_tools_change") { + activeToolNames = [...entry.activeToolNames]; + } else if (entry.type === "compaction") { + compaction = entry; + } + } + + const messages: AgentMessage[] = []; + const appendMessage = (entry: SessionTreeEntry) => { + if (entry.type === "message") { + messages.push(entry.message as AgentMessage); + } else if (entry.type === "custom_message") { + messages.push( + createCustomMessage( + entry.customType, + entry.content as string | (TextContent | ImageContent)[], + entry.display, + entry.details, + entry.timestamp, + ), + ); + } else if (entry.type === "branch_summary" && entry.summary) { + messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); + } + }; + + if (compaction) { + messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp)); + const compactionIdx = pathEntries.findIndex((e) => e.type === "compaction" && e.id === compaction.id); + let foundFirstKept = false; + for (let i = 0; i < compactionIdx; i++) { + const entry = pathEntries[i]!; + if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true; + if (foundFirstKept) appendMessage(entry); + } + for (let i = compactionIdx + 1; i < pathEntries.length; i++) { + appendMessage(pathEntries[i]!); + } + } else { + for (const entry of pathEntries) { + appendMessage(entry); + } + } + + return { messages, thinkingLevel, model, activeToolNames }; +} + +export class Session { + private storage: SessionStorage; + + constructor(storage: SessionStorage) { + this.storage = storage; + } + + getMetadata(): Promise { + return this.storage.getMetadata(); + } + + getStorage(): SessionStorage { + return this.storage; + } + + getLeafId(): Promise { + return this.storage.getLeafId(); + } + + getEntry(id: string): Promise { + return this.storage.getEntry(id); + } + + getEntries(): Promise { + return this.storage.getEntries(); + } + + async getBranch(fromId?: string): Promise { + const leafId = fromId ?? (await this.storage.getLeafId()); + return this.storage.getPathToRoot(leafId); + } + + async buildContext(): Promise { + return buildSessionContext(await this.getBranch()); + } + + getLabel(id: string): Promise { + return this.storage.getLabel(id); + } + + async getSessionName(): Promise { + const entries = await this.storage.findEntries("session_info"); + return entries[entries.length - 1]?.name?.trim() || undefined; + } + + private async appendTypedEntry(entry: TEntry): Promise { + await this.storage.appendEntry(entry); + return entry.id; + } + + async appendMessage(message: AgentMessage): Promise { + return this.appendTypedEntry({ + type: "message", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + message, + } satisfies MessageEntry); + } + + async appendThinkingLevelChange(thinkingLevel: string): Promise { + return this.appendTypedEntry({ + type: "thinking_level_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + thinkingLevel, + } satisfies ThinkingLevelChangeEntry); + } + + async appendModelChange(provider: string, modelId: string): Promise { + return this.appendTypedEntry({ + type: "model_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + provider, + modelId, + } satisfies ModelChangeEntry); + } + + async appendActiveToolsChange(activeToolNames: string[]): Promise { + return this.appendTypedEntry({ + type: "active_tools_change", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + activeToolNames: [...activeToolNames], + } satisfies ActiveToolsChangeEntry); + } + + async appendCompaction( + summary: string, + firstKeptEntryId: string, + tokensBefore: number, + details?: T, + fromHook?: boolean, + ): Promise { + return this.appendTypedEntry({ + type: "compaction", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + summary, + firstKeptEntryId, + tokensBefore, + details, + fromHook, + } satisfies CompactionEntry); + } + + async appendCustomEntry(customType: string, data?: unknown): Promise { + return this.appendTypedEntry({ + type: "custom", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + customType, + data, + } satisfies CustomEntry); + } + + async appendCustomMessageEntry( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details?: T, + ): Promise { + return this.appendTypedEntry({ + type: "custom_message", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + customType, + content, + display, + details, + } satisfies CustomMessageEntry); + } + + async appendLabel(targetId: string, label: string | undefined): Promise { + if (!(await this.storage.getEntry(targetId))) { + throw new SessionError("not_found", `Entry ${targetId} not found`); + } + return this.appendTypedEntry({ + type: "label", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + targetId, + label, + } satisfies LabelEntry); + } + + async appendSessionName(name: string): Promise { + const sanitizedName = name.replace(/[\r\n]+/g, " ").trim(); + return this.appendTypedEntry({ + type: "session_info", + id: await this.storage.createEntryId(), + parentId: await this.storage.getLeafId(), + timestamp: new Date().toISOString(), + name: sanitizedName, + } satisfies SessionInfoEntry); + } + + async moveTo( + entryId: string | null, + summary?: { summary: string; details?: unknown; fromHook?: boolean }, + ): Promise { + if (entryId !== null && !(await this.storage.getEntry(entryId))) { + throw new SessionError("not_found", `Entry ${entryId} not found`); + } + await this.storage.setLeafId(entryId); + if (!summary) return undefined; + return this.appendTypedEntry({ + type: "branch_summary", + id: await this.storage.createEntryId(), + parentId: entryId, + timestamp: new Date().toISOString(), + fromId: entryId ?? "root", + summary: summary.summary, + details: summary.details, + fromHook: summary.fromHook, + } satisfies BranchSummaryEntry); + } +} diff --git a/cactus-code/packages/agent/src/harness/session/uuid.ts b/cactus-code/packages/agent/src/harness/session/uuid.ts new file mode 100644 index 000000000..0c4cef8e0 --- /dev/null +++ b/cactus-code/packages/agent/src/harness/session/uuid.ts @@ -0,0 +1,54 @@ +let lastTimestamp = -Infinity; +let sequence = 0; + +function fillRandomBytes(bytes: Uint8Array): void { + const crypto = globalThis.crypto; + if (crypto?.getRandomValues) { + crypto.getRandomValues(bytes); + return; + } + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } +} + +export function uuidv7(): string { + const random = new Uint8Array(16); + fillRandomBytes(random); + const timestamp = Date.now(); + + if (timestamp > lastTimestamp) { + sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9]; + lastTimestamp = timestamp; + } else { + sequence = (sequence + 1) >>> 0; + if (sequence === 0) { + lastTimestamp++; + } + } + + const bytes = new Uint8Array(16); + bytes[0] = (lastTimestamp / 0x10000000000) & 0xff; + bytes[1] = (lastTimestamp / 0x100000000) & 0xff; + bytes[2] = (lastTimestamp / 0x1000000) & 0xff; + bytes[3] = (lastTimestamp / 0x10000) & 0xff; + bytes[4] = (lastTimestamp / 0x100) & 0xff; + bytes[5] = lastTimestamp & 0xff; + bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f); + bytes[7] = (sequence >>> 20) & 0xff; + bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f); + bytes[9] = (sequence >>> 6) & 0xff; + bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03); + bytes[11] = random[11]; + bytes[12] = random[12]; + bytes[13] = random[13]; + bytes[14] = random[14]; + bytes[15] = random[15]; + + return formatUuid(bytes); +} + +function formatUuid(bytes: Uint8Array): string { + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`; +} diff --git a/cactus-code/packages/agent/src/harness/skills.ts b/cactus-code/packages/agent/src/harness/skills.ts new file mode 100644 index 000000000..a1a4c0c4a --- /dev/null +++ b/cactus-code/packages/agent/src/harness/skills.ts @@ -0,0 +1,375 @@ +import ignore from "ignore"; +import { parse } from "yaml"; +import { type ExecutionEnv, type FileInfo, type Result, type Skill, toError } from "./types.ts"; + +const MAX_NAME_LENGTH = 64; +const MAX_DESCRIPTION_LENGTH = 1024; +const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; + +type IgnoreMatcher = ReturnType; + +export type SkillDiagnosticCode = + | "file_info_failed" + | "list_failed" + | "read_failed" + | "parse_failed" + | "invalid_metadata"; + +/** Warning produced while loading skills. */ +export interface SkillDiagnostic { + /** Diagnostic severity. Currently only warnings are emitted. */ + type: "warning"; + /** Stable diagnostic code. */ + code: SkillDiagnosticCode; + /** Human-readable diagnostic message. */ + message: string; + /** Path associated with the diagnostic. */ + path: string; +} + +interface SkillFrontmatter { + name?: string; + description?: string; + "disable-model-invocation"?: boolean; + [key: string]: unknown; +} + +/** Format a skill invocation prompt, optionally appending additional user instructions. */ +export function formatSkillInvocation(skill: Skill, additionalInstructions?: string): string { + const skillBlock = `\nReferences are relative to ${dirnameEnvPath(skill.filePath)}.\n\n${skill.content}\n`; + return additionalInstructions ? `${skillBlock}\n\n${additionalInstructions}` : skillBlock; +} + +/** + * Load skills from one or more directories. + * + * Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files as skills, honors ignore files, + * and returns diagnostics for invalid skill files. Missing input directories are skipped. + */ +export async function loadSkills( + env: ExecutionEnv, + dirs: string | string[], +): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> { + const skills: Skill[] = []; + const diagnostics: SkillDiagnostic[] = []; + for (const dir of Array.isArray(dirs) ? dirs : [dirs]) { + const rootInfoResult = await env.fileInfo(dir); + if (!rootInfoResult.ok) { + if (rootInfoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: rootInfoResult.error.message, + path: dir, + }); + } + continue; + } + const rootInfo = rootInfoResult.value; + if ((await resolveKind(env, rootInfo, diagnostics)) !== "directory") continue; + const result = await loadSkillsFromDirInternal(env, rootInfo.path, true, ignore(), rootInfo.path); + skills.push(...result.skills); + diagnostics.push(...result.diagnostics); + } + return { skills, diagnostics }; +} + +/** + * Load skills from source-tagged directories. + * + * Source values are preserved exactly and attached to every loaded skill and diagnostic. The agent package does not + * interpret source values; applications define their own provenance shape. + */ +export async function loadSourcedSkills( + env: ExecutionEnv, + inputs: Array<{ path: string; source: TSource }>, + mapSkill?: (skill: Skill, source: TSource) => TSkill, +): Promise<{ + skills: Array<{ skill: TSkill; source: TSource }>; + diagnostics: Array; +}> { + const skills: Array<{ skill: TSkill; source: TSource }> = []; + const diagnostics: Array = []; + for (const input of inputs) { + const result = await loadSkills(env, input.path); + for (const skill of result.skills) { + skills.push({ skill: mapSkill ? mapSkill(skill, input.source) : (skill as TSkill), source: input.source }); + } + for (const diagnostic of result.diagnostics) diagnostics.push({ ...diagnostic, source: input.source }); + } + return { skills, diagnostics }; +} + +async function loadSkillsFromDirInternal( + env: ExecutionEnv, + dir: string, + includeRootFiles: boolean, + ignoreMatcher: IgnoreMatcher, + rootDir: string, +): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> { + const skills: Skill[] = []; + const diagnostics: SkillDiagnostic[] = []; + + const dirInfoResult = await env.fileInfo(dir); + if (!dirInfoResult.ok) { + if (dirInfoResult.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: dirInfoResult.error.message, + path: dir, + }); + } + return { skills, diagnostics }; + } + const dirInfo = dirInfoResult.value; + if ((await resolveKind(env, dirInfo, diagnostics)) !== "directory") return { skills, diagnostics }; + + await addIgnoreRules(env, ignoreMatcher, dir, rootDir, diagnostics); + + const entriesResult = await env.listDir(dir); + if (!entriesResult.ok) { + diagnostics.push({ type: "warning", code: "list_failed", message: entriesResult.error.message, path: dir }); + return { skills, diagnostics }; + } + const entries = entriesResult.value; + + for (const entry of entries) { + if (entry.name !== "SKILL.md") continue; + const fullPath = entry.path; + const kind = await resolveKind(env, entry, diagnostics); + if (kind !== "file") continue; + const relPath = relativeEnvPath(rootDir, fullPath); + if (ignoreMatcher.ignores(relPath)) continue; + + const result = await loadSkillFromFile(env, fullPath); + if (result.skill) skills.push(result.skill); + diagnostics.push(...result.diagnostics); + return { skills, diagnostics }; + } + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith(".") || entry.name === "node_modules") continue; + const fullPath = entry.path; + const kind = await resolveKind(env, entry, diagnostics); + if (!kind) continue; + + const relPath = relativeEnvPath(rootDir, fullPath); + const ignorePath = kind === "directory" ? `${relPath}/` : relPath; + if (ignoreMatcher.ignores(ignorePath)) continue; + + if (kind === "directory") { + const result = await loadSkillsFromDirInternal(env, fullPath, false, ignoreMatcher, rootDir); + skills.push(...result.skills); + diagnostics.push(...result.diagnostics); + continue; + } + + if (kind !== "file" || !includeRootFiles || !entry.name.endsWith(".md")) continue; + const result = await loadSkillFromFile(env, fullPath); + if (result.skill) skills.push(result.skill); + diagnostics.push(...result.diagnostics); + } + + return { skills, diagnostics }; +} + +async function addIgnoreRules( + env: ExecutionEnv, + ig: IgnoreMatcher, + dir: string, + rootDir: string, + diagnostics: SkillDiagnostic[], +): Promise { + const relativeDir = relativeEnvPath(rootDir, dir); + const prefix = relativeDir ? `${relativeDir}/` : ""; + + for (const filename of IGNORE_FILE_NAMES) { + const ignorePath = joinEnvPath(dir, filename); + const info = await env.fileInfo(ignorePath); + if (!info.ok) { + if (info.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: info.error.message, + path: ignorePath, + }); + } + continue; + } + if (info.value.kind !== "file") continue; + const content = await env.readTextFile(ignorePath); + if (!content.ok) { + diagnostics.push({ type: "warning", code: "read_failed", message: content.error.message, path: ignorePath }); + continue; + } + const patterns = content.value + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) ig.add(patterns); + } +} + +function prefixIgnorePattern(line: string, prefix: string): string | null { + const trimmed = line.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null; + + let pattern = line; + let negated = false; + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + if (pattern.startsWith("/")) pattern = pattern.slice(1); + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + return negated ? `!${prefixed}` : prefixed; +} + +async function loadSkillFromFile( + env: ExecutionEnv, + filePath: string, +): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> { + const diagnostics: SkillDiagnostic[] = []; + const rawContent = await env.readTextFile(filePath); + if (!rawContent.ok) { + diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath }); + return { skill: null, diagnostics }; + } + + const parsed = parseFrontmatter(rawContent.value); + if (!parsed.ok) { + diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath }); + return { skill: null, diagnostics }; + } + + const { frontmatter, body } = parsed.value; + const skillDir = dirnameEnvPath(filePath); + const parentDirName = basenameEnvPath(skillDir); + const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined; + + for (const error of validateDescription(description)) { + diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); + } + + const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined; + const name = frontmatterName || parentDirName; + for (const error of validateName(name, parentDirName)) { + diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); + } + + if (!description || description.trim() === "") { + return { skill: null, diagnostics }; + } + + return { + skill: { + name, + description, + content: body, + filePath, + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; +} + +function validateName(name: string, parentDirName: string): string[] { + const errors: string[] = []; + if (name !== parentDirName) errors.push(`name "${name}" does not match parent directory "${parentDirName}"`); + if (name.length > MAX_NAME_LENGTH) errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); + if (!/^[a-z0-9-]+$/.test(name)) { + errors.push("name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)"); + } + if (name.startsWith("-") || name.endsWith("-")) errors.push("name must not start or end with a hyphen"); + if (name.includes("--")) errors.push("name must not contain consecutive hyphens"); + return errors; +} + +function validateDescription(description: string | undefined): string[] { + const errors: string[] = []; + if (!description || description.trim() === "") { + errors.push("description is required"); + } else if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); + } + return errors; +} + +function parseFrontmatter>( + content: string, +): Result<{ frontmatter: T; body: string }, Error> { + try { + const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.startsWith("---")) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) return { ok: true, value: { frontmatter: {} as T, body: normalized } }; + const yamlString = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + return { ok: true, value: { frontmatter: (parse(yamlString) ?? {}) as T, body } }; + } catch (error) { + return { ok: false, error: toError(error) }; + } +} + +async function resolveKind( + env: ExecutionEnv, + info: FileInfo, + diagnostics: SkillDiagnostic[], +): Promise<"file" | "directory" | undefined> { + if (info.kind === "file" || info.kind === "directory") return info.kind; + const canonicalPath = await env.canonicalPath(info.path); + if (!canonicalPath.ok) { + if (canonicalPath.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: canonicalPath.error.message, + path: info.path, + }); + } + return undefined; + } + const target = await env.fileInfo(canonicalPath.value); + if (!target.ok) { + if (target.error.code !== "not_found") { + diagnostics.push({ + type: "warning", + code: "file_info_failed", + message: target.error.message, + path: info.path, + }); + } + return undefined; + } + return target.value.kind === "file" || target.value.kind === "directory" ? target.value.kind : undefined; +} + +function joinEnvPath(base: string, child: string): string { + return `${base.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`; +} + +function dirnameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex); +} + +function basenameEnvPath(path: string): string { + const normalized = path.replace(/\/+$/, ""); + const slashIndex = normalized.lastIndexOf("/"); + return slashIndex === -1 ? normalized : normalized.slice(slashIndex + 1); +} + +function relativeEnvPath(root: string, path: string): string { + const normalizedRoot = root.replace(/\/+$/, ""); + const normalizedPath = path.replace(/\/+$/, ""); + if (normalizedPath === normalizedRoot) return ""; + return normalizedPath.startsWith(`${normalizedRoot}/`) + ? normalizedPath.slice(normalizedRoot.length + 1) + : normalizedPath.replace(/^\/+/, ""); +} diff --git a/cactus-code/packages/agent/src/harness/system-prompt.ts b/cactus-code/packages/agent/src/harness/system-prompt.ts new file mode 100644 index 000000000..ec121da2c --- /dev/null +++ b/cactus-code/packages/agent/src/harness/system-prompt.ts @@ -0,0 +1,34 @@ +import type { Skill } from "./types.ts"; + +export function formatSkillsForSystemPrompt(skills: Skill[]): string { + const visibleSkills = skills.filter((skill) => !skill.disableModelInvocation); + if (visibleSkills.length === 0) return ""; + + const lines = [ + "The following skills provide specialized instructions for specific tasks.", + "Read the full skill file when the task matches its description.", + "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", + "", + "", + ]; + + for (const skill of visibleSkills) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + lines.push(` ${escapeXml(skill.description)}`); + lines.push(` ${escapeXml(skill.filePath)}`); + lines.push(" "); + } + + lines.push(""); + return lines.join("\n"); +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/cactus-code/packages/agent/src/harness/types.ts b/cactus-code/packages/agent/src/harness/types.ts new file mode 100644 index 000000000..29048e617 --- /dev/null +++ b/cactus-code/packages/agent/src/harness/types.ts @@ -0,0 +1,836 @@ +import type { ImageContent, Model, Models, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai"; +import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts"; +import type { Session } from "./session/session.ts"; + +/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */ +export type Result = { ok: true; value: TValue } | { ok: false; error: TError }; + +/** Create a successful {@link Result}. */ +export function ok(value: TValue): Result { + return { ok: true, value }; +} + +/** Create a failed {@link Result}. */ +export function err(error: TError): Result { + return { ok: false, error }; +} + +/** Return the success value or throw the failure error. Intended for tests and explicit adapter boundaries. */ +export function getOrThrow(result: Result): TValue { + if (!result.ok) throw result.error; + return result.value; +} + +/** Return the success value or `undefined`. Only object values are allowed to avoid truthiness bugs with primitives. */ +export function getOrUndefined(result: Result): TValue | undefined { + return result.ok ? result.value : undefined; +} + +/** Normalize unknown thrown values into Error instances before using them as typed error causes. */ +export function toError(error: unknown): Error { + if (error instanceof Error) return error; + if (typeof error === "string") return new Error(error); + try { + return new Error(JSON.stringify(error)); + } catch { + return new Error(String(error)); + } +} + +/** + * Skill loaded from a `SKILL.md` file or provided by an application. + * + * `name`, `description`, and `filePath` are inserted into the system prompt in an XML-formatted block as suggested by agentskills.io. + * Use {@link formatSkillsForSystemPrompt} to generate the spec-compatible system prompt block. + */ +export interface Skill { + /** Stable skill name used for lookup and model-visible listings. */ + name: string; + /** Short model-visible description of when to use the skill. */ + description: string; + /** Full skill instructions. */ + content: string; + /** Absolute path to the skill file. Used for model-visible location and resolving relative references. */ + filePath: string; + /** Exclude this skill from model-visible skill lists while still allowing explicit application invocation. */ + disableModelInvocation?: boolean; +} + +/** Prompt template that can be formatted into a prompt for explicit invocation. */ +export interface PromptTemplate { + /** Stable template name used for lookup or application command routing. */ + name: string; + /** Optional description for command lists or autocomplete. */ + description?: string; + /** Template content. Argument placeholders are formatted by `formatPromptTemplateInvocation`. */ + content: string; +} + +/** Resources made available to explicit invocation methods and system-prompt callbacks. */ +export interface AgentHarnessResources< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + /** Prompt templates available for explicit invocation. */ + promptTemplates?: TPromptTemplate[]; + /** Skills available to the model and explicit skill invocation. */ + skills?: TSkill[]; +} + +/** Curated provider request options owned by the harness and snapshotted per turn. */ +export interface AgentHarnessStreamOptions { + /** Preferred transport forwarded to the stream function. */ + transport?: Transport; + /** Provider request timeout in milliseconds. */ + timeoutMs?: number; + /** Maximum provider retry attempts. */ + maxRetries?: number; + /** Optional cap for provider-requested retry delays. */ + maxRetryDelayMs?: number; + /** Additional request headers merged with auth and lifecycle headers. */ + headers?: Record; + /** Provider metadata forwarded with requests. */ + metadata?: SimpleStreamOptions["metadata"]; + /** Provider cache retention hint. */ + cacheRetention?: SimpleStreamOptions["cacheRetention"]; +} + +/** Per-request stream option patch returned by provider hooks. */ +export interface AgentHarnessStreamOptionsPatch + extends Omit, "headers" | "metadata"> { + /** Header patch. `undefined` values delete keys; explicit `headers: undefined` clears all headers. */ + headers?: Record; + /** Metadata patch. `undefined` values delete keys; explicit `metadata: undefined` clears all metadata. */ + metadata?: Record; +} + +/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */ +export type FileKind = "file" | "directory" | "symlink"; + +/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */ +export type FileErrorCode = + | "aborted" + | "not_found" + | "permission_denied" + | "not_directory" + | "is_directory" + | "invalid" + | "not_supported" + | "unknown"; + +/** Error returned by {@link FileSystem} file operations. */ +export class FileError extends Error { + /** Backend-independent error code. */ + public code: FileErrorCode; + /** Absolute addressed path associated with the failure, when available. */ + public path?: string; + + constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "FileError"; + this.code = code; + this.path = path; + } +} + +/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */ +export type ExecutionErrorCode = + | "aborted" + | "timeout" + | "shell_unavailable" + | "spawn_error" + | "callback_error" + | "unknown"; + +/** Error returned by {@link ExecutionEnv.exec}. */ +export class ExecutionError extends Error { + /** Backend-independent error code. */ + public code: ExecutionErrorCode; + + constructor(code: ExecutionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "ExecutionError"; + this.code = code; + } +} + +/** Stable compaction error codes returned by compaction helpers. */ +export type CompactionErrorCode = "aborted" | "summarization_failed" | "invalid_session" | "unknown"; + +/** Error returned by compaction helpers. */ +export class CompactionError extends Error { + /** Backend-independent error code. */ + public code: CompactionErrorCode; + + constructor(code: CompactionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "CompactionError"; + this.code = code; + } +} + +/** Stable branch-summary error codes returned by branch summarization helpers. */ +export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session"; + +/** Error returned by branch summarization helpers. */ +export class BranchSummaryError extends Error { + /** Backend-independent error code. */ + public code: BranchSummaryErrorCode; + + constructor(code: BranchSummaryErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "BranchSummaryError"; + this.code = code; + } +} + +export type SessionErrorCode = + | "not_found" + | "invalid_session" + | "invalid_entry" + | "invalid_fork_target" + | "storage" + | "unknown"; + +/** Error thrown by session storage, repositories, and session tree operations. */ +export class SessionError extends Error { + /** Session subsystem error code. */ + public code: SessionErrorCode; + + constructor(code: SessionErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "SessionError"; + this.code = code; + } +} + +export type AgentHarnessErrorCode = + | "busy" + | "invalid_state" + | "invalid_argument" + | "session" + | "hook" + | "auth" + | "compaction" + | "branch_summary" + | "unknown"; + +/** Public AgentHarness failure with a stable top-level classification. */ +export class AgentHarnessError extends Error { + public code: AgentHarnessErrorCode; + + constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "AgentHarnessError"; + this.code = code; + } +} + +/** Metadata for one filesystem object in a {@link FileSystem}. */ +export interface FileInfo { + /** Basename of {@link path}. */ + name: string; + /** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */ + path: string; + /** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */ + kind: FileKind; + /** Size in bytes for the addressed filesystem object. */ + size: number; + /** Modification time as milliseconds since Unix epoch. */ + mtimeMs: number; +} + +/** + * Filesystem capability used by the harness. + * + * Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths + * in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}. + * + * Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be + * encoded in the returned {@link Result}. Implementations must preserve this invariant. + */ +export interface FileSystem { + /** Current working directory for relative paths. */ + cwd: string; + + /** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */ + absolutePath(path: string, abortSignal?: AbortSignal): Promise>; + /** Join path segments in the filesystem namespace without requiring the result to exist. */ + joinPath(parts: string[], abortSignal?: AbortSignal): Promise>; + /** Read a UTF-8 text file. */ + readTextFile(path: string, abortSignal?: AbortSignal): Promise>; + /** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */ + readTextLines( + path: string, + options?: { maxLines?: number; abortSignal?: AbortSignal }, + ): Promise>; + /** Read a binary file. */ + readBinaryFile(path: string, abortSignal?: AbortSignal): Promise>; + /** Create or overwrite a file, creating parent directories when supported. */ + writeFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; + /** Create or append to a file, creating parent directories when supported. */ + appendFile(path: string, content: string | Uint8Array, abortSignal?: AbortSignal): Promise>; + /** Return metadata for the addressed path without following symlinks. */ + fileInfo(path: string, abortSignal?: AbortSignal): Promise>; + /** List direct children of a directory without following symlinks. */ + listDir(path: string, abortSignal?: AbortSignal): Promise>; + /** Return the canonical path for an existing path, resolving symlinks where supported. */ + canonicalPath(path: string, abortSignal?: AbortSignal): Promise>; + /** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */ + exists(path: string, abortSignal?: AbortSignal): Promise>; + /** Create a directory. Defaults: `recursive: true`, no abort signal. */ + createDir( + path: string, + options?: { recursive?: boolean; abortSignal?: AbortSignal }, + ): Promise>; + /** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */ + remove( + path: string, + options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal }, + ): Promise>; + /** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */ + createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise>; + /** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */ + createTempFile(options?: { + prefix?: string; + suffix?: string; + abortSignal?: AbortSignal; + }): Promise>; + + /** Release filesystem resources. Must be best-effort and must not throw or reject. */ + cleanup(): Promise; +} + +/** Options for {@link Shell.exec}. */ +export interface ShellExecOptions { + /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */ + cwd?: string; + /** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */ + env?: Record; + /** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */ + timeout?: number; + /** Abort signal used to terminate the command. Defaults to no abort signal. */ + abortSignal?: AbortSignal; + /** Called with stdout chunks as they are produced. */ + onStdout?: (chunk: string) => void; + /** Called with stderr chunks as they are produced. */ + onStderr?: (chunk: string) => void; +} + +/** Shell execution capability used by the harness. */ +export interface Shell { + /** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */ + exec( + command: string, + options?: ShellExecOptions, + ): Promise>; + /** Release shell resources. Must be best-effort and must not throw or reject. */ + cleanup(): Promise; +} + +/** Filesystem and process execution environment used by the harness. */ +export interface ExecutionEnv extends FileSystem, Shell {} + +export interface SessionTreeEntryBase { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} + +export interface MessageEntry extends SessionTreeEntryBase { + type: "message"; + message: AgentMessage; +} + +export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase { + type: "thinking_level_change"; + thinkingLevel: string; +} + +export interface ModelChangeEntry extends SessionTreeEntryBase { + type: "model_change"; + provider: string; + modelId: string; +} + +export interface ActiveToolsChangeEntry extends SessionTreeEntryBase { + type: "active_tools_change"; + activeToolNames: string[]; +} + +export interface CompactionEntry extends SessionTreeEntryBase { + type: "compaction"; + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: T; + fromHook?: boolean; +} + +export interface BranchSummaryEntry extends SessionTreeEntryBase { + type: "branch_summary"; + fromId: string; + summary: string; + details?: T; + fromHook?: boolean; +} + +export interface CustomEntry extends SessionTreeEntryBase { + type: "custom"; + customType: string; + data?: T; +} + +export interface CustomMessageEntry extends SessionTreeEntryBase { + type: "custom_message"; + customType: string; + content: string | (TextContent | ImageContent)[]; + details?: T; + display: boolean; +} + +export interface LabelEntry extends SessionTreeEntryBase { + type: "label"; + targetId: string; + label: string | undefined; +} + +export interface SessionInfoEntry extends SessionTreeEntryBase { + type: "session_info"; // legacy name, kept for backwards compatibility + name?: string; +} + +export interface LeafEntry extends SessionTreeEntryBase { + type: "leaf"; + targetId: string | null; +} + +export type SessionTreeEntry = + | MessageEntry + | ThinkingLevelChangeEntry + | ModelChangeEntry + | ActiveToolsChangeEntry + | CompactionEntry + | BranchSummaryEntry + | CustomEntry + | CustomMessageEntry + | LabelEntry + | SessionInfoEntry + | LeafEntry; + +export interface SessionContext { + messages: AgentMessage[]; + thinkingLevel: string; + model: { provider: string; modelId: string } | null; + activeToolNames: string[] | null; +} + +export interface SessionMetadata { + id: string; + createdAt: string; +} + +export interface JsonlSessionMetadata extends SessionMetadata { + cwd: string; + path: string; + parentSessionPath?: string; +} + +export interface SessionStorage { + getMetadata(): Promise; + getLeafId(): Promise; + /** Persist a leaf entry that records the active session-tree leaf. */ + setLeafId(leafId: string | null): Promise; + createEntryId(): Promise; + appendEntry(entry: SessionTreeEntry): Promise; + getEntry(id: string): Promise; + findEntries( + type: TType, + ): Promise>>; + getLabel(id: string): Promise; + getPathToRoot(leafId: string | null): Promise; + getEntries(): Promise; +} + +export type { Session } from "./session/session.ts"; + +export interface SessionCreateOptions { + id?: string; +} + +export interface SessionForkOptions { + entryId?: string; + position?: "before" | "at"; + id?: string; +} + +export interface SessionRepo< + TMetadata extends SessionMetadata = SessionMetadata, + TCreateOptions extends SessionCreateOptions = SessionCreateOptions, + TListOptions = void, +> { + create(options: TCreateOptions): Promise>; + open(metadata: TMetadata): Promise>; + list(options?: TListOptions): Promise; + delete(metadata: TMetadata): Promise; + fork(source: TMetadata, options: SessionForkOptions & TCreateOptions): Promise>; +} + +export interface JsonlSessionCreateOptions extends SessionCreateOptions { + cwd: string; + parentSessionPath?: string; +} + +export interface JsonlSessionListOptions { + cwd?: string; +} + +export interface JsonlSessionRepoApi + extends SessionRepo {} + +export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry"; + +export type PendingSessionWrite = SessionTreeEntry extends infer TEntry + ? TEntry extends SessionTreeEntry + ? Omit + : never + : never; + +export interface QueueUpdateEvent { + type: "queue_update"; + steer: AgentMessage[]; + followUp: AgentMessage[]; + nextTurn: AgentMessage[]; +} + +export interface SavePointEvent { + type: "save_point"; + hadPendingMutations: boolean; +} + +export interface AbortEvent { + type: "abort"; + clearedSteer: AgentMessage[]; + clearedFollowUp: AgentMessage[]; +} + +export interface SettledEvent { + type: "settled"; + nextTurnCount: number; +} + +export interface BeforeAgentStartEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + type: "before_agent_start"; + prompt: string; + images?: ImageContent[]; + systemPrompt: string; + resources: AgentHarnessResources; +} + +export interface ContextEvent { + type: "context"; + messages: AgentMessage[]; +} + +export interface BeforeProviderRequestEvent { + type: "before_provider_request"; + model: Model; + sessionId: string; + streamOptions: AgentHarnessStreamOptions; +} + +export interface BeforeProviderPayloadEvent { + type: "before_provider_payload"; + model: Model; + payload: unknown; +} + +export interface AfterProviderResponseEvent { + type: "after_provider_response"; + status: number; + headers: Record; +} + +export interface ToolCallEvent { + type: "tool_call"; + toolCallId: string; + toolName: string; + input: Record; +} + +export interface ToolResultEvent { + type: "tool_result"; + toolCallId: string; + toolName: string; + input: Record; + content: Array; + details: unknown; + isError: boolean; +} + +export interface SessionBeforeCompactEvent { + type: "session_before_compact"; + preparation: CompactionPreparation; + branchEntries: SessionTreeEntry[]; + customInstructions?: string; + signal: AbortSignal; +} + +export interface SessionCompactEvent { + type: "session_compact"; + compactionEntry: CompactionEntry; + fromHook: boolean; +} + +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + preparation: TreePreparation; + signal: AbortSignal; +} + +export interface SessionTreeEvent { + type: "session_tree"; + newLeafId: string | null; + oldLeafId: string | null; + summaryEntry?: BranchSummaryEntry; + fromHook?: boolean; +} + +export interface ModelUpdateEvent { + type: "model_update"; + model: Model; + previousModel: Model | undefined; + source: "set" | "restore"; +} + +export interface ThinkingLevelUpdateEvent { + type: "thinking_level_update"; + level: ThinkingLevel; + previousLevel: ThinkingLevel; +} + +export interface ToolsUpdateEvent { + type: "tools_update"; + toolNames: string[]; + previousToolNames: string[]; + activeToolNames: string[]; + previousActiveToolNames: string[]; + source: "set" | "restore"; +} + +export interface ResourcesUpdateEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> { + type: "resources_update"; + resources: AgentHarnessResources; + previousResources: AgentHarnessResources; +} + +export type AgentHarnessOwnEvent< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, +> = + | QueueUpdateEvent + | SavePointEvent + | AbortEvent + | SettledEvent + | BeforeAgentStartEvent + | ContextEvent + | BeforeProviderRequestEvent + | BeforeProviderPayloadEvent + | AfterProviderResponseEvent + | ToolCallEvent + | ToolResultEvent + | SessionBeforeCompactEvent + | SessionCompactEvent + | SessionBeforeTreeEvent + | SessionTreeEvent + | ModelUpdateEvent + | ThinkingLevelUpdateEvent + | ResourcesUpdateEvent + | ToolsUpdateEvent; + +export type AgentHarnessEvent = + | AgentEvent + | AgentHarnessOwnEvent; + +export interface BeforeAgentStartResult { + messages?: AgentMessage[]; + systemPrompt?: string; +} + +export interface ContextResult { + messages: AgentMessage[]; +} + +export interface BeforeProviderRequestResult { + streamOptions?: AgentHarnessStreamOptionsPatch; +} + +export interface BeforeProviderPayloadResult { + payload: unknown; +} + +export interface ToolCallResult { + block?: boolean; + reason?: string; +} + +export interface ToolResultPatch { + content?: Array; + details?: unknown; + isError?: boolean; + terminate?: boolean; +} + +export interface SessionBeforeCompactResult { + cancel?: boolean; + compaction?: CompactResult; +} + +export interface SessionBeforeTreeResult { + cancel?: boolean; + summary?: { summary: string; details?: unknown }; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; +} + +export type AgentHarnessEventResultMap = { + before_agent_start: BeforeAgentStartResult | undefined; + context: ContextResult | undefined; + before_provider_request: BeforeProviderRequestResult | undefined; + before_provider_payload: BeforeProviderPayloadResult | undefined; + after_provider_response: undefined; + tool_call: ToolCallResult | undefined; + tool_result: ToolResultPatch | undefined; + session_before_compact: SessionBeforeCompactResult | undefined; + session_compact: undefined; + session_before_tree: SessionBeforeTreeResult | undefined; + session_tree: undefined; + model_update: undefined; + thinking_level_update: undefined; + resources_update: undefined; + tools_update: undefined; + queue_update: undefined; + save_point: undefined; + abort: undefined; + settled: undefined; +}; + +export interface AgentHarnessPromptOptions { + images?: ImageContent[]; +} + +export interface AbortResult { + clearedSteer: AgentMessage[]; + clearedFollowUp: AgentMessage[]; +} + +export interface CompactResult { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + details?: unknown; +} + +export interface NavigateTreeResult { + cancelled: boolean; + editorText?: string; + summaryEntry?: BranchSummaryEntry; +} + +export interface CompactionSettings { + enabled: boolean; + reserveTokens: number; + keepRecentTokens: number; +} + +export interface CompactionPreparation { + firstKeptEntryId: string; + messagesToSummarize: AgentMessage[]; + turnPrefixMessages: AgentMessage[]; + isSplitTurn: boolean; + tokensBefore: number; + previousSummary?: string; + fileOps: FileOperations; + settings: CompactionSettings; +} + +export interface FileOperations { + read: Set; + written: Set; + edited: Set; +} + +export interface TreePreparation { + targetId: string; + oldLeafId: string | null; + commonAncestorId: string | null; + entriesToSummarize: SessionTreeEntry[]; + userWantsSummary: boolean; + customInstructions?: string; + replaceInstructions?: boolean; + label?: string; +} + +export interface GenerateBranchSummaryOptions { + model: Model; + apiKey: string; + headers?: Record; + signal: AbortSignal; + customInstructions?: string; + replaceInstructions?: boolean; + reserveTokens?: number; +} + +export interface BranchSummaryResult { + summary: string; + readFiles: string[]; + modifiedFiles: string[]; +} + +export interface AgentHarnessOptions< + TSkill extends Skill = Skill, + TPromptTemplate extends PromptTemplate = PromptTemplate, + TTool extends AgentTool = AgentTool, +> { + env: ExecutionEnv; + session: Session; + /** + * Provider collection used for all model requests (turn streaming, + * compaction, branch summarization). Auth resolves through the providers' + * auth. + */ + models: Models; + tools?: TTool[]; + /** + * Concrete resources available to explicit invocation methods and system-prompt callbacks. + * Applications own loading/reloading resources and should call `setResources()` with new values. + */ + resources?: AgentHarnessResources; + systemPrompt?: + | string + | ((context: { + env: ExecutionEnv; + session: Session; + model: Model; + thinkingLevel: ThinkingLevel; + activeTools: TTool[]; + resources: AgentHarnessResources; + }) => string | Promise); + /** Curated stream/provider request options. Snapshotted at turn start. */ + streamOptions?: AgentHarnessStreamOptions; + model: Model; + thinkingLevel?: ThinkingLevel; + activeToolNames?: string[]; + steeringMode?: QueueMode; + followUpMode?: QueueMode; +} + +export type { AgentHarness } from "./agent-harness.ts"; diff --git a/cactus-code/packages/agent/src/harness/utils/shell-output.ts b/cactus-code/packages/agent/src/harness/utils/shell-output.ts new file mode 100644 index 000000000..a98b51a5f --- /dev/null +++ b/cactus-code/packages/agent/src/harness/utils/shell-output.ts @@ -0,0 +1,135 @@ +import { type ExecutionEnv, ExecutionError, err, ok, type Result, type ShellExecOptions, toError } from "../types.ts"; +import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.ts"; + +export interface ShellCaptureOptions extends Omit { + onChunk?: (chunk: string) => void; +} + +export interface ShellCaptureResult { + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; +} + +function toExecutionError(error: unknown): ExecutionError { + if (error instanceof ExecutionError) return error; + const cause = toError(error); + return new ExecutionError("unknown", cause.message, cause); +} + +export function sanitizeBinaryOutput(str: string): string { + return Array.from(str) + .filter((char) => { + const code = char.codePointAt(0); + if (code === undefined) return false; + if (code === 0x09 || code === 0x0a || code === 0x0d) return true; + if (code <= 0x1f) return false; + if (code >= 0xfff9 && code <= 0xfffb) return false; + return true; + }) + .join(""); +} + +export async function executeShellWithCapture( + env: ExecutionEnv, + command: string, + options?: ShellCaptureOptions, +): Promise> { + const outputChunks: string[] = []; + let outputBytes = 0; + const maxOutputBytes = DEFAULT_MAX_BYTES * 2; + const encoder = new TextEncoder(); + + let totalBytes = 0; + let fullOutputPath: string | undefined; + let writeChain: Promise> = Promise.resolve(ok(undefined)); + let captureError: ExecutionError | undefined; + + const appendFullOutput = (text: string): void => { + if (!fullOutputPath || captureError) return; + const path = fullOutputPath; + writeChain = writeChain.then(async (previous) => { + if (!previous.ok) return previous; + const appendResult = await env.appendFile(path, text, options?.abortSignal); + return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error)); + }); + }; + + const ensureFullOutputFile = (initialContent: string): void => { + if (fullOutputPath || captureError) return; + writeChain = writeChain.then(async (previous) => { + if (!previous.ok) return previous; + const tempFile = await env.createTempFile({ + prefix: "bash-", + suffix: ".log", + abortSignal: options?.abortSignal, + }); + if (!tempFile.ok) return err(toExecutionError(tempFile.error)); + fullOutputPath = tempFile.value; + const appendResult = await env.appendFile(tempFile.value, initialContent, options?.abortSignal); + return appendResult.ok ? ok(undefined) : err(toExecutionError(appendResult.error)); + }); + }; + + const onChunk = (chunk: string) => { + try { + totalBytes += encoder.encode(chunk).byteLength; + const text = sanitizeBinaryOutput(chunk).replace(/\r/g, ""); + if (totalBytes > DEFAULT_MAX_BYTES && !fullOutputPath) { + ensureFullOutputFile(outputChunks.join("") + text); + } else { + appendFullOutput(text); + } + outputChunks.push(text); + outputBytes += text.length; + while (outputBytes > maxOutputBytes && outputChunks.length > 1) { + const removed = outputChunks.shift()!; + outputBytes -= removed.length; + } + options?.onChunk?.(text); + } catch (error) { + captureError = toExecutionError(error); + } + }; + + try { + const result = await env.exec(command, { + ...(options ?? {}), + onStdout: onChunk, + onStderr: onChunk, + }); + const tailOutput = outputChunks.join(""); + const truncationResult = truncateTail(tailOutput); + if (truncationResult.truncated && !fullOutputPath) { + ensureFullOutputFile(tailOutput); + } + const writeResult = await writeChain; + if (!writeResult.ok) return err(writeResult.error); + if (captureError) return err(captureError); + + if (!result.ok) { + if (result.error.code === "aborted" || options?.abortSignal?.aborted) { + return ok({ + output: truncationResult.truncated ? truncationResult.content : tailOutput, + exitCode: undefined, + cancelled: true, + truncated: truncationResult.truncated, + fullOutputPath, + }); + } + return err(result.error); + } + const cancelled = options?.abortSignal?.aborted ?? false; + return ok({ + output: truncationResult.truncated ? truncationResult.content : tailOutput, + exitCode: cancelled ? undefined : result.value.exitCode, + cancelled, + truncated: truncationResult.truncated, + fullOutputPath, + }); + } catch (error) { + return err(toExecutionError(error)); + } +} diff --git a/cactus-code/packages/agent/src/harness/utils/truncate.ts b/cactus-code/packages/agent/src/harness/utils/truncate.ts new file mode 100644 index 000000000..2372a941b --- /dev/null +++ b/cactus-code/packages/agent/src/harness/utils/truncate.ts @@ -0,0 +1,344 @@ +/** + * Shared truncation utilities for tool outputs. + * + * Truncation is based on two independent limits - whichever is hit first wins: + * - Line limit (default: 2000 lines) + * - Byte limit (default: 50KB) + * + * Never returns partial lines (except bash tail truncation edge case). + */ + +export const DEFAULT_MAX_LINES = 2000; +export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB +export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line + +export interface TruncationResult { + /** The truncated content */ + content: string; + /** Whether truncation occurred */ + truncated: boolean; + /** Which limit was hit: "lines", "bytes", or null if not truncated */ + truncatedBy: "lines" | "bytes" | null; + /** Total number of lines in the original content */ + totalLines: number; + /** Total number of bytes in the original content */ + totalBytes: number; + /** Number of complete lines in the truncated output */ + outputLines: number; + /** Number of bytes in the truncated output */ + outputBytes: number; + /** Whether the last line was partially truncated (only for tail truncation edge case) */ + lastLinePartial: boolean; + /** Whether the first line exceeded the byte limit (for head truncation) */ + firstLineExceedsLimit: boolean; + /** The max lines limit that was applied */ + maxLines: number; + /** The max bytes limit that was applied */ + maxBytes: number; +} + +export interface TruncationOptions { + /** Maximum number of lines (default: 2000) */ + maxLines?: number; + /** Maximum number of bytes (default: 50KB) */ + maxBytes?: number; +} + +interface RuntimeBuffer { + byteLength(content: string, encoding: "utf8"): number; +} + +const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer; +const nonAsciiPattern = /[^\x00-\x7f]/; + +function utf8ByteLength(content: string): number { + if (runtimeBuffer) return runtimeBuffer.byteLength(content, "utf8"); + + const firstNonAscii = content.search(nonAsciiPattern); + if (firstNonAscii === -1) return content.length; + + let bytes = firstNonAscii; + for (let i = firstNonAscii; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + i++; + } else { + bytes += 3; + } + } else { + bytes += 3; + } + } + return bytes; +} + +function replaceUnpairedSurrogates(content: string): string { + let output = ""; + for (let i = 0; i < content.length; i++) { + const code = content.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff) { + if (i + 1 < content.length) { + const next = content.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + output += content[i] + content[i + 1]; + i++; + continue; + } + } + output += "�"; + } else if (code >= 0xdc00 && code <= 0xdfff) { + output += "�"; + } else { + output += content[i]; + } + } + return output; +} + +/** + * Format bytes as human-readable size. + */ +export function formatSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes}B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB`; + } else { + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + } +} + +/** + * Truncate content from the head (keep first N lines/bytes). + * Suitable for file reads where you want to see the beginning. + * + * Never returns partial lines. If first line exceeds byte limit, + * returns empty content with firstLineExceedsLimit=true. + */ +export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = utf8ByteLength(content); + const lines = content.split("\n"); + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Check if first line alone exceeds byte limit + const firstLineBytes = utf8ByteLength(lines[0]); + if (firstLineBytes > maxBytes) { + return { + content: "", + truncated: true, + truncatedBy: "bytes", + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + maxLines, + maxBytes, + }; + } + + // Collect complete lines that fit + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + + for (let i = 0; i < lines.length && i < maxLines; i++) { + const line = lines[i]; + const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + break; + } + + outputLinesArr.push(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = utf8ByteLength(outputContent); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate content from the tail (keep last N lines/bytes). + * Suitable for bash output where you want to see the end (errors, final results). + * + * May return partial first line if the last line of original content exceeds byte limit. + */ +export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = utf8ByteLength(content); + const lines = content.split("\n"); + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Work backwards from the end + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + let lastLinePartial = false; + + for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { + const line = lines[i]; + const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + // Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes, + // take the end of the line (partial) + if (outputLinesArr.length === 0) { + const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); + outputLinesArr.unshift(truncatedLine); + outputBytesCount = utf8ByteLength(truncatedLine); + lastLinePartial = true; + } + break; + } + + outputLinesArr.unshift(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = utf8ByteLength(outputContent); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate a string to fit within a byte limit (from the end). + * Handles multi-byte UTF-8 characters correctly. + */ +function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { + if (maxBytes <= 0) return ""; + + let outputBytes = 0; + let start = str.length; + let needsReplacement = false; + for (let i = str.length; i > 0; ) { + let characterStart = i - 1; + const code = str.charCodeAt(characterStart); + let characterBytes: number; + let unpairedSurrogate = false; + if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) { + const previous = str.charCodeAt(characterStart - 1); + if (previous >= 0xd800 && previous <= 0xdbff) { + characterStart--; + characterBytes = 4; + } else { + characterBytes = 3; + unpairedSurrogate = true; + } + } else if (code >= 0xd800 && code <= 0xdfff) { + characterBytes = 3; + unpairedSurrogate = true; + } else { + characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3; + } + if (outputBytes + characterBytes > maxBytes) break; + outputBytes += characterBytes; + start = characterStart; + needsReplacement ||= unpairedSurrogate; + i = characterStart; + } + + const output = str.slice(start); + return needsReplacement ? replaceUnpairedSurrogates(output) : output; +} + +/** + * Truncate a single line to max characters, adding [truncated] suffix. + * Used for grep match lines. + */ +export function truncateLine( + line: string, + maxChars: number = GREP_MAX_LINE_LENGTH, +): { text: string; wasTruncated: boolean } { + if (line.length <= maxChars) { + return { text: line, wasTruncated: false }; + } + return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true }; +} diff --git a/cactus-code/packages/agent/src/index.ts b/cactus-code/packages/agent/src/index.ts new file mode 100644 index 000000000..fd3c2b9fc --- /dev/null +++ b/cactus-code/packages/agent/src/index.ts @@ -0,0 +1,44 @@ +// Core Agent +export * from "./agent.ts"; +// Loop functions +export * from "./agent-loop.ts"; +export * from "./harness/agent-harness.ts"; +export { + type BranchPreparation, + type BranchSummaryDetails, + type CollectEntriesResult, + collectEntriesForBranchSummary, + generateBranchSummary, + prepareBranchEntries, +} from "./harness/compaction/branch-summarization.ts"; +export { + calculateContextTokens, + compact, + DEFAULT_COMPACTION_SETTINGS, + estimateContextTokens, + estimateTokens, + findCutPoint, + findTurnStartIndex, + generateSummary, + getLastAssistantUsage, + prepareCompaction, + serializeConversation, + shouldCompact, +} from "./harness/compaction/compaction.ts"; +export * from "./harness/messages.ts"; +export * from "./harness/prompt-templates.ts"; +export * from "./harness/session/jsonl-repo.ts"; +export * from "./harness/session/memory-repo.ts"; +export * from "./harness/session/repo-utils.ts"; +export * from "./harness/session/session.ts"; +export { uuidv7 } from "./harness/session/uuid.ts"; +export * from "./harness/skills.ts"; +export * from "./harness/system-prompt.ts"; +// Harness +export * from "./harness/types.ts"; +export * from "./harness/utils/shell-output.ts"; +export * from "./harness/utils/truncate.ts"; +// Proxy utilities +export * from "./proxy.ts"; +// Types +export * from "./types.ts"; diff --git a/cactus-code/packages/agent/src/node.ts b/cactus-code/packages/agent/src/node.ts new file mode 100644 index 000000000..b6f53a5e0 --- /dev/null +++ b/cactus-code/packages/agent/src/node.ts @@ -0,0 +1,2 @@ +export { NodeExecutionEnv } from "./harness/env/nodejs.ts"; +export * from "./index.ts"; diff --git a/cactus-code/packages/agent/src/proxy.ts b/cactus-code/packages/agent/src/proxy.ts new file mode 100644 index 000000000..5f0925c91 --- /dev/null +++ b/cactus-code/packages/agent/src/proxy.ts @@ -0,0 +1,367 @@ +/** + * Proxy stream function for apps that route LLM calls through a server. + * The server manages auth and proxies requests to LLM providers. + */ + +// Internal import for JSON parsing utility +import { + type AssistantMessage, + type AssistantMessageEvent, + type Context, + EventStream, + type Model, + parseStreamingJson, + type SimpleStreamOptions, + type StopReason, + type ToolCall, +} from "@earendil-works/pi-ai"; + +// Create stream class matching ProxyMessageEventStream +class ProxyMessageEventStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") return event.message; + if (event.type === "error") return event.error; + throw new Error("Unexpected event type"); + }, + ); + } +} + +/** + * Proxy event types - server sends these with partial field stripped to reduce bandwidth. + */ +export type ProxyAssistantMessageEvent = + | { type: "start" } + | { type: "text_start"; contentIndex: number } + | { type: "text_delta"; contentIndex: number; delta: string } + | { type: "text_end"; contentIndex: number; contentSignature?: string } + | { type: "thinking_start"; contentIndex: number } + | { type: "thinking_delta"; contentIndex: number; delta: string } + | { type: "thinking_end"; contentIndex: number; contentSignature?: string } + | { type: "toolcall_start"; contentIndex: number; id: string; toolName: string } + | { type: "toolcall_delta"; contentIndex: number; delta: string } + | { type: "toolcall_end"; contentIndex: number } + | { + type: "done"; + reason: Extract; + usage: AssistantMessage["usage"]; + } + | { + type: "error"; + reason: Extract; + errorMessage?: string; + usage: AssistantMessage["usage"]; + }; + +type ProxySerializableStreamOptions = Pick< + SimpleStreamOptions, + | "temperature" + | "maxTokens" + | "reasoning" + | "cacheRetention" + | "sessionId" + | "headers" + | "metadata" + | "transport" + | "thinkingBudgets" + | "maxRetryDelayMs" +>; + +export interface ProxyStreamOptions extends ProxySerializableStreamOptions { + /** Local abort signal for the proxy request */ + signal?: AbortSignal; + /** Auth token for the proxy server */ + authToken: string; + /** Proxy server URL (e.g., "https://genai.example.com") */ + proxyUrl: string; +} + +/** + * Stream function that proxies through a server instead of calling LLM providers directly. + * The server strips the partial field from delta events to reduce bandwidth. + * We reconstruct the partial message client-side. + * + * Use this as the `streamFn` option when creating an Agent that needs to go through a proxy. + * + * @example + * ```typescript + * const agent = new Agent({ + * streamFn: (model, context, options) => + * streamProxy(model, context, { + * ...options, + * authToken: await getAuthToken(), + * proxyUrl: "https://genai.example.com", + * }), + * }); + * ``` + */ +function buildProxyRequestOptions(options: ProxyStreamOptions): ProxySerializableStreamOptions { + return { + temperature: options.temperature, + maxTokens: options.maxTokens, + reasoning: options.reasoning, + cacheRetention: options.cacheRetention, + sessionId: options.sessionId, + headers: options.headers, + metadata: options.metadata, + transport: options.transport, + thinkingBudgets: options.thinkingBudgets, + maxRetryDelayMs: options.maxRetryDelayMs, + }; +} + +export function streamProxy(model: Model, context: Context, options: ProxyStreamOptions): ProxyMessageEventStream { + const stream = new ProxyMessageEventStream(); + + (async () => { + // Initialize the partial message that we'll build up from events + const partial: AssistantMessage = { + role: "assistant", + stopReason: "stop", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }; + + let reader: ReadableStreamDefaultReader | undefined; + + const abortHandler = () => { + if (reader) { + reader.cancel("Request aborted by user").catch(() => {}); + } + }; + + if (options.signal) { + options.signal.addEventListener("abort", abortHandler); + } + + try { + const response = await fetch(`${options.proxyUrl}/api/stream`, { + method: "POST", + headers: { + Authorization: `Bearer ${options.authToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + context, + options: buildProxyRequestOptions(options), + }), + signal: options.signal, + }); + + if (!response.ok) { + let errorMessage = `Proxy error: ${response.status} ${response.statusText}`; + try { + const errorData = (await response.json()) as { error?: string }; + if (errorData.error) { + errorMessage = `Proxy error: ${errorData.error}`; + } + } catch { + // Couldn't parse error response + } + throw new Error(errorMessage); + } + + reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + if (options.signal?.aborted) { + throw new Error("Request aborted by user"); + } + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim(); + if (data) { + const proxyEvent = JSON.parse(data) as ProxyAssistantMessageEvent; + const event = processProxyEvent(proxyEvent, partial); + if (event) { + stream.push(event); + } + } + } + } + } + + if (options.signal?.aborted) { + throw new Error("Request aborted by user"); + } + + stream.end(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const reason = options.signal?.aborted ? "aborted" : "error"; + partial.stopReason = reason; + partial.errorMessage = errorMessage; + stream.push({ + type: "error", + reason, + error: partial, + }); + stream.end(); + } finally { + if (options.signal) { + options.signal.removeEventListener("abort", abortHandler); + } + } + })(); + + return stream; +} + +/** + * Process a proxy event and update the partial message. + */ +function processProxyEvent( + proxyEvent: ProxyAssistantMessageEvent, + partial: AssistantMessage, +): AssistantMessageEvent | undefined { + switch (proxyEvent.type) { + case "start": + return { type: "start", partial }; + + case "text_start": + partial.content[proxyEvent.contentIndex] = { type: "text", text: "" }; + return { type: "text_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "text_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "text") { + content.text += proxyEvent.delta; + return { + type: "text_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received text_delta for non-text content"); + } + + case "text_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "text") { + content.textSignature = proxyEvent.contentSignature; + return { + type: "text_end", + contentIndex: proxyEvent.contentIndex, + content: content.text, + partial, + }; + } + throw new Error("Received text_end for non-text content"); + } + + case "thinking_start": + partial.content[proxyEvent.contentIndex] = { type: "thinking", thinking: "" }; + return { type: "thinking_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "thinking_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "thinking") { + content.thinking += proxyEvent.delta; + return { + type: "thinking_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received thinking_delta for non-thinking content"); + } + + case "thinking_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "thinking") { + content.thinkingSignature = proxyEvent.contentSignature; + return { + type: "thinking_end", + contentIndex: proxyEvent.contentIndex, + content: content.thinking, + partial, + }; + } + throw new Error("Received thinking_end for non-thinking content"); + } + + case "toolcall_start": + partial.content[proxyEvent.contentIndex] = { + type: "toolCall", + id: proxyEvent.id, + name: proxyEvent.toolName, + arguments: {}, + partialJson: "", + } satisfies ToolCall & { partialJson: string } as ToolCall; + return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial }; + + case "toolcall_delta": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "toolCall") { + (content as any).partialJson += proxyEvent.delta; + content.arguments = parseStreamingJson((content as any).partialJson) || {}; + partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity + return { + type: "toolcall_delta", + contentIndex: proxyEvent.contentIndex, + delta: proxyEvent.delta, + partial, + }; + } + throw new Error("Received toolcall_delta for non-toolCall content"); + } + + case "toolcall_end": { + const content = partial.content[proxyEvent.contentIndex]; + if (content?.type === "toolCall") { + delete (content as any).partialJson; + return { + type: "toolcall_end", + contentIndex: proxyEvent.contentIndex, + toolCall: content, + partial, + }; + } + return undefined; + } + + case "done": + partial.stopReason = proxyEvent.reason; + partial.usage = proxyEvent.usage; + return { type: "done", reason: proxyEvent.reason, message: partial }; + + case "error": + partial.stopReason = proxyEvent.reason; + partial.errorMessage = proxyEvent.errorMessage; + partial.usage = proxyEvent.usage; + return { type: "error", reason: proxyEvent.reason, error: partial }; + + default: { + const _exhaustiveCheck: never = proxyEvent; + console.warn(`Unhandled proxy event type: ${(proxyEvent as any).type}`); + return undefined; + } + } +} diff --git a/cactus-code/packages/agent/src/types.ts b/cactus-code/packages/agent/src/types.ts new file mode 100644 index 000000000..abfa3de6f --- /dev/null +++ b/cactus-code/packages/agent/src/types.ts @@ -0,0 +1,428 @@ +import type { + Api, + AssistantMessage, + AssistantMessageEvent, + AssistantMessageEventStream, + Context, + ImageContent, + Message, + Model, + SimpleStreamOptions, + TextContent, + Tool, + ToolResultMessage, +} from "@earendil-works/pi-ai"; +import type { Static, TSchema } from "typebox"; + +/** + * Stream function used by the agent loop. `Models.streamSimple` satisfies + * this shape. + * + * Contract: + * - Must not throw or return a rejected promise for request/model/runtime failures. + * - Must return an AssistantMessageEventStream. + * - Failures must be encoded in the returned stream via protocol events and a + * final AssistantMessage with stopReason "error" or "aborted" and errorMessage. + */ +export type StreamFn = ( + model: Model, + context: Context, + options?: SimpleStreamOptions, +) => AssistantMessageEventStream | Promise; + +/** + * Configuration for how tool calls from a single assistant message are executed. + * + * - "sequential": each tool call is prepared, executed, and finalized before the next one starts. + * - "parallel": tool calls are prepared sequentially, then allowed tools execute concurrently. + * `tool_execution_end` is emitted in tool completion order after each tool is finalized, + * while tool-result message artifacts are emitted later in assistant source order. + */ +export type ToolExecutionMode = "sequential" | "parallel"; + +/** + * Controls how many queued user messages are injected when the agent loop reaches a queue drain point. + * + * - "all": drain and inject every queued message at that point. + * - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points. + */ +export type QueueMode = "all" | "one-at-a-time"; + +/** A single tool call content block emitted by an assistant message. */ +export type AgentToolCall = Extract; + +/** + * Result returned from `beforeToolCall`. + * + * Returning `{ block: true }` prevents the tool from executing. The loop emits an error tool result instead. + * `reason` becomes the text shown in that error result. If omitted, a default blocked message is used. + */ +export interface BeforeToolCallResult { + block?: boolean; + reason?: string; +} + +/** + * Partial override returned from `afterToolCall`. + * + * Merge semantics are field-by-field: + * - `content`: if provided, replaces the tool result content array in full + * - `details`: if provided, replaces the tool result details value in full + * - `isError`: if provided, replaces the tool result error flag + * - `terminate`: if provided, replaces the early-termination hint + * + * Omitted fields keep the original executed tool result values. + * There is no deep merge for `content` or `details`. + */ +export interface AfterToolCallResult { + content?: (TextContent | ImageContent)[]; + details?: unknown; + isError?: boolean; + /** + * Hint that the agent should stop after the current tool batch. + * Early termination only happens when every finalized tool result in the batch sets this to true. + */ + terminate?: boolean; +} + +/** Context passed to `beforeToolCall`. */ +export interface BeforeToolCallContext { + /** The assistant message that requested the tool call. */ + assistantMessage: AssistantMessage; + /** The raw tool call block from `assistantMessage.content`. */ + toolCall: AgentToolCall; + /** Validated tool arguments for the target tool schema. */ + args: unknown; + /** Current agent context at the time the tool call is prepared. */ + context: AgentContext; +} + +/** Context passed to `afterToolCall`. */ +export interface AfterToolCallContext { + /** The assistant message that requested the tool call. */ + assistantMessage: AssistantMessage; + /** The raw tool call block from `assistantMessage.content`. */ + toolCall: AgentToolCall; + /** Validated tool arguments for the target tool schema. */ + args: unknown; + /** The executed tool result before any `afterToolCall` overrides are applied. */ + result: AgentToolResult; + /** Whether the executed tool result is currently treated as an error. */ + isError: boolean; + /** Current agent context at the time the tool call is finalized. */ + context: AgentContext; +} + +/** Context passed to `shouldStopAfterTurn`. */ +export interface ShouldStopAfterTurnContext { + /** The assistant message that completed the turn. */ + message: AssistantMessage; + /** Tool result messages passed to the preceding `turn_end` event. */ + toolResults: ToolResultMessage[]; + /** Current agent context after the turn's assistant message and tool results have been appended. */ + context: AgentContext; + /** Messages that this loop invocation will return if it exits at this point. Prompt runs include the initial prompt messages; continuation runs do not include pre-existing context messages. */ + newMessages: AgentMessage[]; +} + +/** Replacement runtime state used by the agent loop before starting another provider request. */ +export interface AgentLoopTurnUpdate { + /** Context for the next provider request. */ + context?: AgentContext; + /** Model for the next provider request. */ + model?: Model; + /** Thinking level for the next provider request. */ + thinkingLevel?: ThinkingLevel; +} + +export interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {} + +export interface AgentLoopConfig extends SimpleStreamOptions { + model: Model; + + /** + * Converts AgentMessage[] to LLM-compatible Message[] before each LLM call. + * + * Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage + * that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications, + * status messages) should be filtered out. + * + * Contract: must not throw or reject. Return a safe fallback value instead. + * Throwing interrupts the low-level agent loop without producing a normal event sequence. + * + * @example + * ```typescript + * convertToLlm: (messages) => messages.flatMap(m => { + * if (m.role === "custom") { + * // Convert custom message to user message + * return [{ role: "user", content: m.content, timestamp: m.timestamp }]; + * } + * if (m.role === "notification") { + * // Filter out UI-only messages + * return []; + * } + * // Pass through standard LLM messages + * return [m]; + * }) + * ``` + */ + convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; + + /** + * Optional transform applied to the context before `convertToLlm`. + * + * Use this for operations that work at the AgentMessage level: + * - Context window management (pruning old messages) + * - Injecting context from external sources + * + * Contract: must not throw or reject. Return the original messages or another + * safe fallback value instead. + * + * @example + * ```typescript + * transformContext: async (messages) => { + * if (estimateTokens(messages) > MAX_TOKENS) { + * return pruneOldMessages(messages); + * } + * return messages; + * } + * ``` + */ + transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; + + /** + * Resolves an API key dynamically for each LLM call. + * + * Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire + * during long-running tool execution phases. + * + * Contract: must not throw or reject. Return undefined when no key is available. + */ + getApiKey?: (provider: string) => Promise | string | undefined; + + /** + * Called after each turn fully completes and `turn_end` has been emitted. + * + * If it returns true, the loop emits `agent_end` and exits before polling steering or follow-up queues, + * without starting another LLM call. The current assistant response and any tool executions finish normally. + * + * Use this to request a graceful stop after the current turn, e.g. before context gets too full. + * + * Contract: must not throw or reject. Throwing interrupts the low-level agent loop without producing a normal event sequence. + */ + shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise; + + /** + * Called after `turn_end` and before the loop decides whether another provider request should start. + * Return replacement context/model/thinking state to affect the next turn in this run. + * Return undefined to keep using the current context/config. + */ + prepareNextTurn?: ( + context: PrepareNextTurnContext, + ) => AgentLoopTurnUpdate | undefined | Promise; + + /** + * Returns steering messages to inject into the conversation mid-run. + * + * Called after the current assistant turn finishes executing its tool calls, unless `shouldStopAfterTurn` exits first. + * If messages are returned, they are added to the context before the next LLM call. + * Tool calls from the current assistant message are not skipped. + * + * Use this for "steering" the agent while it's working. + * + * Contract: must not throw or reject. Return [] when no steering messages are available. + */ + getSteeringMessages?: () => Promise; + + /** + * Returns follow-up messages to process after the agent would otherwise stop. + * + * Called when the agent has no more tool calls and no steering messages. + * If messages are returned, they're added to the context and the agent + * continues with another turn. + * + * Use this for follow-up messages that should wait until the agent finishes. + * + * Contract: must not throw or reject. Return [] when no follow-up messages are available. + */ + getFollowUpMessages?: () => Promise; + + /** + * Tool execution mode. + * - "sequential": execute tool calls one by one + * - "parallel": preflight tool calls sequentially, then execute allowed tools concurrently; + * emit `tool_execution_end` in tool completion order after each tool is finalized, + * then emit tool-result message artifacts later in assistant source order + * + * Default: "parallel" + */ + toolExecution?: ToolExecutionMode; + + /** + * Called before a tool is executed, after arguments have been validated. + * + * Return `{ block: true }` to prevent execution. The loop emits an error tool result instead. + * The hook receives the agent abort signal and is responsible for honoring it. + */ + beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise; + + /** + * Called after a tool finishes executing, before `tool_execution_end` and tool-result message events are emitted. + * + * Return an `AfterToolCallResult` to override parts of the executed tool result: + * - `content` replaces the full content array + * - `details` replaces the full details payload + * - `isError` replaces the error flag + * - `terminate` replaces the early-termination hint + * + * Any omitted fields keep their original values. No deep merge is performed. + * The hook receives the agent abort signal and is responsible for honoring it. + */ + afterToolCall?: (context: AfterToolCallContext, signal?: AbortSignal) => Promise; +} + +/** + * Thinking/reasoning level for models that support it. + * Note: "xhigh" is only supported by selected model families. Use model thinking-level metadata + * from @earendil-works/pi-ai to detect support for a concrete model. + */ +export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +/** + * Extensible interface for custom app messages. + * Apps can extend via declaration merging: + * + * @example + * ```typescript + * declare module "@mariozechner/agent" { + * interface CustomAgentMessages { + * artifact: ArtifactMessage; + * notification: NotificationMessage; + * } + * } + * ``` + */ +export interface CustomAgentMessages { + // Empty by default - apps extend via declaration merging +} + +/** + * AgentMessage: Union of LLM messages + custom messages. + * This abstraction allows apps to add custom message types while maintaining + * type safety and compatibility with the base LLM messages. + */ +export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]; + +/** + * Public agent state. + * + * `tools` and `messages` use accessor properties so implementations can copy + * assigned arrays before storing them. + */ +export interface AgentState { + /** System prompt sent with each model request. */ + systemPrompt: string; + /** Active model used for future turns. */ + model: Model; + /** Requested reasoning level for future turns. */ + thinkingLevel: ThinkingLevel; + /** Available tools. Assigning a new array copies the top-level array. */ + set tools(tools: AgentTool[]); + get tools(): AgentTool[]; + /** Conversation transcript. Assigning a new array copies the top-level array. */ + set messages(messages: AgentMessage[]); + get messages(): AgentMessage[]; + /** + * True while the agent is processing a prompt or continuation. + * + * This remains true until awaited `agent_end` listeners settle. + */ + readonly isStreaming: boolean; + /** Partial assistant message for the current streamed response, if any. */ + readonly streamingMessage?: AgentMessage; + /** Tool call ids currently executing. */ + readonly pendingToolCalls: ReadonlySet; + /** Error message from the most recent failed or aborted assistant turn, if any. */ + readonly errorMessage?: string; +} + +/** Final or partial result produced by a tool. */ +export interface AgentToolResult { + /** Text or image content returned to the model. */ + content: (TextContent | ImageContent)[]; + /** Arbitrary structured details for logs or UI rendering. */ + details: T; + /** + * Hint that the agent should stop after the current tool batch. + * Early termination only happens when every finalized tool result in the batch sets this to true. + */ + terminate?: boolean; +} + +/** + * Callback used by tools to stream partial execution updates. + * + * The callback is scoped to the current `execute()` invocation. Calls made after + * the tool promise settles are ignored. + */ +export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; + +/** Tool definition used by the agent runtime. */ +export interface AgentTool extends Tool { + /** Human-readable label for UI display. */ + label: string; + /** + * Optional compatibility shim for raw tool-call arguments before schema validation. + * Must return an object that matches `TParameters`. + */ + prepareArguments?: (args: unknown) => Static; + /** Execute the tool call. Throw on failure instead of encoding errors in `content`. */ + execute: ( + toolCallId: string, + params: Static, + signal?: AbortSignal, + onUpdate?: AgentToolUpdateCallback, + ) => Promise>; + /** + * Per-tool execution mode override. + * - "sequential": this tool must execute one at a time with other tool calls. + * - "parallel": this tool can execute concurrently with other tool calls. + * + * If omitted, the default execution mode applies. + */ + executionMode?: ToolExecutionMode; +} + +/** Context snapshot passed into the low-level agent loop. */ +export interface AgentContext { + /** System prompt included with the request. */ + systemPrompt: string; + /** Transcript visible to the model. */ + messages: AgentMessage[]; + /** Tools available for this run. */ + tools?: AgentTool[]; +} + +/** + * Events emitted by the Agent for UI updates. + * + * `agent_end` is the last event emitted for a run, but awaited `Agent.subscribe()` + * listeners for that event are still part of run settlement. The agent becomes + * idle only after those listeners finish. + */ +export type AgentEvent = + // Agent lifecycle + | { type: "agent_start" } + | { type: "agent_end"; messages: AgentMessage[] } + // Turn lifecycle - a turn is one assistant response + any tool calls/results + | { type: "turn_start" } + | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] } + // Message lifecycle - emitted for user, assistant, and toolResult messages + | { type: "message_start"; message: AgentMessage } + // Only emitted for assistant messages during streaming + | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent } + | { type: "message_end"; message: AgentMessage } + // Tool execution lifecycle + | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any } + | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any } + | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean }; diff --git a/cactus-code/packages/agent/tsconfig.build.json b/cactus-code/packages/agent/tsconfig.build.json new file mode 100644 index 000000000..979dbc899 --- /dev/null +++ b/cactus-code/packages/agent/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "paths": { + "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], + "@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"] + }, + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +} diff --git a/cactus-code/packages/ai/package.json b/cactus-code/packages/ai/package.json new file mode 100644 index 000000000..6c63411de --- /dev/null +++ b/cactus-code/packages/ai/package.json @@ -0,0 +1,70 @@ +{ + "name": "@earendil-works/pi-ai", + "version": "0.80.2", + "description": "Unified LLM API with automatic model discovery and provider configuration", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": [ + "./dist/compat.js" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./compat": { + "types": "./dist/compat.d.ts", + "import": "./dist/compat.js" + }, + "./providers/*": { + "types": "./dist/providers/*.d.ts", + "import": "./dist/providers/*.js" + }, + "./api/*": { + "types": "./dist/api/*.d.ts", + "import": "./dist/api/*.js" + }, + "./oauth": { + "types": "./dist/oauth.d.ts", + "import": "./dist/oauth.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "clean": "shx rm -rf dist", + "build": "tsgo -p tsconfig.build.json", + "prepublishOnly": "npm run clean && npm run build" + }, + "dependencies": { + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "keywords": [ + "ai", + "llm", + "openai", + "anthropic", + "gemini", + "bedrock", + "unified", + "api" + ], + "author": "Mario Zechner", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/earendil-works/pi.git", + "directory": "packages/ai" + }, + "engines": { + "node": ">=22.19.0" + }, + "devDependencies": { + "@types/node": "24.12.4" + } +} diff --git a/cactus-code/packages/ai/src/api/github-copilot-headers.ts b/cactus-code/packages/ai/src/api/github-copilot-headers.ts new file mode 100644 index 000000000..602750c49 --- /dev/null +++ b/cactus-code/packages/ai/src/api/github-copilot-headers.ts @@ -0,0 +1,37 @@ +import type { Message } from "../types.ts"; + +// Copilot expects X-Initiator to indicate whether the request is user-initiated +// or agent-initiated (e.g. follow-up after assistant/tool messages). +export function inferCopilotInitiator(messages: Message[]): "user" | "agent" { + const last = messages[messages.length - 1]; + return last && last.role !== "user" ? "agent" : "user"; +} + +// Copilot requires Copilot-Vision-Request header when sending images +export function hasCopilotVisionInput(messages: Message[]): boolean { + return messages.some((msg) => { + if (msg.role === "user" && Array.isArray(msg.content)) { + return msg.content.some((c) => c.type === "image"); + } + if (msg.role === "toolResult" && Array.isArray(msg.content)) { + return msg.content.some((c) => c.type === "image"); + } + return false; + }); +} + +export function buildCopilotDynamicHeaders(params: { + messages: Message[]; + hasImages: boolean; +}): Record { + const headers: Record = { + "X-Initiator": inferCopilotInitiator(params.messages), + "Openai-Intent": "conversation-edits", + }; + + if (params.hasImages) { + headers["Copilot-Vision-Request"] = "true"; + } + + return headers; +} diff --git a/cactus-code/packages/ai/src/api/lazy.ts b/cactus-code/packages/ai/src/api/lazy.ts new file mode 100644 index 000000000..fe1836aea --- /dev/null +++ b/cactus-code/packages/ai/src/api/lazy.ts @@ -0,0 +1,70 @@ +import type { Api, AssistantMessage, AssistantMessageEvent, Model, ProviderStreams } from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; + +function createSetupErrorMessage(model: Model, error: unknown): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; +} + +function forwardStream(target: AssistantMessageEventStream, source: AsyncIterable): void { + (async () => { + for await (const event of source) { + target.push(event); + } + target.end(); + })(); +} + +/** + * Returns a stream synchronously while running async setup (auth resolution, + * lazy module loading) behind it. Setup failures terminate the stream with an + * error event. + */ +export function lazyStream( + model: Model, + setup: () => Promise>, +): AssistantMessageEventStream { + const outer = new AssistantMessageEventStream(); + + setup() + .then((inner) => { + forwardStream(outer, inner); + }) + .catch((error) => { + const message = createSetupErrorMessage(model, error); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + }); + + return outer; +} + +/** + * Wraps a dynamically imported API implementation module as `ProviderStreams`. + * The module loads on first stream call; the host's import cache deduplicates + * loads. Load failures terminate the returned stream with an error event. + */ +export function lazyApi(load: () => Promise): ProviderStreams { + return { + stream: (model, context, options) => + lazyStream(model, async () => (await load()).stream(model, context, options)), + streamSimple: (model, context, options) => + lazyStream(model, async () => (await load()).streamSimple(model, context, options)), + }; +} diff --git a/cactus-code/packages/ai/src/api/openai-completions.lazy.ts b/cactus-code/packages/ai/src/api/openai-completions.lazy.ts new file mode 100644 index 000000000..6f6c6f61a --- /dev/null +++ b/cactus-code/packages/ai/src/api/openai-completions.lazy.ts @@ -0,0 +1,4 @@ +import type { ProviderStreams } from "../types.ts"; +import { lazyApi } from "./lazy.ts"; + +export const openAICompletionsApi = (): ProviderStreams => lazyApi(() => import("./openai-completions.ts")); diff --git a/cactus-code/packages/ai/src/api/openai-completions.ts b/cactus-code/packages/ai/src/api/openai-completions.ts new file mode 100644 index 000000000..2dae2c6e3 --- /dev/null +++ b/cactus-code/packages/ai/src/api/openai-completions.ts @@ -0,0 +1,1288 @@ +import OpenAI from "openai"; +import type { + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionContentPart, + ChatCompletionContentPartImage, + ChatCompletionContentPartText, + ChatCompletionDeveloperMessageParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, +} from "openai/resources/chat/completions.js"; +import { calculateCost, clampThinkingLevel } from "../models.ts"; +import type { + AssistantMessage, + CacheRetention, + ChatTemplateKwargValue, + Context, + ImageContent, + Message, + Model, + OpenAICompletionsCompat, + ProviderEnv, + ProviderHeaders, + SimpleStreamOptions, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, + ToolResultMessage, +} from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { headersToRecord } from "../utils/headers.ts"; +import { parseStreamingJson } from "../utils/json-parse.ts"; +import { getProviderEnvValue } from "../utils/provider-env.ts"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; +import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts"; +import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; +import { buildBaseOptions } from "./simple-options.ts"; +import { transformMessages } from "./transform-messages.ts"; + +/** + * Check if conversation messages contain tool calls or tool results. + * This is needed because Anthropic (via proxy) requires the tools param + * to be present when messages include tool_calls or tool role messages. + */ +function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean { + if (!headers) return false; + const expected = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === expected && value !== null && value.trim().length > 0) return true; + } + return false; +} + +function getClientApiKey(provider: string, apiKey: string | undefined, headers: ProviderHeaders | undefined): string { + if (apiKey) return apiKey; + if (hasHeader(headers, "authorization") || hasHeader(headers, "cf-aig-authorization")) return "unused"; + throw new Error(`No API key for provider: ${provider}`); +} + +function hasToolHistory(messages: Message[]): boolean { + for (const msg of messages) { + if (msg.role === "toolResult") { + return true; + } + if (msg.role === "assistant") { + if (msg.content.some((block) => block.type === "toolCall")) { + return true; + } + } + } + return false; +} + +function isTextContentBlock(block: { type: string }): block is TextContent { + return block.type === "text"; +} + +function isThinkingContentBlock(block: { type: string }): block is ThinkingContent { + return block.type === "thinking"; +} + +function isToolCallBlock(block: { type: string }): block is ToolCall { + return block.type === "toolCall"; +} + +function isImageContentBlock(block: { type: string }): block is ImageContent { + return block.type === "image"; +} + +function isEncryptedReasoningDetail(detail: unknown): detail is OpenAIEncryptedReasoningDetail { + if (typeof detail !== "object" || detail === null) { + return false; + } + const candidate = detail as Record; + return ( + candidate.type === "reasoning.encrypted" && + typeof candidate.id === "string" && + candidate.id.length > 0 && + typeof candidate.data === "string" && + candidate.data.length > 0 + ); +} + +export interface OpenAICompletionsOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } }; + reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; +} + +interface OpenAICompatCacheControl { + type: "ephemeral"; + ttl?: string; +} + +type ResolvedOpenAICompletionsCompat = Omit, "cacheControlFormat"> & { + cacheControlFormat?: OpenAICompletionsCompat["cacheControlFormat"]; +}; + +type ResolvedChatTemplateKwargValue = string | number | boolean | null; + +type ChatCompletionInstructionMessageParam = ChatCompletionDeveloperMessageParam | ChatCompletionSystemMessageParam; + +type OpenAIEncryptedReasoningDetail = { + type: "reasoning.encrypted"; + id: string; + data: string; +}; + +type ChatCompletionTextPartWithCacheControl = ChatCompletionContentPartText & { + cache_control?: OpenAICompatCacheControl; +}; + +type ChatCompletionToolWithCacheControl = OpenAI.Chat.Completions.ChatCompletionTool & { + cache_control?: OpenAICompatCacheControl; +}; + +function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention { + if (cacheRetention) { + return cacheRetention; + } + if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") { + return "long"; + } + return "short"; +} + +export const stream: StreamFunction<"openai-completions", OpenAICompletionsOptions> = ( + model: Model<"openai-completions">, + context: Context, + options?: OpenAICompletionsOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + (async () => { + const output: AssistantMessage = { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + + try { + const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers); + const compat = getCompat(model); + const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env); + const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; + const client = createClient(model, context, apiKey, options?.headers, cacheSessionId, compat); + let params = buildParams(model, context, options, compat, cacheRetention); + const nextParams = await options?.onPayload?.(params, model); + if (nextParams !== undefined) { + params = nextParams as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming; + } + const requestOptions = { + ...(options?.signal ? { signal: options.signal } : {}), + ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), + maxRetries: options?.maxRetries ?? 0, + }; + const { data: openaiStream, response } = await client.chat.completions + .create(params, requestOptions) + .withResponse(); + await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model); + stream.push({ type: "start", partial: output }); + + interface StreamingToolCallBlock extends ToolCall { + partialArgs?: string; + streamIndex?: number; + } + type StreamingBlock = TextContent | ThinkingContent | StreamingToolCallBlock; + type StreamingToolCallDelta = NonNullable[number]; + + let textBlock: TextContent | null = null; + let thinkingBlock: ThinkingContent | null = null; + let hasFinishReason = false; + const toolCallBlocksByIndex = new Map(); + const toolCallBlocksById = new Map(); + const pendingReasoningDetailsByToolCallId = new Map(); + const blocks = output.content as StreamingBlock[]; + const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block); + const finishBlock = (block: StreamingBlock) => { + const contentIndex = getContentIndex(block); + if (contentIndex === -1) { + return; + } + if (block.type === "text") { + stream.push({ + type: "text_end", + contentIndex, + content: block.text, + partial: output, + }); + } else if (block.type === "thinking") { + stream.push({ + type: "thinking_end", + contentIndex, + content: block.thinking, + partial: output, + }); + } else if (block.type === "toolCall") { + block.arguments = parseStreamingJson(block.partialArgs); + // Finalize in-place and strip the scratch buffers so replay only + // carries parsed arguments. + delete block.partialArgs; + delete block.streamIndex; + stream.push({ + type: "toolcall_end", + contentIndex, + toolCall: block, + partial: output, + }); + } + }; + const ensureTextBlock = () => { + if (!textBlock) { + textBlock = { type: "text", text: "" }; + blocks.push(textBlock); + stream.push({ type: "text_start", contentIndex: getContentIndex(textBlock), partial: output }); + } + return textBlock; + }; + const ensureThinkingBlock = (thinkingSignature: string) => { + if (!thinkingBlock) { + thinkingBlock = { + type: "thinking", + thinking: "", + thinkingSignature, + }; + blocks.push(thinkingBlock); + stream.push({ type: "thinking_start", contentIndex: getContentIndex(thinkingBlock), partial: output }); + } + return thinkingBlock; + }; + const applyPendingReasoningDetail = (block: StreamingToolCallBlock) => { + if (!block.id) { + return; + } + const pendingReasoningDetail = pendingReasoningDetailsByToolCallId.get(block.id); + if (pendingReasoningDetail) { + block.thoughtSignature = pendingReasoningDetail; + pendingReasoningDetailsByToolCallId.delete(block.id); + } + }; + const ensureToolCallBlock = (toolCall: StreamingToolCallDelta) => { + const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; + let block = streamIndex !== undefined ? toolCallBlocksByIndex.get(streamIndex) : undefined; + if (!block && toolCall.id) { + block = toolCallBlocksById.get(toolCall.id); + } + if (!block) { + block = { + type: "toolCall", + id: toolCall.id || "", + name: toolCall.function?.name || "", + arguments: {}, + partialArgs: "", + streamIndex, + }; + if (streamIndex !== undefined) { + toolCallBlocksByIndex.set(streamIndex, block); + } + if (toolCall.id) { + toolCallBlocksById.set(toolCall.id, block); + } + blocks.push(block); + stream.push({ + type: "toolcall_start", + contentIndex: getContentIndex(block), + partial: output, + }); + } + if (streamIndex !== undefined && block.streamIndex === undefined) { + block.streamIndex = streamIndex; + toolCallBlocksByIndex.set(streamIndex, block); + } + if (toolCall.id) { + toolCallBlocksById.set(toolCall.id, block); + } + applyPendingReasoningDetail(block); + return block; + }; + + for await (const chunk of openaiStream) { + if (!chunk || typeof chunk !== "object") continue; + + // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier, + // and each chunk in a streamed completion carries the same id. + output.responseId ||= chunk.id; + if (typeof chunk.model === "string" && chunk.model.length > 0 && chunk.model !== model.id) { + output.responseModel ||= chunk.model; + } + if (chunk.usage) { + output.usage = parseChunkUsage(chunk.usage, model); + } + if (typeof (chunk as any).cloud_handoff === "boolean") { + output.cloudHandoff = (chunk as any).cloud_handoff; + } + + const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; + if (!choice) continue; + + // Fallback: some providers (e.g., Moonshot) return usage + // in choice.usage instead of the standard chunk.usage + if (!chunk.usage && (choice as any).usage) { + output.usage = parseChunkUsage((choice as any).usage, model); + } + + if (choice.finish_reason) { + const finishReasonResult = mapStopReason(choice.finish_reason); + output.stopReason = finishReasonResult.stopReason; + if (finishReasonResult.errorMessage) { + output.errorMessage = finishReasonResult.errorMessage; + } + hasFinishReason = true; + } + + if (choice.delta) { + if ( + choice.delta.content !== null && + choice.delta.content !== undefined && + choice.delta.content.length > 0 + ) { + const block = ensureTextBlock(); + block.text += choice.delta.content; + stream.push({ + type: "text_delta", + contentIndex: getContentIndex(block), + delta: choice.delta.content, + partial: output, + }); + } + + // Some endpoints return reasoning in reasoning_content (llama.cpp), + // or reasoning (other openai compatible endpoints) + // Use the first non-empty reasoning field to avoid duplication + // (e.g., chutes.ai returns both reasoning_content and reasoning with same content) + const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"]; + const deltaFields = choice.delta as Record; + let foundReasoningField: string | null = null; + for (const field of reasoningFields) { + const value = deltaFields[field]; + if (typeof value === "string" && value.length > 0) { + foundReasoningField = field; + break; + } + } + + if (foundReasoningField) { + const delta = deltaFields[foundReasoningField]; + if (typeof delta === "string" && delta.length > 0) { + const thinkingSignature = + model.provider === "opencode-go" && foundReasoningField === "reasoning" + ? "reasoning_content" + : foundReasoningField; + const block = ensureThinkingBlock(thinkingSignature); + block.thinking += delta; + stream.push({ + type: "thinking_delta", + contentIndex: getContentIndex(block), + delta, + partial: output, + }); + } + } + + if (choice?.delta?.tool_calls) { + for (const toolCall of choice.delta.tool_calls) { + const block = ensureToolCallBlock(toolCall); + if (!block.id && toolCall.id) { + block.id = toolCall.id; + toolCallBlocksById.set(toolCall.id, block); + } + if (!block.name && toolCall.function?.name) { + block.name = toolCall.function.name; + } + + let delta = ""; + if (toolCall.function?.arguments) { + delta = toolCall.function.arguments; + block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments; + block.arguments = parseStreamingJson(block.partialArgs); + } + stream.push({ + type: "toolcall_delta", + contentIndex: getContentIndex(block), + delta, + partial: output, + }); + } + } + + const reasoningDetails = (choice.delta as { reasoning_details?: unknown }).reasoning_details; + if (Array.isArray(reasoningDetails)) { + for (const detail of reasoningDetails) { + if (isEncryptedReasoningDetail(detail)) { + const serializedDetail = JSON.stringify(detail); + const matchingToolCall = toolCallBlocksById.get(detail.id); + if (matchingToolCall) { + matchingToolCall.thoughtSignature = serializedDetail; + } else { + pendingReasoningDetailsByToolCallId.set(detail.id, serializedDetail); + } + } + } + } + } + } + + for (const block of blocks) { + finishBlock(block); + } + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted") { + throw new Error("Request was aborted"); + } + if (output.stopReason === "error") { + throw new Error(output.errorMessage || "Provider returned an error stop reason"); + } + if (!hasFinishReason) { + throw new Error("Stream ended without finish_reason"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + for (const block of output.content) { + delete (block as { index?: number }).index; + // Streaming scratch buffers are only used during parsing; never persist them. + delete (block as { partialArgs?: string }).partialArgs; + delete (block as { streamIndex?: number }).streamIndex; + } + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); + // Some providers via OpenRouter give additional information in this field. + const rawMetadata = (error as any)?.error?.metadata?.raw; + if (rawMetadata) output.errorMessage += `\n${rawMetadata}`; + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +export const streamSimple: StreamFunction<"openai-completions", SimpleStreamOptions> = ( + model: Model<"openai-completions">, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream => { + getClientApiKey(model.provider, options?.apiKey, options?.headers); + + const base = buildBaseOptions(model, options, options?.apiKey); + const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined; + const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning; + const toolChoice = (options as OpenAICompletionsOptions | undefined)?.toolChoice; + + return stream(model, context, { + ...base, + reasoningEffort, + toolChoice, + } satisfies OpenAICompletionsOptions); +}; + +function createClient( + model: Model<"openai-completions">, + context: Context, + apiKey: string, + optionsHeaders?: ProviderHeaders, + sessionId?: string, + compat: ResolvedOpenAICompletionsCompat = getCompat(model), +) { + const headers: ProviderHeaders = { ...model.headers }; + if (model.provider === "github-copilot") { + const hasImages = hasCopilotVisionInput(context.messages); + const copilotHeaders = buildCopilotDynamicHeaders({ + messages: context.messages, + hasImages, + }); + Object.assign(headers, copilotHeaders); + } + + if (sessionId && compat.sendSessionAffinityHeaders) { + headers.session_id = sessionId; + headers["x-client-request-id"] = sessionId; + headers["x-session-affinity"] = sessionId; + } + + // Merge options headers last so they can override defaults + if (optionsHeaders) { + Object.assign(headers, optionsHeaders); + } + + return new OpenAI({ + apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: headers, + }); +} + +function buildParams( + model: Model<"openai-completions">, + context: Context, + options?: OpenAICompletionsOptions, + compat: ResolvedOpenAICompletionsCompat = getCompat(model), + cacheRetention: CacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env), +) { + const messages = convertMessages(model, context, compat); + const cacheControl = getCompatCacheControl(compat, cacheRetention); + + const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { + model: model.id, + messages, + stream: true, + prompt_cache_key: + (model.baseUrl.includes("api.openai.com") && cacheRetention !== "none") || + (cacheRetention === "long" && compat.supportsLongCacheRetention) + ? clampOpenAIPromptCacheKey(options?.sessionId) + : undefined, + prompt_cache_retention: cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined, + }; + + if (compat.supportsUsageInStreaming !== false) { + (params as any).stream_options = { include_usage: true }; + } + + if (compat.supportsStore) { + params.store = false; + } + + if (options?.maxTokens) { + if (compat.maxTokensField === "max_tokens") { + (params as any).max_tokens = options.maxTokens; + } else { + params.max_completion_tokens = options.maxTokens; + } + } + + if (options?.temperature !== undefined) { + params.temperature = options.temperature; + } + + if (context.tools && context.tools.length > 0) { + params.tools = convertTools(context.tools, compat); + if (compat.zaiToolStream) { + (params as any).tool_stream = true; + } + } else if (hasToolHistory(context.messages)) { + // Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results + params.tools = []; + } + + if (cacheControl) { + applyAnthropicCacheControl(messages, params.tools, cacheControl); + } + + if (options?.toolChoice) { + params.tool_choice = options.toolChoice; + } + + if (compat.thinkingFormat === "zai" && model.reasoning) { + const zaiParams = params as Omit & { + thinking?: { type: "enabled" | "disabled" }; + reasoning_effort?: string; + }; + zaiParams.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; + if (options?.reasoningEffort && compat.supportsReasoningEffort) { + const mappedEffort = model.thinkingLevelMap?.[options.reasoningEffort]; + const effort = mappedEffort === undefined ? options.reasoningEffort : mappedEffort; + if (typeof effort === "string") { + zaiParams.reasoning_effort = effort; + } + } + } else if (compat.thinkingFormat === "qwen" && model.reasoning) { + (params as any).enable_thinking = !!options?.reasoningEffort; + } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { + (params as any).chat_template_kwargs = { + enable_thinking: !!options?.reasoningEffort, + preserve_thinking: true, + }; + } else if (compat.thinkingFormat === "chat-template" && model.reasoning) { + const chatTemplateKwargs = buildChatTemplateKwargs(model, options, compat); + if (chatTemplateKwargs) { + (params as any).chat_template_kwargs = chatTemplateKwargs; + } + } else if (compat.thinkingFormat === "deepseek" && model.reasoning) { + if (options?.reasoningEffort) { + (params as any).thinking = { type: "enabled" }; + } else if (model.thinkingLevelMap?.off !== null) { + (params as any).thinking = { type: "disabled" }; + } + if (options?.reasoningEffort && compat.supportsReasoningEffort) { + (params as any).reasoning_effort = + model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } + } else if (compat.thinkingFormat === "openrouter" && model.reasoning) { + // OpenRouter normalizes reasoning across providers via a nested reasoning object. + const openRouterParams = params as typeof params & { reasoning?: { effort?: string } }; + if (options?.reasoningEffort) { + openRouterParams.reasoning = { + effort: model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort, + }; + } else if (model.thinkingLevelMap?.off !== null) { + openRouterParams.reasoning = { effort: model.thinkingLevelMap?.off ?? "none" }; + } + } else if (compat.thinkingFormat === "ant-ling" && model.reasoning && options?.reasoningEffort) { + const effort = model.thinkingLevelMap?.[options.reasoningEffort]; + if (typeof effort === "string") { + (params as typeof params & { reasoning?: { effort: string } }).reasoning = { effort }; + } + } else if (compat.thinkingFormat === "together" && model.reasoning) { + const togetherParams = params as Omit & { + reasoning?: { enabled: boolean }; + reasoning_effort?: string; + }; + togetherParams.reasoning = { enabled: !!options?.reasoningEffort }; + if (options?.reasoningEffort && compat.supportsReasoningEffort) { + togetherParams.reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } + } else if (compat.thinkingFormat === "string-thinking" && model.reasoning) { + const stringThinkingParams = params as typeof params & { thinking?: string }; + if (options?.reasoningEffort) { + stringThinkingParams.thinking = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } else if (model.thinkingLevelMap?.off !== null) { + stringThinkingParams.thinking = model.thinkingLevelMap?.off ?? "none"; + } + } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { + // OpenAI-style reasoning_effort + (params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; + } else if (!options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { + const offValue = model.thinkingLevelMap?.off; + if (typeof offValue === "string") { + (params as any).reasoning_effort = offValue; + } + } + + // OpenRouter provider routing preferences + if (model.compat?.openRouterRouting) { + (params as any).provider = model.compat.openRouterRouting; + } + + // Vercel AI Gateway provider routing preferences + if (model.compat?.vercelGatewayRouting) { + const routing = model.compat.vercelGatewayRouting; + if (routing.only || routing.order) { + const gatewayOptions: Record = {}; + if (routing.only) gatewayOptions.only = routing.only; + if (routing.order) gatewayOptions.order = routing.order; + (params as any).providerOptions = { gateway: gatewayOptions }; + } + } + + return params; +} + +function buildChatTemplateKwargs( + model: Model<"openai-completions">, + options: OpenAICompletionsOptions | undefined, + compat: ResolvedOpenAICompletionsCompat, +): Record | undefined { + const kwargs: Record = {}; + + for (const [key, value] of Object.entries(compat.chatTemplateKwargs)) { + const resolved = resolveChatTemplateKwargValue(model, options, value); + if (resolved !== undefined) { + kwargs[key] = resolved; + } + } + + return Object.keys(kwargs).length > 0 ? kwargs : undefined; +} + +function resolveChatTemplateKwargValue( + model: Model<"openai-completions">, + options: OpenAICompletionsOptions | undefined, + value: ChatTemplateKwargValue, +): ResolvedChatTemplateKwargValue | undefined { + if (typeof value !== "object" || value === null) { + return value; + } + + const reasoningEffort = options?.reasoningEffort; + if (!reasoningEffort && value.omitWhenOff) { + return undefined; + } + if (value.$var === "thinking.enabled") { + return !!reasoningEffort; + } + + const mappedValue = reasoningEffort ? model.thinkingLevelMap?.[reasoningEffort] : model.thinkingLevelMap?.off; + return mappedValue === undefined ? reasoningEffort : typeof mappedValue === "string" ? mappedValue : undefined; +} + +function getCompatCacheControl( + compat: ResolvedOpenAICompletionsCompat, + cacheRetention: CacheRetention, +): OpenAICompatCacheControl | undefined { + if (compat.cacheControlFormat !== "anthropic" || cacheRetention === "none") { + return undefined; + } + + const ttl = cacheRetention === "long" && compat.supportsLongCacheRetention ? "1h" : undefined; + return { type: "ephemeral", ...(ttl ? { ttl } : {}) }; +} + +function applyAnthropicCacheControl( + messages: ChatCompletionMessageParam[], + tools: OpenAI.Chat.Completions.ChatCompletionTool[] | undefined, + cacheControl: OpenAICompatCacheControl, +): void { + addCacheControlToSystemPrompt(messages, cacheControl); + addCacheControlToLastTool(tools, cacheControl); + addCacheControlToLastConversationMessage(messages, cacheControl); +} + +function addCacheControlToSystemPrompt( + messages: ChatCompletionMessageParam[], + cacheControl: OpenAICompatCacheControl, +): void { + for (const message of messages) { + if (message.role === "system" || message.role === "developer") { + addCacheControlToInstructionMessage(message, cacheControl); + return; + } + } +} + +function addCacheControlToLastConversationMessage( + messages: ChatCompletionMessageParam[], + cacheControl: OpenAICompatCacheControl, +): void { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === "user" || message.role === "assistant") { + if (addCacheControlToMessage(message, cacheControl)) { + return; + } + } + } +} + +function addCacheControlToLastTool( + tools: OpenAI.Chat.Completions.ChatCompletionTool[] | undefined, + cacheControl: OpenAICompatCacheControl, +): void { + if (!tools || tools.length === 0) { + return; + } + + const lastTool = tools[tools.length - 1] as ChatCompletionToolWithCacheControl; + lastTool.cache_control = cacheControl; +} + +function addCacheControlToInstructionMessage( + message: ChatCompletionInstructionMessageParam, + cacheControl: OpenAICompatCacheControl, +): boolean { + return addCacheControlToTextContent(message, cacheControl); +} + +function addCacheControlToMessage( + message: ChatCompletionMessageParam, + cacheControl: OpenAICompatCacheControl, +): boolean { + if (message.role === "user" || message.role === "assistant") { + return addCacheControlToTextContent(message, cacheControl); + } + return false; +} + +function addCacheControlToTextContent( + message: + | ChatCompletionInstructionMessageParam + | ChatCompletionAssistantMessageParam + | Extract, + cacheControl: OpenAICompatCacheControl, +): boolean { + const content = message.content; + if (typeof content === "string") { + if (content.length === 0) { + return false; + } + message.content = [ + { + type: "text", + text: content, + cache_control: cacheControl, + }, + ] as ChatCompletionTextPartWithCacheControl[]; + return true; + } + + if (!Array.isArray(content)) { + return false; + } + + for (let i = content.length - 1; i >= 0; i--) { + const part = content[i]; + if (part?.type === "text") { + const textPart = part as ChatCompletionTextPartWithCacheControl; + textPart.cache_control = cacheControl; + return true; + } + } + + return false; +} + +export function convertMessages( + model: Model<"openai-completions">, + context: Context, + compat: ResolvedOpenAICompletionsCompat, +): ChatCompletionMessageParam[] { + const params: ChatCompletionMessageParam[] = []; + + const normalizeToolCallId = (id: string): string => { + // Handle pipe-separated IDs from OpenAI Responses API + // Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =) + // These come from providers like github-copilot, openai-codex, opencode + // Extract just the call_id part and normalize it + if (id.includes("|")) { + const [callId] = id.split("|"); + // Sanitize to allowed chars and truncate to 40 chars (OpenAI limit) + return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40); + } + + if (model.provider === "openai") return id.length > 40 ? id.slice(0, 40) : id; + return id; + }; + + const transformedMessages = transformMessages(context.messages, model, (id) => normalizeToolCallId(id)); + + if (context.systemPrompt) { + const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole; + const role = useDeveloperRole ? "developer" : "system"; + params.push({ role: role, content: sanitizeSurrogates(context.systemPrompt) }); + } + + let lastRole: string | null = null; + + for (let i = 0; i < transformedMessages.length; i++) { + const msg = transformedMessages[i]; + // Some providers don't allow user messages directly after tool results + // Insert a synthetic assistant message to bridge the gap + if (compat.requiresAssistantAfterToolResult && lastRole === "toolResult" && msg.role === "user") { + params.push({ + role: "assistant", + content: "I have processed the tool results.", + }); + } + + if (msg.role === "user") { + if (typeof msg.content === "string") { + params.push({ + role: "user", + content: sanitizeSurrogates(msg.content), + }); + } else { + const content: ChatCompletionContentPart[] = msg.content.map((item): ChatCompletionContentPart => { + if (item.type === "text") { + return { + type: "text", + text: sanitizeSurrogates(item.text), + } satisfies ChatCompletionContentPartText; + } else { + return { + type: "image_url", + image_url: { + url: `data:${item.mimeType};base64,${item.data}`, + }, + } satisfies ChatCompletionContentPartImage; + } + }); + if (content.length === 0) continue; + params.push({ + role: "user", + content, + }); + } + } else if (msg.role === "assistant") { + // Some providers don't accept null content, use empty string instead + const assistantMsg: ChatCompletionAssistantMessageParam = { + role: "assistant", + content: compat.requiresAssistantAfterToolResult ? "" : null, + }; + + const assistantTextParts = msg.content + .filter(isTextContentBlock) + .filter((block) => block.text.trim().length > 0) + .map( + (block) => + ({ + type: "text", + text: sanitizeSurrogates(block.text), + }) satisfies ChatCompletionContentPartText, + ); + const assistantText = assistantTextParts.map((part) => part.text).join(""); + + const nonEmptyThinkingBlocks = msg.content + .filter(isThinkingContentBlock) + .filter((block) => block.thinking.trim().length > 0); + if (nonEmptyThinkingBlocks.length > 0) { + if (compat.requiresThinkingAsText) { + // Convert thinking blocks to plain text (no tags to avoid model mimicking them) + const thinkingText = nonEmptyThinkingBlocks + .map((block) => sanitizeSurrogates(block.thinking)) + .join("\n\n"); + assistantMsg.content = [{ type: "text", text: thinkingText }, ...assistantTextParts]; + } else { + // Always send assistant content as a plain string (OpenAI Chat Completions + // API standard format). Sending as an array of {type:"text", text:"..."} + // objects is non-standard and causes some models (e.g. DeepSeek V3.2 via + // NVIDIA NIM) to mirror the content-block structure literally in their + // output, producing recursive nesting like [{'type':'text','text':'[{...}]'}]. + if (assistantText.length > 0) { + assistantMsg.content = assistantText; + } + + // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) + let signature = nonEmptyThinkingBlocks[0].thinkingSignature; + if (model.provider === "opencode-go" && signature === "reasoning") { + signature = "reasoning_content"; + } + if (signature && signature.length > 0) { + (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((block) => block.thinking).join("\n"); + } + } + } else if (assistantText.length > 0) { + // Always send assistant content as a plain string (OpenAI Chat Completions + // API standard format). Sending as an array of {type:"text", text:"..."} + // objects is non-standard and causes some models (e.g. DeepSeek V3.2 via + // NVIDIA NIM) to mirror the content-block structure literally in their + // output, producing recursive nesting like [{'type':'text','text':'[{...}]'}]. + assistantMsg.content = assistantText; + } + + const toolCalls = msg.content.filter(isToolCallBlock); + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls.map((tc) => ({ + id: tc.id, + type: "function" as const, + function: { + name: tc.name, + arguments: JSON.stringify(tc.arguments), + }, + })); + const reasoningDetails = toolCalls + .filter((tc) => tc.thoughtSignature) + .map((tc) => { + try { + return JSON.parse(tc.thoughtSignature!); + } catch { + return null; + } + }) + .filter(Boolean); + if (reasoningDetails.length > 0) { + (assistantMsg as any).reasoning_details = reasoningDetails; + } + } + if ( + compat.requiresReasoningContentOnAssistantMessages && + model.reasoning && + (assistantMsg as { reasoning_content?: string }).reasoning_content === undefined + ) { + (assistantMsg as { reasoning_content?: string }).reasoning_content = ""; + } + // Skip assistant messages that have no content and no tool calls. + // Some providers require "either content or tool_calls, but not none". + // Other providers also don't accept empty assistant messages. + // This handles aborted assistant responses that got no content. + const content = assistantMsg.content; + const hasContent = + content !== null && + content !== undefined && + (typeof content === "string" ? content.length > 0 : content.length > 0); + if (!hasContent && !assistantMsg.tool_calls) { + continue; + } + params.push(assistantMsg); + } else if (msg.role === "toolResult") { + const imageBlocks: Array<{ type: "image_url"; image_url: { url: string } }> = []; + let j = i; + + for (; j < transformedMessages.length && transformedMessages[j].role === "toolResult"; j++) { + const toolMsg = transformedMessages[j] as ToolResultMessage; + + // Extract text and image content + const textResult = toolMsg.content + .filter(isTextContentBlock) + .map((block) => block.text) + .join("\n"); + const hasImages = toolMsg.content.some((c) => c.type === "image"); + + // Always send tool result with text (or placeholder if only images) + const hasText = textResult.length > 0; + // Some providers require the 'name' field in tool results + const toolResultMsg: ChatCompletionToolMessageParam = { + role: "tool", + content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"), + tool_call_id: toolMsg.toolCallId, + }; + if (compat.requiresToolResultName && toolMsg.toolName) { + (toolResultMsg as any).name = toolMsg.toolName; + } + params.push(toolResultMsg); + + if (hasImages && model.input.includes("image")) { + for (const block of toolMsg.content) { + if (isImageContentBlock(block)) { + imageBlocks.push({ + type: "image_url", + image_url: { + url: `data:${block.mimeType};base64,${block.data}`, + }, + }); + } + } + } + } + + i = j - 1; + + if (imageBlocks.length > 0) { + if (compat.requiresAssistantAfterToolResult) { + params.push({ + role: "assistant", + content: "I have processed the tool results.", + }); + } + + params.push({ + role: "user", + content: [ + { + type: "text", + text: "Attached image(s) from tool result:", + }, + ...imageBlocks, + ], + }); + lastRole = "user"; + } else { + lastRole = "toolResult"; + } + continue; + } + + lastRole = msg.role; + } + + return params; +} + +function convertTools( + tools: Tool[], + compat: ResolvedOpenAICompletionsCompat, +): OpenAI.Chat.Completions.ChatCompletionTool[] { + return tools.map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters as any, // TypeBox already generates JSON Schema + // Only include strict if provider supports it. Some reject unknown fields. + ...(compat.supportsStrictMode !== false && { strict: false }), + }, + })); +} + +function parseChunkUsage( + rawUsage: { + prompt_tokens?: number; + completion_tokens?: number; + prompt_cache_hit_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; + }, + model: Model<"openai-completions">, +): AssistantMessage["usage"] { + const promptTokens = rawUsage.prompt_tokens || 0; + const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0; + const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0; + + // Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read + // tokens (hits). OpenAI does not document or emit cache_write_tokens, but + // OpenRouter-compatible providers can include it as a separate write count. + // OpenRouter's own provider/tests affirm the separate mapping: + // https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409 + // Do not subtract writes from cached_tokens, otherwise spec-compliant + // providers are under-reported. DS4 mirrors this contract too: + // https://github.com/antirez/ds4/pull/29 + const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens); + // OpenAI completion_tokens already includes reasoning_tokens. + const outputTokens = rawUsage.completion_tokens || 0; + const usage: AssistantMessage["usage"] = { + input, + output: outputTokens, + cacheRead: cacheReadTokens, + cacheWrite: cacheWriteTokens, + totalTokens: input + outputTokens + cacheReadTokens + cacheWriteTokens, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + calculateCost(model, usage); + return usage; +} + +function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): { + stopReason: StopReason; + errorMessage?: string; +} { + if (reason === null) return { stopReason: "stop" }; + switch (reason) { + case "stop": + case "end": + return { stopReason: "stop" }; + case "length": + return { stopReason: "length" }; + case "function_call": + case "tool_calls": + return { stopReason: "toolUse" }; + case "content_filter": + return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" }; + case "network_error": + return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" }; + default: + return { + stopReason: "error", + errorMessage: `Provider finish_reason: ${reason}`, + }; + } +} + +/** + * Auto-detect compatibility settings from provider name and baseUrl. + * Used as the base when model.compat is not set; explicit model.compat + * entries override these detected values. + */ +function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompletionsCompat { + const provider = model.provider; + const baseUrl = model.baseUrl; + + const isZai = + provider === "zai" || + provider === "zai-coding-cn" || + baseUrl.includes("api.z.ai") || + baseUrl.includes("open.bigmodel.cn"); + const isTogether = + provider === "together" || baseUrl.includes("api.together.ai") || baseUrl.includes("api.together.xyz"); + const isMoonshot = provider === "moonshotai" || provider === "moonshotai-cn" || baseUrl.includes("api.moonshot."); + const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai"); + const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com"); + const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com"); + const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com"); + const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com"); + + const isNonStandard = + isNvidia || + provider === "cerebras" || + baseUrl.includes("cerebras.ai") || + provider === "xai" || + baseUrl.includes("api.x.ai") || + isTogether || + baseUrl.includes("chutes.ai") || + baseUrl.includes("deepseek.com") || + isZai || + isMoonshot || + provider === "opencode" || + baseUrl.includes("opencode.ai") || + isCloudflareWorkersAI || + isCloudflareAiGateway || + isAntLing; + + const useMaxTokens = + baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether || isNvidia || isAntLing; + + const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); + const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); + const isOpenRouterDeveloperRoleModel = + isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/")); + const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined; + + return { + supportsStore: !isNonStandard, + supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter), + supportsReasoningEffort: + !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing, + supportsUsageInStreaming: true, + maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", + requiresToolResultName: false, + requiresAssistantAfterToolResult: false, + requiresThinkingAsText: false, + requiresReasoningContentOnAssistantMessages: isDeepSeek, + thinkingFormat: isDeepSeek + ? "deepseek" + : isZai + ? "zai" + : isTogether + ? "together" + : isAntLing + ? "ant-ling" + : isOpenRouter + ? "openrouter" + : "openai", + openRouterRouting: {}, + vercelGatewayRouting: {}, + chatTemplateKwargs: {}, + zaiToolStream: false, + supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, + cacheControlFormat, + sendSessionAffinityHeaders: false, + supportsLongCacheRetention: !( + isTogether || + isCloudflareWorkersAI || + isCloudflareAiGateway || + isNvidia || + isAntLing + ), + }; +} + +/** + * Get resolved compatibility settings for a model. + * Auto-detects from provider/URL then overrides with explicit model.compat. + */ +function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompletionsCompat { + const detected = detectCompat(model); + if (!model.compat) return detected; + + return { + supportsStore: model.compat.supportsStore ?? detected.supportsStore, + supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole, + supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort, + supportsUsageInStreaming: model.compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming, + maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField, + requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName, + requiresAssistantAfterToolResult: + model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult, + requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText, + requiresReasoningContentOnAssistantMessages: + model.compat.requiresReasoningContentOnAssistantMessages ?? + detected.requiresReasoningContentOnAssistantMessages, + thinkingFormat: model.compat.thinkingFormat ?? detected.thinkingFormat, + openRouterRouting: model.compat.openRouterRouting ?? {}, + vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting, + chatTemplateKwargs: model.compat.chatTemplateKwargs ?? detected.chatTemplateKwargs, + zaiToolStream: model.compat.zaiToolStream ?? detected.zaiToolStream, + supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode, + cacheControlFormat: model.compat.cacheControlFormat ?? detected.cacheControlFormat, + sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders, + supportsLongCacheRetention: model.compat.supportsLongCacheRetention ?? detected.supportsLongCacheRetention, + }; +} diff --git a/cactus-code/packages/ai/src/api/openai-prompt-cache.ts b/cactus-code/packages/ai/src/api/openai-prompt-cache.ts new file mode 100644 index 000000000..1c3355d52 --- /dev/null +++ b/cactus-code/packages/ai/src/api/openai-prompt-cache.ts @@ -0,0 +1,8 @@ +export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64; + +export function clampOpenAIPromptCacheKey(key: string | undefined): string | undefined { + if (key === undefined) return undefined; + const chars = Array.from(key); + if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key; + return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join(""); +} diff --git a/cactus-code/packages/ai/src/api/simple-options.ts b/cactus-code/packages/ai/src/api/simple-options.ts new file mode 100644 index 000000000..773e8d95a --- /dev/null +++ b/cactus-code/packages/ai/src/api/simple-options.ts @@ -0,0 +1,54 @@ +import type { Api, Model, SimpleStreamOptions, StreamOptions, ThinkingBudgets, ThinkingLevel } from "../types.ts"; + +export function buildBaseOptions(_model: Model, options?: SimpleStreamOptions, apiKey?: string): StreamOptions { + return { + temperature: options?.temperature, + maxTokens: options?.maxTokens, + signal: options?.signal, + apiKey: apiKey || options?.apiKey, + transport: options?.transport, + cacheRetention: options?.cacheRetention, + sessionId: options?.sessionId, + headers: options?.headers, + onPayload: options?.onPayload, + onResponse: options?.onResponse, + timeoutMs: options?.timeoutMs, + websocketConnectTimeoutMs: options?.websocketConnectTimeoutMs, + maxRetries: options?.maxRetries, + maxRetryDelayMs: options?.maxRetryDelayMs, + metadata: options?.metadata, + env: options?.env, + }; +} + +export function clampReasoning(effort: ThinkingLevel | undefined): Exclude | undefined { + return effort === "xhigh" ? "high" : effort; +} + +export function adjustMaxTokensForThinking( + // Undefined means no explicit caller cap. Use the model cap and fit thinking inside it. + baseMaxTokens: number | undefined, + modelMaxTokens: number, + reasoningLevel: ThinkingLevel, + customBudgets?: ThinkingBudgets, +): { maxTokens: number; thinkingBudget: number } { + const defaultBudgets: ThinkingBudgets = { + minimal: 1024, + low: 2048, + medium: 8192, + high: 16384, + }; + const budgets = { ...defaultBudgets, ...customBudgets }; + + const minOutputTokens = 1024; + const level = clampReasoning(reasoningLevel)!; + let thinkingBudget = budgets[level]!; + const maxTokens = + baseMaxTokens === undefined ? modelMaxTokens : Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens); + + if (maxTokens <= thinkingBudget) { + thinkingBudget = Math.max(0, maxTokens - minOutputTokens); + } + + return { maxTokens, thinkingBudget }; +} diff --git a/cactus-code/packages/ai/src/api/transform-messages.ts b/cactus-code/packages/ai/src/api/transform-messages.ts new file mode 100644 index 000000000..4029a0643 --- /dev/null +++ b/cactus-code/packages/ai/src/api/transform-messages.ts @@ -0,0 +1,220 @@ +import type { + Api, + AssistantMessage, + ImageContent, + Message, + Model, + TextContent, + ToolCall, + ToolResultMessage, +} from "../types.ts"; + +const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)"; +const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)"; + +function replaceImagesWithPlaceholder(content: (TextContent | ImageContent)[], placeholder: string): TextContent[] { + const result: TextContent[] = []; + let previousWasPlaceholder = false; + + for (const block of content) { + if (block.type === "image") { + if (!previousWasPlaceholder) { + result.push({ type: "text", text: placeholder }); + } + previousWasPlaceholder = true; + continue; + } + + result.push(block); + previousWasPlaceholder = block.text === placeholder; + } + + return result; +} + +function downgradeUnsupportedImages(messages: Message[], model: Model): Message[] { + if (model.input.includes("image")) { + return messages; + } + + return messages.map((msg) => { + if (msg.role === "user" && Array.isArray(msg.content)) { + return { + ...msg, + content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER), + }; + } + + if (msg.role === "toolResult") { + return { + ...msg, + content: replaceImagesWithPlaceholder(msg.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER), + }; + } + + return msg; + }); +} + +/** + * Normalize tool call ID for cross-provider compatibility. + * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`. + * Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars). + */ +export function transformMessages( + messages: Message[], + model: Model, + normalizeToolCallId?: (id: string, model: Model, source: AssistantMessage) => string, +): Message[] { + // Build a map of original tool call IDs to normalized IDs + const toolCallIdMap = new Map(); + const imageAwareMessages = downgradeUnsupportedImages(messages, model); + + // First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization) + const transformed = imageAwareMessages.map((msg) => { + // User messages pass through unchanged + if (msg.role === "user") { + return msg; + } + + // Handle toolResult messages - normalize toolCallId if we have a mapping + if (msg.role === "toolResult") { + const normalizedId = toolCallIdMap.get(msg.toolCallId); + if (normalizedId && normalizedId !== msg.toolCallId) { + return { ...msg, toolCallId: normalizedId }; + } + return msg; + } + + // Assistant messages need transformation check + if (msg.role === "assistant") { + const assistantMsg = msg as AssistantMessage; + const isSameModel = + assistantMsg.provider === model.provider && + assistantMsg.api === model.api && + assistantMsg.model === model.id; + + const transformedContent = assistantMsg.content.flatMap((block) => { + if (block.type === "thinking") { + // Redacted thinking is opaque encrypted content, only valid for the same model. + // Drop it for cross-model to avoid API errors. + if (block.redacted) { + return isSameModel ? block : []; + } + // For same model: keep thinking blocks with signatures (needed for replay) + // even if the thinking text is empty (OpenAI encrypted reasoning) + if (isSameModel && block.thinkingSignature) return block; + // Skip empty thinking blocks, convert others to plain text + if (!block.thinking || block.thinking.trim() === "") return []; + if (isSameModel) return block; + return { + type: "text" as const, + text: block.thinking, + }; + } + + if (block.type === "text") { + if (isSameModel) return block; + return { + type: "text" as const, + text: block.text, + }; + } + + if (block.type === "toolCall") { + const toolCall = block as ToolCall; + let normalizedToolCall: ToolCall = toolCall; + + if (!isSameModel && toolCall.thoughtSignature) { + normalizedToolCall = { ...toolCall }; + delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature; + } + + if (!isSameModel && normalizeToolCallId) { + const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg); + if (normalizedId !== toolCall.id) { + toolCallIdMap.set(toolCall.id, normalizedId); + normalizedToolCall = { ...normalizedToolCall, id: normalizedId }; + } + } + + return normalizedToolCall; + } + + return block; + }); + + return { + ...assistantMsg, + content: transformedContent, + }; + } + return msg; + }); + + // Second pass: insert synthetic empty tool results for orphaned tool calls + // This preserves thinking signatures and satisfies API requirements + const result: Message[] = []; + let pendingToolCalls: ToolCall[] = []; + let existingToolResultIds = new Set(); + const insertSyntheticToolResults = () => { + if (pendingToolCalls.length > 0) { + for (const tc of pendingToolCalls) { + if (!existingToolResultIds.has(tc.id)) { + result.push({ + role: "toolResult", + toolCallId: tc.id, + toolName: tc.name, + content: [{ type: "text", text: "No result provided" }], + isError: true, + timestamp: Date.now(), + } as ToolResultMessage); + } + } + pendingToolCalls = []; + existingToolResultIds = new Set(); + } + }; + + for (let i = 0; i < transformed.length; i++) { + const msg = transformed[i]; + + if (msg.role === "assistant") { + // If we have pending orphaned tool calls from a previous assistant, insert synthetic results now + insertSyntheticToolResults(); + + // Skip errored/aborted assistant messages entirely. + // These are incomplete turns that shouldn't be replayed: + // - May have partial content (reasoning without message, incomplete tool calls) + // - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item") + // - The model should retry from the last valid state + const assistantMsg = msg as AssistantMessage; + if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { + continue; + } + + // Track tool calls from this assistant message + const toolCalls = assistantMsg.content.filter((b) => b.type === "toolCall") as ToolCall[]; + if (toolCalls.length > 0) { + pendingToolCalls = toolCalls; + existingToolResultIds = new Set(); + } + + result.push(msg); + } else if (msg.role === "toolResult") { + existingToolResultIds.add(msg.toolCallId); + result.push(msg); + } else if (msg.role === "user") { + // User message interrupts tool flow - insert synthetic results for orphaned calls + insertSyntheticToolResults(); + result.push(msg); + } else { + result.push(msg); + } + } + + // If the conversation ends with unresolved tool calls, synthesize results now. + insertSyntheticToolResults(); + + return result; +} diff --git a/cactus-code/packages/ai/src/auth/context.ts b/cactus-code/packages/ai/src/auth/context.ts new file mode 100644 index 000000000..30e088bfa --- /dev/null +++ b/cactus-code/packages/ai/src/auth/context.ts @@ -0,0 +1,45 @@ +import type { AuthContext } from "./types.ts"; + +interface NodeFsModule { + access(path: string): Promise; +} + +interface NodeOsModule { + homedir(): string; +} + +// Variable specifier so browser bundlers do not try to resolve node builtins. +const importNodeModule = (specifier: string): Promise => import(specifier); + +function getProcessEnv(): Record | undefined { + const proc = (globalThis as { process?: { env?: Record } }).process; + return proc?.env; +} + +/** + * Default auth context: env vars from `process.env` (undefined in browsers), + * file existence via node:fs (always false in browsers). + */ +export function defaultProviderAuthContext(): AuthContext { + return { + async env(name: string): Promise { + const value = getProcessEnv()?.[name]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; + }, + + async fileExists(path: string): Promise { + try { + const fs = (await importNodeModule("node:fs/promises")) as NodeFsModule; + let resolved = path; + if (resolved.startsWith("~")) { + const os = (await importNodeModule("node:os")) as NodeOsModule; + resolved = os.homedir() + resolved.slice(1); + } + await fs.access(resolved); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/cactus-code/packages/ai/src/auth/credential-store.ts b/cactus-code/packages/ai/src/auth/credential-store.ts new file mode 100644 index 000000000..beeb9d858 --- /dev/null +++ b/cactus-code/packages/ai/src/auth/credential-store.ts @@ -0,0 +1,47 @@ +import type { Credential, CredentialStore } from "./types.ts"; + +/** + * Default in-memory credential store. Apps inject persistent stores. + * Keyed by `Provider.id`, one credential per provider; see `CredentialStore`. + * Writes are serialized per provider through a promise chain. + */ +export class InMemoryCredentialStore implements CredentialStore { + private credentials = new Map(); + private chains = new Map>(); + + /** Serialize tasks per provider id. */ + private enqueue(providerId: string, task: () => Promise): Promise { + const previous = this.chains.get(providerId) ?? Promise.resolve(); + const next = (async () => { + await previous.catch(() => {}); + return task(); + })(); + this.chains.set( + providerId, + next.catch(() => {}), + ); + return next; + } + + async read(providerId: string): Promise { + return this.credentials.get(providerId); + } + + modify( + providerId: string, + fn: (current: Credential | undefined) => Promise, + ): Promise { + return this.enqueue(providerId, async () => { + const current = this.credentials.get(providerId); + const next = await fn(current); + if (next !== undefined) this.credentials.set(providerId, next); + return next ?? current; + }); + } + + delete(providerId: string): Promise { + return this.enqueue(providerId, async () => { + this.credentials.delete(providerId); + }); + } +} diff --git a/cactus-code/packages/ai/src/auth/helpers.ts b/cactus-code/packages/ai/src/auth/helpers.ts new file mode 100644 index 000000000..d9a34ad27 --- /dev/null +++ b/cactus-code/packages/ai/src/auth/helpers.ts @@ -0,0 +1,46 @@ +import type { ApiKeyAuth, OAuthAuth } from "./types.ts"; + +/** + * Standard api-key auth: a stored credential key wins, otherwise the first + * set env var resolves. Includes a `login` that prompts for the key. + * Providers with non-standard resolution (provider env, ambient files, IAM) + * write their own `ApiKeyAuth`. + */ +export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth { + return { + name, + login: async (callbacks) => { + const key = await callbacks.prompt({ type: "secret", message: `Enter ${name}` }); + return { type: "api_key", key }; + }, + resolve: async ({ ctx, credential }) => { + if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored credential" }; + for (const envVar of envVars) { + const value = await ctx.env(envVar); + if (value) return { auth: { apiKey: value }, source: envVar }; + } + return undefined; + }, + }; +} + +/** + * Wraps a dynamically imported `OAuthAuth` so provider definitions can + * advertise OAuth without importing the implementation. The flow loads on + * first `login`/`refresh`/`toAuth` call; callers keep Node-only flow code out + * of bundles by loading through a bundler-opaque dynamic import (variable + * specifier, see the bedrock lazy wrapper). + */ +export function lazyOAuth(input: { name: string; load: () => Promise }): OAuthAuth { + let promise: Promise | undefined; + const loaded = () => { + promise ??= input.load(); + return promise; + }; + return { + name: input.name, + login: async (callbacks) => (await loaded()).login(callbacks), + refresh: async (credential) => (await loaded()).refresh(credential), + toAuth: async (credential) => (await loaded()).toAuth(credential), + }; +} diff --git a/cactus-code/packages/ai/src/auth/resolve.ts b/cactus-code/packages/ai/src/auth/resolve.ts new file mode 100644 index 000000000..81d7a2707 --- /dev/null +++ b/cactus-code/packages/ai/src/auth/resolve.ts @@ -0,0 +1,141 @@ +import type { Api, ImagesApi, ImagesModel, Model, ProviderEnv } from "../types.ts"; +import type { + ApiKeyAuth, + ApiKeyCredential, + AuthContext, + AuthResult, + Credential, + CredentialStore, + OAuthAuth, + OAuthCredential, + ProviderAuth, +} from "./types.ts"; + +export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth"; + +export interface AuthResolutionOverrides { + apiKey?: string; + env?: ProviderEnv; +} + +export class ModelsError extends Error { + readonly code: ModelsErrorCode; + + constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ModelsError"; + this.code = code; + } +} + +/** Model shape auth resolution receives: chat or image-generation models. */ +export type AuthModel = Model | ImagesModel; + +/** + * Auth resolution shared by the `Models` and `ImagesModels` collections. + * A stored credential owns the provider: ambient/env is consulted only when + * nothing is stored. No silent env fallback after a failed refresh or for a + * credential type without a matching handler. + */ +export async function resolveProviderAuth( + provider: { id: string; auth: ProviderAuth }, + model: AuthModel, + credentials: CredentialStore, + authContext: AuthContext, + overrides?: AuthResolutionOverrides, +): Promise { + const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext; + + if (overrides?.apiKey !== undefined && provider.auth.apiKey) { + return resolveApiKey(requestAuthContext, provider.auth.apiKey, model, { + type: "api_key", + key: overrides.apiKey, + env: overrides.env, + }); + } + + const stored = await readCredential(credentials, provider.id); + if (stored) { + if (stored.type === "oauth" && provider.auth.oauth) { + return resolveStoredOAuth(credentials, provider.id, provider.auth.oauth, stored); + } + if (stored.type === "api_key" && provider.auth.apiKey) { + const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored; + return resolveApiKey(requestAuthContext, provider.auth.apiKey, model, credential); + } + return undefined; + } + + // Ambient (env vars, AWS profiles, ADC files). + return provider.auth.apiKey ? resolveApiKey(requestAuthContext, provider.auth.apiKey, model, undefined) : undefined; +} + +function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext { + return { + env: async (name) => env[name] || (await base.env(name)), + fileExists: (path) => base.fileExists(path), + }; +} + +/** + * OAuth resolution with double-checked locking (same pattern as today's + * AuthStorage): valid tokens cost zero locks; expired tokens lock, re-check + * expiry under the lock, refresh once globally, and persist the rotated + * credential before release. + */ +async function resolveStoredOAuth( + credentials: CredentialStore, + providerId: string, + oauth: OAuthAuth, + stored: OAuthCredential, +): Promise { + let credential = stored; + + if (Date.now() >= credential.expires) { + // Optimistic check said expired; the authoritative check runs under the lock. + let post: Credential | undefined; + try { + post = await credentials.modify(providerId, async (current) => { + if (current?.type !== "oauth") return undefined; // logged out meanwhile + if (Date.now() < current.expires) return undefined; // another process/request refreshed + try { + return await oauth.refresh(current); + } catch (error) { + throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error }); + } + }); + } catch (error) { + if (error instanceof ModelsError) throw error; + throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error }); + } + if (post?.type !== "oauth") return undefined; // logged out meanwhile + credential = post; + } + + try { + return { auth: await oauth.toAuth(credential), source: "OAuth" }; + } catch (error) { + throw new ModelsError("oauth", `OAuth auth derivation failed for ${providerId}`, { cause: error }); + } +} + +async function resolveApiKey( + authContext: AuthContext, + apiKey: ApiKeyAuth, + model: AuthModel, + credential: ApiKeyCredential | undefined, +): Promise { + try { + return await apiKey.resolve({ model, ctx: authContext, credential }); + } catch (error) { + throw new ModelsError("auth", `API key auth failed for provider ${model.provider}`, { cause: error }); + } +} + +async function readCredential(credentials: CredentialStore, providerId: string): Promise { + try { + return await credentials.read(providerId); + } catch (error) { + throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error }); + } +} diff --git a/cactus-code/packages/ai/src/auth/types.ts b/cactus-code/packages/ai/src/auth/types.ts new file mode 100644 index 000000000..9710dbcbc --- /dev/null +++ b/cactus-code/packages/ai/src/auth/types.ts @@ -0,0 +1,182 @@ +import type { Api, ImagesApi, ImagesModel, Model, ProviderEnv, ProviderHeaders } from "../types.ts"; +import type { OAuthCredentials } from "../utils/oauth/types.ts"; + +/** + * Request auth for a single model request. If a value cannot be expressed as + * `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth. + */ +export interface ModelAuth { + apiKey?: string; + headers?: ProviderHeaders; + baseUrl?: string; +} + +/** + * Stored api-key credential. `env` holds provider-scoped environment/config + * values such as Cloudflare account/gateway ids. + */ +export interface ApiKeyCredential { + type: "api_key"; + key?: string; + env?: ProviderEnv; +} + +/** Stored OAuth credential (`access`, `refresh`, `expires` from OAuthCredentials). */ +export interface OAuthCredential extends OAuthCredentials { + type: "oauth"; +} + +/** One type-tagged credential per provider — the shape of today's auth.json. */ +export type Credential = ApiKeyCredential | OAuthCredential; + +/** + * App-owned credential storage, keyed by `Provider.id`, one credential per + * provider. `modify` is the only write path, so every mutation is a + * serialized read-modify-write; `Models.getAuth()` runs OAuth refresh inside + * `modify` so concurrent requests cannot double-refresh a rotated token. The + * app persists a credential after login via + * `modify(provider.id, async () => credential)`. Login/logout orchestration + * is app-owned. + * + * Error semantics: `read` resolves `undefined` for missing entries. Methods + * reject only on storage failure; `Models` wraps such rejections in + * `ModelsError` with code "auth". Best-effort stores that serve an in-memory + * view and record persistence errors internally (like coding-agent's + * AuthStorage) are valid implementations. + */ +export interface CredentialStore { + /** + * Read the stored credential, possibly expired. Display/status use; + * resolved request auth comes from `Models.getAuth()`. + */ + read(providerId: string): Promise; + + /** + * Serialized write — the only write path. `fn` sees the current credential + * because correct writes (refresh, login-during-refresh) depend on it; + * return the new credential, or undefined to leave the entry unchanged. + * Mutual exclusion per provider id, cross-process too where the backing + * store supports it (e.g. a file lock). Resolves with the post-write + * credential. Rejections from `fn` propagate. + */ + modify( + providerId: string, + fn: (current: Credential | undefined) => Promise, + ): Promise; + + /** Remove a credential (logout). Implementations serialize this against `modify`. */ + delete(providerId: string): Promise; +} + +/** Environment access for auth resolution. Injectable for tests and browsers. */ +export interface AuthContext { + env(name: string): Promise; + /** Check whether a file exists. Supports a leading `~`. Always false in browsers. */ + fileExists(path: string): Promise; +} + +/** Result of resolving auth for a model. */ +export interface AuthResult { + auth: ModelAuth; + /** Provider-scoped environment/config values resolved from credentials and ambient context. */ + env?: ProviderEnv; + /** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */ + source?: string; +} + +/** + * Prompt shown to the user during login. `signal` lets the flow cancel a + * pending prompt when an out-of-band event resolves the step, e.g. a + * `manual_code` prompt raced against a callback server, aborted when the + * callback wins. + */ +export type AuthPrompt = { signal?: AbortSignal } & ( + | { type: "text"; message: string; placeholder?: string } + | { type: "secret"; message: string; placeholder?: string } + | { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] } + | { type: "manual_code"; message: string; placeholder?: string } +); + +export type AuthEvent = + | { type: "auth_url"; url: string; instructions?: string } + | { + type: "device_code"; + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; + } + | { type: "progress"; message: string }; + +/** + * Login interaction callbacks serving both api-key and OAuth flows. + * + * `prompt()` returns the entered/selected string (`select` returns the option + * id). Rejects on cancel/abort. `signal` aborts the whole login flow; + * per-prompt cancellation uses `AuthPrompt.signal`. + */ +export interface AuthLoginCallbacks { + signal?: AbortSignal; + + prompt(prompt: AuthPrompt): Promise; + notify(event: AuthEvent): void; +} + +/** + * Api-key auth: stored key/provider env plus ambient sources (env vars, AWS + * profiles, ADC files). Ambient-only providers omit `login`. + */ +export interface ApiKeyAuth { + /** Display name, e.g. "Anthropic API key". */ + name: string; + + /** Interactive setup (prompt for key/provider env). Absent = ambient-only. */ + login?(callbacks: AuthLoginCallbacks): Promise; + + /** + * Resolve auth from the stored credential and/or ambient sources, merging + * per field (`credential.key ?? env("...")`, `credential.env?.NAME ?? env("...")`). + * undefined = not configured. Receives the chat or image-generation model + * the request is for (both carry `provider` and `baseUrl`). + */ + resolve(input: { + model: Model | ImagesModel; + ctx: AuthContext; + credential?: ApiKeyCredential; + }): Promise; +} + +/** + * OAuth auth. The `refresh`/`toAuth` split lets `Models` own the locked + * refresh pattern: `refresh` produces a credential, `toAuth` derives request + * auth from whatever credential ends up stored. + */ +export interface OAuthAuth { + /** Display name, e.g. "Anthropic (Claude Pro/Max)". */ + name: string; + + login(callbacks: AuthLoginCallbacks): Promise; + + /** + * Exchange the refresh token. Network call; throws on failure + * (invalid_grant etc.). `Models` runs this under the store lock. + */ + refresh(credential: OAuthCredential): Promise; + + /** + * Side-effect-free derivation of request auth from a valid credential. + * Covers per-credential baseUrl (GitHub Copilot). Async so lazy wrappers + * can load the implementation on first use. + */ + toAuth(credential: OAuthCredential): Promise; +} + +/** + * Provider auth. At least one of `apiKey`/`oauth` must be present: even + * ambient-credential providers and keyless local servers provide `apiKey` + * auth whose `resolve()` reports whether the provider is configured. + */ +export interface ProviderAuth { + apiKey?: ApiKeyAuth; + oauth?: OAuthAuth; +} diff --git a/cactus-code/packages/ai/src/compat.ts b/cactus-code/packages/ai/src/compat.ts new file mode 100644 index 000000000..e5d3f7761 --- /dev/null +++ b/cactus-code/packages/ai/src/compat.ts @@ -0,0 +1,246 @@ +/** + * Temporary compatibility entrypoint preserving the old global pi-ai API + * surface: api-dispatch `stream()`/`complete()` with env API key injection, + * the api-registry, generated catalog reads (`getModel`/`getModels`/ + * `getProviders`), per-API lazy stream wrappers, and image generation. + * + * Existing apps switch imports from "@earendil-works/pi-ai" to + * "@earendil-works/pi-ai/compat" unchanged; new code uses `createModels()` + * and the provider factories. This module is deleted with the coding-agent + * ModelManager migration. + */ + +export * from "./api/openai-completions.lazy.ts"; +export * from "./env-api-keys.ts"; +export * from "./index.ts"; + +import { openAICompletionsApi } from "./api/openai-completions.lazy.ts"; +import { getEnvApiKey } from "./env-api-keys.ts"; +import { builtinModels, getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "./providers/all.ts"; +import { createFauxCore, type FauxProviderRegistration, type RegisterFauxProviderOptions } from "./providers/faux.ts"; +import type { + Api, + ApiStreamOptions, + AssistantMessage, + AssistantMessageEventStream, + Context, + Model, + ProviderStreamOptions, + ProviderStreams, + SimpleStreamOptions, + StreamFunction, + StreamOptions, +} from "./types.ts"; + +/** @deprecated Static catalog read. Use `getBuiltinModel` from "@earendil-works/pi-ai/providers/all" or `Models.getModel()`. */ +export const getModel = getBuiltinModel; + +/** @deprecated Static catalog read. Use `getBuiltinModels` from "@earendil-works/pi-ai/providers/all" or `Models.getModels()`. */ +export const getModels = getBuiltinModels; + +/** @deprecated Static catalog read. Use `getBuiltinProviders` from "@earendil-works/pi-ai/providers/all" or `Models.getProviders()`. */ +export const getProviders = getBuiltinProviders; + +export type ApiStreamFunction = ( + model: Model, + context: Context, + options?: StreamOptions, +) => AssistantMessageEventStream; + +export type ApiStreamSimpleFunction = ( + model: Model, + context: Context, + options?: SimpleStreamOptions, +) => AssistantMessageEventStream; + +export interface ApiProvider { + api: TApi; + stream: StreamFunction; + streamSimple: StreamFunction; +} + +interface ApiProviderInternal { + api: Api; + stream: ApiStreamFunction; + streamSimple: ApiStreamSimpleFunction; +} + +type RegisteredApiProvider = { + provider: ApiProviderInternal; + sourceId?: string; +}; + +const apiProviderRegistry = new Map(); + +function wrapStream( + api: TApi, + stream: StreamFunction, +): ApiStreamFunction { + return (model, context, options) => { + if (model.api !== api) { + throw new Error(`Mismatched api: ${model.api} expected ${api}`); + } + return stream(model as Model, context, options as TOptions); + }; +} + +function wrapStreamSimple( + api: TApi, + streamSimple: StreamFunction, +): ApiStreamSimpleFunction { + return (model, context, options) => { + if (model.api !== api) { + throw new Error(`Mismatched api: ${model.api} expected ${api}`); + } + return streamSimple(model as Model, context, options); + }; +} + +export function registerApiProvider( + provider: ApiProvider, + sourceId?: string, +): void { + apiProviderRegistry.set(provider.api, { + provider: { + api: provider.api, + stream: wrapStream(provider.api, provider.stream), + streamSimple: wrapStreamSimple(provider.api, provider.streamSimple), + }, + sourceId, + }); +} + +export function getApiProvider(api: Api): ApiProviderInternal | undefined { + return apiProviderRegistry.get(api)?.provider; +} + +export function getApiProviders(): ApiProviderInternal[] { + return Array.from(apiProviderRegistry.values(), (entry) => entry.provider); +} + +export function unregisterApiProviders(sourceId: string): void { + for (const [api, entry] of apiProviderRegistry.entries()) { + if (entry.sourceId === sourceId) { + apiProviderRegistry.delete(api); + } + } +} + +function clearApiProviders(): void { + apiProviderRegistry.clear(); +} + +export function registerFauxProvider(options: RegisterFauxProviderOptions = {}): FauxProviderRegistration { + const core = createFauxCore(options); + const sourceId = `faux-provider-${Math.random().toString(36).slice(2, 10)}`; + registerApiProvider({ api: core.api, stream: core.stream, streamSimple: core.streamSimple }, sourceId); + return { + api: core.api, + models: core.models, + getModel: core.getModel, + state: core.state, + setResponses: core.setResponses, + appendResponses: core.appendResponses, + getPendingResponseCount: core.getPendingResponseCount, + unregister() { + unregisterApiProviders(sourceId); + }, + }; +} + +const BUILTIN_APIS: [Api, ProviderStreams][] = [["openai-completions", openAICompletionsApi()]]; + +const builtinApiProviderInstances = new Map>(); + +/** + * Registers the builtin API implementations into the api-registry without + * clobbering existing entries: compat may load after a test or extension has + * already registered an override for a builtin api id. + */ +export function registerBuiltInApiProviders(): void { + for (const [api, streams] of BUILTIN_APIS) { + if (!getApiProvider(api)) { + registerApiProvider({ api, stream: streams.stream, streamSimple: streams.streamSimple }); + } + builtinApiProviderInstances.set(api, getApiProvider(api)); + } +} + +export function resetApiProviders(): void { + clearApiProviders(); + builtinApiProviderInstances.clear(); + registerBuiltInApiProviders(); +} + +registerBuiltInApiProviders(); + +const compatModels = builtinModels(); + +function hasExplicitApiKey(apiKey: string | undefined): apiKey is string { + return typeof apiKey === "string" && apiKey.trim().length > 0; +} + +function withEnvApiKey( + model: Model, + options: TOptions | undefined, +): TOptions | undefined { + if (hasExplicitApiKey(options?.apiKey)) return options; + const apiKey = getEnvApiKey(model.provider, options?.env); + if (!apiKey) return options; + return { ...options, apiKey } as TOptions; +} + +function shouldUseBuiltinModels(model: Model): boolean { + const builtin = compatModels.getModel(model.provider, model.id); + return builtin?.api === model.api && getApiProvider(model.api) === builtinApiProviderInstances.get(model.api); +} + +function resolveApiProvider(api: Api) { + const provider = getApiProvider(api); + if (!provider) { + throw new Error(`No API provider registered for api: ${api}`); + } + return provider; +} + +export function stream( + model: Model, + context: Context, + options?: ProviderStreamOptions, +): AssistantMessageEventStream { + if (shouldUseBuiltinModels(model)) { + return compatModels.stream(model, context, options as ApiStreamOptions | undefined); + } + const provider = resolveApiProvider(model.api); + return provider.stream(model, context, withEnvApiKey(model, options) as StreamOptions); +} + +export async function complete( + model: Model, + context: Context, + options?: ProviderStreamOptions, +): Promise { + const s = stream(model, context, options); + return s.result(); +} + +export function streamSimple( + model: Model, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream { + if (shouldUseBuiltinModels(model)) { + return compatModels.streamSimple(model, context, options); + } + const provider = resolveApiProvider(model.api); + return provider.streamSimple(model, context, withEnvApiKey(model, options)); +} + +export async function completeSimple( + model: Model, + context: Context, + options?: SimpleStreamOptions, +): Promise { + const s = streamSimple(model, context, options); + return s.result(); +} diff --git a/cactus-code/packages/ai/src/env-api-keys.ts b/cactus-code/packages/ai/src/env-api-keys.ts new file mode 100644 index 000000000..7bd955e1f --- /dev/null +++ b/cactus-code/packages/ai/src/env-api-keys.ts @@ -0,0 +1,177 @@ +// NEVER convert to top-level imports - breaks browser/Vite builds +let _existsSync: typeof import("node:fs").existsSync | null = null; +let _homedir: typeof import("node:os").homedir | null = null; +let _join: typeof import("node:path").join | null = null; + +type DynamicImport = (specifier: string) => Promise; + +const dynamicImport: DynamicImport = (specifier) => import(specifier); +const NODE_FS_SPECIFIER = "node:" + "fs"; +const NODE_OS_SPECIFIER = "node:" + "os"; +const NODE_PATH_SPECIFIER = "node:" + "path"; + +// Eagerly load in Node.js/Bun environment only +if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { + dynamicImport(NODE_FS_SPECIFIER).then((m) => { + _existsSync = (m as typeof import("node:fs")).existsSync; + }); + dynamicImport(NODE_OS_SPECIFIER).then((m) => { + _homedir = (m as typeof import("node:os")).homedir; + }); + dynamicImport(NODE_PATH_SPECIFIER).then((m) => { + _join = (m as typeof import("node:path")).join; + }); +} + +import type { KnownProvider, ProviderEnv } from "./types.ts"; +import { getProviderEnvValue } from "./utils/provider-env.ts"; + +let cachedVertexAdcCredentialsExists: boolean | null = null; + +function hasVertexAdcCredentials(env?: ProviderEnv): boolean { + const explicitCredentialsPath = env?.GOOGLE_APPLICATION_CREDENTIALS; + if (explicitCredentialsPath) { + return _existsSync ? _existsSync(explicitCredentialsPath) : false; + } + + if (cachedVertexAdcCredentialsExists === null) { + // If node modules haven't loaded yet (async import race at startup), + // return false WITHOUT caching so the next call retries once they're ready. + // Only cache false permanently in a browser environment where fs is never available. + if (!_existsSync || !_homedir || !_join) { + const isNode = typeof process !== "undefined" && (process.versions?.node || process.versions?.bun); + if (!isNode) { + // Definitively in a browser — safe to cache false permanently + cachedVertexAdcCredentialsExists = false; + } + return false; + } + + // Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way) + const gacPath = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env); + if (gacPath) { + cachedVertexAdcCredentialsExists = _existsSync(gacPath); + } else { + // Fall back to default ADC path (lazy evaluation) + cachedVertexAdcCredentialsExists = _existsSync( + _join(_homedir(), ".config", "gcloud", "application_default_credentials.json"), + ); + } + } + return cachedVertexAdcCredentialsExists; +} + +function getApiKeyEnvVars(provider: string): readonly string[] | undefined { + if (provider === "github-copilot") { + return ["COPILOT_GITHUB_TOKEN"]; + } + + // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY + if (provider === "anthropic") { + return ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]; + } + + const envMap: Record = { + "ant-ling": "ANT_LING_API_KEY", + openai: "OPENAI_API_KEY", + "azure-openai-responses": "AZURE_OPENAI_API_KEY", + nvidia: "NVIDIA_API_KEY", + deepseek: "DEEPSEEK_API_KEY", + google: "GEMINI_API_KEY", + "google-vertex": "GOOGLE_CLOUD_API_KEY", + groq: "GROQ_API_KEY", + cerebras: "CEREBRAS_API_KEY", + xai: "XAI_API_KEY", + openrouter: "OPENROUTER_API_KEY", + "vercel-ai-gateway": "AI_GATEWAY_API_KEY", + zai: "ZAI_API_KEY", + "zai-coding-cn": "ZAI_CODING_CN_API_KEY", + mistral: "MISTRAL_API_KEY", + minimax: "MINIMAX_API_KEY", + "minimax-cn": "MINIMAX_CN_API_KEY", + moonshotai: "MOONSHOT_API_KEY", + "moonshotai-cn": "MOONSHOT_API_KEY", + huggingface: "HF_TOKEN", + fireworks: "FIREWORKS_API_KEY", + together: "TOGETHER_API_KEY", + opencode: "OPENCODE_API_KEY", + "opencode-go": "OPENCODE_API_KEY", + "kimi-coding": "KIMI_API_KEY", + "cloudflare-workers-ai": "CLOUDFLARE_API_KEY", + "cloudflare-ai-gateway": "CLOUDFLARE_API_KEY", + xiaomi: "XIAOMI_API_KEY", + "xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY", + "xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY", + "xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY", + }; + + const envVar = envMap[provider]; + return envVar ? [envVar] : undefined; +} + +/** + * Find configured environment variables that can provide an API key for a provider. + * + * This only reports actual API key variables. It intentionally excludes ambient + * credential sources such as AWS profiles, AWS IAM credentials, and Google + * Application Default Credentials. + */ +export function findEnvKeys(provider: KnownProvider, env?: ProviderEnv): string[] | undefined; +export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined; +export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined { + const envVars = getApiKeyEnvVars(provider); + if (!envVars) return undefined; + + const found = envVars.filter((envVar) => !!getProviderEnvValue(envVar, env)); + return found.length > 0 ? found : undefined; +} + +/** + * Get API key for provider from known environment variables, e.g. OPENAI_API_KEY. + * + * Will not return API keys for providers that require OAuth tokens. + */ +export function getEnvApiKey(provider: KnownProvider, env?: ProviderEnv): string | undefined; +export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined; +export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined { + const envKeys = findEnvKeys(provider, env); + if (envKeys?.[0]) { + return getProviderEnvValue(envKeys[0], env); + } + + // Vertex AI supports either an explicit API key or Application Default Credentials. + // Auth is configured via `gcloud auth application-default login`. + if (provider === "google-vertex") { + const hasCredentials = hasVertexAdcCredentials(env); + const hasProject = !!( + getProviderEnvValue("GOOGLE_CLOUD_PROJECT", env) || getProviderEnvValue("GCLOUD_PROJECT", env) + ); + const hasLocation = !!getProviderEnvValue("GOOGLE_CLOUD_LOCATION", env); + + if (hasCredentials && hasProject && hasLocation) { + return ""; + } + } + + if (provider === "amazon-bedrock") { + // Amazon Bedrock supports multiple credential sources: + // 1. AWS_PROFILE - named profile from ~/.aws/credentials + // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys + // 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock bearer token + // 4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - ECS task roles + // 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI) + // 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts) + if ( + getProviderEnvValue("AWS_PROFILE", env) || + (getProviderEnvValue("AWS_ACCESS_KEY_ID", env) && getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env)) || + getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", env) || + getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", env) || + getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI", env) || + getProviderEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE", env) + ) { + return ""; + } + } + + return undefined; +} diff --git a/cactus-code/packages/ai/src/index.ts b/cactus-code/packages/ai/src/index.ts new file mode 100644 index 000000000..ddfbb6ef6 --- /dev/null +++ b/cactus-code/packages/ai/src/index.ts @@ -0,0 +1,37 @@ +export type { Static, TSchema } from "typebox"; +export { Type } from "typebox"; + +// Core only, side-effect free: no generated catalogs, no provider factories, +// no api-registry, no OAuth implementations, no compat. Provider factories +// live under "@earendil-works/pi-ai/providers/*", API implementations under +// "@earendil-works/pi-ai/api/*", the old global API under +// "@earendil-works/pi-ai/compat". +export * from "./api/lazy.ts"; +export type { OpenAICompletionsOptions } from "./api/openai-completions.ts"; +export * from "./auth/context.ts"; +export * from "./auth/credential-store.ts"; +export * from "./auth/helpers.ts"; +export * from "./auth/types.ts"; +export * from "./models.ts"; +export * from "./providers/faux.ts"; +export * from "./session-resources.ts"; +export * from "./types.ts"; +export * from "./utils/diagnostics.ts"; +export * from "./utils/event-stream.ts"; +export * from "./utils/json-parse.ts"; +export type { + OAuthAuthInfo, + OAuthCredentials, + OAuthDeviceCodeInfo, + OAuthLoginCallbacks, + OAuthPrompt, + OAuthProvider, + OAuthProviderId, + OAuthProviderInfo, + OAuthProviderInterface, + OAuthSelectOption, + OAuthSelectPrompt, +} from "./utils/oauth/types.ts"; +export * from "./utils/overflow.ts"; +export * from "./utils/typebox-helpers.ts"; +export * from "./utils/validation.ts"; diff --git a/cactus-code/packages/ai/src/models.generated.ts b/cactus-code/packages/ai/src/models.generated.ts new file mode 100644 index 000000000..c06f3454a --- /dev/null +++ b/cactus-code/packages/ai/src/models.generated.ts @@ -0,0 +1,3 @@ +export const MODELS = { + cactus: {} as Record, +} as const; diff --git a/cactus-code/packages/ai/src/models.ts b/cactus-code/packages/ai/src/models.ts new file mode 100644 index 000000000..f9cf27d36 --- /dev/null +++ b/cactus-code/packages/ai/src/models.ts @@ -0,0 +1,441 @@ +import { lazyStream } from "./api/lazy.ts"; +import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts"; +import { InMemoryCredentialStore } from "./auth/credential-store.ts"; +import { ModelsError, resolveProviderAuth } from "./auth/resolve.ts"; +import type { AuthContext, AuthResult, CredentialStore, ProviderAuth } from "./auth/types.ts"; +import type { + Api, + ApiStreamOptions, + AssistantMessage, + AssistantMessageEventStream, + Context, + Model, + ModelThinkingLevel, + ProviderHeaders, + ProviderStreams, + SimpleStreamOptions, + StreamOptions, + Usage, +} from "./types.ts"; + +export { type AuthModel, ModelsError, type ModelsErrorCode } from "./auth/resolve.ts"; + +/** + * A provider is the concrete runtime unit. It owns id/name/base metadata, + * auth methods, model listing, and stream behavior. + * + * `TApi` lets concrete provider factories declare which APIs their models + * use (e.g. `openaiProvider(): Provider<"openai-responses" | "openai-completions">`), + * giving typed model lists to direct factory users. Inside a `Models` + * collection providers are held as `Provider`. + */ +export interface Provider { + readonly id: string; + readonly name: string; + + readonly baseUrl?: string; + readonly headers?: ProviderHeaders; + + /** + * Required: at least one of `apiKey`/`oauth`. Every provider has auth + * semantics — even providers with only ambient credentials (env vars, AWS + * profiles, ADC files) and keyless local servers provide `apiKey` auth + * whose `resolve()` reports whether the provider is configured. + * `Models.getAuth()` returns undefined when the provider is unconfigured. + */ + readonly auth: ProviderAuth; + + /** + * Current known models, sync. Static providers return their catalog; + * dynamic providers return the list as of the last `refreshModels()` + * (empty before the first). Must not throw; `Models` treats a throwing + * implementation as having no models. + */ + getModels(): readonly Model[]; + + /** + * Dynamic providers only: fetch and update the model list. Side-effect-free + * discovery (no loading/downloading); provider-specific model lifecycle + * belongs in app commands. Concurrent calls share one in-flight fetch. + * May reject (network); on rejection the model list stays at its last-known + * state and a later call retries. + */ + refreshModels?(): Promise; + + stream( + model: Model, + context: Context, + options?: ApiStreamOptions, + ): AssistantMessageEventStream; + + streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; +} + +/** + * Runtime collection of providers plus auth application and stream + * convenience. Providers own stream behavior; `Models` resolves auth and + * delegates each request to the provider that owns the model. + */ +export interface Models { + getProviders(): readonly Provider[]; + getProvider(id: string): Provider | undefined; + + /** + * Sync read of last-known models from one provider or all providers. + * Best-effort: a provider whose `getModels()` throws yields no models. + */ + getModels(provider?: string): readonly Model[]; + + /** + * Sync runtime model lookup against last-known lists. Dynamic model lists + * are typed as `Model`; narrow with the `hasApi()` type guard. + */ + getModel(provider: string, id: string): Model | undefined; + + /** + * Ask dynamic providers to re-fetch their model lists. With a provider id, + * rejects with `ModelsError` ("model_source") on that provider's fetch + * failure; without one, refreshes all providers concurrently best-effort. + * Static providers (no `refreshModels`) are no-ops. + */ + refresh(provider?: string): Promise; + + /** + * Resolve request auth for a model. Includes a source label for status UI. + * Resolves `undefined` when the provider is unknown or unconfigured. + * Rejects with `ModelsError`: code "oauth" when a token refresh fails (the + * stored credential is preserved for retry; re-login fixes it), code "auth" + * when api-key resolution or the credential store fails. Request paths + * surface rejections as stream errors; status/availability UIs catch them + * and render "needs re-login" instead of treating them as unconfigured. + */ + getAuth(model: Model): Promise; + + stream( + model: Model, + context: Context, + options?: ApiStreamOptions, + ): AssistantMessageEventStream; + + complete( + model: Model, + context: Context, + options?: ApiStreamOptions, + ): Promise; + + streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; + completeSimple(model: Model, context: Context, options?: SimpleStreamOptions): Promise; +} + +export interface MutableModels extends Models { + /** Upsert/replace by provider.id. Provider ids are unique. */ + setProvider(provider: Provider): void; + deleteProvider(id: string): void; + clearProviders(): void; +} + +export interface CreateModelsOptions { + credentials?: CredentialStore; + authContext?: AuthContext; +} + +class ModelsImpl implements MutableModels { + private providers = new Map(); + private credentials: CredentialStore; + private authContext: AuthContext; + + constructor(options?: CreateModelsOptions) { + this.credentials = options?.credentials ?? new InMemoryCredentialStore(); + this.authContext = options?.authContext ?? defaultAuthContext(); + } + + setProvider(provider: Provider): void { + this.providers.set(provider.id, provider); + } + + deleteProvider(id: string): void { + this.providers.delete(id); + } + + clearProviders(): void { + this.providers.clear(); + } + + getProviders(): readonly Provider[] { + return Array.from(this.providers.values()); + } + + getProvider(id: string): Provider | undefined { + return this.providers.get(id); + } + + getModels(provider?: string): readonly Model[] { + if (provider !== undefined) { + const entry = this.providers.get(provider); + if (!entry) return []; + try { + return entry.getModels(); + } catch { + return []; + } + } + + const models: Model[] = []; + for (const entry of this.providers.values()) { + try { + models.push(...entry.getModels()); + } catch { + // Best-effort: ill-behaved providers yield no models. + } + } + return models; + } + + getModel(provider: string, id: string): Model | undefined { + return this.getModels(provider).find((model) => model.id === id); + } + + async refresh(provider?: string): Promise { + if (provider !== undefined) { + const entry = this.providers.get(provider); + if (!entry?.refreshModels) return; + try { + await entry.refreshModels(); + } catch (error) { + if (error instanceof ModelsError) throw error; + throw new ModelsError("model_source", `Model refresh failed for ${provider}`, { cause: error }); + } + return; + } + + // Cannot reject: the async mapper turns even sync throws from ill-behaved + // providers into rejections, and allSettled captures all of them. + await Promise.allSettled(Array.from(this.providers.values(), async (entry) => entry.refreshModels?.())); + } + + async getAuth(model: Model): Promise { + const provider = this.providers.get(model.provider); + if (!provider) return undefined; + return resolveProviderAuth(provider, model, this.credentials, this.authContext); + } + + private requireProvider(model: Model): Provider { + const provider = this.providers.get(model.provider); + if (!provider) { + throw new ModelsError("provider", `Unknown provider: ${model.provider}`); + } + return provider; + } + + private async applyAuth( + model: Model, + options: TOptions | undefined, + ): Promise<{ requestModel: Model; requestOptions: TOptions | undefined }> { + const resolution = await resolveProviderAuth( + this.requireProvider(model), + model, + this.credentials, + this.authContext, + { + apiKey: options?.apiKey, + env: options?.env, + }, + ); + const auth = resolution?.auth; + if (!auth) return { requestModel: model, requestOptions: options }; + + const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; + + // Explicit request options win per-field; headers/env merge per key. + const apiKey = options?.apiKey ?? auth.apiKey; + const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined; + const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined; + const requestOptions = { ...options, apiKey, headers, env } as TOptions; + + return { requestModel, requestOptions }; + } + + stream( + model: Model, + context: Context, + options?: ApiStreamOptions, + ): AssistantMessageEventStream { + return lazyStream(model, async () => { + const provider = this.requireProvider(model); + const { requestModel, requestOptions } = await this.applyAuth(model, options as StreamOptions | undefined); + return provider.stream(requestModel as Model, context, requestOptions as ApiStreamOptions); + }); + } + + async complete( + model: Model, + context: Context, + options?: ApiStreamOptions, + ): Promise { + return this.stream(model, context, options).result(); + } + + streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream { + return lazyStream(model, async () => { + const provider = this.requireProvider(model); + const { requestModel, requestOptions } = await this.applyAuth(model, options); + return provider.streamSimple(requestModel, context, requestOptions); + }); + } + + async completeSimple(model: Model, context: Context, options?: SimpleStreamOptions): Promise { + return this.streamSimple(model, context, options).result(); + } +} + +export function createModels(options?: CreateModelsOptions): MutableModels { + return new ModelsImpl(options); +} + +export interface CreateProviderOptions { + id: string; + /** Display name. Default: `id`. */ + name?: string; + baseUrl?: string; + headers?: ProviderHeaders; + /** Required — every provider has auth semantics, even ambient/keyless ones. */ + auth: ProviderAuth; + /** Initial model list (empty for purely dynamic providers). */ + models: readonly Model[]; + /** + * Dynamic providers: fetch the current list. Stored on success; concurrent + * calls share one in-flight fetch. May reject: the stored list then stays + * at its last-known state, the rejection propagates to the caller of + * `refreshModels()` (wrapped as ModelsError "model_source" by + * `Models.refresh(provider)`), and a later call retries. + */ + refreshModels?: () => Promise[]>; + /** Single implementation, or map keyed by `model.api` for mixed-API providers. */ + api: ProviderStreams | Partial>; +} + +/** + * Builds a provider from parts. Built-in provider factories and models.json + * custom providers both go through this. A single `api` streams all models; + * an `api` map dispatches on `model.api`, and a model whose api has no entry + * produces a stream error. + */ +export function createProvider(input: CreateProviderOptions): Provider { + let models = input.models; + let inflightRefresh: Promise | undefined; + const refreshModels = input.refreshModels; + const single = + typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined; + const byApi = single ? undefined : (input.api as Partial>); + + const apiFor = (model: Model): ProviderStreams | undefined => single ?? byApi?.[model.api]; + + const dispatch = ( + model: Model, + run: (streams: ProviderStreams) => AssistantMessageEventStream, + ): AssistantMessageEventStream => { + const streams = apiFor(model); + if (!streams) { + return lazyStream(model, async () => { + throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`); + }); + } + return run(streams); + }; + + return { + id: input.id, + name: input.name ?? input.id, + baseUrl: input.baseUrl, + headers: input.headers, + auth: input.auth, + getModels: () => models, + refreshModels: refreshModels + ? () => { + inflightRefresh ??= (async () => { + try { + models = await refreshModels(); + } finally { + inflightRefresh = undefined; + } + })(); + return inflightRefresh; + } + : undefined, + stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)), + streamSimple: (model, context, options) => + dispatch(model, (streams) => streams.streamSimple(model, context, options)), + }; +} + +/** + * Runtime-checked narrowing for dynamically looked-up models: + * + * ```ts + * const model = models.getModel("anthropic", "claude-opus-4-7"); + * if (model && hasApi(model, "anthropic-messages")) { + * // model: Model<"anthropic-messages">, stream options fully typed + * } + * ``` + */ +export function hasApi(model: Model, api: TApi): model is Model { + return model.api === api; +} + +export function calculateCost(model: Model, usage: Usage): Usage["cost"] { + // Anthropic charges 2x base input for 1h cache writes. + const longWrite = usage.cacheWrite1h ?? 0; + const shortWrite = usage.cacheWrite - longWrite; + usage.cost.input = (model.cost.input / 1000000) * usage.input; + usage.cost.output = (model.cost.output / 1000000) * usage.output; + usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead; + usage.cost.cacheWrite = (model.cost.cacheWrite * shortWrite + model.cost.input * 2 * longWrite) / 1000000; + usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite; + return usage.cost; +} + +const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; + +export function getSupportedThinkingLevels(model: Model): ModelThinkingLevel[] { + if (!model.reasoning) return ["off"]; + + return EXTENDED_THINKING_LEVELS.filter((level) => { + const mapped = model.thinkingLevelMap?.[level]; + if (mapped === null) return false; + if (level === "xhigh") return mapped !== undefined; + return true; + }); +} + +export function clampThinkingLevel( + model: Model, + level: ModelThinkingLevel, +): ModelThinkingLevel { + const availableLevels = getSupportedThinkingLevels(model); + if (availableLevels.includes(level)) return level; + + const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level); + if (requestedIndex === -1) return availableLevels[0] ?? "off"; + + for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) { + const candidate = EXTENDED_THINKING_LEVELS[i]; + if (availableLevels.includes(candidate)) return candidate; + } + for (let i = requestedIndex - 1; i >= 0; i--) { + const candidate = EXTENDED_THINKING_LEVELS[i]; + if (availableLevels.includes(candidate)) return candidate; + } + return availableLevels[0] ?? "off"; +} + +/** + * Check if two models are equal by comparing both their id and provider. + * Returns false if either model is null or undefined. + */ +export function modelsAreEqual( + a: Model | null | undefined, + b: Model | null | undefined, +): boolean { + if (!a || !b) return false; + return a.id === b.id && a.provider === b.provider; +} diff --git a/cactus-code/packages/ai/src/oauth.ts b/cactus-code/packages/ai/src/oauth.ts new file mode 100644 index 000000000..489c8b89e --- /dev/null +++ b/cactus-code/packages/ai/src/oauth.ts @@ -0,0 +1,61 @@ +import type { + OAuthCredentials, + OAuthProviderId, + OAuthProviderInfo, + OAuthProviderInterface, +} from "./utils/oauth/types.ts"; + +export * from "./utils/oauth/types.ts"; + +const oauthProviderRegistry = new Map(); + +export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined { + return oauthProviderRegistry.get(id); +} + +export function registerOAuthProvider(provider: OAuthProviderInterface): void { + oauthProviderRegistry.set(provider.id, provider); +} + +export function unregisterOAuthProvider(id: string): void { + oauthProviderRegistry.delete(id); +} + +export function resetOAuthProviders(): void { + oauthProviderRegistry.clear(); +} + +export function getOAuthProviders(): OAuthProviderInterface[] { + return Array.from(oauthProviderRegistry.values()); +} + +export function getOAuthProviderInfoList(): OAuthProviderInfo[] { + return getOAuthProviders().map((p) => ({ id: p.id, name: p.name, available: true })); +} + +export async function refreshOAuthToken( + providerId: OAuthProviderId, + credentials: OAuthCredentials, +): Promise { + const provider = getOAuthProvider(providerId); + if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`); + return provider.refreshToken(credentials); +} + +export async function getOAuthApiKey( + providerId: OAuthProviderId, + credentials: Record, +): Promise<{ newCredentials: OAuthCredentials; apiKey: string } | null> { + const provider = getOAuthProvider(providerId); + if (!provider) throw new Error(`Unknown OAuth provider: ${providerId}`); + let creds = credentials[providerId]; + if (!creds) return null; + if (Date.now() >= creds.expires) { + try { + creds = await provider.refreshToken(creds); + } catch (_error) { + throw new Error(`Failed to refresh OAuth token for ${providerId}`); + } + } + return { newCredentials: creds, apiKey: provider.getApiKey(creds) }; +} diff --git a/cactus-code/packages/ai/src/providers/all.ts b/cactus-code/packages/ai/src/providers/all.ts new file mode 100644 index 000000000..3eebad1cd --- /dev/null +++ b/cactus-code/packages/ai/src/providers/all.ts @@ -0,0 +1,42 @@ +import { MODELS } from "../models.generated.ts"; +import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.ts"; +import type { Api, KnownProvider, Model } from "../types.ts"; +import { cactusProvider } from "./cactus.ts"; + +type BuiltinModelApi< + TProvider extends KnownProvider, + TModelId extends keyof (typeof MODELS)[TProvider], +> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never; + +export function getBuiltinModel( + provider: TProvider, + modelId: TModelId, +): Model> { + const models = MODELS[provider] as Record> | undefined; + return models?.[modelId as string] as Model>; +} + +export function getBuiltinProviders(): KnownProvider[] { + return Object.keys(MODELS) as KnownProvider[]; +} + +export function getBuiltinModels( + provider: TProvider, +): Model>[] { + const models = MODELS[provider] as Record> | undefined; + return models + ? (Object.values(models) as Model>[]) + : []; +} + +export function builtinProviders(): Provider[] { + return [cactusProvider()]; +} + +export function builtinModels(options?: CreateModelsOptions): MutableModels { + const models = createModels(options); + for (const provider of builtinProviders()) { + models.setProvider(provider); + } + return models; +} diff --git a/cactus-code/packages/ai/src/providers/cactus.ts b/cactus-code/packages/ai/src/providers/cactus.ts new file mode 100644 index 000000000..80fa3dd4f --- /dev/null +++ b/cactus-code/packages/ai/src/providers/cactus.ts @@ -0,0 +1,95 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import type { ApiKeyAuth } from "../auth/types.ts"; +import { createProvider, type Provider } from "../models.ts"; +import type { Model } from "../types.ts"; + +const DEFAULT_BASE_URL = "http://127.0.0.1:8080/v1"; +const DEFAULT_CONTEXT_WINDOW = 32768; +const DEFAULT_MAX_TOKENS = 8192; +const MAX_CONTEXT_WINDOW = 8_000_000; + +function sanitizeContextWindow(cw: unknown): number { + return typeof cw === "number" && Number.isFinite(cw) && cw > 0 && cw <= MAX_CONTEXT_WINDOW + ? Math.floor(cw) + : DEFAULT_CONTEXT_WINDOW; +} + +export function cactusBaseUrl(): string { + return process.env.CACTUS_BASE_URL?.trim().replace(/\/+$/, "") || DEFAULT_BASE_URL; +} + +function cactusAuth(): ApiKeyAuth { + return { + name: "Cactus (local, no auth)", + resolve: async () => ({ auth: { apiKey: "cactus" }, source: "local server" }), + }; +} + +function isVisionModel(id: string, modelType: string): boolean { + const s = `${id} ${modelType}`.toLowerCase(); + return /(?:^|[^a-z])vl(?:[^a-z]|$)|gemma-?4|gemma-?3n|qwen3\.5/.test(s); +} + +function isThinkingModel(id: string, modelType: string): boolean { + const s = `${id} ${modelType}`.toLowerCase(); + return /gemma-?4|qwen3/.test(s); +} + +interface OpenAIModelObject { + id?: string; + context_window?: number; + model_type?: string; +} +interface OpenAIModelsResponse { + data?: OpenAIModelObject[]; +} + +export function cactusModelFromId( + baseUrl: string, + id: string, + contextWindow: number = DEFAULT_CONTEXT_WINDOW, + modelType = "", +): Model<"openai-completions"> { + return { + id, + name: id, + api: "openai-completions", + provider: "cactus", + baseUrl, + reasoning: isThinkingModel(id, modelType), + input: isVisionModel(id, modelType) ? ["text", "image"] : ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: sanitizeContextWindow(contextWindow), + maxTokens: DEFAULT_MAX_TOKENS, + }; +} + +export async function fetchCactusModels(baseUrl: string = cactusBaseUrl()): Promise[]> { + const res = await fetch(`${baseUrl}/models`); + if (!res.ok) throw new Error(`Cactus model discovery failed: HTTP ${res.status}`); + const body = (await res.json()) as OpenAIModelsResponse; + const data = Array.isArray(body?.data) ? body.data : []; + return data + .filter((m): m is OpenAIModelObject & { id: string } => typeof m?.id === "string" && m.id.length > 0) + .map((m) => + cactusModelFromId( + baseUrl, + m.id, + typeof m.context_window === "number" ? m.context_window : DEFAULT_CONTEXT_WINDOW, + typeof m.model_type === "string" ? m.model_type : "", + ), + ); +} + +export function cactusProvider(): Provider<"openai-completions"> { + const baseUrl = cactusBaseUrl(); + return createProvider({ + id: "cactus", + name: "Cactus", + baseUrl, + auth: { apiKey: cactusAuth() }, + models: [], + refreshModels: () => fetchCactusModels(baseUrl), + api: openAICompletionsApi(), + }); +} diff --git a/cactus-code/packages/ai/src/providers/faux.ts b/cactus-code/packages/ai/src/providers/faux.ts new file mode 100644 index 000000000..4a26f1ad6 --- /dev/null +++ b/cactus-code/packages/ai/src/providers/faux.ts @@ -0,0 +1,538 @@ +import { createProvider, type Provider } from "../models.ts"; +import type { + AssistantMessage, + AssistantMessageEventStream, + Context, + ImageContent, + Message, + Model, + SimpleStreamOptions, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + ToolCall, + ToolResultMessage, + Usage, +} from "../types.ts"; +import { createAssistantMessageEventStream } from "../utils/event-stream.ts"; + +const DEFAULT_API = "faux"; +const DEFAULT_PROVIDER = "faux"; +const DEFAULT_MODEL_ID = "faux-1"; +const DEFAULT_MODEL_NAME = "Faux Model"; +const DEFAULT_BASE_URL = "http://localhost:0"; +const DEFAULT_MIN_TOKEN_SIZE = 3; +const DEFAULT_MAX_TOKEN_SIZE = 5; + +const DEFAULT_USAGE: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +export interface FauxModelDefinition { + id: string; + name?: string; + reasoning?: boolean; + input?: ("text" | "image")[]; + cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow?: number; + maxTokens?: number; +} + +export type FauxContentBlock = TextContent | ThinkingContent | ToolCall; + +export function fauxText(text: string): TextContent { + return { type: "text", text }; +} + +export function fauxThinking(thinking: string): ThinkingContent { + return { type: "thinking", thinking }; +} + +export function fauxToolCall(name: string, arguments_: ToolCall["arguments"], options: { id?: string } = {}): ToolCall { + return { + type: "toolCall", + id: options.id ?? randomId("tool"), + name, + arguments: arguments_, + }; +} + +function normalizeFauxAssistantContent(content: string | FauxContentBlock | FauxContentBlock[]): FauxContentBlock[] { + if (typeof content === "string") { + return [fauxText(content)]; + } + return Array.isArray(content) ? content : [content]; +} + +export function fauxAssistantMessage( + content: string | FauxContentBlock | FauxContentBlock[], + options: { + stopReason?: AssistantMessage["stopReason"]; + errorMessage?: string; + responseId?: string; + timestamp?: number; + } = {}, +): AssistantMessage { + return { + role: "assistant", + content: normalizeFauxAssistantContent(content), + api: DEFAULT_API, + provider: DEFAULT_PROVIDER, + model: DEFAULT_MODEL_ID, + usage: DEFAULT_USAGE, + stopReason: options.stopReason ?? "stop", + errorMessage: options.errorMessage, + responseId: options.responseId, + timestamp: options.timestamp ?? Date.now(), + }; +} + +export type FauxResponseFactory = ( + context: Context, + options: StreamOptions | undefined, + state: { callCount: number }, + model: Model, +) => AssistantMessage | Promise; + +export type FauxResponseStep = AssistantMessage | FauxResponseFactory; + +export interface RegisterFauxProviderOptions { + api?: string; + provider?: string; + models?: FauxModelDefinition[]; + tokensPerSecond?: number; + tokenSize?: { + min?: number; + max?: number; + }; +} + +export interface FauxProviderRegistration { + api: string; + models: [Model, ...Model[]]; + getModel(): Model; + getModel(modelId: string): Model | undefined; + state: { callCount: number }; + setResponses: (responses: FauxResponseStep[]) => void; + appendResponses: (responses: FauxResponseStep[]) => void; + getPendingResponseCount: () => number; + unregister: () => void; +} + +export interface FauxProviderHandle { + provider: Provider; + api: string; + models: [Model, ...Model[]]; + getModel(): Model; + getModel(modelId: string): Model | undefined; + state: { callCount: number }; + setResponses: (responses: FauxResponseStep[]) => void; + appendResponses: (responses: FauxResponseStep[]) => void; + getPendingResponseCount: () => number; +} + +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +function randomId(prefix: string): string { + return `${prefix}:${Date.now()}:${Math.random().toString(36).slice(2)}`; +} + +function contentToText(content: string | Array): string { + if (typeof content === "string") { + return content; + } + return content + .map((block) => { + if (block.type === "text") { + return block.text; + } + return `[image:${block.mimeType}:${block.data.length}]`; + }) + .join("\n"); +} + +function assistantContentToText(content: Array): string { + return content + .map((block) => { + if (block.type === "text") { + return block.text; + } + if (block.type === "thinking") { + return block.thinking; + } + return `${block.name}:${JSON.stringify(block.arguments)}`; + }) + .join("\n"); +} + +function toolResultToText(message: ToolResultMessage): string { + return [message.toolName, ...message.content.map((block) => contentToText([block]))].join("\n"); +} + +function messageToText(message: Message): string { + if (message.role === "user") { + return contentToText(message.content); + } + if (message.role === "assistant") { + return assistantContentToText(message.content); + } + return toolResultToText(message); +} + +function serializeContext(context: Context): string { + const parts: string[] = []; + if (context.systemPrompt) { + parts.push(`system:${context.systemPrompt}`); + } + for (const message of context.messages) { + parts.push(`${message.role}:${messageToText(message)}`); + } + if (context.tools?.length) { + parts.push(`tools:${JSON.stringify(context.tools)}`); + } + return parts.join("\n\n"); +} + +function commonPrefixLength(a: string, b: string): number { + const length = Math.min(a.length, b.length); + let index = 0; + while (index < length && a[index] === b[index]) { + index++; + } + return index; +} + +function withUsageEstimate( + message: AssistantMessage, + context: Context, + options: StreamOptions | undefined, + promptCache: Map, +): AssistantMessage { + const promptText = serializeContext(context); + const promptTokens = estimateTokens(promptText); + const outputTokens = estimateTokens(assistantContentToText(message.content)); + let input = promptTokens; + let cacheRead = 0; + let cacheWrite = 0; + const sessionId = options?.sessionId; + + if (sessionId && options?.cacheRetention !== "none") { + const previousPrompt = promptCache.get(sessionId); + if (previousPrompt) { + const cachedChars = commonPrefixLength(previousPrompt, promptText); + cacheRead = estimateTokens(previousPrompt.slice(0, cachedChars)); + cacheWrite = estimateTokens(promptText.slice(cachedChars)); + input = Math.max(0, promptTokens - cacheRead); + } else { + cacheWrite = promptTokens; + } + promptCache.set(sessionId, promptText); + } + + return { + ...message, + usage: { + input, + output: outputTokens, + cacheRead, + cacheWrite, + totalTokens: input + outputTokens + cacheRead + cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +function splitStringByTokenSize(text: string, minTokenSize: number, maxTokenSize: number): string[] { + const chunks: string[] = []; + let index = 0; + while (index < text.length) { + const tokenSize = minTokenSize + Math.floor(Math.random() * (maxTokenSize - minTokenSize + 1)); + const charSize = Math.max(1, tokenSize * 4); + chunks.push(text.slice(index, index + charSize)); + index += charSize; + } + return chunks.length > 0 ? chunks : [""]; +} + +function cloneMessage(message: AssistantMessage, api: string, provider: string, modelId: string): AssistantMessage { + const cloned = structuredClone(message); + return { + ...cloned, + api, + provider, + model: modelId, + timestamp: cloned.timestamp ?? Date.now(), + usage: cloned.usage ?? DEFAULT_USAGE, + }; +} + +function createErrorMessage(error: unknown, api: string, provider: string, modelId: string): AssistantMessage { + return { + role: "assistant", + content: [], + api, + provider, + model: modelId, + usage: DEFAULT_USAGE, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; +} + +function createAbortedMessage(partial: AssistantMessage): AssistantMessage { + return { + ...partial, + stopReason: "aborted", + errorMessage: "Request was aborted", + timestamp: Date.now(), + }; +} + +function scheduleChunk(chunk: string, tokensPerSecond: number | undefined): Promise { + if (!tokensPerSecond || tokensPerSecond <= 0) { + return new Promise((resolve) => queueMicrotask(resolve)); + } + const delayMs = (estimateTokens(chunk) / tokensPerSecond) * 1000; + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +async function streamWithDeltas( + stream: AssistantMessageEventStream, + message: AssistantMessage, + minTokenSize: number, + maxTokenSize: number, + tokensPerSecond: number | undefined, + signal: AbortSignal | undefined, +): Promise { + const partial: AssistantMessage = { ...message, content: [] }; + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + + stream.push({ type: "start", partial: { ...partial } }); + + for (let index = 0; index < message.content.length; index++) { + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + + const block = message.content[index]; + + if (block.type === "thinking") { + partial.content = [...partial.content, { type: "thinking", thinking: "" }]; + stream.push({ type: "thinking_start", contentIndex: index, partial: { ...partial } }); + for (const chunk of splitStringByTokenSize(block.thinking, minTokenSize, maxTokenSize)) { + await scheduleChunk(chunk, tokensPerSecond); + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + (partial.content[index] as ThinkingContent).thinking += chunk; + stream.push({ type: "thinking_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); + } + stream.push({ + type: "thinking_end", + contentIndex: index, + content: block.thinking, + partial: { ...partial }, + }); + continue; + } + + if (block.type === "text") { + partial.content = [...partial.content, { type: "text", text: "" }]; + stream.push({ type: "text_start", contentIndex: index, partial: { ...partial } }); + for (const chunk of splitStringByTokenSize(block.text, minTokenSize, maxTokenSize)) { + await scheduleChunk(chunk, tokensPerSecond); + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + (partial.content[index] as TextContent).text += chunk; + stream.push({ type: "text_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); + } + stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: { ...partial } }); + continue; + } + + partial.content = [...partial.content, { type: "toolCall", id: block.id, name: block.name, arguments: {} }]; + stream.push({ type: "toolcall_start", contentIndex: index, partial: { ...partial } }); + for (const chunk of splitStringByTokenSize(JSON.stringify(block.arguments), minTokenSize, maxTokenSize)) { + await scheduleChunk(chunk, tokensPerSecond); + if (signal?.aborted) { + const aborted = createAbortedMessage(partial); + stream.push({ type: "error", reason: "aborted", error: aborted }); + stream.end(aborted); + return; + } + stream.push({ type: "toolcall_delta", contentIndex: index, delta: chunk, partial: { ...partial } }); + } + (partial.content[index] as ToolCall).arguments = block.arguments; + stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: { ...partial } }); + } + + if (message.stopReason === "error" || message.stopReason === "aborted") { + stream.push({ type: "error", reason: message.stopReason, error: message }); + stream.end(message); + return; + } + + stream.push({ type: "done", reason: message.stopReason, message }); + stream.end(message); +} + +export function createFauxCore(options: RegisterFauxProviderOptions) { + const api = options.api ?? randomId(DEFAULT_API); + const provider = options.provider ?? DEFAULT_PROVIDER; + const minTokenSize = Math.max( + 1, + Math.min(options.tokenSize?.min ?? DEFAULT_MIN_TOKEN_SIZE, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE), + ); + const maxTokenSize = Math.max(minTokenSize, options.tokenSize?.max ?? DEFAULT_MAX_TOKEN_SIZE); + let pendingResponses: FauxResponseStep[] = []; + const tokensPerSecond = options.tokensPerSecond; + const state = { callCount: 0 }; + const promptCache = new Map(); + + const modelDefinitions = options.models?.length + ? options.models + : [ + { + id: DEFAULT_MODEL_ID, + name: DEFAULT_MODEL_NAME, + reasoning: false, + input: ["text", "image"] as ("text" | "image")[], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ]; + const models = modelDefinitions.map((definition) => ({ + id: definition.id, + name: definition.name ?? definition.id, + api, + provider, + baseUrl: DEFAULT_BASE_URL, + reasoning: definition.reasoning ?? false, + input: definition.input ?? ["text", "image"], + cost: definition.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: definition.contextWindow ?? 128000, + maxTokens: definition.maxTokens ?? 16384, + })) as [Model, ...Model[]]; + + const stream: StreamFunction = (requestModel, context, streamOptions) => { + const outer = createAssistantMessageEventStream(); + const step = pendingResponses.shift(); + state.callCount++; + + queueMicrotask(async () => { + try { + await streamOptions?.onResponse?.({ status: 200, headers: {} }, requestModel); + if (!step) { + let message = createErrorMessage( + new Error("No more faux responses queued"), + api, + provider, + requestModel.id, + ); + message = withUsageEstimate(message, context, streamOptions, promptCache); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + return; + } + + const resolved = + typeof step === "function" ? await step(context, streamOptions, state, requestModel) : step; + let message = cloneMessage(resolved, api, provider, requestModel.id); + message = withUsageEstimate(message, context, streamOptions, promptCache); + await streamWithDeltas(outer, message, minTokenSize, maxTokenSize, tokensPerSecond, streamOptions?.signal); + } catch (error) { + const message = createErrorMessage(error, api, provider, requestModel.id); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + } + }); + + return outer; + }; + + const streamSimple: StreamFunction = (streamModel, context, streamOptions) => + stream(streamModel, context, streamOptions); + + function getModel(): Model; + function getModel(requestedModelId: string): Model | undefined; + function getModel(requestedModelId?: string): Model | undefined { + if (!requestedModelId) { + return models[0]; + } + return models.find((candidate) => candidate.id === requestedModelId); + } + + return { + api, + provider, + models, + stream, + streamSimple, + getModel, + state, + setResponses(responses: FauxResponseStep[]) { + pendingResponses = [...responses]; + }, + appendResponses(responses: FauxResponseStep[]) { + pendingResponses.push(...responses); + }, + getPendingResponseCount() { + return pendingResponses.length; + }, + }; +} + +/** + * Faux provider for tests built on explicit `Models` collections: + * + * ```ts + * const faux = fauxProvider(); + * const models = createModels(); + * models.setProvider(faux.provider); + * faux.setResponses([fauxAssistantMessage("hi")]); + * ``` + */ +export function fauxProvider(options: RegisterFauxProviderOptions = {}): FauxProviderHandle { + const core = createFauxCore(options); + const provider = createProvider({ + id: core.provider, + auth: { apiKey: { name: "Faux", resolve: async () => ({ auth: {} }) } }, + models: core.models, + api: { stream: core.stream, streamSimple: core.streamSimple }, + }); + return { + provider, + api: core.api, + models: core.models, + getModel: core.getModel, + state: core.state, + setResponses: core.setResponses, + appendResponses: core.appendResponses, + getPendingResponseCount: core.getPendingResponseCount, + }; +} diff --git a/cactus-code/packages/ai/src/session-resources.ts b/cactus-code/packages/ai/src/session-resources.ts new file mode 100644 index 000000000..d63009268 --- /dev/null +++ b/cactus-code/packages/ai/src/session-resources.ts @@ -0,0 +1,24 @@ +export type SessionResourceCleanup = (sessionId?: string) => void; + +const sessionResourceCleanups = new Set(); + +export function registerSessionResourceCleanup(cleanup: SessionResourceCleanup): () => void { + sessionResourceCleanups.add(cleanup); + return () => { + sessionResourceCleanups.delete(cleanup); + }; +} + +export function cleanupSessionResources(sessionId?: string): void { + const errors: unknown[] = []; + for (const cleanup of sessionResourceCleanups) { + try { + cleanup(sessionId); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new AggregateError(errors, "Failed to cleanup session resources"); + } +} diff --git a/cactus-code/packages/ai/src/types.ts b/cactus-code/packages/ai/src/types.ts new file mode 100644 index 000000000..0d388db7b --- /dev/null +++ b/cactus-code/packages/ai/src/types.ts @@ -0,0 +1,638 @@ +import type { OpenAICompletionsOptions } from "./api/openai-completions.ts"; +import type { AssistantMessageDiagnostic } from "./utils/diagnostics.ts"; +import type { AssistantMessageEventStream } from "./utils/event-stream.ts"; + +export type { AssistantMessageEventStream } from "./utils/event-stream.ts"; + +export type KnownApi = "openai-completions"; + +export type Api = KnownApi | (string & {}); + +export type KnownImagesApi = "openrouter-images"; + +export type ImagesApi = KnownImagesApi | (string & {}); + +export type KnownProvider = "cactus"; +export type ProviderId = KnownProvider | string; + +export type KnownImagesProvider = "openrouter"; + +export type ImagesProviderId = KnownImagesProvider | string; + +export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh"; +export type ModelThinkingLevel = "off" | ThinkingLevel; +export type ThinkingLevelMap = Partial>; +export type ChatTemplateKwargValue = + | string + | number + | boolean + | null + | { + $var: "thinking.enabled" | "thinking.effort"; + omitWhenOff?: boolean; + }; + +/** Token budgets for each thinking level (token-based providers only) */ +export interface ThinkingBudgets { + minimal?: number; + low?: number; + medium?: number; + high?: number; +} + +// Base options all providers share +export type CacheRetention = "none" | "short" | "long"; + +export type Transport = "sse" | "websocket" | "websocket-cached" | "auto"; + +/** Provider-scoped environment overrides. Values take precedence over process.env. */ +export type ProviderEnv = Record; +export type ProviderHeaders = Record; + +export interface ProviderResponse { + status: number; + headers: Record; +} + +export interface StreamOptions { + temperature?: number; + maxTokens?: number; + signal?: AbortSignal; + apiKey?: string; + /** + * Preferred transport for providers that support multiple transports. + * Providers that do not support this option ignore it. + */ + transport?: Transport; + /** + * Prompt cache retention preference. Providers map this to their supported values. + * Default: "short". + */ + cacheRetention?: CacheRetention; + /** + * Optional session identifier for providers that support session-based caching. + * Providers can use this to enable prompt caching, request routing, or other + * session-aware features. Ignored by providers that don't support it. + */ + sessionId?: string; + /** + * Optional callback for inspecting or replacing provider payloads before sending. + * Return undefined to keep the payload unchanged. + */ + onPayload?: (payload: unknown, model: Model) => unknown | undefined | Promise; + /** + * Optional callback invoked after an HTTP response is received and before + * its body stream is consumed. + */ + onResponse?: (response: ProviderResponse, model: Model) => void | Promise; + /** + * Optional custom HTTP headers to include in API requests. + * Merged with provider defaults; caller values override default headers. + * On AWS Bedrock these are injected via a Smithy `build`-step middleware so + * they are covered by SigV4 signing; reserved headers (`x-amz-*`, + * `authorization`, `host`) are silently ignored to preserve SigV4 / bearer auth. + * A null value suppresses a provider/API default header with the same name. + */ + headers?: ProviderHeaders; + /** + * HTTP request timeout in milliseconds for providers/SDKs that support it. + * For example, OpenAI and Anthropic SDK clients default to 10 minutes. + */ + timeoutMs?: number; + /** + * WebSocket connect timeout in milliseconds for providers that support + * WebSocket transports. This covers the connection/open handshake only; + * stream idleness after connection uses timeoutMs. + */ + websocketConnectTimeoutMs?: number; + /** + * Maximum retry attempts for providers/SDKs that support client-side retries. + * For example, OpenAI and Anthropic SDK clients default to 2. + */ + maxRetries?: number; + /** + * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. + * If the server's requested delay exceeds this value, the request fails immediately + * with an error containing the requested delay, allowing higher-level retry logic + * to handle it with user visibility. + * Default: 60000 (60 seconds). Set to 0 to disable the cap. + */ + maxRetryDelayMs?: number; + /** + * Optional metadata to include in API requests. + * Providers extract the fields they understand and ignore the rest. + * For example, Anthropic uses `user_id` for abuse tracking and rate limiting. + */ + metadata?: Record; + /** + * Provider-scoped environment values. These take precedence over process.env for + * provider configuration such as regional settings, endpoint placeholders, and + * proxy variables. + */ + env?: ProviderEnv; +} + +export type ProviderStreamOptions = StreamOptions & Record; + +/** + * Maps known APIs to their full provider-specific stream option types. + * Type-only imports from API implementation modules are erased at emit, so + * this is tree-shake safe. + */ +export interface ApiOptionsMap { + "openai-completions": OpenAICompletionsOptions; +} + +/** + * Full stream options for an API. Known APIs resolve to their concrete option + * type; custom API strings fall back to the generic shape. + */ +export type ApiStreamOptions = TApi extends keyof ApiOptionsMap + ? ApiOptionsMap[TApi] + : StreamOptions & Record; + +/** + * The uniform stream contract of an API implementation module: every module + * under `src/api/` exports exactly `stream` and `streamSimple`, so the module + * itself satisfies this interface. Lazy wrappers (`lazyApi()`) and provider + * factories pass these around as values. This is the untyped dispatch shape; + * per-API option typing lives on the implementation modules themselves and on + * `Provider.stream()` via `ApiStreamOptions`. + */ +export interface ProviderStreams { + stream(model: Model, context: Context, options?: StreamOptions): AssistantMessageEventStream; + streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; +} + +/** + * The uniform contract of an image-generation API implementation module: + * every image API module under `src/api/` exports exactly `generateImages`, + * so the module itself satisfies this interface. Lazy wrappers and image + * provider factories pass these around as values. + */ +export interface ProviderImages { + generateImages( + model: ImagesModel, + context: ImagesContext, + options?: ImagesOptions, + ): Promise; +} + +export interface ImagesOptions { + signal?: AbortSignal; + apiKey?: string; + /** + * Provider-scoped environment values. These take precedence over process.env for + * provider configuration such as endpoint placeholders and proxy variables. + */ + env?: ProviderEnv; + /** + * Optional callback for inspecting or replacing provider payloads before sending. + * Return undefined to keep the payload unchanged. + */ + onPayload?: (payload: unknown, model: ImagesModel) => unknown | undefined | Promise; + /** + * Optional callback invoked after an HTTP response is received. + */ + onResponse?: (response: ProviderResponse, model: ImagesModel) => void | Promise; + /** + * Optional custom HTTP headers to include in API requests. + * Merged with provider defaults; can override default headers. + * A null value suppresses a provider/API default header with the same name. + */ + headers?: ProviderHeaders; + /** + * HTTP request timeout in milliseconds for providers/SDKs that support it. + */ + timeoutMs?: number; + /** + * Maximum retry attempts for providers/SDKs that support client-side retries. + */ + maxRetries?: number; + /** + * Maximum delay in milliseconds to wait for a retry when the server requests a long wait. + * If the server's requested delay exceeds this value, the request fails immediately + * with an error containing the requested delay, allowing higher-level retry logic + * to handle it with user visibility. + * Default: 60000 (60 seconds). Set to 0 to disable the cap. + */ + maxRetryDelayMs?: number; + /** + * Optional metadata to include in API requests. + * Providers extract the fields they understand and ignore the rest. + */ + metadata?: Record; +} + +export type ProviderImagesOptions = ImagesOptions & Record; + +// Unified options with reasoning passed to streamSimple() and completeSimple() +export interface SimpleStreamOptions extends StreamOptions { + reasoning?: ThinkingLevel; + /** Custom token budgets for thinking levels (token-based providers only) */ + thinkingBudgets?: ThinkingBudgets; +} + +// Generic StreamFunction with typed options. +// +// Contract: +// - Must return an AssistantMessageEventStream. +// - Once invoked, request/model/runtime failures should be encoded in the +// returned stream, not thrown. +// - Error termination must produce an AssistantMessage with stopReason +// "error" or "aborted" and errorMessage, emitted via the stream protocol. +export type StreamFunction = ( + model: Model, + context: Context, + options?: TOptions, +) => AssistantMessageEventStream; + +export type ImagesFunction = ( + model: ImagesModel, + context: ImagesContext, + options?: TOptions, +) => Promise; + +export interface TextSignatureV1 { + v: 1; + id: string; + phase?: "commentary" | "final_answer"; +} + +export interface TextContent { + type: "text"; + text: string; + textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON) +} + +export interface ThinkingContent { + type: "thinking"; + thinking: string; + thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID + /** When true, the thinking content was redacted by safety filters. The opaque + * encrypted payload is stored in `thinkingSignature` so it can be passed back + * to the API for multi-turn continuity. */ + redacted?: boolean; +} + +export interface ImageContent { + type: "image"; + data: string; // base64 encoded image data + mimeType: string; // e.g., "image/jpeg", "image/png" +} + +export interface ToolCall { + type: "toolCall"; + id: string; + name: string; + arguments: Record; + thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context +} + +export interface Usage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + /** Subset of `cacheWrite` written with 1h retention. Only Anthropic reports this split. */ + cacheWrite1h?: number; + totalTokens: number; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; +} + +export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted"; + +export interface UserMessage { + role: "user"; + content: string | (TextContent | ImageContent)[]; + timestamp: number; // Unix timestamp in milliseconds +} + +export interface AssistantMessage { + role: "assistant"; + content: (TextContent | ThinkingContent | ToolCall)[]; + api: Api; + provider: ProviderId; + model: string; + responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) + responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one + diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries. + cloudHandoff?: boolean; // Cactus hybrid: true if this response was produced by the cloud model, false if local, undefined if unknown + usage: Usage; + stopReason: StopReason; + errorMessage?: string; + timestamp: number; // Unix timestamp in milliseconds +} + +export interface ToolResultMessage { + role: "toolResult"; + toolCallId: string; + toolName: string; + content: (TextContent | ImageContent)[]; // Supports text and images + details?: TDetails; + isError: boolean; + timestamp: number; // Unix timestamp in milliseconds +} + +export type Message = UserMessage | AssistantMessage | ToolResultMessage; + +export type ImagesInputContent = TextContent | ImageContent; +export type ImagesOutputContent = TextContent | ImageContent; + +export interface ImagesContext { + input: ImagesInputContent[]; +} + +export type ImagesStopReason = "stop" | "error" | "aborted"; + +export interface AssistantImages { + api: ImagesApi; + provider: ImagesProviderId; + model: string; + output: ImagesOutputContent[]; + responseId?: string; + usage?: Usage; + stopReason: ImagesStopReason; + errorMessage?: string; + timestamp: number; // Unix timestamp in milliseconds +} + +import type { TSchema } from "typebox"; + +export interface Tool { + name: string; + description: string; + parameters: TParameters; +} + +export interface Context { + systemPrompt?: string; + messages: Message[]; + tools?: Tool[]; +} + +/** + * Event protocol for AssistantMessageEventStream. + * + * Streams should emit `start` before partial updates, then terminate with either: + * - `done` carrying the final successful AssistantMessage, or + * - `error` carrying the final AssistantMessage with stopReason "error" or "aborted" + * and errorMessage. + */ +export type AssistantMessageEvent = + | { type: "start"; partial: AssistantMessage } + | { type: "text_start"; contentIndex: number; partial: AssistantMessage } + | { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage } + | { type: "thinking_start"; contentIndex: number; partial: AssistantMessage } + | { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage } + | { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage } + | { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage } + | { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage } + | { type: "done"; reason: Extract; message: AssistantMessage } + | { type: "error"; reason: Extract; error: AssistantMessage }; + +/** + * Compatibility settings for OpenAI-compatible completions APIs. + * Use this to override URL-based auto-detection for custom providers. + */ +export interface OpenAICompletionsCompat { + /** Whether the provider supports the `store` field. Default: auto-detected from URL. */ + supportsStore?: boolean; + /** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */ + supportsDeveloperRole?: boolean; + /** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */ + supportsReasoningEffort?: boolean; + /** Whether the provider supports `stream_options: { include_usage: true }` for token usage in streaming responses. Default: true. */ + supportsUsageInStreaming?: boolean; + /** Which field to use for max tokens. Default: auto-detected from URL. */ + maxTokensField?: "max_completion_tokens" | "max_tokens"; + /** Whether tool results require the `name` field. Default: auto-detected from URL. */ + requiresToolResultName?: boolean; + /** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */ + requiresAssistantAfterToolResult?: boolean; + /** Whether thinking blocks must be converted to text blocks with delimiters. Default: auto-detected from URL. */ + requiresThinkingAsText?: boolean; + /** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */ + requiresReasoningContentOnAssistantMessages?: boolean; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses thinking: { type }, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking and preserve_thinking, "chat-template" uses configurable chat_template_kwargs, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */ + thinkingFormat?: + | "openai" + | "openrouter" + | "deepseek" + | "together" + | "zai" + | "qwen" + | "chat-template" + | "qwen-chat-template" + | "string-thinking" + | "ant-ling"; + /** Kwargs to send as `chat_template_kwargs` when `thinkingFormat` is `chat-template`. Use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values. */ + chatTemplateKwargs?: Record; + /** OpenRouter-compatible routing preferences sent as the `provider` request field. */ + openRouterRouting?: OpenRouterRouting; + /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ + vercelGatewayRouting?: VercelGatewayRouting; + /** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */ + zaiToolStream?: boolean; + /** Whether the provider supports the `strict` field in tool definitions. Default: true. */ + supportsStrictMode?: boolean; + /** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */ + cacheControlFormat?: "anthropic"; + /** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */ + sendSessionAffinityHeaders?: boolean; + /** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */ + supportsLongCacheRetention?: boolean; +} + +/** Compatibility settings for OpenAI Responses APIs. */ +export interface OpenAIResponsesCompat { + /** Whether the provider supports the `developer` role (vs `system`). Default: true. */ + supportsDeveloperRole?: boolean; + /** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */ + sendSessionIdHeader?: boolean; + /** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */ + supportsLongCacheRetention?: boolean; +} + +/** Compatibility settings for Anthropic Messages-compatible APIs. */ +export interface AnthropicMessagesCompat { + /** + * Whether the provider accepts per-tool `eager_input_streaming`. + * When false, the Anthropic provider omits `tools[].eager_input_streaming` + * and sends the legacy `fine-grained-tool-streaming-2025-05-14` beta header + * for tool-enabled requests. + * Default: true. + */ + supportsEagerToolInputStreaming?: boolean; + /** Whether the provider supports Anthropic long cache retention (`cache_control.ttl: "1h"`). Default: true. */ + supportsLongCacheRetention?: boolean; + /** + * Whether to send the `x-session-affinity` header from `options.sessionId` + * when caching is enabled. Required for providers like Fireworks that use + * session affinity for prompt cache routing (requests to the same replica + * maximize cache hits). + * Default: false. + */ + sendSessionAffinityHeaders?: boolean; + /** + * Whether the provider supports Anthropic-style `cache_control` markers on + * tool definitions. When false, `cache_control` is omitted from tool params. + * Some Anthropic-compatible providers (e.g., Fireworks) do not support this + * field on tools and may reject or ignore it. + * Default: true. + */ + supportsCacheControlOnTools?: boolean; + /** + * Whether the model accepts the Anthropic `temperature` request field. + * Claude Opus 4.7+ rejects non-default temperature values. + * Default: true. + */ + supportsTemperature?: boolean; + /** + * Whether to force adaptive thinking (`thinking.type: "adaptive"` plus + * `output_config.effort`) regardless of the model id. Built-in models that + * require adaptive thinking set this in generated metadata. Custom + * Anthropic-compatible providers can set this to `true` for any model whose + * upstream requires the adaptive format. Set to `false` to + * opt out on overridden built-in models. + * Default: false. + */ + forceAdaptiveThinking?: boolean; + /** Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: false. */ + allowEmptySignature?: boolean; +} + +/** + * OpenRouter provider routing preferences. + * Controls which upstream providers OpenRouter routes requests to. + * Sent as the `provider` field in the OpenRouter API request body. + * @see https://openrouter.ai/docs/guides/routing/provider-selection + */ +export interface OpenRouterRouting { + /** Whether to allow backup providers to serve requests. Default: true. */ + allow_fallbacks?: boolean; + /** Whether to filter providers to only those that support all parameters in the request. Default: false. */ + require_parameters?: boolean; + /** Data collection setting. "allow" (default): allow providers that may store/train on data. "deny": only use providers that don't collect user data. */ + data_collection?: "deny" | "allow"; + /** Whether to restrict routing to only ZDR (Zero Data Retention) endpoints. */ + zdr?: boolean; + /** Whether to restrict routing to only models that allow text distillation. */ + enforce_distillable_text?: boolean; + /** An ordered list of provider names/slugs to try in sequence, falling back to the next if unavailable. */ + order?: string[]; + /** List of provider names/slugs to exclusively allow for this request. */ + only?: string[]; + /** List of provider names/slugs to skip for this request. */ + ignore?: string[]; + /** A list of quantization levels to filter providers by (e.g., ["fp16", "bf16", "fp8", "fp6", "int8", "int4", "fp4", "fp32"]). */ + quantizations?: string[]; + /** Sorting strategy. Can be a string (e.g., "price", "throughput", "latency") or an object with `by` and `partition`. */ + sort?: + | string + | { + /** The sorting metric: "price", "throughput", "latency". */ + by?: string; + /** Partitioning strategy: "model" (default) or "none". */ + partition?: string | null; + }; + /** Maximum price per million tokens (USD). */ + max_price?: { + /** Price per million prompt tokens. */ + prompt?: number | string; + /** Price per million completion tokens. */ + completion?: number | string; + /** Price per image. */ + image?: number | string; + /** Price per audio unit. */ + audio?: number | string; + /** Price per request. */ + request?: number | string; + }; + /** Preferred minimum throughput (tokens/second). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */ + preferred_min_throughput?: + | number + | { + /** Minimum tokens/second at the 50th percentile. */ + p50?: number; + /** Minimum tokens/second at the 75th percentile. */ + p75?: number; + /** Minimum tokens/second at the 90th percentile. */ + p90?: number; + /** Minimum tokens/second at the 99th percentile. */ + p99?: number; + }; + /** Preferred maximum latency (seconds). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */ + preferred_max_latency?: + | number + | { + /** Maximum latency in seconds at the 50th percentile. */ + p50?: number; + /** Maximum latency in seconds at the 75th percentile. */ + p75?: number; + /** Maximum latency in seconds at the 90th percentile. */ + p90?: number; + /** Maximum latency in seconds at the 99th percentile. */ + p99?: number; + }; +} + +/** + * Vercel AI Gateway routing preferences. + * Controls which upstream providers the gateway routes requests to. + * @see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options + */ +export interface VercelGatewayRouting { + /** List of provider slugs to exclusively use for this request (e.g., ["bedrock", "anthropic"]). */ + only?: string[]; + /** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */ + order?: string[]; +} + +// Model interface for the unified model system +export interface Model { + id: string; + name: string; + api: TApi; + provider: ProviderId; + baseUrl: string; + reasoning: boolean; + /** + * Maps pi thinking levels to provider/model-specific values. + * Missing keys use provider defaults. null marks a level as unsupported. + */ + thinkingLevelMap?: ThinkingLevelMap; + input: ("text" | "image")[]; + cost: { + input: number; // $/million tokens + output: number; // $/million tokens + cacheRead: number; // $/million tokens + cacheWrite: number; // $/million tokens + }; + contextWindow: number; + maxTokens: number; + headers?: Record; + /** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */ + compat?: TApi extends "openai-completions" + ? OpenAICompletionsCompat + : TApi extends "openai-responses" + ? OpenAIResponsesCompat + : TApi extends "anthropic-messages" + ? AnthropicMessagesCompat + : never; +} + +export interface ImagesModel + extends Omit, "api" | "provider" | "reasoning" | "contextWindow" | "maxTokens" | "compat"> { + api: TApi; + provider: ImagesProviderId; + output: ("text" | "image")[]; +} diff --git a/cactus-code/packages/ai/src/utils/abort-signals.ts b/cactus-code/packages/ai/src/utils/abort-signals.ts new file mode 100644 index 000000000..8e1a9ade9 --- /dev/null +++ b/cactus-code/packages/ai/src/utils/abort-signals.ts @@ -0,0 +1,41 @@ +export interface CombinedAbortSignal { + signal?: AbortSignal; + cleanup: () => void; +} + +export function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): CombinedAbortSignal { + const activeSignals = signals.filter((signal): signal is AbortSignal => signal !== undefined); + if (activeSignals.length === 0) { + return { cleanup: () => {} }; + } + if (activeSignals.length === 1) { + return { signal: activeSignals[0], cleanup: () => {} }; + } + + const controller = new AbortController(); + const listeners: Array<{ signal: AbortSignal; listener: () => void }> = []; + const abort = (signal: AbortSignal) => { + if (!controller.signal.aborted) { + controller.abort(signal.reason); + } + }; + + for (const signal of activeSignals) { + if (signal.aborted) { + abort(signal); + break; + } + const listener = () => abort(signal); + signal.addEventListener("abort", listener, { once: true }); + listeners.push({ signal, listener }); + } + + return { + signal: controller.signal, + cleanup: () => { + for (const { signal, listener } of listeners) { + signal.removeEventListener("abort", listener); + } + }, + }; +} diff --git a/cactus-code/packages/ai/src/utils/diagnostics.ts b/cactus-code/packages/ai/src/utils/diagnostics.ts new file mode 100644 index 000000000..89d135619 --- /dev/null +++ b/cactus-code/packages/ai/src/utils/diagnostics.ts @@ -0,0 +1,45 @@ +export interface DiagnosticErrorInfo { + name?: string; + message: string; + stack?: string; + code?: string | number; +} + +export interface AssistantMessageDiagnostic { + type: string; + timestamp: number; + error?: DiagnosticErrorInfo; + details?: Record; +} + +export function formatThrownValue(value: unknown): string { + if (value instanceof Error) return value.message || value.name; + if (typeof value === "string") return value; + return String(value); +} + +export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo { + if (!(error instanceof Error)) return { name: "ThrownValue", message: formatThrownValue(error) }; + const code = (error as Error & { code?: unknown }).code; + return { + name: error.name || undefined, + message: error.message || error.name, + stack: error.stack, + code: typeof code === "string" || typeof code === "number" ? code : undefined, + }; +} + +export function createAssistantMessageDiagnostic( + type: string, + error: unknown, + details?: Record, +): AssistantMessageDiagnostic { + return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details }; +} + +export function appendAssistantMessageDiagnostic( + message: T, + diagnostic: AssistantMessageDiagnostic, +): void { + message.diagnostics = [...(message.diagnostics ?? []), diagnostic]; +} diff --git a/cactus-code/packages/ai/src/utils/event-stream.ts b/cactus-code/packages/ai/src/utils/event-stream.ts new file mode 100644 index 000000000..e5eef921d --- /dev/null +++ b/cactus-code/packages/ai/src/utils/event-stream.ts @@ -0,0 +1,88 @@ +import type { AssistantMessage, AssistantMessageEvent } from "../types.ts"; + +// Generic event stream class for async iteration +export class EventStream implements AsyncIterable { + private queue: T[] = []; + private waiting: ((value: IteratorResult) => void)[] = []; + private done = false; + private finalResultPromise: Promise; + private resolveFinalResult!: (result: R) => void; + private isComplete: (event: T) => boolean; + private extractResult: (event: T) => R; + + constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) { + this.isComplete = isComplete; + this.extractResult = extractResult; + this.finalResultPromise = new Promise((resolve) => { + this.resolveFinalResult = resolve; + }); + } + + push(event: T): void { + if (this.done) return; + + if (this.isComplete(event)) { + this.done = true; + this.resolveFinalResult(this.extractResult(event)); + } + + // Deliver to waiting consumer or queue it + const waiter = this.waiting.shift(); + if (waiter) { + waiter({ value: event, done: false }); + } else { + this.queue.push(event); + } + } + + end(result?: R): void { + this.done = true; + if (result !== undefined) { + this.resolveFinalResult(result); + } + // Notify all waiting consumers that we're done + while (this.waiting.length > 0) { + const waiter = this.waiting.shift()!; + waiter({ value: undefined as any, done: true }); + } + } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { + if (this.queue.length > 0) { + yield this.queue.shift()!; + } else if (this.done) { + return; + } else { + const result = await new Promise>((resolve) => this.waiting.push(resolve)); + if (result.done) return; + yield result.value; + } + } + } + + result(): Promise { + return this.finalResultPromise; + } +} + +export class AssistantMessageEventStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") { + return event.message; + } else if (event.type === "error") { + return event.error; + } + throw new Error("Unexpected event type for final result"); + }, + ); + } +} + +/** Factory function for AssistantMessageEventStream (for use in extensions) */ +export function createAssistantMessageEventStream(): AssistantMessageEventStream { + return new AssistantMessageEventStream(); +} diff --git a/cactus-code/packages/ai/src/utils/hash.ts b/cactus-code/packages/ai/src/utils/hash.ts new file mode 100644 index 000000000..1ff55e8b4 --- /dev/null +++ b/cactus-code/packages/ai/src/utils/hash.ts @@ -0,0 +1,13 @@ +/** Fast deterministic hash to shorten long strings */ +export function shortHash(str: string): string { + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < str.length; i++) { + const ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36); +} diff --git a/cactus-code/packages/ai/src/utils/headers.ts b/cactus-code/packages/ai/src/utils/headers.ts new file mode 100644 index 000000000..2d923d287 --- /dev/null +++ b/cactus-code/packages/ai/src/utils/headers.ts @@ -0,0 +1,18 @@ +import type { ProviderHeaders } from "../types.ts"; + +export function headersToRecord(headers: Headers): Record { + const result: Record = {}; + for (const [key, value] of headers.entries()) { + result[key] = value; + } + return result; +} + +export function providerHeadersToRecord(headers: ProviderHeaders | undefined): Record | undefined { + if (!headers) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (value !== null) result[key] = value; + } + return Object.keys(result).length > 0 ? result : undefined; +} diff --git a/cactus-code/packages/ai/src/utils/json-parse.ts b/cactus-code/packages/ai/src/utils/json-parse.ts new file mode 100644 index 000000000..f39f9f09d --- /dev/null +++ b/cactus-code/packages/ai/src/utils/json-parse.ts @@ -0,0 +1,124 @@ +import { parse as partialParse } from "partial-json"; + +const VALID_JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t", "u"]); + +function isControlCharacter(char: string): boolean { + const codePoint = char.codePointAt(0); + return codePoint !== undefined && codePoint >= 0x00 && codePoint <= 0x1f; +} + +function escapeControlCharacter(char: string): string { + switch (char) { + case "\b": + return "\\b"; + case "\f": + return "\\f"; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case "\t": + return "\\t"; + default: + return `\\u${char.codePointAt(0)?.toString(16).padStart(4, "0") ?? "0000"}`; + } +} + +/** + * Repairs malformed JSON string literals by: + * - escaping raw control characters inside strings + * - doubling backslashes before invalid escape characters + */ +export function repairJson(json: string): string { + let repaired = ""; + let inString = false; + + for (let index = 0; index < json.length; index++) { + const char = json[index]; + + if (!inString) { + repaired += char; + if (char === '"') { + inString = true; + } + continue; + } + + if (char === '"') { + repaired += char; + inString = false; + continue; + } + + if (char === "\\") { + const nextChar = json[index + 1]; + if (nextChar === undefined) { + repaired += "\\\\"; + continue; + } + + if (nextChar === "u") { + const unicodeDigits = json.slice(index + 2, index + 6); + if (/^[0-9a-fA-F]{4}$/.test(unicodeDigits)) { + repaired += `\\u${unicodeDigits}`; + index += 5; + continue; + } + } + + if (VALID_JSON_ESCAPES.has(nextChar)) { + repaired += `\\${nextChar}`; + index += 1; + continue; + } + + repaired += "\\\\"; + continue; + } + + repaired += isControlCharacter(char) ? escapeControlCharacter(char) : char; + } + + return repaired; +} + +export function parseJsonWithRepair(json: string): T { + try { + return JSON.parse(json) as T; + } catch (error) { + const repairedJson = repairJson(json); + if (repairedJson !== json) { + return JSON.parse(repairedJson) as T; + } + throw error; + } +} + +/** + * Attempts to parse potentially incomplete JSON during streaming. + * Always returns a valid object, even if the JSON is incomplete. + * + * @param partialJson The partial JSON string from streaming + * @returns Parsed object or empty object if parsing fails + */ +export function parseStreamingJson>(partialJson: string | undefined): T { + if (!partialJson || partialJson.trim() === "") { + return {} as T; + } + + try { + return parseJsonWithRepair(partialJson); + } catch { + try { + const result = partialParse(partialJson); + return (result ?? {}) as T; + } catch { + try { + const result = partialParse(repairJson(partialJson)); + return (result ?? {}) as T; + } catch { + return {} as T; + } + } + } +} diff --git a/cactus-code/packages/ai/src/utils/oauth/types.ts b/cactus-code/packages/ai/src/utils/oauth/types.ts new file mode 100644 index 000000000..008be405e --- /dev/null +++ b/cactus-code/packages/ai/src/utils/oauth/types.ts @@ -0,0 +1,79 @@ +import type { Api, Model } from "../../types.ts"; + +export type OAuthCredentials = { + refresh: string; + access: string; + expires: number; + [key: string]: unknown; +}; + +export type OAuthProviderId = string; + +/** @deprecated Use OAuthProviderId instead */ +export type OAuthProvider = OAuthProviderId; + +export type OAuthPrompt = { + message: string; + placeholder?: string; + allowEmpty?: boolean; +}; + +export type OAuthAuthInfo = { + url: string; + instructions?: string; +}; + +export type OAuthDeviceCodeInfo = { + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; +}; + +export type OAuthSelectOption = { + id: string; + label: string; +}; + +export type OAuthSelectPrompt = { + message: string; + options: OAuthSelectOption[]; +}; + +export interface OAuthLoginCallbacks { + onAuth: (info: OAuthAuthInfo) => void; + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + onManualCodeInput?: () => Promise; + /** Show an interactive selector and return the selected option id, or undefined on cancel. */ + onSelect: (prompt: OAuthSelectPrompt) => Promise; + signal?: AbortSignal; +} + +export interface OAuthProviderInterface { + readonly id: OAuthProviderId; + readonly name: string; + + /** Run the login flow, return credentials to persist */ + login(callbacks: OAuthLoginCallbacks): Promise; + + /** Whether login uses a local callback server and supports manual code input. */ + usesCallbackServer?: boolean; + + /** Refresh expired credentials, return updated credentials to persist */ + refreshToken(credentials: OAuthCredentials): Promise; + + /** Convert credentials to API key string for the provider */ + getApiKey(credentials: OAuthCredentials): string; + + /** Optional: modify models for this provider (e.g., update baseUrl) */ + modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; +} + +/** @deprecated Use OAuthProviderInterface instead */ +export interface OAuthProviderInfo { + id: OAuthProviderId; + name: string; + available: boolean; +} diff --git a/cactus-code/packages/ai/src/utils/overflow.ts b/cactus-code/packages/ai/src/utils/overflow.ts new file mode 100644 index 000000000..623b873ab --- /dev/null +++ b/cactus-code/packages/ai/src/utils/overflow.ts @@ -0,0 +1,162 @@ +import type { AssistantMessage } from "../types.ts"; + +/** + * Regex patterns to detect context overflow errors from different providers. + * + * These patterns match error messages returned when the input exceeds + * the model's context window. + * + * Provider-specific patterns (with example error messages): + * + * - Anthropic: "prompt is too long: 213462 tokens > 200000 maximum" + * - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}" + * - OpenAI: "Your input exceeds the context window of this model" + * - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens" + * - OpenAI-compatible: "Input length (265330) exceeds model's maximum context length (262144)." + * - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)" + * - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens" + * - Groq: "Please reduce the length of the messages or completion" + * - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens" + * - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens." + * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." + * - llama.cpp: "the request exceeds the available context size, try increasing it" + * - LM Studio: "tokens to keep from the initial prompt is greater than the context length" + * - GitHub Copilot: "prompt token count of X exceeds the limit of Y" + * - MiniMax: "invalid params, context window exceeds limit" + * - Kimi For Coding: "Your request exceeded model token limit: X (requested: Y)" + * - Cerebras: "400/413 status code (no body)" + * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" + * - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow + * - Xiaomi MiMo: Truncates input to fill contextWindow exactly, then returns finish_reason "length" + * with output=0 (no room left to generate). Detected via stopReason "length" + zero output + + * input filling the context window. + * - Ollama: Some deployments truncate silently, others return errors like "prompt too long; exceeded max context length by X tokens" + */ +const OVERFLOW_PATTERNS = [ + /prompt is too long/i, // Anthropic token overflow + /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413) + /input is too long for requested model/i, // Amazon Bedrock + /exceeds the context window/i, // OpenAI (Completions & Responses API) + /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies (LiteLLM) + /input token count.*exceeds the maximum/i, // Google (Gemini) + /maximum prompt length is \d+/i, // xAI (Grok) + /reduce the length of the messages/i, // Groq + /maximum context length is \d+ tokens/i, // OpenRouter (most backends) + /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside + /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI + /exceeds the limit of \d+/i, // GitHub Copilot + /exceeds the available context size/i, // llama.cpp server + /greater than the context length/i, // LM Studio + /context window exceeds limit/i, // MiniMax + /exceeded model token limit/i, // Kimi For Coding + /too large for model with \d+ maximum context length/i, // Mistral + /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text + /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error + /context[_ ]length[_ ]exceeded/i, // Generic fallback + /too many tokens/i, // Generic fallback + /token limit exceeded/i, // Generic fallback + /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i, // Cerebras: 400/413 with no body +]; + +/** + * Patterns that indicate non-overflow errors (e.g. rate limiting, server errors). + * Error messages matching any of these are excluded from overflow detection + * even if they also match an OVERFLOW_PATTERN. + * + * Example: Bedrock formats throttling errors as "ThrottlingException: Too many tokens, + * please wait before trying again." which would match the /too many tokens/i overflow + * pattern without this exclusion. + */ +const NON_OVERFLOW_PATTERNS = [ + /^(Throttling error|Service unavailable):/i, // AWS Bedrock non-overflow errors (human-readable prefixes from formatBedrockError) + /rate limit/i, // Generic rate limiting + /too many requests/i, // Generic HTTP 429 style +]; + +/** + * Check if an assistant message represents a context overflow error. + * + * This handles two cases: + * 1. Error-based overflow: Most providers return stopReason "error" with a + * specific error message pattern. + * 2. Silent overflow: Some providers accept overflow requests and return + * successfully. For these, we check if usage.input exceeds the context window. + * + * ## Reliability by Provider + * + * **Reliable detection (returns error with detectable message):** + * - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large" + * - OpenAI (Completions & Responses): "exceeds the context window", "exceeds the model's maximum context length of X tokens", or "exceeds model's maximum context length (X)" + * - Google Gemini: "input token count exceeds the maximum" + * - xAI (Grok): "maximum prompt length is X but request contains Y" + * - Groq: "reduce the length of the messages" + * - Cerebras: 400/413 status code (no body) + * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" + * - OpenRouter (most backends): "maximum context length is X tokens" + * - OpenRouter/Poolside: "Input length X exceeds the maximum allowed input length of Y tokens." + * - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)." + * - llama.cpp: "exceeds the available context size" + * - LM Studio: "greater than the context length" + * - Kimi For Coding: "exceeded model token limit: X (requested: Y)" + * + * **Unreliable detection:** + * - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow), + * sometimes returns rate limit errors. Pass contextWindow param to detect silent overflow. + * - Xiaomi MiMo: Truncates input to fit contextWindow then returns stopReason "length" with + * output=0. Pass contextWindow param to detect via the "filled context + zero output" signal. + * - Ollama: May truncate input silently for some setups, but may also return explicit + * overflow errors that match the patterns above. Silent truncation still cannot be + * detected here because we do not know the expected token count. + * + * ## Custom Providers + * + * If you've added custom models via settings.json, this function may not detect + * overflow errors from those providers. To add support: + * + * 1. Send a request that exceeds the model's context window + * 2. Check the errorMessage in the response + * 3. Create a regex pattern that matches the error + * 4. The pattern should be added to OVERFLOW_PATTERNS in this file, or + * check the errorMessage yourself before calling this function + * + * @param message - The assistant message to check + * @param contextWindow - Optional context window size for detecting silent overflow (z.ai) + * @returns true if the message indicates a context overflow + */ +export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean { + // Case 1: Check error message patterns + if (message.stopReason === "error" && message.errorMessage) { + // Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit) + const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!)); + if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) { + return true; + } + } + + // Case 2: Silent overflow (z.ai style) - successful but usage exceeds context + if (contextWindow && message.stopReason === "stop") { + const inputTokens = message.usage.input + message.usage.cacheRead; + if (inputTokens > contextWindow) { + return true; + } + } + + // Case 3: Length-stop overflow (Xiaomi MiMo style) - server truncates oversized input + // to fit the context window, leaving no room for output. Returns stopReason "length" + // with output=0 and input+cacheRead filling the context window. + if (contextWindow && message.stopReason === "length" && message.usage.output === 0) { + const inputTokens = message.usage.input + message.usage.cacheRead; + if (inputTokens >= contextWindow * 0.99) { + return true; + } + } + + return false; +} + +/** + * Get the overflow patterns for testing purposes. + */ +export function getOverflowPatterns(): RegExp[] { + return [...OVERFLOW_PATTERNS]; +} diff --git a/cactus-code/packages/ai/src/utils/provider-env.ts b/cactus-code/packages/ai/src/utils/provider-env.ts new file mode 100644 index 000000000..db4960672 --- /dev/null +++ b/cactus-code/packages/ai/src/utils/provider-env.ts @@ -0,0 +1,52 @@ +import type { ProviderEnv } from "../types.ts"; + +let procEnvCache: Map | null = null; + +/** + * Fallback for https://github.com/oven-sh/bun/issues/27802. + * Bun compiled binaries can expose an empty process.env inside Linux sandboxes + * even though /proc/self/environ contains the environment. + * + * This intentionally duplicates restoreSandboxEnv() in + * packages/coding-agent/src/bun/restore-sandbox-env.ts. The ai package can be + * used directly, without going through that entrypoint, so provider env lookup + * must not depend on process.env having been patched. + */ +function getBunSandboxEnvValue(name: string): string | undefined { + if (typeof process === "undefined" || !process.versions?.bun || Object.keys(process.env).length > 0) { + return undefined; + } + + if (procEnvCache === null) { + procEnvCache = new Map(); + try { + const { readFileSync } = require("node:fs") as { + readFileSync(path: string, encoding: BufferEncoding): string; + }; + const data = readFileSync("/proc/self/environ", "utf-8"); + for (const entry of data.split("\0")) { + const idx = entry.indexOf("="); + if (idx > 0) { + procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1)); + } + } + } catch { + // /proc/self/environ may not exist or may not be readable. + } + } + + return procEnvCache.get(name); +} + +/** + * Resolve a provider env value from scoped overrides, normal process.env, then + * the duplicated Bun sandbox fallback for direct pi-ai consumers. + */ +export function getProviderEnvValue(name: string, env?: ProviderEnv): string | undefined { + return ( + env?.[name] || + (typeof process !== "undefined" ? process.env[name] : undefined) || + getBunSandboxEnvValue(name) || + undefined + ); +} diff --git a/cactus-code/packages/ai/src/utils/sanitize-unicode.ts b/cactus-code/packages/ai/src/utils/sanitize-unicode.ts new file mode 100644 index 000000000..d869ee9dc --- /dev/null +++ b/cactus-code/packages/ai/src/utils/sanitize-unicode.ts @@ -0,0 +1,25 @@ +/** + * Removes unpaired Unicode surrogate characters from a string. + * + * Unpaired surrogates (high surrogates 0xD800-0xDBFF without matching low surrogates 0xDC00-0xDFFF, + * or vice versa) cause JSON serialization errors in many API providers. + * + * Valid emoji and other characters outside the Basic Multilingual Plane use properly paired + * surrogates and will NOT be affected by this function. + * + * @param text - The text to sanitize + * @returns The sanitized text with unpaired surrogates removed + * + * @example + * // Valid emoji (properly paired surrogates) are preserved + * sanitizeSurrogates("Hello 🙈 World") // => "Hello 🙈 World" + * + * // Unpaired high surrogate is removed + * const unpaired = String.fromCharCode(0xD83D); // high surrogate without low + * sanitizeSurrogates(`Text ${unpaired} here`) // => "Text here" + */ +export function sanitizeSurrogates(text: string): string { + // Replace unpaired high surrogates (0xD800-0xDBFF not followed by low surrogate) + // Replace unpaired low surrogates (0xDC00-0xDFFF not preceded by high surrogate) + return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?; // "add" | "subtract" | "multiply" | "divide" + */ +export function StringEnum( + values: T, + options?: { description?: string; default?: T[number] }, +): TUnsafe { + return Type.Unsafe({ + type: "string", + enum: values as any, + ...(options?.description && { description: options.description }), + ...(options?.default && { default: options.default }), + }); +} diff --git a/cactus-code/packages/ai/src/utils/validation.ts b/cactus-code/packages/ai/src/utils/validation.ts new file mode 100644 index 000000000..060898c74 --- /dev/null +++ b/cactus-code/packages/ai/src/utils/validation.ts @@ -0,0 +1,324 @@ +import { Compile } from "typebox/compile"; +import type { TLocalizedValidationError } from "typebox/error"; +import { Value } from "typebox/value"; +import type { Tool, ToolCall } from "../types.ts"; + +const validatorCache = new WeakMap>(); +const TYPEBOX_KIND = Symbol.for("TypeBox.Kind"); + +interface JsonSchemaObject { + type?: string | string[]; + properties?: Record; + items?: JsonSchemaObject | JsonSchemaObject[]; + additionalProperties?: boolean | JsonSchemaObject; + allOf?: JsonSchemaObject[]; + anyOf?: JsonSchemaObject[]; + oneOf?: JsonSchemaObject[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isJsonSchemaObject(value: unknown): value is JsonSchemaObject { + return isRecord(value); +} + +function hasTypeBoxMetadata(schema: unknown): boolean { + return isRecord(schema) && Object.getOwnPropertySymbols(schema).includes(TYPEBOX_KIND); +} + +function getSchemaTypes(schema: JsonSchemaObject): string[] { + if (typeof schema.type === "string") { + return [schema.type]; + } + if (Array.isArray(schema.type)) { + return schema.type.filter((type): type is string => typeof type === "string"); + } + return []; +} + +function matchesJsonType(value: unknown, type: string): boolean { + switch (type) { + case "number": + return typeof value === "number"; + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "boolean": + return typeof value === "boolean"; + case "string": + return typeof value === "string"; + case "null": + return value === null; + case "array": + return Array.isArray(value); + case "object": + return isRecord(value) && !Array.isArray(value); + default: + return false; + } +} + +function isValidatorSchema(value: unknown): value is Tool["parameters"] { + return isRecord(value); +} + +function getSubSchemaValidator(schema: JsonSchemaObject): ReturnType | undefined { + if (!isValidatorSchema(schema)) { + return undefined; + } + try { + return getValidator(schema); + } catch { + return undefined; + } +} + +function coercePrimitiveByType(value: unknown, type: string): unknown { + switch (type) { + case "number": { + if (value === null) { + return 0; + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + return value; + } + case "integer": { + if (value === null) { + return 0; + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isInteger(parsed)) { + return parsed; + } + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + return value; + } + case "boolean": { + if (value === null) { + return false; + } + if (typeof value === "string") { + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + } + if (typeof value === "number") { + if (value === 1) { + return true; + } + if (value === 0) { + return false; + } + } + return value; + } + case "string": { + if (value === null) { + return ""; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return value; + } + case "null": { + if (value === "" || value === 0 || value === false) { + return null; + } + return value; + } + default: + return value; + } +} + +function applySchemaObjectCoercion(value: Record, schema: JsonSchemaObject): void { + const properties = schema.properties; + const definedKeys = new Set(properties ? Object.keys(properties) : []); + + if (properties) { + for (const [key, propertySchema] of Object.entries(properties)) { + if (!(key in value)) { + continue; + } + value[key] = coerceWithJsonSchema(value[key], propertySchema); + } + } + + if (schema.additionalProperties && isJsonSchemaObject(schema.additionalProperties)) { + for (const [key, propertyValue] of Object.entries(value)) { + if (definedKeys.has(key)) { + continue; + } + value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties); + } + } +} + +function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): void { + if (Array.isArray(schema.items)) { + for (let index = 0; index < value.length; index++) { + const itemSchema = schema.items[index]; + if (!itemSchema) { + continue; + } + value[index] = coerceWithJsonSchema(value[index], itemSchema); + } + return; + } + + if (isJsonSchemaObject(schema.items)) { + for (let index = 0; index < value.length; index++) { + value[index] = coerceWithJsonSchema(value[index], schema.items); + } + } +} + +function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown { + for (const schema of schemas) { + const candidate = structuredClone(value); + const coerced = coerceWithJsonSchema(candidate, schema); + const validator = getSubSchemaValidator(schema); + if (validator?.Check(coerced)) { + return coerced; + } + } + return value; +} + +function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown { + let nextValue = value; + + if (Array.isArray(schema.allOf)) { + for (const nested of schema.allOf) { + nextValue = coerceWithJsonSchema(nextValue, nested); + } + } + + if (Array.isArray(schema.anyOf)) { + nextValue = coerceWithUnionSchema(nextValue, schema.anyOf); + } + + if (Array.isArray(schema.oneOf)) { + nextValue = coerceWithUnionSchema(nextValue, schema.oneOf); + } + + const schemaTypes = getSchemaTypes(schema); + const matchesUnionMember = + schemaTypes.length > 1 && schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType)); + if (schemaTypes.length > 0 && !matchesUnionMember) { + for (const schemaType of schemaTypes) { + const candidate = coercePrimitiveByType(nextValue, schemaType); + if (candidate !== nextValue) { + nextValue = candidate; + break; + } + } + } + + if (schemaTypes.includes("object") && isRecord(nextValue) && !Array.isArray(nextValue)) { + applySchemaObjectCoercion(nextValue, schema); + } + + if (schemaTypes.includes("array") && Array.isArray(nextValue)) { + applySchemaArrayCoercion(nextValue, schema); + } + + return nextValue; +} + +function getValidator(schema: Tool["parameters"]): ReturnType { + const key = schema as object; + const cached = validatorCache.get(key); + if (cached) { + return cached; + } + const validator = Compile(schema); + validatorCache.set(key, validator); + return validator; +} + +function formatValidationPath(error: TLocalizedValidationError): string { + if (error.keyword === "required") { + const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties; + const requiredProperty = requiredProperties?.[0]; + if (requiredProperty) { + const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, "."); + return basePath ? `${basePath}.${requiredProperty}` : requiredProperty; + } + } + const path = error.instancePath.replace(/^\//, "").replace(/\//g, "."); + return path || "root"; +} + +/** + * Finds a tool by name and validates the tool call arguments against its TypeBox schema + * @param tools Array of tool definitions + * @param toolCall The tool call from the LLM + * @returns The validated arguments + * @throws Error if tool is not found or validation fails + */ +export function validateToolCall(tools: Tool[], toolCall: ToolCall): any { + const tool = tools.find((t) => t.name === toolCall.name); + if (!tool) { + throw new Error(`Tool "${toolCall.name}" not found`); + } + return validateToolArguments(tool, toolCall); +} + +/** + * Validates tool call arguments against the tool's TypeBox schema + * @param tool The tool definition with TypeBox schema + * @param toolCall The tool call from the LLM + * @returns The validated (and potentially coerced) arguments + * @throws Error with formatted message if validation fails + */ +export function validateToolArguments(tool: Tool, toolCall: ToolCall): any { + const args = structuredClone(toolCall.arguments); + Value.Convert(tool.parameters, args); + + const validator = getValidator(tool.parameters); + if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) { + const coerced = coerceWithJsonSchema(args, tool.parameters); + if (coerced !== args) { + if (isRecord(args) && isRecord(coerced)) { + for (const key of Object.keys(args)) { + delete args[key]; + } + Object.assign(args, coerced); + } else { + return validator.Check(coerced) ? coerced : args; + } + } + } + + if (validator.Check(args)) { + return args; + } + + const errors = + validator + .Errors(args) + .map((error) => ` - ${formatValidationPath(error)}: ${error.message}`) + .join("\n") || "Unknown validation error"; + + const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`; + + throw new Error(errorMessage); +} diff --git a/cactus-code/packages/ai/tsconfig.build.json b/cactus-code/packages/ai/tsconfig.build.json new file mode 100644 index 000000000..6089faa7f --- /dev/null +++ b/cactus-code/packages/ai/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +} \ No newline at end of file diff --git a/cactus-code/packages/coding-agent/package.json b/cactus-code/packages/coding-agent/package.json new file mode 100644 index 000000000..34382a6d6 --- /dev/null +++ b/cactus-code/packages/coding-agent/package.json @@ -0,0 +1,90 @@ +{ + "name": "@earendil-works/pi-coding-agent", + "version": "0.80.2", + "description": "Coding agent CLI with read, bash, edit, write tools and session management", + "type": "module", + "piConfig": { + "name": "cactus", + "configDir": ".cactus" + }, + "bin": { + "cactus": "dist/cli.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "shx rm -rf dist", + "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets", + "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile dist/pi && npm run copy-binary-assets", + "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/", + "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", + "shrinkwrap": "node ../../scripts/generate-coding-agent-shrinkwrap.mjs", + "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" + }, + "dependencies": { + "@earendil-works/pi-agent-core": "^0.80.2", + "@earendil-works/pi-ai": "^0.80.2", + "@earendil-works/pi-tui": "^0.80.2", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.1.38", + "undici": "8.5.0", + "yaml": "2.9.0" + }, + "overrides": { + "rimraf": "6.1.2", + "gaxios": { + "rimraf": "6.1.2" + } + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + }, + "devDependencies": { + "@types/cross-spawn": "6.0.6", + "@types/diff": "7.0.2", + "@types/hosted-git-info": "3.0.5", + "@types/ms": "2.1.0", + "@types/node": "24.12.4", + "@types/proper-lockfile": "4.1.4", + "@types/semver": "7.7.1", + "shx": "0.4.0" + }, + "keywords": [ + "coding-agent", + "ai", + "llm", + "cli", + "tui", + "agent" + ], + "author": "Mario Zechner", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/earendil-works/pi.git", + "directory": "packages/coding-agent" + }, + "engines": { + "node": ">=22.19.0" + } +} diff --git a/cactus-code/packages/coding-agent/src/cli.ts b/cactus-code/packages/coding-agent/src/cli.ts new file mode 100644 index 000000000..b0b11ca5c --- /dev/null +++ b/cactus-code/packages/coding-agent/src/cli.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** + * CLI entry point for the refactored coding agent. + * Uses main.ts with AgentSession and new mode modules. + * + * Test with: npx tsx src/cli-new.ts [args...] + */ +import { APP_NAME } from "./config.ts"; +import { configureHttpDispatcher } from "./core/http-dispatcher.ts"; +import { main } from "./main.ts"; + +process.title = APP_NAME; +process.env.PI_CODING_AGENT = "true"; +process.emitWarning = (() => {}) as typeof process.emitWarning; + +// Configure undici's global dispatcher before provider SDKs issue requests. +// Runtime settings are applied once SettingsManager has loaded global/project settings. +configureHttpDispatcher(); + +main(process.argv.slice(2)); diff --git a/cactus-code/packages/coding-agent/src/cli/args.ts b/cactus-code/packages/coding-agent/src/cli/args.ts new file mode 100644 index 000000000..0d0a6c60d --- /dev/null +++ b/cactus-code/packages/coding-agent/src/cli/args.ts @@ -0,0 +1,292 @@ +/** + * CLI argument parsing and help display + */ + +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import chalk from "chalk"; +import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.ts"; +import type { ExtensionFlag } from "../core/extensions/types.ts"; + +export type Mode = "text" | "json"; + +export interface Args { + provider?: string; + model?: string; + apiKey?: string; + systemPrompt?: string; + appendSystemPrompt?: string[]; + thinking?: ThinkingLevel; + continue?: boolean; + resume?: boolean; + help?: boolean; + version?: boolean; + mode?: Mode; + name?: string; + noSession?: boolean; + session?: string; + sessionId?: string; + fork?: string; + sessionDir?: string; + models?: string[]; + tools?: string[]; + excludeTools?: string[]; + noTools?: boolean; + noBuiltinTools?: boolean; + print?: boolean; + noSkills?: boolean; + skills?: string[]; + promptTemplates?: string[]; + noPromptTemplates?: boolean; + themes?: string[]; + noThemes?: boolean; + noContextFiles?: boolean; + listModels?: string | true; + offline?: boolean; + verbose?: boolean; + projectTrustOverride?: boolean; + messages: string[]; + fileArgs: string[]; + /** Unknown flags (potentially extension flags) - map of flag name to value */ + unknownFlags: Map; + diagnostics: Array<{ type: "warning" | "error"; message: string }>; +} + +const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; + +export function isValidThinkingLevel(level: string): level is ThinkingLevel { + return VALID_THINKING_LEVELS.includes(level as ThinkingLevel); +} + +export function parseArgs(args: string[]): Args { + const result: Args = { + messages: [], + fileArgs: [], + unknownFlags: new Map(), + diagnostics: [], + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "--help" || arg === "-h") { + result.help = true; + } else if (arg === "--version" || arg === "-v") { + result.version = true; + } else if (arg === "--mode" && i + 1 < args.length) { + const mode = args[++i]; + if (mode === "text" || mode === "json") { + result.mode = mode; + } + } else if (arg === "--continue" || arg === "-c") { + result.continue = true; + } else if (arg === "--resume" || arg === "-r") { + result.resume = true; + } else if (arg === "--provider" && i + 1 < args.length) { + result.provider = args[++i]; + } else if (arg === "--model" && i + 1 < args.length) { + result.model = args[++i]; + } else if (arg === "--api-key" && i + 1 < args.length) { + result.apiKey = args[++i]; + } else if (arg === "--system-prompt" && i + 1 < args.length) { + result.systemPrompt = args[++i]; + } else if (arg === "--append-system-prompt" && i + 1 < args.length) { + result.appendSystemPrompt = result.appendSystemPrompt ?? []; + result.appendSystemPrompt.push(args[++i]); + } else if (arg === "--name" || arg === "-n") { + if (i + 1 < args.length) { + result.name = args[++i]; + } else { + result.diagnostics.push({ type: "error", message: "--name requires a value" }); + } + } else if (arg === "--no-session") { + result.noSession = true; + } else if (arg === "--session" && i + 1 < args.length) { + result.session = args[++i]; + } else if (arg === "--session-id" && i + 1 < args.length) { + result.sessionId = args[++i]; + } else if (arg === "--fork" && i + 1 < args.length) { + result.fork = args[++i]; + } else if (arg === "--session-dir" && i + 1 < args.length) { + result.sessionDir = args[++i]; + } else if (arg === "--models" && i + 1 < args.length) { + result.models = args[++i].split(",").map((s) => s.trim()); + } else if (arg === "--no-tools" || arg === "-nt") { + result.noTools = true; + } else if (arg === "--no-builtin-tools" || arg === "-nbt") { + result.noBuiltinTools = true; + } else if ((arg === "--tools" || arg === "-t") && i + 1 < args.length) { + result.tools = args[++i] + .split(",") + .map((s) => s.trim()) + .filter((name) => name.length > 0); + } else if ((arg === "--exclude-tools" || arg === "-xt") && i + 1 < args.length) { + result.excludeTools = args[++i] + .split(",") + .map((s) => s.trim()) + .filter((name) => name.length > 0); + } else if (arg === "--thinking" && i + 1 < args.length) { + const level = args[++i]; + if (isValidThinkingLevel(level)) { + result.thinking = level; + } else { + result.diagnostics.push({ + type: "warning", + message: `Invalid thinking level "${level}". Valid values: ${VALID_THINKING_LEVELS.join(", ")}`, + }); + } + } else if (arg === "--print" || arg === "-p") { + result.print = true; + const next = args[i + 1]; + if (next !== undefined && !next.startsWith("@") && (!next.startsWith("-") || next.startsWith("---"))) { + result.messages.push(next); + i++; + } + } else if (arg === "--skill" && i + 1 < args.length) { + result.skills = result.skills ?? []; + result.skills.push(args[++i]); + } else if (arg === "--prompt-template" && i + 1 < args.length) { + result.promptTemplates = result.promptTemplates ?? []; + result.promptTemplates.push(args[++i]); + } else if (arg === "--theme" && i + 1 < args.length) { + result.themes = result.themes ?? []; + result.themes.push(args[++i]); + } else if (arg === "--no-skills" || arg === "-ns") { + result.noSkills = true; + } else if (arg === "--no-prompt-templates" || arg === "-np") { + result.noPromptTemplates = true; + } else if (arg === "--no-themes") { + result.noThemes = true; + } else if (arg === "--no-context-files" || arg === "-nc") { + result.noContextFiles = true; + } else if (arg === "--list-models") { + // Check if next arg is a search pattern (not a flag or file arg) + if (i + 1 < args.length && !args[i + 1].startsWith("-") && !args[i + 1].startsWith("@")) { + result.listModels = args[++i]; + } else { + result.listModels = true; + } + } else if (arg === "--verbose") { + result.verbose = true; + } else if (arg === "--approve" || arg === "-a") { + result.projectTrustOverride = true; + } else if (arg === "--no-approve" || arg === "-na") { + result.projectTrustOverride = false; + } else if (arg === "--offline") { + result.offline = true; + } else if (arg.startsWith("@")) { + result.fileArgs.push(arg.slice(1)); // Remove @ prefix + } else if (arg.startsWith("--")) { + const eqIndex = arg.indexOf("="); + if (eqIndex !== -1) { + result.unknownFlags.set(arg.slice(2, eqIndex), arg.slice(eqIndex + 1)); + } else { + const flagName = arg.slice(2); + const next = args[i + 1]; + if (next !== undefined && !next.startsWith("-") && !next.startsWith("@")) { + result.unknownFlags.set(flagName, next); + i++; + } else { + result.unknownFlags.set(flagName, true); + } + } + } else if (arg.startsWith("-") && !arg.startsWith("--")) { + result.diagnostics.push({ type: "error", message: `Unknown option: ${arg}` }); + } else if (!arg.startsWith("-")) { + result.messages.push(arg); + } + } + + return result; +} + +export function printHelp(extensionFlags?: ExtensionFlag[]): void { + const extensionFlagsText = + extensionFlags && extensionFlags.length > 0 + ? `\n${chalk.bold("Extension CLI Flags:")}\n${extensionFlags + .map((flag) => { + const value = flag.type === "string" ? " " : ""; + const description = flag.description ?? `Registered by ${flag.extensionPath}`; + return ` --${flag.name}${value}`.padEnd(30) + description; + }) + .join("\n")}\n` + : ""; + console.log(`${chalk.bold(`${APP_NAME} code`)} - AI coding assistant for the local Cactus model + +${chalk.bold("Usage:")} + ${APP_NAME} code [options] [@files...] [messages...] + +${chalk.bold("Options:")} + --model Model pattern or ID (defaults to the model the Cactus server serves) + --system-prompt System prompt (default: coding assistant prompt) + --append-system-prompt Append text or file contents to the system prompt (can be used multiple times) + --mode Output mode: text (default) or json + --print, -p Non-interactive mode: process prompt and exit + --continue, -c Continue previous session + --resume, -r Select a session to resume + --session Use specific session file or partial UUID + --session-id Use exact project session ID, creating it if missing + --fork Fork specific session file or partial UUID into a new session + --session-dir Directory for session storage and lookup + --no-session Don't save session (ephemeral) + --name, -n Set session display name + --models Comma-separated model patterns for Ctrl+P cycling + Supports glob patterns and fuzzy matching + --no-tools, -nt Disable all tools by default (built-in and extension) + --no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled + --tools, -t Comma-separated allowlist of tool names to enable + Applies to built-in, extension, and custom tools + --exclude-tools, -xt Comma-separated denylist of tool names to disable + Applies to built-in, extension, and custom tools + --thinking Set thinking level: off, minimal, low, medium, high, xhigh + --skill Load a skill file or directory (can be used multiple times) + --no-skills, -ns Disable skills discovery and loading + --prompt-template Load a prompt template file or directory (can be used multiple times) + --no-prompt-templates, -np Disable prompt template discovery and loading + --theme Load a theme file or directory (can be used multiple times) + --no-themes Disable theme discovery and loading + --no-context-files, -nc Disable AGENTS.md and CLAUDE.md discovery and loading + --list-models [search] List available models (with optional fuzzy search) + --verbose Force verbose startup (overrides quietStartup setting) + --approve, -a Trust project-local files for this run + --no-approve, -na Ignore project-local files for this run + --offline Disable startup network operations + --help, -h Show this help + --version, -v Show version number + +${chalk.bold("Examples:")} + # Interactive mode + ${APP_NAME} code + + # Interactive mode with an initial prompt + ${APP_NAME} code "List all .ts files in src/" + + # Include files in the initial message + ${APP_NAME} code @prompt.md @image.png "What color is the sky?" + + # Non-interactive mode (process and exit) + ${APP_NAME} code -p "List all .ts files in src/" + + # Continue the previous session + ${APP_NAME} code --continue "What did we discuss?" + + # Start with a specific thinking level + ${APP_NAME} code --thinking high "Solve this complex problem" + + # Read-only mode (no file modifications) + ${APP_NAME} code --tools read,grep,find,ls -p "Review the code in src/" + +${chalk.bold("Environment Variables:")} + CACTUS_BASE_URL - Cactus server URL (default: http://127.0.0.1:8080/v1) + ${ENV_AGENT_DIR.padEnd(32)} - Config directory (default: ~/${CONFIG_DIR_NAME}/agent) + ${ENV_SESSION_DIR.padEnd(32)} - Session storage directory (overridden by --session-dir) + +${chalk.bold("Built-in Tools:")} + read - Read file contents + bash - Execute bash commands + edit - Edit files with find/replace + write - Write files (creates/overwrites) + grep - Search file contents + find - Find files by glob pattern + ls - List directory contents +`); +} diff --git a/cactus-code/packages/coding-agent/src/cli/file-processor.ts b/cactus-code/packages/coding-agent/src/cli/file-processor.ts new file mode 100644 index 000000000..e1da052a8 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/cli/file-processor.ts @@ -0,0 +1,99 @@ +/** + * Process @file CLI arguments into text content and image attachments + */ + +import { access, readFile, stat } from "node:fs/promises"; +import type { ImageContent } from "@earendil-works/pi-ai"; +import chalk from "chalk"; +import { resolve } from "path"; +import { resolveReadPath } from "../core/tools/path-utils.ts"; +import { formatDimensionNote, resizeImage } from "../utils/image-resize.ts"; +import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.ts"; + +export interface ProcessedFiles { + text: string; + images: ImageContent[]; +} + +export interface ProcessFileOptions { + /** Whether to auto-resize images to 2000x2000 max. Default: true */ + autoResizeImages?: boolean; +} + +/** Process @file arguments into text content and image attachments */ +export async function processFileArguments(fileArgs: string[], options?: ProcessFileOptions): Promise { + const autoResizeImages = options?.autoResizeImages ?? true; + let text = ""; + const images: ImageContent[] = []; + + for (const fileArg of fileArgs) { + // Expand and resolve path (handles ~ expansion and macOS screenshot Unicode spaces) + const absolutePath = resolve(resolveReadPath(fileArg, process.cwd())); + + // Check if file exists + try { + await access(absolutePath); + } catch { + console.error(chalk.red(`Error: File not found: ${absolutePath}`)); + process.exit(1); + } + + // Check if file is empty + const stats = await stat(absolutePath); + if (stats.size === 0) { + // Skip empty files + continue; + } + + const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); + + if (mimeType) { + // Handle image file + const content = await readFile(absolutePath); + + let attachment: ImageContent; + let dimensionNote: string | undefined; + + if (autoResizeImages) { + const resized = await resizeImage(content, mimeType); + if (!resized) { + text += `[Image omitted: could not be resized below the inline image size limit.]\n`; + continue; + } + dimensionNote = formatDimensionNote(resized); + attachment = { + type: "image", + mimeType: resized.mimeType, + data: resized.data, + }; + } else { + attachment = { + type: "image", + mimeType, + data: content.toString("base64"), + }; + } + + images.push(attachment); + + // Add text reference to image with optional dimension note + if (dimensionNote) { + text += `${dimensionNote}\n`; + } else { + text += `\n`; + } + } else { + // Handle text file + try { + const content = await readFile(absolutePath, "utf-8"); + text += `\n${content}\n\n`; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${message}`)); + process.exit(1); + } + } + } + + return { text, images }; +} diff --git a/cactus-code/packages/coding-agent/src/cli/initial-message.ts b/cactus-code/packages/coding-agent/src/cli/initial-message.ts new file mode 100644 index 000000000..c73f18a2f --- /dev/null +++ b/cactus-code/packages/coding-agent/src/cli/initial-message.ts @@ -0,0 +1,43 @@ +import type { ImageContent } from "@earendil-works/pi-ai"; +import type { Args } from "./args.ts"; + +export interface InitialMessageInput { + parsed: Args; + fileText?: string; + fileImages?: ImageContent[]; + stdinContent?: string; +} + +export interface InitialMessageResult { + initialMessage?: string; + initialImages?: ImageContent[]; +} + +/** + * Combine stdin content, @file text, and the first CLI message into a single + * initial prompt for non-interactive mode. + */ +export function buildInitialMessage({ + parsed, + fileText, + fileImages, + stdinContent, +}: InitialMessageInput): InitialMessageResult { + const parts: string[] = []; + if (stdinContent !== undefined) { + parts.push(stdinContent); + } + if (fileText) { + parts.push(fileText); + } + + if (parsed.messages.length > 0) { + parts.push(parsed.messages[0]); + parsed.messages.shift(); + } + + return { + initialMessage: parts.length > 0 ? parts.join("") : undefined, + initialImages: fileImages && fileImages.length > 0 ? fileImages : undefined, + }; +} diff --git a/cactus-code/packages/coding-agent/src/cli/list-models.ts b/cactus-code/packages/coding-agent/src/cli/list-models.ts new file mode 100644 index 000000000..04840daab --- /dev/null +++ b/cactus-code/packages/coding-agent/src/cli/list-models.ts @@ -0,0 +1,112 @@ +/** + * List available models with optional fuzzy search + */ + +import type { Api, Model } from "@earendil-works/pi-ai"; +import { fuzzyFilter } from "@earendil-works/pi-tui"; +import chalk from "chalk"; +import { formatNoModelsAvailableMessage } from "../core/auth-guidance.ts"; +import type { ModelRegistry } from "../core/model-registry.ts"; + +/** + * Format a number as human-readable (e.g., 200000 -> "200K", 1000000 -> "1M") + */ +function formatTokenCount(count: number): string { + if (!Number.isFinite(count)) return "?"; + if (count >= 1_000_000) { + const millions = count / 1_000_000; + return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`; + } + if (count >= 1_000) { + const thousands = count / 1_000; + return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`; + } + return count.toString(); +} + +/** + * List available models, optionally filtered by search pattern + */ +export async function listModels(modelRegistry: ModelRegistry, searchPattern?: string): Promise { + const loadError = modelRegistry.getError(); + if (loadError) { + console.error(chalk.yellow(`Warning: errors loading models.json:\n${loadError}`)); + } + + const models = modelRegistry.getAvailable(); + + if (models.length === 0) { + console.log(formatNoModelsAvailableMessage()); + return; + } + + // Apply fuzzy filter if search pattern provided + let filteredModels: Model[] = models; + if (searchPattern) { + filteredModels = fuzzyFilter(models, searchPattern, (m) => `${m.provider} ${m.id}`); + } + + if (filteredModels.length === 0) { + console.log(`No models matching "${searchPattern}"`); + return; + } + + // Sort by provider, then by model id + filteredModels.sort((a, b) => { + const providerCmp = a.provider.localeCompare(b.provider); + if (providerCmp !== 0) return providerCmp; + return a.id.localeCompare(b.id); + }); + + // Calculate column widths + const rows = filteredModels.map((m) => ({ + provider: m.provider, + model: m.id, + context: formatTokenCount(m.contextWindow), + maxOut: formatTokenCount(m.maxTokens), + thinking: m.reasoning ? "yes" : "no", + images: m.input.includes("image") ? "yes" : "no", + })); + + const headers = { + provider: "provider", + model: "model", + context: "context", + maxOut: "max-out", + thinking: "thinking", + images: "images", + }; + + const widths = { + provider: Math.max(headers.provider.length, ...rows.map((r) => r.provider.length)), + model: Math.max(headers.model.length, ...rows.map((r) => r.model.length)), + context: Math.max(headers.context.length, ...rows.map((r) => r.context.length)), + maxOut: Math.max(headers.maxOut.length, ...rows.map((r) => r.maxOut.length)), + thinking: Math.max(headers.thinking.length, ...rows.map((r) => r.thinking.length)), + images: Math.max(headers.images.length, ...rows.map((r) => r.images.length)), + }; + + // Print header + const headerLine = [ + headers.provider.padEnd(widths.provider), + headers.model.padEnd(widths.model), + headers.context.padEnd(widths.context), + headers.maxOut.padEnd(widths.maxOut), + headers.thinking.padEnd(widths.thinking), + headers.images.padEnd(widths.images), + ].join(" "); + console.log(headerLine); + + // Print rows + for (const row of rows) { + const line = [ + row.provider.padEnd(widths.provider), + row.model.padEnd(widths.model), + row.context.padEnd(widths.context), + row.maxOut.padEnd(widths.maxOut), + row.thinking.padEnd(widths.thinking), + row.images.padEnd(widths.images), + ].join(" "); + console.log(line); + } +} diff --git a/cactus-code/packages/coding-agent/src/cli/project-trust.ts b/cactus-code/packages/coding-agent/src/cli/project-trust.ts new file mode 100644 index 000000000..b27871a95 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/cli/project-trust.ts @@ -0,0 +1,62 @@ +import chalk from "chalk"; +import type { ProjectTrustContext } from "../core/extensions/types.ts"; +import type { AppMode } from "../core/project-trust.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; +import { showStartupInput, showStartupSelector } from "./startup-ui.ts"; + +export function createProjectTrustContext(options: { + cwd: string; + mode: AppMode; + settingsManager: SettingsManager; + hasUI: boolean; +}): ProjectTrustContext { + return { + cwd: options.cwd, + mode: options.mode === "interactive" ? "tui" : options.mode, + hasUI: options.hasUI, + ui: { + select: async (title, selectOptions) => { + if (!options.hasUI) { + return undefined; + } + if (options.mode !== "interactive") { + return undefined; + } + return showStartupSelector( + options.settingsManager, + title, + selectOptions.map((option) => ({ label: option, value: option })), + ); + }, + confirm: async (title, message) => { + if (!options.hasUI) { + return false; + } + if (options.mode !== "interactive") { + return false; + } + return ( + (await showStartupSelector(options.settingsManager, `${title}\n${message}`, [ + { label: "Yes", value: true }, + { label: "No", value: false }, + ])) ?? false + ); + }, + input: async (title, placeholder) => { + if (!options.hasUI) { + return undefined; + } + if (options.mode !== "interactive") { + return undefined; + } + return showStartupInput(options.settingsManager, title, placeholder); + }, + notify: (message, type = "info") => { + if (options.mode !== "interactive") { + const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan; + console.error(color(message)); + } + }, + }, + }; +} diff --git a/cactus-code/packages/coding-agent/src/cli/session-picker.ts b/cactus-code/packages/coding-agent/src/cli/session-picker.ts new file mode 100644 index 000000000..793f1d0a3 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/cli/session-picker.ts @@ -0,0 +1,55 @@ +/** + * TUI session selector for --resume flag + */ + +import { setKeybindings } from "@earendil-works/pi-tui"; +import { KeybindingsManager } from "../core/keybindings.ts"; +import type { SessionInfo, SessionListProgress } from "../core/session-manager.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; +import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.ts"; +import { createStartupTui, startStartupTui } from "./startup-ui.ts"; + +type SessionsLoader = (onProgress?: SessionListProgress) => Promise; + +/** Show TUI session selector and return selected session path or null if cancelled */ +export async function selectSession( + currentSessionsLoader: SessionsLoader, + allSessionsLoader: SessionsLoader, + settingsManager: SettingsManager, +): Promise { + const ui = await createStartupTui(settingsManager); + return new Promise((resolve) => { + const keybindings = KeybindingsManager.create(); + setKeybindings(keybindings); + let resolved = false; + + const selector = new SessionSelectorComponent( + currentSessionsLoader, + allSessionsLoader, + (path: string) => { + if (!resolved) { + resolved = true; + ui.stop(); + resolve(path); + } + }, + () => { + if (!resolved) { + resolved = true; + ui.stop(); + resolve(null); + } + }, + () => { + ui.stop(); + process.exit(0); + }, + () => ui.requestRender(), + { showRenameHint: false, keybindings }, + ); + + ui.addChild(selector); + ui.setFocus(selector.getSessionList()); + startStartupTui(ui, settingsManager); + }); +} diff --git a/cactus-code/packages/coding-agent/src/cli/startup-ui.ts b/cactus-code/packages/coding-agent/src/cli/startup-ui.ts new file mode 100644 index 000000000..73c0271c8 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/cli/startup-ui.ts @@ -0,0 +1,239 @@ +import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; +import { existsSync } from "fs"; +import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getAgentDir, getSettingsPath, PACKAGE_NAME } from "../config.ts"; +import { areExperimentalFeaturesEnabled } from "../core/experimental.ts"; +import { KeybindingsManager } from "../core/keybindings.ts"; +import { DefaultPackageManager, type ResolvedResource } from "../core/package-manager.ts"; +import { SettingsManager } from "../core/settings-manager.ts"; +import { ExtensionInputComponent } from "../modes/interactive/components/extension-input.ts"; +import { ExtensionSelectorComponent } from "../modes/interactive/components/extension-selector.ts"; +import { + FirstTimeSetupComponent, + type FirstTimeSetupResult, +} from "../modes/interactive/components/first-time-setup.ts"; +import { + detectTerminalBackgroundFromEnv, + detectTerminalThemeForAuto, + initTheme, + loadThemeFromPath, + parseAutoThemeSetting, + resolveThemeSetting, + setRegisteredThemes, + setTheme, + type Theme, +} from "../modes/interactive/theme/theme.ts"; + +const OFFICIAL_PACKAGE_NAME = "@earendil-works/pi-coding-agent"; +const OFFICIAL_APP_NAME = "pi"; +const OFFICIAL_CONFIG_DIR_NAME = ".pi"; + +interface DistributionMetadata { + packageName: string; + appName: string; + configDirName: string; +} + +function isOfficialDistribution({ packageName, appName, configDirName }: DistributionMetadata): boolean { + return ( + packageName === OFFICIAL_PACKAGE_NAME && + appName === OFFICIAL_APP_NAME && + configDirName === OFFICIAL_CONFIG_DIR_NAME + ); +} + +function loadThemes(resources: ResolvedResource[]): Theme[] { + const themes: Theme[] = []; + const seen = new Set(); + for (const resource of resources) { + if (!resource.enabled) continue; + try { + const loadedTheme = loadThemeFromPath(resource.path); + if (loadedTheme.name) { + if (seen.has(loadedTheme.name)) continue; + seen.add(loadedTheme.name); + } + themes.push(loadedTheme); + } catch { + // Startup prompts should not fail because a theme is broken. The normal + // resource loader reports theme diagnostics later in startup. + } + } + return themes; +} + +async function loadStartupThemes(settingsManager: SettingsManager): Promise { + const globalSettingsManager = SettingsManager.inMemory(settingsManager.getGlobalSettings(), { + projectTrusted: false, + }); + const packageManager = new DefaultPackageManager({ + cwd: process.cwd(), + agentDir: getAgentDir(), + settingsManager: globalSettingsManager, + }); + const resolvedPaths = await packageManager.resolve(async () => "skip"); + return loadThemes(resolvedPaths.themes); +} + +export async function createStartupTui(settingsManager: SettingsManager): Promise { + setRegisteredThemes(await loadStartupThemes(settingsManager)); + const terminalTheme = detectTerminalBackgroundFromEnv().theme; + initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme); + setKeybindings(KeybindingsManager.create()); + const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor()); + ui.setClearOnShrink(settingsManager.getClearOnShrink()); + return ui; +} + +export function startStartupTui(ui: TUI, settingsManager: SettingsManager): void { + ui.start(); + void applyDetectedStartupTheme(ui, settingsManager); +} + +async function applyDetectedStartupTheme(ui: TUI, settingsManager: SettingsManager): Promise { + const themeSetting = settingsManager.getThemeSetting(); + if (themeSetting && !parseAutoThemeSetting(themeSetting)) return; + + const terminalTheme = await detectTerminalThemeForAuto({ ui, timeoutMs: 100 }); + setTheme(resolveThemeSetting(themeSetting, terminalTheme) ?? terminalTheme); + ui.invalidate(); + ui.requestRender(); +} + +async function clearStartupTui(ui: TUI): Promise { + ui.clear(); + ui.requestRender(); + await new Promise((resolve) => setTimeout(resolve, 25)); +} + +/** + * First-time setup runs when all of these hold: + * - this is the official Pi distribution (not a fork/rebrand) + * - experimental features are enabled (PI_EXPERIMENTAL=1) + * - the default agent directory is used (no custom agent dir override) + * - setup was not completed before (settings.json does not exist) + */ +export function shouldRunFirstTimeSetup(settingsPath: string = getSettingsPath()): boolean { + if ( + !isOfficialDistribution({ + packageName: PACKAGE_NAME, + appName: APP_NAME, + configDirName: CONFIG_DIR_NAME, + }) + ) { + return false; + } + if (!areExperimentalFeaturesEnabled()) { + return false; + } + if (process.env[ENV_AGENT_DIR]) { + return false; + } + return !existsSync(settingsPath); +} + +export async function showStartupSelector( + settingsManager: SettingsManager, + title: string, + options: Array<{ label: string; value: T }>, +): Promise { + const ui = await createStartupTui(settingsManager); + return new Promise((resolve) => { + let settled = false; + const finish = async (result: T | undefined) => { + if (settled) { + return; + } + settled = true; + await clearStartupTui(ui); + ui.stop(); + resolve(result); + }; + + const selector = new ExtensionSelectorComponent( + title, + options.map((option) => option.label), + (option) => void finish(options.find((entry) => entry.label === option)?.value), + () => void finish(undefined), + { tui: ui }, + ); + ui.addChild(selector); + ui.setFocus(selector); + startStartupTui(ui, settingsManager); + }); +} + +/** Show the first-time setup dialog and persist the result */ +export async function showFirstTimeSetup(settingsManager: SettingsManager): Promise { + const ui = await createStartupTui(settingsManager); + return new Promise((resolve) => { + let settled = false; + const finish = async (result: FirstTimeSetupResult | undefined) => { + if (settled) { + return; + } + settled = true; + if (result) { + settingsManager.setTheme(result.theme); + settingsManager.setEnableAnalytics(result.shareAnalytics); + await settingsManager.flush(); + } + await clearStartupTui(ui); + ui.stop(); + resolve(); + }; + + const showSetup = async () => { + ui.start(); + const detectedTheme = await detectTerminalThemeForAuto({ ui, timeoutMs: 100 }); + setTheme(detectedTheme); + const component = new FirstTimeSetupComponent({ + detectedTheme, + onThemePreview: (themeName) => { + setTheme(themeName); + ui.requestRender(); + }, + onSubmit: (result) => void finish(result), + onCancel: () => void finish(undefined), + }); + ui.addChild(component); + ui.setFocus(component); + ui.requestRender(); + }; + + void showSetup(); + }); +} + +export async function showStartupInput( + settingsManager: SettingsManager, + title: string, + placeholder?: string, +): Promise { + const ui = await createStartupTui(settingsManager); + return new Promise((resolve) => { + let settled = false; + const finish = async (result: string | undefined) => { + if (settled) { + return; + } + settled = true; + input.dispose(); + await clearStartupTui(ui); + ui.stop(); + resolve(result); + }; + + const input = new ExtensionInputComponent( + title, + placeholder, + (value) => void finish(value), + () => void finish(undefined), + { + tui: ui, + }, + ); + ui.addChild(input); + ui.setFocus(input); + startStartupTui(ui, settingsManager); + }); +} diff --git a/cactus-code/packages/coding-agent/src/config.ts b/cactus-code/packages/coding-agent/src/config.ts new file mode 100644 index 000000000..94a23da5c --- /dev/null +++ b/cactus-code/packages/coding-agent/src/config.ts @@ -0,0 +1,554 @@ +import { accessSync, constants, existsSync, readFileSync, realpathSync } from "fs"; +import { homedir } from "os"; +import { basename, dirname, join, resolve, sep, win32 } from "path"; +import { fileURLToPath } from "url"; +import { spawnProcessSync } from "./utils/child-process.ts"; +import { normalizePath } from "./utils/paths.ts"; + +// ============================================================================= +// Package Detection +// ============================================================================= + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Detect if we're running as a Bun compiled binary. + * Bun binaries have import.meta.url containing "$bunfs", "~BUN", or "%7EBUN" (Bun's virtual filesystem path) + */ +export const isBunBinary = + import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN"); + +/** Detect if Bun is the runtime (compiled binary or bun run) */ +export const isBunRuntime = !!process.versions.bun; + +// ============================================================================= +// Install Method Detection +// ============================================================================= + +export type InstallMethod = "bun-binary" | "npm" | "pnpm" | "yarn" | "bun" | "unknown"; + +interface SelfUpdateCommandStep { + command: string; + args: string[]; + display: string; +} + +export interface SelfUpdateCommand extends SelfUpdateCommandStep { + steps?: SelfUpdateCommandStep[]; +} + +export type SelfUpdatePackageTarget = string | { packageName: string; installSpec?: string }; + +function normalizeSelfUpdatePackageTarget(target: SelfUpdatePackageTarget): { + packageName: string; + installSpec: string; +} { + if (typeof target === "string") { + return { packageName: target, installSpec: target }; + } + return { packageName: target.packageName, installSpec: target.installSpec ?? target.packageName }; +} + +function makeSelfUpdateCommand( + installStep: SelfUpdateCommandStep, + uninstallStep?: SelfUpdateCommandStep, +): SelfUpdateCommand { + if (!uninstallStep) return installStep; + return { + ...installStep, + display: `${uninstallStep.display} && ${installStep.display}`, + steps: [uninstallStep, installStep], + }; +} + +function makeSelfUpdateCommandStep(command: string, args: string[]): SelfUpdateCommandStep { + return { + command, + args, + display: [command, ...args].map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "), + }; +} + +export function detectInstallMethod(): InstallMethod { + if (isBunBinary) { + return "bun-binary"; + } + + const resolvedPath = `${__dirname}\0${process.execPath || ""}`.toLowerCase().replace(/\\/g, "/"); + + if (resolvedPath.includes("/pnpm/") || resolvedPath.includes("/.pnpm/")) { + return "pnpm"; + } + if (resolvedPath.includes("/yarn/") || resolvedPath.includes("/.yarn/")) { + return "yarn"; + } + if (isBunRuntime || resolvedPath.includes("/install/global/node_modules/")) { + return "bun"; + } + if (resolvedPath.includes("/npm/") || resolvedPath.includes("/node_modules/")) { + return "npm"; + } + + return "unknown"; +} + +function getInferredNpmInstall(): { root: string; prefix: string } | undefined { + const packageDir = getPackageDir(); + const path = process.platform === "win32" || packageDir.includes("\\") ? win32 : { basename, dirname }; + const parent = path.dirname(packageDir); + let root: string | undefined; + if (path.basename(parent).startsWith("@") && path.basename(path.dirname(parent)) === "node_modules") { + root = path.dirname(parent); + } else if (path.basename(parent) === "node_modules") { + root = parent; + } + if (!root) return undefined; + const rootParent = path.dirname(root); + if (path.basename(rootParent) === "lib") return { root, prefix: path.dirname(rootParent) }; + // Windows global npm prefixes use `\\node_modules`, which is + // indistinguishable from local project installs by path shape alone. Do not + // infer unsupported Windows custom prefixes without `npm root -g` evidence. + return undefined; +} + +function getSelfUpdateCommandForMethod( + method: InstallMethod, + installedPackageName: string, + updatePackageTarget: SelfUpdatePackageTarget = installedPackageName, + npmCommand?: string[], +): SelfUpdateCommand | undefined { + const target = normalizeSelfUpdatePackageTarget(updatePackageTarget); + switch (method) { + case "bun-binary": + return undefined; + case "pnpm": { + const match = readCommandOutput("pnpm", ["root", "-g"]) + ? undefined + : /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir()); + const binDirArgs = match + ? [`--config.global-bin-dir=${process.env.PNPM_HOME || dirname(dirname(match[1]))}`] + : []; + return makeSelfUpdateCommand( + makeSelfUpdateCommandStep("pnpm", [ + "install", + "-g", + "--ignore-scripts", + "--config.minimumReleaseAge=0", + ...binDirArgs, + target.installSpec, + ]), + target.packageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep("pnpm", ["remove", "-g", ...binDirArgs, installedPackageName]), + ); + } + case "yarn": + return makeSelfUpdateCommand( + makeSelfUpdateCommandStep("yarn", ["global", "add", "--ignore-scripts", target.installSpec]), + target.packageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep("yarn", ["global", "remove", installedPackageName]), + ); + case "bun": + return makeSelfUpdateCommand( + makeSelfUpdateCommandStep("bun", [ + "install", + "-g", + "--ignore-scripts", + "--minimum-release-age=0", + target.installSpec, + ]), + target.packageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep("bun", ["uninstall", "-g", installedPackageName]), + ); + case "npm": { + const [command = "npm", ...npmArgs] = npmCommand ?? []; + const inferred = npmCommand?.length ? undefined : getInferredNpmInstall(); + const prefixArgs = [...npmArgs, ...(inferred ? ["--prefix", inferred.prefix] : [])]; + const installStep = makeSelfUpdateCommandStep(command, [ + ...prefixArgs, + "install", + "-g", + "--ignore-scripts", + "--min-release-age=0", + target.installSpec, + ]); + const uninstallStep = + target.packageName === installedPackageName + ? undefined + : makeSelfUpdateCommandStep(command, [...prefixArgs, "uninstall", "-g", installedPackageName]); + return makeSelfUpdateCommand(installStep, uninstallStep); + } + case "unknown": + return undefined; + } +} + +function readCommandOutput( + command: string, + args: string[], + options: { requireSuccess?: boolean } = {}, +): string | undefined { + const result = spawnProcessSync(command, args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status === 0) return result.stdout.trim() || undefined; + if (options.requireSuccess) { + const reason = result.error?.message || result.stderr.trim() || `exit code ${result.status ?? "unknown"}`; + throw new Error(`Failed to run ${[command, ...args].join(" ")}: ${reason}`); + } + return undefined; +} + +function getGlobalPackageRoots(method: InstallMethod, _packageName: string, npmCommand?: string[]): string[] { + switch (method) { + case "npm": { + const configured = !!npmCommand?.length; + const [command = "npm", ...npmArgs] = npmCommand ?? []; + if (configured && command === "bun") { + const bunBin = readCommandOutput(command, [...npmArgs, "pm", "bin", "-g"], { + requireSuccess: true, + }); + const roots = [join(homedir(), ".bun", "install", "global", "node_modules")]; + if (bunBin) { + roots.push(join(dirname(bunBin), "install", "global", "node_modules")); + } + return roots; + } + const root = readCommandOutput(command, [...npmArgs, "root", "-g"], { + requireSuccess: configured, + }); + const inferred = configured ? undefined : getInferredNpmInstall(); + return [root, inferred?.root].filter((x): x is string => !!x); + } + case "pnpm": { + const root = readCommandOutput("pnpm", ["root", "-g"]); + if (root) return [root, dirname(root)]; + const match = /^(.*[\\/]global[\\/][^\\/]+)[\\/]\.pnpm[\\/]/.exec(getPackageDir()); + return match ? [match[1]] : []; + } + case "yarn": { + const dir = readCommandOutput("yarn", ["global", "dir"]); + return dir ? [dir, join(dir, "node_modules")] : []; + } + case "bun": { + const bunBin = readCommandOutput("bun", ["pm", "bin", "-g"]); + const roots = [join(homedir(), ".bun", "install", "global", "node_modules")]; + if (bunBin) { + roots.push(join(dirname(bunBin), "install", "global", "node_modules")); + } + return roots; + } + case "bun-binary": + case "unknown": + return []; + } +} + +function normalizeExistingPathForComparison(path: string, resolveSymlinks: boolean): string | undefined { + const resolvedPath = resolve(path); + if (!existsSync(resolvedPath)) { + return undefined; + } + let normalizedPath = resolvedPath; + if (resolveSymlinks) { + try { + normalizedPath = realpathSync(resolvedPath); + } catch { + return undefined; + } + } + if (process.platform === "win32") { + normalizedPath = normalizedPath.toLowerCase(); + } + return normalizedPath; +} + +function getPathComparisonCandidates(path: string): string[] { + return Array.from( + new Set( + [normalizeExistingPathForComparison(path, false), normalizeExistingPathForComparison(path, true)].filter( + (candidate): candidate is string => !!candidate, + ), + ), + ); +} + +function getEntrypointPackageDir(): string | undefined { + const entrypoint = process.argv[1]; + if (!entrypoint) return undefined; + let dir = dirname(entrypoint); + while (dir !== dirname(dir)) { + if (existsSync(join(dir, "package.json"))) { + return dir; + } + dir = dirname(dir); + } + return undefined; +} + +function isSelfUpdatePathWritable(): boolean { + const packageDir = getPackageDir(); + try { + accessSync(packageDir, constants.W_OK); + accessSync(dirname(packageDir), constants.W_OK); + return true; + } catch { + return false; + } +} + +function isManagedByGlobalPackageManager(method: InstallMethod, packageName: string, npmCommand?: string[]): boolean { + const packageDirs = [getPackageDir(), getEntrypointPackageDir()].filter((dir): dir is string => !!dir); + const packageDirCandidates = packageDirs.flatMap((dir) => getPathComparisonCandidates(dir)); + return getGlobalPackageRoots(method, packageName, npmCommand).some((root) => { + return getPathComparisonCandidates(root).some((normalizedRoot) => { + const rootPrefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return packageDirCandidates.some((packageDir) => packageDir.startsWith(rootPrefix)); + }); + }); +} + +export function getSelfUpdateCommand( + packageName: string, + npmCommand?: string[], + updatePackageTarget: SelfUpdatePackageTarget = packageName, +): SelfUpdateCommand | undefined { + const method = detectInstallMethod(); + const command = getSelfUpdateCommandForMethod(method, packageName, updatePackageTarget, npmCommand); + if (!command || !isManagedByGlobalPackageManager(method, packageName, npmCommand) || !isSelfUpdatePathWritable()) { + return undefined; + } + return command; +} + +export function getSelfUpdateUnavailableInstruction( + packageName: string, + npmCommand?: string[], + updatePackageTarget: SelfUpdatePackageTarget = packageName, +): string { + const method = detectInstallMethod(); + const target = normalizeSelfUpdatePackageTarget(updatePackageTarget); + if (method === "bun-binary") { + return `Download from: https://github.com/earendil-works/pi-mono/releases/latest`; + } + const command = getSelfUpdateCommandForMethod(method, packageName, target, npmCommand); + if (command) { + if (isManagedByGlobalPackageManager(method, packageName, npmCommand) && !isSelfUpdatePathWritable()) { + return `This installation is managed by a global ${method} install, but the install path is not writable. Update it yourself with: ${command.display}`; + } + return `This installation is not managed by a global ${method} install. Update it with the package manager, wrapper, or source checkout that provides it.`; + } + return `Update ${target.installSpec} using the package manager, wrapper, or source checkout that provides this installation.`; +} + +export function getUpdateInstruction(packageName: string): string { + const method = detectInstallMethod(); + const command = getSelfUpdateCommandForMethod(method, packageName); + if (command) { + return `Run: ${command.display}`; + } + return getSelfUpdateUnavailableInstruction(packageName); +} + +// ============================================================================= +// Package Asset Paths (shipped with executable) +// ============================================================================= + +/** + * Get the base directory for resolving package assets (themes, package.json, README.md, CHANGELOG.md). + * - For Bun binary: returns the directory containing the executable + * - For Node.js (dist/): returns __dirname (the dist/ directory) + * - For tsx (src/): returns parent directory (the package root) + */ +export function getPackageDir(): string { + // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) + const envDir = process.env.PI_PACKAGE_DIR; + if (envDir) { + return normalizePath(envDir); + } + + if (isBunBinary) { + // Bun binary: process.execPath points to the compiled executable + return dirname(process.execPath); + } + // Node.js: walk up from __dirname until we find package.json + let dir = __dirname; + while (dir !== dirname(dir)) { + if (existsSync(join(dir, "package.json"))) { + return dir; + } + dir = dirname(dir); + } + // Fallback (shouldn't happen) + return __dirname; +} + +/** + * Get path to built-in themes directory (shipped with package) + * - For Bun binary: theme/ next to executable + * - For Node.js (dist/): dist/modes/interactive/theme/ + * - For tsx (src/): src/modes/interactive/theme/ + */ +export function getThemesDir(): string { + if (isBunBinary) { + return join(getPackageDir(), "theme"); + } + // Theme is in modes/interactive/theme/ relative to src/ or dist/ + const packageDir = getPackageDir(); + const srcOrDist = existsSync(join(packageDir, "src")) ? "src" : "dist"; + return join(packageDir, srcOrDist, "modes", "interactive", "theme"); +} + + +/** Get path to package.json */ +export function getPackageJsonPath(): string { + return join(getPackageDir(), "package.json"); +} + +/** Get path to README.md */ +export function getReadmePath(): string { + return resolve(join(getPackageDir(), "README.md")); +} + +/** Get path to docs directory */ +export function getDocsPath(): string { + return resolve(join(getPackageDir(), "docs")); +} + +/** Get path to examples directory */ +export function getExamplesPath(): string { + return resolve(join(getPackageDir(), "examples")); +} + +/** + * Get path to built-in interactive assets directory. + * - For Bun binary: assets/ next to executable + * - For Node.js (dist/): dist/modes/interactive/assets/ + * - For tsx (src/): src/modes/interactive/assets/ + */ +export function getInteractiveAssetsDir(): string { + if (isBunBinary) { + return join(getPackageDir(), "assets"); + } + const packageDir = getPackageDir(); + const srcOrDist = existsSync(join(packageDir, "src")) ? "src" : "dist"; + return join(packageDir, srcOrDist, "modes", "interactive", "assets"); +} + +/** Get path to a bundled interactive asset */ +export function getBundledInteractiveAssetPath(name: string): string { + return join(getInteractiveAssetsDir(), name); +} + +// ============================================================================= +// App Config (from package.json piConfig) +// ============================================================================= + +interface PackageJson { + name?: string; + version?: string; + piConfig?: { + name?: string; + configDir?: string; + }; +} + +let pkg: PackageJson = {}; +try { + pkg = JSON.parse(readFileSync(getPackageJsonPath(), "utf-8")) as PackageJson; +} catch (e: unknown) { + const err = e as NodeJS.ErrnoException; + if (err.code !== "ENOENT") throw e; +} + +const piConfigName: string | undefined = pkg.piConfig?.name; +export const PACKAGE_NAME: string = pkg.name || "@earendil-works/pi-coding-agent"; +export const APP_NAME: string = piConfigName || "cactus"; +export const APP_TITLE: string = piConfigName || "cactus"; +export const CONFIG_DIR_NAME: string = pkg.piConfig?.configDir || ".cactus"; + +function readCactusVersion(): string | undefined { + const pkgDir = getPackageDir(); + for (const rel of [ + ["..", "..", "CACTUS_VERSION"], // bundled wheel: /CACTUS_VERSION + ["..", "..", "..", "CACTUS_VERSION"], // dev checkout: /CACTUS_VERSION + ]) { + try { + const value = readFileSync(join(pkgDir, ...rel), "utf-8").trim(); + if (value) return value.replace(/^v/i, ""); + } catch { + } + } + return undefined; +} + +export const VERSION: string = readCactusVersion() || pkg.version || "0.0.0"; + +export const ENV_AGENT_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`; +export const ENV_SESSION_DIR = `${APP_NAME.toUpperCase()}_CODING_AGENT_SESSION_DIR`; + +export function expandTildePath(path: string): string { + return normalizePath(path); +} + +// ============================================================================= +// User Config Paths (~/.cactus/agent/*) +// ============================================================================= + +/** Get the agent config directory (e.g., ~/.cactus/agent/) */ +export function getAgentDir(): string { + const envDir = process.env[ENV_AGENT_DIR]; + if (envDir) { + return expandTildePath(envDir); + } + return join(homedir(), CONFIG_DIR_NAME, "agent"); +} + +/** Get path to user's custom themes directory */ +export function getCustomThemesDir(): string { + return join(getAgentDir(), "themes"); +} + +/** Get path to models.json */ +export function getModelsPath(): string { + return join(getAgentDir(), "models.json"); +} + +/** Get path to auth.json */ +export function getAuthPath(): string { + return join(getAgentDir(), "auth.json"); +} + +/** Get path to settings.json */ +export function getSettingsPath(): string { + return join(getAgentDir(), "settings.json"); +} + +/** Get path to tools directory */ +export function getToolsDir(): string { + return join(getAgentDir(), "tools"); +} + +/** Get path to managed binaries directory (fd, rg) */ +export function getBinDir(): string { + return join(getAgentDir(), "bin"); +} + +/** Get path to prompt templates directory */ +export function getPromptsDir(): string { + return join(getAgentDir(), "prompts"); +} + +/** Get path to sessions directory */ +export function getSessionsDir(): string { + return join(getAgentDir(), "sessions"); +} + +/** Get path to debug log file */ +export function getDebugLogPath(): string { + return join(getAgentDir(), `${APP_NAME}-debug.log`); +} diff --git a/cactus-code/packages/coding-agent/src/core/agent-session-runtime.ts b/cactus-code/packages/coding-agent/src/core/agent-session-runtime.ts new file mode 100644 index 000000000..7f29275aa --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/agent-session-runtime.ts @@ -0,0 +1,433 @@ +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import { resolvePath } from "../utils/paths.ts"; +import type { AgentSession } from "./agent-session.ts"; +import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts"; +import type { + ProjectTrustContext, + ReplacedSessionContext, + SessionShutdownEvent, + SessionStartEvent, +} from "./extensions/index.ts"; +import { emitSessionShutdownEvent } from "./extensions/runner.ts"; +import type { CreateAgentSessionResult } from "./sdk.ts"; +import { assertSessionCwdExists } from "./session-cwd.ts"; +import { SessionManager } from "./session-manager.ts"; + +/** + * Result returned by runtime creation. + * + * The caller gets the created session, its cwd-bound services, and all + * diagnostics collected during setup. + */ +export interface CreateAgentSessionRuntimeResult extends CreateAgentSessionResult { + services: AgentSessionServices; + diagnostics: AgentSessionRuntimeDiagnostic[]; +} + +/** + * Creates a full runtime for a target cwd and session manager. + * + * The factory closes over process-global fixed inputs, recreates cwd-bound + * services for the effective cwd, resolves session options against those + * services, and finally creates the AgentSession. + */ +export type CreateAgentSessionRuntimeFactory = (options: { + cwd: string; + agentDir: string; + sessionManager: SessionManager; + sessionStartEvent?: SessionStartEvent; + projectTrustContext?: ProjectTrustContext; +}) => Promise; + +/** + * Thrown when /import references a JSONL file path that does not exist. + */ +export class SessionImportFileNotFoundError extends Error { + readonly filePath: string; + + constructor(filePath: string) { + super(`File not found: ${filePath}`); + this.name = "SessionImportFileNotFoundError"; + this.filePath = filePath; + } +} + +function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") { + return content; + } + + return content + .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join(""); +} + +/** + * Owns the current AgentSession plus its cwd-bound services. + * + * Session replacement methods tear down the current runtime first, then create + * and apply the next runtime. If creation fails, the error is propagated to the + * caller. The caller is responsible for user-facing error handling. + */ +export class AgentSessionRuntime { + private rebindSession?: (session: AgentSession) => Promise; + private beforeSessionInvalidate?: () => void; + private _session: AgentSession; + private _services: AgentSessionServices; + private readonly createRuntime: CreateAgentSessionRuntimeFactory; + private _diagnostics: AgentSessionRuntimeDiagnostic[]; + private _modelFallbackMessage?: string; + + constructor( + _session: AgentSession, + _services: AgentSessionServices, + createRuntime: CreateAgentSessionRuntimeFactory, + _diagnostics: AgentSessionRuntimeDiagnostic[] = [], + _modelFallbackMessage?: string, + ) { + this._session = _session; + this._services = _services; + this.createRuntime = createRuntime; + this._diagnostics = _diagnostics; + this._modelFallbackMessage = _modelFallbackMessage; + } + + get services(): AgentSessionServices { + return this._services; + } + + get session(): AgentSession { + return this._session; + } + + get cwd(): string { + return this._services.cwd; + } + + get diagnostics(): readonly AgentSessionRuntimeDiagnostic[] { + return this._diagnostics; + } + + get modelFallbackMessage(): string | undefined { + return this._modelFallbackMessage; + } + + setRebindSession(rebindSession?: (session: AgentSession) => Promise): void { + this.rebindSession = rebindSession; + } + + /** + * Set a synchronous callback that runs after `session_shutdown` handlers finish + * but before the current session is invalidated. + * + * This is for host-owned UI teardown that must not yield to the event loop, + * such as detaching extension-provided TUI components before the old extension + * context becomes stale. + */ + setBeforeSessionInvalidate(beforeSessionInvalidate?: () => void): void { + this.beforeSessionInvalidate = beforeSessionInvalidate; + } + + private async emitBeforeSwitch( + reason: "new" | "resume", + targetSessionFile?: string, + ): Promise<{ cancelled: boolean }> { + const runner = this.session.extensionRunner; + if (!runner.hasHandlers("session_before_switch")) { + return { cancelled: false }; + } + + const result = await runner.emit({ + type: "session_before_switch", + reason, + targetSessionFile, + }); + return { cancelled: result?.cancel === true }; + } + + private async emitBeforeFork( + entryId: string, + options: { position: "before" | "at" }, + ): Promise<{ cancelled: boolean }> { + const runner = this.session.extensionRunner; + if (!runner.hasHandlers("session_before_fork")) { + return { cancelled: false }; + } + + const result = await runner.emit({ + type: "session_before_fork", + entryId, + ...options, + }); + return { cancelled: result?.cancel === true }; + } + + private async teardownCurrent(reason: SessionShutdownEvent["reason"], targetSessionFile?: string): Promise { + await emitSessionShutdownEvent(this.session.extensionRunner, { + type: "session_shutdown", + reason, + targetSessionFile, + }); + this.beforeSessionInvalidate?.(); + this.session.dispose(); + } + + private apply(result: CreateAgentSessionRuntimeResult): void { + this._session = result.session; + this._services = result.services; + this._diagnostics = result.diagnostics; + this._modelFallbackMessage = result.modelFallbackMessage; + } + + private async finishSessionReplacement(withSession?: (ctx: ReplacedSessionContext) => Promise): Promise { + if (this.rebindSession) { + await this.rebindSession(this.session); + } + if (withSession) { + await withSession(this.session.createReplacedSessionContext()); + } + } + + async switchSession( + sessionPath: string, + options?: { + cwdOverride?: string; + withSession?: (ctx: ReplacedSessionContext) => Promise; + projectTrustContextFactory?: (cwd: string) => ProjectTrustContext; + }, + ): Promise<{ cancelled: boolean }> { + const beforeResult = await this.emitBeforeSwitch("resume", sessionPath); + if (beforeResult.cancelled) { + return beforeResult; + } + + const previousSessionFile = this.session.sessionFile; + const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride); + assertSessionCwdExists(sessionManager, this.cwd); + await this.teardownCurrent("resume", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, + projectTrustContext: options?.projectTrustContextFactory?.(sessionManager.getCwd()), + }), + ); + await this.finishSessionReplacement(options?.withSession); + return { cancelled: false }; + } + + async newSession(options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }): Promise<{ cancelled: boolean }> { + const beforeResult = await this.emitBeforeSwitch("new"); + if (beforeResult.cancelled) { + return beforeResult; + } + + const previousSessionFile = this.session.sessionFile; + const sessionDir = this.session.sessionManager.getSessionDir(); + const sessionManager = this.session.sessionManager.isPersisted() + ? SessionManager.create(this.cwd, sessionDir) + : SessionManager.inMemory(this.cwd); + if (options?.parentSession) { + sessionManager.newSession({ parentSession: options.parentSession }); + } + + await this.teardownCurrent("new", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: this.cwd, + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile }, + }), + ); + if (options?.setup) { + await options.setup(this.session.sessionManager); + this.session.agent.state.messages = this.session.sessionManager.buildSessionContext().messages; + } + await this.finishSessionReplacement(options?.withSession); + return { cancelled: false }; + } + + async fork( + entryId: string, + options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise }, + ): Promise<{ cancelled: boolean; selectedText?: string }> { + const position = options?.position ?? "before"; + const beforeResult = await this.emitBeforeFork(entryId, { position }); + if (beforeResult.cancelled) { + return { cancelled: true }; + } + let targetLeafId: string | null; + let selectedText: string | undefined; + + const selectedEntry = this.session.sessionManager.getEntry(entryId); + if (!selectedEntry) { + throw new Error("Invalid entry ID for forking"); + } + + if (position === "at") { + targetLeafId = selectedEntry.id; + } else { + if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") { + throw new Error("Invalid entry ID for forking"); + } + targetLeafId = selectedEntry.parentId; + selectedText = extractUserMessageText(selectedEntry.message.content); + } + + const previousSessionFile = this.session.sessionFile; + if (this.session.sessionManager.isPersisted()) { + const currentSessionFile = this.session.sessionFile; + if (!currentSessionFile) { + throw new Error("Persisted session is missing a session file"); + } + const sessionDir = this.session.sessionManager.getSessionDir(); + if (!targetLeafId) { + const sessionManager = SessionManager.create(this.cwd, sessionDir); + sessionManager.newSession({ parentSession: currentSessionFile }); + await this.teardownCurrent("fork", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: this.cwd, + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + }), + ); + await this.finishSessionReplacement(options?.withSession); + return { cancelled: false, selectedText }; + } + + const sessionManager = SessionManager.open(currentSessionFile, sessionDir); + const forkedSessionPath = sessionManager.createBranchedSession(targetLeafId); + if (!forkedSessionPath) { + throw new Error("Failed to create forked session"); + } + await this.teardownCurrent("fork", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + }), + ); + await this.finishSessionReplacement(options?.withSession); + return { cancelled: false, selectedText }; + } + + const sessionManager = this.session.sessionManager; + if (!targetLeafId) { + sessionManager.newSession({ parentSession: this.session.sessionFile }); + } else { + sessionManager.createBranchedSession(targetLeafId); + } + await this.teardownCurrent("fork", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: this.cwd, + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + }), + ); + await this.finishSessionReplacement(options?.withSession); + return { cancelled: false, selectedText }; + } + + /** + * Import a session JSONL file and switch runtime state to the imported session. + * + * @returns `{ cancelled: true }` when cancelled by `session_before_switch`, otherwise `{ cancelled: false }`. + * @throws {SessionImportFileNotFoundError} When the input path does not exist. + * @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided. + */ + async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> { + const resolvedPath = resolvePath(inputPath); + if (!existsSync(resolvedPath)) { + throw new SessionImportFileNotFoundError(resolvedPath); + } + + const sessionDir = this.session.sessionManager.getSessionDir(); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + + const destinationPath = join(sessionDir, basename(resolvedPath)); + const beforeResult = await this.emitBeforeSwitch("resume", destinationPath); + if (beforeResult.cancelled) { + return beforeResult; + } + + const previousSessionFile = this.session.sessionFile; + if (resolve(destinationPath) !== resolvedPath) { + copyFileSync(resolvedPath, destinationPath); + } + + const sessionManager = SessionManager.open(destinationPath, sessionDir, cwdOverride); + assertSessionCwdExists(sessionManager, this.cwd); + await this.teardownCurrent("resume", sessionManager.getSessionFile()); + this.apply( + await this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, + }), + ); + await this.finishSessionReplacement(); + return { cancelled: false }; + } + + async dispose(): Promise { + await emitSessionShutdownEvent(this.session.extensionRunner, { + type: "session_shutdown", + reason: "quit", + }); + this.beforeSessionInvalidate?.(); + this.session.dispose(); + } +} + +/** + * Create the initial runtime from a runtime factory and initial session target. + * + * The same factory is stored on the returned AgentSessionRuntime and reused for + * later /new, /resume, /fork, and import flows. + */ +export async function createAgentSessionRuntime( + createRuntime: CreateAgentSessionRuntimeFactory, + options: { + cwd: string; + agentDir: string; + sessionManager: SessionManager; + sessionStartEvent?: SessionStartEvent; + }, +): Promise { + assertSessionCwdExists(options.sessionManager, options.cwd); + const result = await createRuntime(options); + return new AgentSessionRuntime( + result.session, + result.services, + createRuntime, + result.diagnostics, + result.modelFallbackMessage, + ); +} + +export { + type AgentSessionRuntimeDiagnostic, + type AgentSessionServices, + type CreateAgentSessionFromServicesOptions, + type CreateAgentSessionServicesOptions, + createAgentSessionFromServices, + createAgentSessionServices, +} from "./agent-session-services.ts"; diff --git a/cactus-code/packages/coding-agent/src/core/agent-session-services.ts b/cactus-code/packages/coding-agent/src/core/agent-session-services.ts new file mode 100644 index 000000000..62ca3aec5 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/agent-session-services.ts @@ -0,0 +1,242 @@ +import { join } from "node:path"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { Model } from "@earendil-works/pi-ai"; +import { cactusBaseUrl, cactusModelFromId, fetchCactusModels } from "@earendil-works/pi-ai/providers/cactus"; +import { getAgentDir } from "../config.ts"; +import { resolvePath } from "../utils/paths.ts"; +import { AuthStorage } from "./auth-storage.ts"; +import type { SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; +import { ModelRegistry } from "./model-registry.ts"; +import { + DefaultResourceLoader, + type DefaultResourceLoaderOptions, + type ResourceLoader, + type ResourceLoaderReloadOptions, +} from "./resource-loader.ts"; +import { type CreateAgentSessionOptions, type CreateAgentSessionResult, createAgentSession } from "./sdk.ts"; +import type { SessionManager } from "./session-manager.ts"; +import { SettingsManager } from "./settings-manager.ts"; + +/** + * Non-fatal issues collected while creating services or sessions. + * + * Runtime creation returns diagnostics to the caller instead of printing or + * exiting. The app layer decides whether warnings should be shown and whether + * errors should abort startup. + */ +export interface AgentSessionRuntimeDiagnostic { + type: "info" | "warning" | "error"; + message: string; +} + +/** + * Inputs for creating cwd-bound runtime services. + * + * These services are recreated whenever the effective session cwd changes. + * CLI-provided resource paths should be resolved to absolute paths before they + * reach this function, so later cwd switches do not reinterpret them. + */ +export interface CreateAgentSessionServicesOptions { + cwd: string; + agentDir?: string; + authStorage?: AuthStorage; + settingsManager?: SettingsManager; + modelRegistry?: ModelRegistry; + extensionFlagValues?: Map; + resourceLoaderOptions?: Omit; + resourceLoaderReloadOptions?: ResourceLoaderReloadOptions; +} + +/** + * Inputs for creating an AgentSession from already-created services. + * + * Use this after services exist and any cwd-bound model/tool/session options + * have been resolved against those services. + */ +export interface CreateAgentSessionFromServicesOptions { + services: AgentSessionServices; + sessionManager: SessionManager; + sessionStartEvent?: SessionStartEvent; + model?: Model; + thinkingLevel?: ThinkingLevel; + scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + tools?: string[]; + excludeTools?: CreateAgentSessionOptions["excludeTools"]; + noTools?: CreateAgentSessionOptions["noTools"]; + customTools?: ToolDefinition[]; +} + +/** + * Coherent cwd-bound runtime services for one effective session cwd. + * + * This is infrastructure only. The AgentSession itself is created separately so + * session options can be resolved against these services first. + */ +export interface AgentSessionServices { + cwd: string; + agentDir: string; + authStorage: AuthStorage; + settingsManager: SettingsManager; + modelRegistry: ModelRegistry; + resourceLoader: ResourceLoader; + diagnostics: AgentSessionRuntimeDiagnostic[]; +} + +function applyExtensionFlagValues( + resourceLoader: ResourceLoader, + extensionFlagValues: Map | undefined, +): AgentSessionRuntimeDiagnostic[] { + if (!extensionFlagValues) { + return []; + } + + const diagnostics: AgentSessionRuntimeDiagnostic[] = []; + const extensionsResult = resourceLoader.getExtensions(); + const registeredFlags = new Map(); + for (const extension of extensionsResult.extensions) { + for (const [name, flag] of extension.flags) { + registeredFlags.set(name, { type: flag.type }); + } + } + + const unknownFlags: string[] = []; + for (const [name, value] of extensionFlagValues) { + const flag = registeredFlags.get(name); + if (!flag) { + unknownFlags.push(name); + continue; + } + if (flag.type === "boolean") { + extensionsResult.runtime.flagValues.set(name, true); + continue; + } + if (typeof value === "string") { + extensionsResult.runtime.flagValues.set(name, value); + continue; + } + diagnostics.push({ + type: "error", + message: `Extension flag "--${name}" requires a value`, + }); + } + + if (unknownFlags.length > 0) { + diagnostics.push({ + type: "error", + message: `Unknown option${unknownFlags.length === 1 ? "" : "s"}: ${unknownFlags.map((name) => `--${name}`).join(", ")}`, + }); + } + + return diagnostics; +} + +/** + * Create cwd-bound runtime services. + * + * Returns services plus diagnostics. It does not create an AgentSession. + */ +export async function createAgentSessionServices( + options: CreateAgentSessionServicesOptions, +): Promise { + const cwd = resolvePath(options.cwd); + const agentDir = options.agentDir ? resolvePath(options.agentDir) : getAgentDir(); + const authStorage = options.authStorage ?? AuthStorage.create(join(agentDir, "auth.json")); + const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); + const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, join(agentDir, "models.json")); + const resourceLoader = new DefaultResourceLoader({ + ...(options.resourceLoaderOptions ?? {}), + cwd, + agentDir, + settingsManager, + }); + await resourceLoader.reload(options.resourceLoaderReloadOptions); + + const diagnostics: AgentSessionRuntimeDiagnostic[] = []; + + const cactusUrl = cactusBaseUrl(); + let cactusModels: Model<"openai-completions">[] = []; + try { + cactusModels = await fetchCactusModels(cactusUrl); + } catch (error) { + diagnostics.push({ + type: "warning", + message: `Cactus model discovery failed at ${cactusUrl}: ${ + error instanceof Error ? error.message : String(error) + }. Start the local Cactus server and retry.`, + }); + } + if (cactusModels.length === 0) { + cactusModels = [cactusModelFromId(cactusUrl, "cactus")]; + } + modelRegistry.registerProvider("cactus", { + name: "Cactus", + baseUrl: cactusUrl, + api: "openai-completions", + apiKey: "cactus", + models: cactusModels.map((m) => ({ + id: m.id, + name: m.name, + api: m.api, + baseUrl: m.baseUrl, + reasoning: m.reasoning, + input: m.input, + cost: m.cost, + contextWindow: m.contextWindow, + maxTokens: m.maxTokens, + })), + }); + + const extensionsResult = resourceLoader.getExtensions(); + for (const { name, config, extensionPath } of extensionsResult.runtime.pendingProviderRegistrations) { + try { + modelRegistry.registerProvider(name, config); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + diagnostics.push({ + type: "error", + message: `Extension "${extensionPath}" error: ${message}`, + }); + } + } + extensionsResult.runtime.pendingProviderRegistrations = []; + diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues)); + + return { + cwd, + agentDir, + authStorage, + settingsManager, + modelRegistry, + resourceLoader, + diagnostics, + }; +} + +/** + * Create an AgentSession from previously created services. + * + * This keeps session creation separate from service creation so callers can + * resolve model, thinking, tools, and other session inputs against the target + * cwd before constructing the session. + */ +export async function createAgentSessionFromServices( + options: CreateAgentSessionFromServicesOptions, +): Promise { + return createAgentSession({ + cwd: options.services.cwd, + agentDir: options.services.agentDir, + authStorage: options.services.authStorage, + settingsManager: options.services.settingsManager, + modelRegistry: options.services.modelRegistry, + resourceLoader: options.services.resourceLoader, + sessionManager: options.sessionManager, + model: options.model, + thinkingLevel: options.thinkingLevel, + scopedModels: options.scopedModels, + tools: options.tools, + excludeTools: options.excludeTools, + noTools: options.noTools, + customTools: options.customTools, + sessionStartEvent: options.sessionStartEvent, + }); +} diff --git a/cactus-code/packages/coding-agent/src/core/agent-session.ts b/cactus-code/packages/coding-agent/src/core/agent-session.ts new file mode 100644 index 000000000..83a541da6 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/agent-session.ts @@ -0,0 +1,3149 @@ +/** + * AgentSession - Core abstraction for agent lifecycle and session management. + * + * This class is shared between all run modes (interactive, print, rpc). + * It encapsulates: + * - Agent state access + * - Event subscription with automatic session persistence + * - Model and thinking level management + * - Compaction (manual and auto) + * - Bash execution + * - Session switching and branching + * + * Modes use this class and add their own I/O layer on top. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname } from "node:path"; +import type { + Agent, + AgentEvent, + AgentMessage, + AgentState, + AgentTool, + ThinkingLevel, +} from "@earendil-works/pi-agent-core"; +import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@earendil-works/pi-ai/compat"; +import { + clampThinkingLevel, + cleanupSessionResources, + getSupportedThinkingLevels, + isContextOverflow, + modelsAreEqual, + resetApiProviders, + streamSimple, +} from "@earendil-works/pi-ai/compat"; +import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts"; +import { stripFrontmatter } from "../utils/frontmatter.ts"; +import { resolvePath } from "../utils/paths.ts"; +import { sleep } from "../utils/sleep.ts"; +import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; +import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; +import { + type CompactionResult, + calculateContextTokens, + collectEntriesForBranchSummary, + compact, + estimateContextTokens, + estimateTokens, + generateBranchSummary, + prepareCompaction, + resolveCompactionSettings, + shouldCompact, +} from "./compaction/index.ts"; +import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import { + type ContextUsage, + type ExtensionCommandContextActions, + type ExtensionErrorListener, + type ExtensionMode, + ExtensionRunner, + type ExtensionUIContext, + type InputSource, + type MessageEndEvent, + type MessageStartEvent, + type MessageUpdateEvent, + type ReplacedSessionContext, + type SessionBeforeCompactResult, + type SessionBeforeTreeResult, + type SessionStartEvent, + type ShutdownHandler, + type ToolDefinition, + type ToolExecutionEndEvent, + type ToolExecutionStartEvent, + type ToolExecutionUpdateEvent, + type ToolInfo, + type TreePreparation, + type TurnEndEvent, + type TurnStartEvent, + wrapRegisteredTools, +} from "./extensions/index.ts"; +import { emitSessionShutdownEvent } from "./extensions/runner.ts"; +import type { BashExecutionMessage, CustomMessage } from "./messages.ts"; +import type { ModelRegistry } from "./model-registry.ts"; +import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts"; +import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts"; +import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.ts"; +import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, type SessionHeader } from "./session-manager.ts"; +import type { SettingsManager } from "./settings-manager.ts"; +import type { SlashCommandInfo } from "./slash-commands.ts"; +import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; +import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-prompt.ts"; +import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts"; +import { createAllToolDefinitions } from "./tools/index.ts"; +import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts"; + +// ============================================================================ +// Skill Block Parsing +// ============================================================================ + +/** Parsed skill block from a user message */ +export interface ParsedSkillBlock { + name: string; + location: string; + content: string; + userMessage: string | undefined; +} + +/** + * Parse a skill block from message text. + * Returns null if the text doesn't contain a skill block. + */ +export function parseSkillBlock(text: string): ParsedSkillBlock | null { + const match = text.match(/^\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/); + if (!match) return null; + return { + name: match[1], + location: match[2], + content: match[3], + userMessage: match[4]?.trim() || undefined, + }; +} + +/** Session-specific events that extend the core AgentEvent */ +export type AgentSessionEvent = + | Exclude + | { + type: "agent_end"; + messages: AgentMessage[]; + willRetry: boolean; + } + | { + type: "queue_update"; + steering: readonly string[]; + followUp: readonly string[]; + } + | { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" } + | { type: "session_info_changed"; name: string | undefined } + | { type: "thinking_level_changed"; level: ThinkingLevel } + | { + type: "compaction_end"; + reason: "manual" | "threshold" | "overflow"; + result: CompactionResult | undefined; + aborted: boolean; + willRetry: boolean; + errorMessage?: string; + } + | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string } + | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }; + +/** Listener function for agent session events */ +export type AgentSessionEventListener = (event: AgentSessionEvent) => void; + +// ============================================================================ +// Types +// ============================================================================ + +export interface AgentSessionConfig { + agent: Agent; + sessionManager: SessionManager; + settingsManager: SettingsManager; + cwd: string; + /** Models to cycle through with Ctrl+P (from --models flag) */ + scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + /** Resource loader for skills, prompts, themes, context files, system prompt */ + resourceLoader: ResourceLoader; + /** SDK custom tools registered outside extensions */ + customTools?: ToolDefinition[]; + /** Model registry for API key resolution and model discovery */ + modelRegistry: ModelRegistry; + /** Initial active built-in tool names. Default: [read, bash, edit, write] */ + initialActiveToolNames?: string[]; + /** Optional allowlist of tool names. When provided, only these tool names are exposed. */ + allowedToolNames?: string[]; + /** Optional denylist of tool names. When provided, these tool names are not exposed. */ + excludedToolNames?: string[]; + /** + * Override base tools (useful for custom runtimes). + * + * These are synthesized into minimal ToolDefinitions internally so AgentSession can keep + * a definition-first registry even when callers provide plain AgentTool instances. + */ + baseToolsOverride?: Record; + /** Mutable ref used by Agent to access the current ExtensionRunner */ + extensionRunnerRef?: { current?: ExtensionRunner }; + /** Session start event metadata emitted when extensions bind to this runtime. */ + sessionStartEvent?: SessionStartEvent; +} + +export interface ExtensionBindings { + uiContext?: ExtensionUIContext; + mode?: ExtensionMode; + commandContextActions?: ExtensionCommandContextActions; + abortHandler?: () => void; + shutdownHandler?: ShutdownHandler; + onError?: ExtensionErrorListener; +} + +/** Options for AgentSession.prompt() */ +export interface PromptOptions { + /** Whether to expand file-based prompt templates (default: true) */ + expandPromptTemplates?: boolean; + /** Image attachments */ + images?: ImageContent[]; + /** When streaming, how to queue the message: "steer" (interrupt) or "followUp" (wait). Required if streaming. */ + streamingBehavior?: "steer" | "followUp"; + /** Source of input for extension input event handlers. Defaults to "interactive". */ + source?: InputSource; + /** Internal hook used by RPC mode to observe prompt preflight acceptance or rejection. */ + preflightResult?: (success: boolean) => void; +} + +/** Result from cycleModel() */ +export interface ModelCycleResult { + model: Model; + thinkingLevel: ThinkingLevel; + /** Whether cycling through scoped models (--models flag) or all available */ + isScoped: boolean; +} + +/** Session statistics for /session command */ +export interface SessionStats { + sessionFile: string | undefined; + sessionId: string; + userMessages: number; + assistantMessages: number; + toolCalls: number; + toolResults: number; + totalMessages: number; + tokens: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + total: number; + }; + cost: number; + contextUsage?: ContextUsage; +} + +interface ToolDefinitionEntry { + definition: ToolDefinition; + sourceInfo: SourceInfo; +} + +function estimateMessagesTokens(messages: AgentMessage[]): number { + let tokens = 0; + for (const message of messages) { + tokens += estimateTokens(message); + } + return tokens; +} + +// ============================================================================ +// Constants +// ============================================================================ + +/** Standard thinking levels */ +const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"]; + +// ============================================================================ +// AgentSession Class +// ============================================================================ + +export class AgentSession { + readonly agent: Agent; + readonly sessionManager: SessionManager; + readonly settingsManager: SettingsManager; + + private _scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + + // Event subscription state + private _unsubscribeAgent?: () => void; + private _eventListeners: AgentSessionEventListener[] = []; + + /** Tracks pending steering messages for UI display. Removed when delivered. */ + private _steeringMessages: string[] = []; + /** Tracks pending follow-up messages for UI display. Removed when delivered. */ + private _followUpMessages: string[] = []; + /** Messages queued to be included with the next user prompt as context ("asides"). */ + private _pendingNextTurnMessages: CustomMessage[] = []; + + // Compaction state + private _compactionAbortController: AbortController | undefined = undefined; + private _autoCompactionAbortController: AbortController | undefined = undefined; + private _overflowRecoveryAttempted = false; + + // Branch summarization state + private _branchSummaryAbortController: AbortController | undefined = undefined; + + // Retry state + private _retryAbortController: AbortController | undefined = undefined; + private _retryAttempt = 0; + + // Bash execution state + private _bashAbortController: AbortController | undefined = undefined; + private _pendingBashMessages: BashExecutionMessage[] = []; + + // Extension system + private _extensionRunner!: ExtensionRunner; + private _turnIndex = 0; + + private _resourceLoader: ResourceLoader; + private _customTools: ToolDefinition[]; + private _baseToolDefinitions: Map = new Map(); + private _cwd: string; + private _extensionRunnerRef?: { current?: ExtensionRunner }; + private _initialActiveToolNames?: string[]; + private _allowedToolNames?: Set; + private _excludedToolNames?: Set; + private _baseToolsOverride?: Record; + private _sessionStartEvent: SessionStartEvent; + private _extensionUIContext?: ExtensionUIContext; + private _extensionMode: ExtensionMode = "print"; + private _extensionCommandContextActions?: ExtensionCommandContextActions; + private _extensionAbortHandler?: () => void; + private _extensionShutdownHandler?: ShutdownHandler; + private _extensionErrorListener?: ExtensionErrorListener; + private _extensionErrorUnsubscriber?: () => void; + + // Model registry for API key resolution + private _modelRegistry: ModelRegistry; + + // Tool registry for extension getTools/setTools + private _toolRegistry: Map = new Map(); + private _toolDefinitions: Map = new Map(); + private _toolPromptSnippets: Map = new Map(); + private _toolPromptGuidelines: Map = new Map(); + + // Base system prompt (without extension appends) - used to apply fresh appends each turn + private _baseSystemPrompt = ""; + private _baseSystemPromptOptions!: BuildSystemPromptOptions; + + constructor(config: AgentSessionConfig) { + this.agent = config.agent; + this.sessionManager = config.sessionManager; + this.settingsManager = config.settingsManager; + this._scopedModels = config.scopedModels ?? []; + this._resourceLoader = config.resourceLoader; + this._customTools = config.customTools ?? []; + this._cwd = config.cwd; + this._modelRegistry = config.modelRegistry; + this._extensionRunnerRef = config.extensionRunnerRef; + this._initialActiveToolNames = config.initialActiveToolNames; + this._allowedToolNames = config.allowedToolNames ? new Set(config.allowedToolNames) : undefined; + this._excludedToolNames = config.excludedToolNames ? new Set(config.excludedToolNames) : undefined; + this._baseToolsOverride = config.baseToolsOverride; + this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" }; + + // Always subscribe to agent events for internal handling + // (session persistence, extensions, auto-compaction, retry logic) + this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); + this._installAgentToolHooks(); + + this._buildRuntime({ + activeToolNames: this._initialActiveToolNames, + includeAllExtensionTools: true, + }); + } + + /** Model registry for API key resolution and model discovery */ + get modelRegistry(): ModelRegistry { + return this._modelRegistry; + } + + private async _getRequiredRequestAuth(model: Model): Promise<{ + apiKey: string; + headers?: Record; + env?: Record; + }> { + const result = await this._modelRegistry.getApiKeyAndHeaders(model); + if (!result.ok) { + if (result.error.startsWith("No API key found")) { + throw new Error(formatNoApiKeyFoundMessage(model.provider)); + } + throw new Error(result.error); + } + if (result.apiKey) { + return { apiKey: result.apiKey, headers: result.headers, env: result.env }; + } + + const isOAuth = this._modelRegistry.isUsingOAuth(model); + if (isOAuth) { + throw new Error( + `Authentication failed for "${model.provider}". ` + + `Credentials may have expired or network is unavailable. ` + + `Run '/login ${model.provider}' to re-authenticate.`, + ); + } + throw new Error(formatNoApiKeyFoundMessage(model.provider)); + } + + private async _getCompactionRequestAuth(model: Model): Promise<{ + apiKey?: string; + headers?: Record; + env?: Record; + }> { + if (this.agent.streamFn === streamSimple) { + return this._getRequiredRequestAuth(model); + } + + const result = await this._modelRegistry.getApiKeyAndHeaders(model); + return result.ok ? { apiKey: result.apiKey, headers: result.headers, env: result.env } : {}; + } + + /** + * Install tool hooks once on the Agent instance. + * + * The callbacks read `this._extensionRunner` at execution time, so extension reload swaps in the + * new runner without reinstalling hooks. Extension-specific tool wrappers are still used to adapt + * registered tool execution to the extension context. Tool call and tool result interception now + * happens here instead of in wrappers. + */ + private _installAgentToolHooks(): void { + this.agent.beforeToolCall = async ({ toolCall, args }) => { + const runner = this._extensionRunner; + if (!runner.hasHandlers("tool_call")) { + return undefined; + } + + try { + return await runner.emitToolCall({ + type: "tool_call", + toolName: toolCall.name, + toolCallId: toolCall.id, + input: args as Record, + }); + } catch (err) { + if (err instanceof Error) { + throw err; + } + throw new Error(`Extension failed, blocking execution: ${String(err)}`); + } + }; + + this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => { + const runner = this._extensionRunner; + if (!runner.hasHandlers("tool_result")) { + return undefined; + } + + const hookResult = await runner.emitToolResult({ + type: "tool_result", + toolName: toolCall.name, + toolCallId: toolCall.id, + input: args as Record, + content: result.content, + details: result.details, + isError, + }); + + if (!hookResult) { + return undefined; + } + + return { + content: hookResult.content, + details: hookResult.details, + isError: hookResult.isError ?? isError, + }; + }; + } + + // ========================================================================= + // Event Subscription + // ========================================================================= + + /** Emit an event to all listeners */ + private _emit(event: AgentSessionEvent): void { + for (const l of this._eventListeners) { + l(event); + } + } + + private _emitQueueUpdate(): void { + this._emit({ + type: "queue_update", + steering: [...this._steeringMessages], + followUp: [...this._followUpMessages], + }); + } + + // Track last assistant message for auto-compaction check + private _lastAssistantMessage: AssistantMessage | undefined = undefined; + + /** Internal handler for agent events - shared by subscribe and reconnect */ + private _handleAgentEvent = async (event: AgentEvent): Promise => { + // When a user message starts, check if it's from either queue and remove it BEFORE emitting + // This ensures the UI sees the updated queue state + if (event.type === "message_start" && event.message.role === "user") { + this._overflowRecoveryAttempted = false; + const messageText = this._getUserMessageText(event.message); + if (messageText) { + // Check steering queue first + const steeringIndex = this._steeringMessages.indexOf(messageText); + if (steeringIndex !== -1) { + this._steeringMessages.splice(steeringIndex, 1); + this._emitQueueUpdate(); + } else { + // Check follow-up queue + const followUpIndex = this._followUpMessages.indexOf(messageText); + if (followUpIndex !== -1) { + this._followUpMessages.splice(followUpIndex, 1); + this._emitQueueUpdate(); + } + } + } + } + + // Emit to extensions first + await this._emitExtensionEvent(event); + + // Notify all listeners + this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event); + + // Handle session persistence + if (event.type === "message_end") { + // Check if this is a custom message from extensions + if (event.message.role === "custom") { + // Persist as CustomMessageEntry + this.sessionManager.appendCustomMessageEntry( + event.message.customType, + event.message.content, + event.message.display, + event.message.details, + ); + } else if ( + event.message.role === "user" || + event.message.role === "assistant" || + event.message.role === "toolResult" + ) { + // Regular LLM message - persist as SessionMessageEntry + this.sessionManager.appendMessage(event.message); + } + // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere + + // Track assistant message for auto-compaction (checked on agent_end) + if (event.message.role === "assistant") { + this._lastAssistantMessage = event.message; + + const assistantMsg = event.message as AssistantMessage; + if (assistantMsg.stopReason !== "error") { + this._overflowRecoveryAttempted = false; + } + + // Reset retry counter immediately on successful assistant response + // This prevents accumulation across multiple LLM calls within a turn + if (assistantMsg.stopReason !== "error" && this._retryAttempt > 0) { + this._emit({ + type: "auto_retry_end", + success: true, + attempt: this._retryAttempt, + }); + this._retryAttempt = 0; + } + } + } + }; + + private _willRetryAfterAgentEnd(event: Extract): boolean { + const settings = this.settingsManager.getRetrySettings(); + if (!settings.enabled || this._retryAttempt >= settings.maxRetries) { + return false; + } + + for (let i = event.messages.length - 1; i >= 0; i--) { + const message = event.messages[i]; + if (message.role === "assistant") { + return this._isRetryableError(message as AssistantMessage); + } + } + return false; + } + + /** Extract text content from a message */ + private _getUserMessageText(message: Message): string { + if (message.role !== "user") return ""; + const content = message.content; + if (typeof content === "string") return content; + const textBlocks = content.filter((c) => c.type === "text"); + return textBlocks.map((c) => (c as TextContent).text).join(""); + } + + /** Find the last assistant message in agent state (including aborted ones) */ + private _findLastAssistantMessage(): AssistantMessage | undefined { + const messages = this.agent.state.messages; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "assistant") { + return msg as AssistantMessage; + } + } + return undefined; + } + + private _replaceMessageInPlace(target: AgentMessage, replacement: AgentMessage): void { + // Agent-core stores the finalized message object in its state before emitting message_end. + // SessionManager persistence happens later in _handleAgentEvent() with event.message. + // Mutating this object in place keeps agent state, later turn/agent events, listeners, + // and the eventual SessionManager.appendMessage(event.message) persistence in sync. + if (target === replacement) { + return; + } + + const targetRecord = target as unknown as Record; + for (const key of Object.keys(targetRecord)) { + delete targetRecord[key]; + } + Object.assign(targetRecord, replacement); + } + + /** Emit extension events based on agent events */ + private async _emitExtensionEvent(event: AgentEvent): Promise { + if (event.type === "agent_start") { + this._turnIndex = 0; + await this._extensionRunner.emit({ type: "agent_start" }); + } else if (event.type === "agent_end") { + await this._extensionRunner.emit({ type: "agent_end", messages: event.messages }); + } else if (event.type === "turn_start") { + const extensionEvent: TurnStartEvent = { + type: "turn_start", + turnIndex: this._turnIndex, + timestamp: Date.now(), + }; + await this._extensionRunner.emit(extensionEvent); + } else if (event.type === "turn_end") { + const extensionEvent: TurnEndEvent = { + type: "turn_end", + turnIndex: this._turnIndex, + message: event.message, + toolResults: event.toolResults, + }; + await this._extensionRunner.emit(extensionEvent); + this._turnIndex++; + } else if (event.type === "message_start") { + const extensionEvent: MessageStartEvent = { + type: "message_start", + message: event.message, + }; + await this._extensionRunner.emit(extensionEvent); + } else if (event.type === "message_update") { + const extensionEvent: MessageUpdateEvent = { + type: "message_update", + message: event.message, + assistantMessageEvent: event.assistantMessageEvent, + }; + await this._extensionRunner.emit(extensionEvent); + } else if (event.type === "message_end") { + const extensionEvent: MessageEndEvent = { + type: "message_end", + message: event.message, + }; + const replacement = await this._extensionRunner.emitMessageEnd(extensionEvent); + if (replacement) { + this._replaceMessageInPlace(event.message, replacement); + } + } else if (event.type === "tool_execution_start") { + const extensionEvent: ToolExecutionStartEvent = { + type: "tool_execution_start", + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + }; + await this._extensionRunner.emit(extensionEvent); + } else if (event.type === "tool_execution_update") { + const extensionEvent: ToolExecutionUpdateEvent = { + type: "tool_execution_update", + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + partialResult: event.partialResult, + }; + await this._extensionRunner.emit(extensionEvent); + } else if (event.type === "tool_execution_end") { + const extensionEvent: ToolExecutionEndEvent = { + type: "tool_execution_end", + toolCallId: event.toolCallId, + toolName: event.toolName, + result: event.result, + isError: event.isError, + }; + await this._extensionRunner.emit(extensionEvent); + } + } + + /** + * Subscribe to agent events. + * Session persistence is handled internally (saves messages on message_end). + * Multiple listeners can be added. Returns unsubscribe function for this listener. + */ + subscribe(listener: AgentSessionEventListener): () => void { + this._eventListeners.push(listener); + + // Return unsubscribe function for this specific listener + return () => { + const index = this._eventListeners.indexOf(listener); + if (index !== -1) { + this._eventListeners.splice(index, 1); + } + }; + } + + /** + * Temporarily disconnect from agent events. + * User listeners are preserved and will receive events again after resubscribe(). + * Used internally during operations that need to pause event processing. + */ + private _disconnectFromAgent(): void { + if (this._unsubscribeAgent) { + this._unsubscribeAgent(); + this._unsubscribeAgent = undefined; + } + } + + /** + * Reconnect to agent events after _disconnectFromAgent(). + * Preserves all existing listeners. + */ + private _reconnectToAgent(): void { + if (this._unsubscribeAgent) return; // Already connected + this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); + } + + /** + * Remove all listeners and disconnect from agent. + * Call this when completely done with the session. + */ + dispose(): void { + try { + this.abortRetry(); + this.abortCompaction(); + this.abortBranchSummary(); + this.abortBash(); + this.agent.abort(); + } catch { + // Dispose must succeed even if an abort hook throws. + } + + this._extensionRunner.invalidate( + "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().", + ); + this._disconnectFromAgent(); + this._eventListeners = []; + cleanupSessionResources(this.sessionId); + } + + // ========================================================================= + // Read-only State Access + // ========================================================================= + + /** Full agent state */ + get state(): AgentState { + return this.agent.state; + } + + /** Current model (may be undefined if not yet selected) */ + get model(): Model | undefined { + return this.agent.state.model; + } + + /** Current thinking level */ + get thinkingLevel(): ThinkingLevel { + return this.agent.state.thinkingLevel; + } + + /** Whether agent is currently streaming a response */ + get isStreaming(): boolean { + return this.agent.state.isStreaming; + } + + /** Current effective system prompt (includes any per-turn extension modifications) */ + get systemPrompt(): string { + return this.agent.state.systemPrompt; + } + + /** Current retry attempt (0 if not retrying) */ + get retryAttempt(): number { + return this._retryAttempt; + } + + /** + * Get the names of currently active tools. + * Returns the names of tools currently set on the agent. + */ + getActiveToolNames(): string[] { + return this.agent.state.tools.map((t) => t.name); + } + + /** + * Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata. + */ + getAllTools(): ToolInfo[] { + return Array.from(this._toolDefinitions.values()).map(({ definition, sourceInfo }) => ({ + name: definition.name, + description: definition.description, + parameters: definition.parameters, + promptGuidelines: definition.promptGuidelines, + sourceInfo, + })); + } + + getToolDefinition(name: string): ToolDefinition | undefined { + return this._toolDefinitions.get(name)?.definition; + } + + /** + * Set active tools by name. + * Only tools in the registry can be enabled. Unknown tool names are ignored. + * Also rebuilds the system prompt to reflect the new tool set. + * Changes take effect on the next agent turn. + */ + setActiveToolsByName(toolNames: string[]): void { + const tools: AgentTool[] = []; + const validToolNames: string[] = []; + for (const name of toolNames) { + const tool = this._toolRegistry.get(name); + if (tool) { + tools.push(tool); + validToolNames.push(name); + } + } + this.agent.state.tools = tools; + + // Rebuild base system prompt with new tool set + this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); + this.agent.state.systemPrompt = this._baseSystemPrompt; + } + + /** Whether compaction or branch summarization is currently running */ + get isCompacting(): boolean { + return ( + this._autoCompactionAbortController !== undefined || + this._compactionAbortController !== undefined || + this._branchSummaryAbortController !== undefined + ); + } + + /** All messages including custom types like BashExecutionMessage */ + get messages(): AgentMessage[] { + return this.agent.state.messages; + } + + /** Current steering mode */ + get steeringMode(): "all" | "one-at-a-time" { + return this.agent.steeringMode; + } + + /** Current follow-up mode */ + get followUpMode(): "all" | "one-at-a-time" { + return this.agent.followUpMode; + } + + /** Current session file path, or undefined if sessions are disabled */ + get sessionFile(): string | undefined { + return this.sessionManager.getSessionFile(); + } + + /** Current session ID */ + get sessionId(): string { + return this.sessionManager.getSessionId(); + } + + /** Current session display name, if set */ + get sessionName(): string | undefined { + return this.sessionManager.getSessionName(); + } + + /** Scoped models for cycling (from --models flag) */ + get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> { + return this._scopedModels; + } + + /** Update scoped models for cycling */ + setScopedModels(scopedModels: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>): void { + this._scopedModels = scopedModels; + } + + /** File-based prompt templates */ + get promptTemplates(): ReadonlyArray { + return this._resourceLoader.getPrompts().prompts; + } + + private _normalizePromptSnippet(text: string | undefined): string | undefined { + if (!text) return undefined; + const oneLine = text + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return oneLine.length > 0 ? oneLine : undefined; + } + + private _normalizePromptGuidelines(guidelines: string[] | undefined): string[] { + if (!guidelines || guidelines.length === 0) { + return []; + } + + const unique = new Set(); + for (const guideline of guidelines) { + const normalized = guideline.trim(); + if (normalized.length > 0) { + unique.add(normalized); + } + } + return Array.from(unique); + } + + private _rebuildSystemPrompt(toolNames: string[]): string { + const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name)); + const toolSnippets: Record = {}; + const promptGuidelines: string[] = []; + for (const name of validToolNames) { + const snippet = this._toolPromptSnippets.get(name); + if (snippet) { + toolSnippets[name] = snippet; + } + + const toolGuidelines = this._toolPromptGuidelines.get(name); + if (toolGuidelines) { + promptGuidelines.push(...toolGuidelines); + } + } + + const loaderSystemPrompt = this._resourceLoader.getSystemPrompt(); + const loaderAppendSystemPrompt = this._resourceLoader.getAppendSystemPrompt(); + const appendSystemPrompt = + loaderAppendSystemPrompt.length > 0 ? loaderAppendSystemPrompt.join("\n\n") : undefined; + const loadedSkills = this._resourceLoader.getSkills().skills; + const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles; + + this._baseSystemPromptOptions = { + cwd: this._cwd, + skills: loadedSkills, + contextFiles: loadedContextFiles, + customPrompt: loaderSystemPrompt, + appendSystemPrompt, + selectedTools: validToolNames, + toolSnippets, + promptGuidelines, + }; + return buildSystemPrompt(this._baseSystemPromptOptions); + } + + // ========================================================================= + // Prompting + // ========================================================================= + + private async _runAgentPrompt(messages: AgentMessage | AgentMessage[]): Promise { + try { + await this.agent.prompt(messages); + while (await this._handlePostAgentRun()) { + await this.agent.continue(); + } + } finally { + this._flushPendingBashMessages(); + } + } + + private async _handlePostAgentRun(): Promise { + const msg = this._lastAssistantMessage; + this._lastAssistantMessage = undefined; + if (!msg) { + return false; + } + + if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) { + return true; + } + + if (msg.stopReason === "error" && this._retryAttempt > 0) { + this._emit({ + type: "auto_retry_end", + success: false, + attempt: this._retryAttempt, + finalError: msg.errorMessage, + }); + this._retryAttempt = 0; + } + + if (await this._checkCompaction(msg)) { + return true; + } + + // The agent loop drains both queues before emitting agent_end. Any messages + // here were queued by agent_end extension handlers and need a continuation. + return this.agent.hasQueuedMessages(); + } + + /** + * Send a prompt to the agent. + * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming + * - Expands file-based prompt templates by default + * - During streaming, queues via steer() or followUp() based on streamingBehavior option + * - Validates model and API key before sending (when not streaming) + * @throws Error if streaming and no streamingBehavior specified + * @throws Error if no model selected or no API key available (when not streaming) + */ + async prompt(text: string, options?: PromptOptions): Promise { + const expandPromptTemplates = options?.expandPromptTemplates ?? true; + const preflightResult = options?.preflightResult; + let messages: AgentMessage[] | undefined; + + try { + // Handle extension commands first (execute immediately, even during streaming) + // Extension commands manage their own LLM interaction via pi.sendMessage() + if (expandPromptTemplates && text.startsWith("/")) { + const handled = await this._tryExecuteExtensionCommand(text); + if (handled) { + // Extension command executed, no prompt to send + preflightResult?.(true); + return; + } + } + + // Emit input event for extension interception (before skill/template expansion) + let currentText = text; + let currentImages = options?.images; + if (this._extensionRunner.hasHandlers("input")) { + const inputResult = await this._extensionRunner.emitInput( + currentText, + currentImages, + options?.source ?? "interactive", + this.isStreaming ? options?.streamingBehavior : undefined, + ); + if (inputResult.action === "handled") { + preflightResult?.(true); + return; + } + if (inputResult.action === "transform") { + currentText = inputResult.text; + currentImages = inputResult.images ?? currentImages; + } + } + + // Expand skill commands (/skill:name args) and prompt templates (/template args) + let expandedText = currentText; + if (expandPromptTemplates) { + expandedText = this._expandSkillCommand(expandedText); + expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); + } + + // If streaming, queue via steer() or followUp() based on option + if (this.isStreaming) { + if (!options?.streamingBehavior) { + throw new Error( + "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.", + ); + } + if (options.streamingBehavior === "followUp") { + await this._queueFollowUp(expandedText, currentImages); + } else { + await this._queueSteer(expandedText, currentImages); + } + preflightResult?.(true); + return; + } + + // Flush any pending bash messages before the new prompt + this._flushPendingBashMessages(); + + // Validate model + if (!this.model) { + throw new Error(formatNoModelSelectedMessage()); + } + + if (!this._modelRegistry.hasConfiguredAuth(this.model)) { + const isOAuth = this._modelRegistry.isUsingOAuth(this.model); + if (isOAuth) { + throw new Error( + `Authentication failed for "${this.model.provider}". ` + + `Credentials may have expired or network is unavailable. ` + + `Run '/login ${this.model.provider}' to re-authenticate.`, + ); + } + throw new Error(formatNoApiKeyFoundMessage(this.model.provider)); + } + + // Check if we need to compact before sending (catches aborted responses) + const lastAssistant = this._findLastAssistantMessage(); + if (lastAssistant && (await this._checkCompaction(lastAssistant, false))) { + try { + await this.agent.continue(); + while (await this._handlePostAgentRun()) { + await this.agent.continue(); + } + } finally { + this._flushPendingBashMessages(); + } + } + + // Build messages array (custom message if any, then user message) + messages = []; + + // Add user message + const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }]; + if (currentImages) { + userContent.push(...currentImages); + } + messages.push({ + role: "user", + content: userContent, + timestamp: Date.now(), + }); + + // Inject any pending "nextTurn" messages as context alongside the user message + for (const msg of this._pendingNextTurnMessages) { + messages.push(msg); + } + this._pendingNextTurnMessages = []; + + // Emit before_agent_start extension event + const result = await this._extensionRunner.emitBeforeAgentStart( + expandedText, + currentImages, + this._baseSystemPrompt, + this._baseSystemPromptOptions, + ); + // Add all custom messages from extensions + if (result?.messages) { + for (const msg of result.messages) { + messages.push({ + role: "custom", + customType: msg.customType, + content: msg.content, + display: msg.display, + details: msg.details, + timestamp: Date.now(), + }); + } + } + // Apply extension-modified system prompt, or reset to base + if (result?.systemPrompt) { + this.agent.state.systemPrompt = result.systemPrompt; + } else { + // Ensure we're using the base prompt (in case previous turn had modifications) + this.agent.state.systemPrompt = this._baseSystemPrompt; + } + } catch (error) { + preflightResult?.(false); + throw error; + } + + if (!messages) { + return; + } + + preflightResult?.(true); + await this._runAgentPrompt(messages); + } + + /** + * Try to execute an extension command. Returns true if command was found and executed. + */ + private async _tryExecuteExtensionCommand(text: string): Promise { + // Parse command name and args + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1); + + const command = this._extensionRunner.getCommand(commandName); + if (!command) return false; + + // Get command context from extension runner (includes session control methods) + const ctx = this._extensionRunner.createCommandContext(); + + try { + await command.handler(args, ctx); + return true; + } catch (err) { + // Emit error via extension runner + this._extensionRunner.emitError({ + extensionPath: `command:${commandName}`, + event: "command", + error: err instanceof Error ? err.message : String(err), + }); + return true; + } + } + + /** + * Expand skill commands (/skill:name args) to their full content. + * Returns the expanded text, or the original text if not a skill command or skill not found. + * Emits errors via extension runner if file read fails. + */ + private _expandSkillCommand(text: string): string { + if (!text.startsWith("/skill:")) return text; + + const spaceIndex = text.indexOf(" "); + const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex); + const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim(); + + const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName); + if (!skill) return text; // Unknown skill, pass through + + try { + const content = readFileSync(skill.filePath, "utf-8"); + const body = stripFrontmatter(content).trim(); + const skillBlock = `\nReferences are relative to ${skill.baseDir}.\n\n${body}\n`; + return args ? `${skillBlock}\n\n${args}` : skillBlock; + } catch (err) { + // Emit error like extension commands do + this._extensionRunner.emitError({ + extensionPath: skill.filePath, + event: "skill_expansion", + error: err instanceof Error ? err.message : String(err), + }); + return text; // Return original on error + } + } + + /** + * Queue a steering message while the agent is running. + * Delivered after the current assistant turn finishes executing its tool calls, + * before the next LLM call. + * Expands skill commands and prompt templates. Errors on extension commands. + * @param images Optional image attachments to include with the message + * @throws Error if text is an extension command + */ + async steer(text: string, images?: ImageContent[]): Promise { + // Check for extension commands (cannot be queued) + if (text.startsWith("/")) { + this._throwIfExtensionCommand(text); + } + + // Expand skill commands and prompt templates + let expandedText = this._expandSkillCommand(text); + expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); + + await this._queueSteer(expandedText, images); + } + + /** + * Queue a follow-up message to be processed after the agent finishes. + * Delivered only when agent has no more tool calls or steering messages. + * Expands skill commands and prompt templates. Errors on extension commands. + * @param images Optional image attachments to include with the message + * @throws Error if text is an extension command + */ + async followUp(text: string, images?: ImageContent[]): Promise { + // Check for extension commands (cannot be queued) + if (text.startsWith("/")) { + this._throwIfExtensionCommand(text); + } + + // Expand skill commands and prompt templates + let expandedText = this._expandSkillCommand(text); + expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]); + + await this._queueFollowUp(expandedText, images); + } + + /** + * Internal: Queue a steering message (already expanded, no extension command check). + */ + private async _queueSteer(text: string, images?: ImageContent[]): Promise { + this._steeringMessages.push(text); + this._emitQueueUpdate(); + const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; + if (images) { + content.push(...images); + } + this.agent.steer({ + role: "user", + content, + timestamp: Date.now(), + }); + } + + /** + * Internal: Queue a follow-up message (already expanded, no extension command check). + */ + private async _queueFollowUp(text: string, images?: ImageContent[]): Promise { + this._followUpMessages.push(text); + this._emitQueueUpdate(); + const content: (TextContent | ImageContent)[] = [{ type: "text", text }]; + if (images) { + content.push(...images); + } + this.agent.followUp({ + role: "user", + content, + timestamp: Date.now(), + }); + } + + /** + * Throw an error if the text is an extension command. + */ + private _throwIfExtensionCommand(text: string): void { + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + const command = this._extensionRunner.getCommand(commandName); + + if (command) { + throw new Error( + `Extension command "/${commandName}" cannot be queued. Use prompt() or execute the command when not streaming.`, + ); + } + } + + /** + * Send a custom message to the session. Creates a CustomMessageEntry. + * + * Handles three cases: + * - Streaming: queues message, processed when loop pulls from queue + * - Not streaming + triggerTurn: appends to state/session, starts new turn + * - Not streaming + no trigger: appends to state/session, no turn + * + * @param message Custom message with customType, content, display, details + * @param options.triggerTurn If true and not streaming, triggers a new LLM turn + * @param options.deliverAs Delivery mode: "steer", "followUp", or "nextTurn" + */ + async sendCustomMessage( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, + ): Promise { + const appMessage = { + role: "custom" as const, + customType: message.customType, + content: message.content, + display: message.display, + details: message.details, + timestamp: Date.now(), + } satisfies CustomMessage; + if (options?.deliverAs === "nextTurn") { + this._pendingNextTurnMessages.push(appMessage); + } else if (this.isStreaming) { + if (options?.deliverAs === "followUp") { + this.agent.followUp(appMessage); + } else { + this.agent.steer(appMessage); + } + } else if (options?.triggerTurn) { + await this._runAgentPrompt(appMessage); + } else { + this.agent.state.messages.push(appMessage); + this.sessionManager.appendCustomMessageEntry( + message.customType, + message.content, + message.display, + message.details, + ); + this._emit({ type: "message_start", message: appMessage }); + this._emit({ type: "message_end", message: appMessage }); + } + } + + /** + * Send a user message to the agent. Always triggers a turn. + * When the agent is streaming, use deliverAs to specify how to queue the message. + * + * @param content User message content (string or content array) + * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp" + */ + async sendUserMessage( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, + ): Promise { + // Normalize content to text string + optional images + let text: string; + let images: ImageContent[] | undefined; + + if (typeof content === "string") { + text = content; + } else { + const textParts: string[] = []; + images = []; + for (const part of content) { + if (part.type === "text") { + textParts.push(part.text); + } else { + images.push(part); + } + } + text = textParts.join("\n"); + if (images.length === 0) images = undefined; + } + + // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion + await this.prompt(text, { + expandPromptTemplates: false, + streamingBehavior: options?.deliverAs, + images, + source: "extension", + }); + } + + /** + * Clear all queued messages and return them. + * Useful for restoring to editor when user aborts. + * @returns Object with steering and followUp arrays + */ + clearQueue(): { steering: string[]; followUp: string[] } { + const steering = [...this._steeringMessages]; + const followUp = [...this._followUpMessages]; + this._steeringMessages = []; + this._followUpMessages = []; + this.agent.clearAllQueues(); + this._emitQueueUpdate(); + return { steering, followUp }; + } + + /** Number of pending messages (includes both steering and follow-up) */ + get pendingMessageCount(): number { + return this._steeringMessages.length + this._followUpMessages.length; + } + + /** Get pending steering messages (read-only) */ + getSteeringMessages(): readonly string[] { + return this._steeringMessages; + } + + /** Get pending follow-up messages (read-only) */ + getFollowUpMessages(): readonly string[] { + return this._followUpMessages; + } + + get resourceLoader(): ResourceLoader { + return this._resourceLoader; + } + + /** + * Abort current operation and wait for agent to become idle. + */ + async abort(): Promise { + this.abortRetry(); + this.agent.abort(); + await this.agent.waitForIdle(); + } + + // ========================================================================= + // Model Management + // ========================================================================= + + private async _emitModelSelect( + nextModel: Model, + previousModel: Model | undefined, + source: "set" | "cycle" | "restore", + ): Promise { + if (modelsAreEqual(previousModel, nextModel)) return; + await this._extensionRunner.emit({ + type: "model_select", + model: nextModel, + previousModel, + source, + }); + } + + /** + * Set model directly. + * Validates that auth is configured, saves to session and settings. + * @throws Error if no auth is configured for the model + */ + async setModel(model: Model): Promise { + if (!this._modelRegistry.hasConfiguredAuth(model)) { + throw new Error(`No API key for ${model.provider}/${model.id}`); + } + + const previousModel = this.model; + const thinkingLevel = this._getThinkingLevelForModelSwitch(); + this.agent.state.model = model; + this.sessionManager.appendModelChange(model.provider, model.id); + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + + // Re-clamp thinking level for new model's capabilities + this.setThinkingLevel(thinkingLevel); + + await this._emitModelSelect(model, previousModel, "set"); + } + + /** + * Cycle to next/previous model. + * Uses scoped models (from --models flag) if available, otherwise all available models. + * @param direction - "forward" (default) or "backward" + * @returns The new model info, or undefined if only one model available + */ + async cycleModel(direction: "forward" | "backward" = "forward"): Promise { + if (this._scopedModels.length > 0) { + return this._cycleScopedModel(direction); + } + return this._cycleAvailableModel(direction); + } + + private async _cycleScopedModel(direction: "forward" | "backward"): Promise { + const scopedModels = this._scopedModels.filter((scoped) => this._modelRegistry.hasConfiguredAuth(scoped.model)); + if (scopedModels.length <= 1) return undefined; + + const currentModel = this.model; + let currentIndex = scopedModels.findIndex((sm) => modelsAreEqual(sm.model, currentModel)); + + if (currentIndex === -1) currentIndex = 0; + const len = scopedModels.length; + const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; + const next = scopedModels[nextIndex]; + const thinkingLevel = this._getThinkingLevelForModelSwitch(next.thinkingLevel); + + // Apply model + this.agent.state.model = next.model; + this.sessionManager.appendModelChange(next.model.provider, next.model.id); + this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); + + // Apply thinking level. + // - Explicit scoped model thinking level overrides current session level + // - Undefined scoped model thinking level inherits the current session preference + // setThinkingLevel clamps to model capabilities. + this.setThinkingLevel(thinkingLevel); + + await this._emitModelSelect(next.model, currentModel, "cycle"); + + return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true }; + } + + private async _cycleAvailableModel(direction: "forward" | "backward"): Promise { + const availableModels = await this._modelRegistry.getAvailable(); + if (availableModels.length <= 1) return undefined; + + const currentModel = this.model; + let currentIndex = availableModels.findIndex((m) => modelsAreEqual(m, currentModel)); + + if (currentIndex === -1) currentIndex = 0; + const len = availableModels.length; + const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len; + const nextModel = availableModels[nextIndex]; + + const thinkingLevel = this._getThinkingLevelForModelSwitch(); + this.agent.state.model = nextModel; + this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); + this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); + + // Re-clamp thinking level for new model's capabilities + this.setThinkingLevel(thinkingLevel); + + await this._emitModelSelect(nextModel, currentModel, "cycle"); + + return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false }; + } + + // ========================================================================= + // Thinking Level Management + // ========================================================================= + + /** + * Set thinking level. + * Clamps to model capabilities based on available thinking levels. + * Saves to session and settings only if the level actually changes. + */ + setThinkingLevel(level: ThinkingLevel): void { + const availableLevels = this.getAvailableThinkingLevels(); + const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); + + // Only persist if actually changing + const previousLevel = this.agent.state.thinkingLevel; + const isChanging = effectiveLevel !== previousLevel; + + this.agent.state.thinkingLevel = effectiveLevel; + + if (isChanging) { + this.sessionManager.appendThinkingLevelChange(effectiveLevel); + if (this.supportsThinking() || effectiveLevel !== "off") { + this.settingsManager.setDefaultThinkingLevel(effectiveLevel); + } + this._emit({ type: "thinking_level_changed", level: effectiveLevel }); + void this._extensionRunner.emit({ + type: "thinking_level_select", + level: effectiveLevel, + previousLevel, + }); + } + } + + /** + * Cycle to next thinking level. + * @returns New level, or undefined if model doesn't support thinking + */ + cycleThinkingLevel(): ThinkingLevel | undefined { + if (!this.supportsThinking()) return undefined; + + const levels = this.getAvailableThinkingLevels(); + const currentIndex = levels.indexOf(this.thinkingLevel); + const nextIndex = (currentIndex + 1) % levels.length; + const nextLevel = levels[nextIndex]; + + this.setThinkingLevel(nextLevel); + return nextLevel; + } + + /** + * Get available thinking levels for current model. + * The provider will clamp to what the specific model supports internally. + */ + getAvailableThinkingLevels(): ThinkingLevel[] { + if (!this.model) return THINKING_LEVELS; + return getSupportedThinkingLevels(this.model) as ThinkingLevel[]; + } + + /** + * Check if current model supports thinking/reasoning. + */ + supportsThinking(): boolean { + return !!this.model?.reasoning; + } + + private _getThinkingLevelForModelSwitch(explicitLevel?: ThinkingLevel): ThinkingLevel { + if (explicitLevel !== undefined) { + return explicitLevel; + } + if (!this.supportsThinking()) { + return this.settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + } + return this.thinkingLevel; + } + + private _clampThinkingLevel(level: ThinkingLevel, _availableLevels: ThinkingLevel[]): ThinkingLevel { + return this.model ? (clampThinkingLevel(this.model, level) as ThinkingLevel) : "off"; + } + + // ========================================================================= + // Queue Mode Management + // ========================================================================= + + private syncQueueModesFromSettings(): void { + this.agent.steeringMode = this.settingsManager.getSteeringMode(); + this.agent.followUpMode = this.settingsManager.getFollowUpMode(); + } + + /** + * Set steering message mode. + * Saves to settings. + */ + setSteeringMode(mode: "all" | "one-at-a-time"): void { + this.agent.steeringMode = mode; + this.settingsManager.setSteeringMode(mode); + } + + /** + * Set follow-up message mode. + * Saves to settings. + */ + setFollowUpMode(mode: "all" | "one-at-a-time"): void { + this.agent.followUpMode = mode; + this.settingsManager.setFollowUpMode(mode); + } + + // ========================================================================= + // Compaction + // ========================================================================= + + /** + * Manually compact the session context. + * Aborts current agent operation first. + * @param customInstructions Optional instructions for the compaction summary + */ + async compact(customInstructions?: string): Promise { + this._disconnectFromAgent(); + await this.abort(); + this._compactionAbortController = new AbortController(); + this._emit({ type: "compaction_start", reason: "manual" }); + + try { + if (!this.model) { + throw new Error(formatNoModelSelectedMessage()); + } + + const { apiKey, headers, env } = await this._getCompactionRequestAuth(this.model); + + const pathEntries = this.sessionManager.getBranch(); + const settings = resolveCompactionSettings( + this.settingsManager.getCompactionSettings(), + this.model?.contextWindow ?? 0, + ); + + const preparation = prepareCompaction(pathEntries, settings); + if (!preparation) { + // Check why we can't compact + const lastEntry = pathEntries[pathEntries.length - 1]; + if (lastEntry?.type === "compaction") { + throw new Error("Already compacted"); + } + throw new Error("Nothing to compact (session too small)"); + } + + let extensionCompaction: CompactionResult | undefined; + let fromExtension = false; + + if (this._extensionRunner.hasHandlers("session_before_compact")) { + const result = (await this._extensionRunner.emit({ + type: "session_before_compact", + preparation, + branchEntries: pathEntries, + customInstructions, + reason: "manual", + willRetry: false, + signal: this._compactionAbortController.signal, + })) as SessionBeforeCompactResult | undefined; + + if (result?.cancel) { + throw new Error("Compaction cancelled"); + } + + if (result?.compaction) { + extensionCompaction = result.compaction; + fromExtension = true; + } + } + + let summary: string; + let firstKeptEntryId: string; + let tokensBefore: number; + let details: unknown; + + if (extensionCompaction) { + // Extension provided compaction content + summary = extensionCompaction.summary; + firstKeptEntryId = extensionCompaction.firstKeptEntryId; + tokensBefore = extensionCompaction.tokensBefore; + details = extensionCompaction.details; + } else { + // Generate compaction result + const result = await compact( + preparation, + this.model, + apiKey, + headers, + customInstructions, + this._compactionAbortController.signal, + this.thinkingLevel, + this.agent.streamFn, + env, + ); + summary = result.summary; + firstKeptEntryId = result.firstKeptEntryId; + tokensBefore = result.tokensBefore; + details = result.details; + } + + if (this._compactionAbortController.signal.aborted) { + throw new Error("Compaction cancelled"); + } + + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); + const newEntries = this.sessionManager.getEntries(); + const sessionContext = this.sessionManager.buildSessionContext(); + this.agent.state.messages = sessionContext.messages; + const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages); + + // Get the saved compaction entry for the extension event + const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as + | CompactionEntry + | undefined; + + if (this._extensionRunner && savedCompactionEntry) { + await this._extensionRunner.emit({ + type: "session_compact", + compactionEntry: savedCompactionEntry, + fromExtension, + reason: "manual", + willRetry: false, + }); + } + + const compactionResult: CompactionResult = { + summary, + firstKeptEntryId, + tokensBefore, + estimatedTokensAfter, + details, + }; + this._emit({ + type: "compaction_end", + reason: "manual", + result: compactionResult, + aborted: false, + willRetry: false, + }); + return compactionResult; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); + this._emit({ + type: "compaction_end", + reason: "manual", + result: undefined, + aborted, + willRetry: false, + errorMessage: aborted ? undefined : `Compaction failed: ${message}`, + }); + throw error; + } finally { + this._compactionAbortController = undefined; + this._reconnectToAgent(); + } + } + + /** + * Cancel in-progress compaction (manual or auto). + */ + abortCompaction(): void { + this._compactionAbortController?.abort(); + this._autoCompactionAbortController?.abort(); + } + + /** + * Cancel in-progress branch summarization. + */ + abortBranchSummary(): void { + this._branchSummaryAbortController?.abort(); + } + + /** + * Check if compaction is needed and run it. + * Called after agent_end and before prompt submission. + * + * Two cases: + * 1. Overflow: LLM returned context overflow error, remove error message from agent state, compact, auto-retry + * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually) + * + * @param assistantMessage The assistant message to check + * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true + */ + private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { + const settings = resolveCompactionSettings( + this.settingsManager.getCompactionSettings(), + this.model?.contextWindow ?? 0, + ); + if (!settings.enabled) return false; + + // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false + if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return false; + + const contextWindow = this.model?.contextWindow ?? 0; + + // Skip overflow check if the message came from a different model. + // This handles the case where user switched from a smaller-context model (e.g. opus) + // to a larger-context model (e.g. codex) - the overflow error from the old model + // shouldn't trigger compaction for the new model. + const sameModel = + this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id; + + // Skip compaction checks if this assistant message is older than the latest + // compaction boundary. This prevents a stale pre-compaction usage/error + // from retriggering compaction on the first prompt after compaction. + const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch()); + const assistantIsFromBeforeCompaction = + compactionEntry !== null && assistantMessage.timestamp <= new Date(compactionEntry.timestamp).getTime(); + if (assistantIsFromBeforeCompaction) { + return false; + } + + // Case 1: Overflow - LLM returned context overflow error, or reported usage exceeded + // the configured window. A successful response over the configured window should compact + // but must not retry: the assistant answer already completed and agent.continue() cannot + // continue from an assistant message. + if (sameModel && isContextOverflow(assistantMessage, contextWindow)) { + const willRetry = assistantMessage.stopReason !== "stop"; + + if (!willRetry) { + return await this._runAutoCompaction("overflow", false); + } + + if (this._overflowRecoveryAttempted) { + this._emit({ + type: "compaction_end", + reason: "overflow", + result: undefined, + aborted: false, + willRetry: false, + errorMessage: + "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.", + }); + return false; + } + + this._overflowRecoveryAttempted = true; + // Remove the error message from agent state (it IS saved to session for history, + // but we don't want it in context for the retry) + const messages = this.agent.state.messages; + if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { + this.agent.state.messages = messages.slice(0, -1); + } + return await this._runAutoCompaction("overflow", willRetry); + } + + // Case 2: Threshold - context is getting large + // For error messages or all-zero usage messages, estimate from the last valid response. + // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage + // responses can still compact and do not reset context accounting. + let contextTokens: number; + const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0; + if (assistantMessage.stopReason === "error" || directContextTokens === 0) { + const messages = this.agent.state.messages; + const estimate = estimateContextTokens(messages); + if (estimate.lastUsageIndex === null) return false; // No usage data at all + // Verify the usage source is post-compaction. Kept pre-compaction messages + // have stale usage reflecting the old (larger) context and would falsely + // trigger compaction right after one just finished. + const usageMsg = messages[estimate.lastUsageIndex]; + if ( + compactionEntry && + usageMsg.role === "assistant" && + (usageMsg as AssistantMessage).timestamp <= new Date(compactionEntry.timestamp).getTime() + ) { + return false; + } + contextTokens = estimate.tokens; + } else { + contextTokens = directContextTokens; + } + if (shouldCompact(contextTokens, contextWindow, settings)) { + return await this._runAutoCompaction("threshold", false); + } + return false; + } + + /** + * Internal: Run auto-compaction with events. + */ + private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { + const settings = resolveCompactionSettings( + this.settingsManager.getCompactionSettings(), + this.model?.contextWindow ?? 0, + ); + let started = false; + + try { + if (!this.model) { + return false; + } + + let apiKey: string | undefined; + let headers: Record | undefined; + let env: Record | undefined; + if (this.agent.streamFn === streamSimple) { + const authResult = await this._modelRegistry.getApiKeyAndHeaders(this.model); + if (!authResult.ok || !authResult.apiKey) { + return false; + } + apiKey = authResult.apiKey; + headers = authResult.headers; + env = authResult.env; + } else { + ({ apiKey, headers, env } = await this._getCompactionRequestAuth(this.model)); + } + + const pathEntries = this.sessionManager.getBranch(); + + const preparation = prepareCompaction(pathEntries, settings); + if (!preparation) { + return false; + } + + this._emit({ type: "compaction_start", reason }); + this._autoCompactionAbortController = new AbortController(); + started = true; + + let extensionCompaction: CompactionResult | undefined; + let fromExtension = false; + + if (this._extensionRunner.hasHandlers("session_before_compact")) { + const extensionResult = (await this._extensionRunner.emit({ + type: "session_before_compact", + preparation, + branchEntries: pathEntries, + customInstructions: undefined, + reason, + willRetry, + signal: this._autoCompactionAbortController.signal, + })) as SessionBeforeCompactResult | undefined; + + if (extensionResult?.cancel) { + this._emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: true, + willRetry: false, + }); + return false; + } + + if (extensionResult?.compaction) { + extensionCompaction = extensionResult.compaction; + fromExtension = true; + } + } + + let summary: string; + let firstKeptEntryId: string; + let tokensBefore: number; + let details: unknown; + + if (extensionCompaction) { + // Extension provided compaction content + summary = extensionCompaction.summary; + firstKeptEntryId = extensionCompaction.firstKeptEntryId; + tokensBefore = extensionCompaction.tokensBefore; + details = extensionCompaction.details; + } else { + // Generate compaction result + const compactResult = await compact( + preparation, + this.model, + apiKey, + headers, + undefined, + this._autoCompactionAbortController.signal, + this.thinkingLevel, + this.agent.streamFn, + env, + ); + summary = compactResult.summary; + firstKeptEntryId = compactResult.firstKeptEntryId; + tokensBefore = compactResult.tokensBefore; + details = compactResult.details; + } + + if (this._autoCompactionAbortController.signal.aborted) { + this._emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: true, + willRetry: false, + }); + return false; + } + + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); + const newEntries = this.sessionManager.getEntries(); + const sessionContext = this.sessionManager.buildSessionContext(); + this.agent.state.messages = sessionContext.messages; + const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages); + + // Get the saved compaction entry for the extension event + const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary) as + | CompactionEntry + | undefined; + + if (this._extensionRunner && savedCompactionEntry) { + await this._extensionRunner.emit({ + type: "session_compact", + compactionEntry: savedCompactionEntry, + fromExtension, + reason, + willRetry, + }); + } + + const result: CompactionResult = { + summary, + firstKeptEntryId, + tokensBefore, + estimatedTokensAfter, + details, + }; + this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); + + if (willRetry) { + const messages = this.agent.state.messages; + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") { + this.agent.state.messages = messages.slice(0, -1); + } + return true; + } + + // Auto-compaction can complete while follow-up/steering/custom messages are waiting. + // Continue once so queued messages are delivered. + return this.agent.hasQueuedMessages(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "compaction failed"; + if (started) { + this._emit({ + type: "compaction_end", + reason, + result: undefined, + aborted: false, + willRetry: false, + errorMessage: + reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`, + }); + } + return false; + } finally { + this._autoCompactionAbortController = undefined; + } + } + + /** + * Toggle auto-compaction setting. + */ + setAutoCompactionEnabled(enabled: boolean): void { + this.settingsManager.setCompactionEnabled(enabled); + } + + /** Whether auto-compaction is enabled */ + get autoCompactionEnabled(): boolean { + return this.settingsManager.getCompactionEnabled(); + } + + async bindExtensions(bindings: ExtensionBindings): Promise { + if (bindings.uiContext !== undefined) { + this._extensionUIContext = bindings.uiContext; + } + if (bindings.mode !== undefined) { + this._extensionMode = bindings.mode; + } + if (bindings.commandContextActions !== undefined) { + this._extensionCommandContextActions = bindings.commandContextActions; + } + if (bindings.abortHandler !== undefined) { + this._extensionAbortHandler = bindings.abortHandler; + } + if (bindings.shutdownHandler !== undefined) { + this._extensionShutdownHandler = bindings.shutdownHandler; + } + if (bindings.onError !== undefined) { + this._extensionErrorListener = bindings.onError; + } + + this._applyExtensionBindings(this._extensionRunner); + await this._extensionRunner.emit(this._sessionStartEvent); + await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup"); + } + + private async extendResourcesFromExtensions(reason: "startup" | "reload"): Promise { + if (!this._extensionRunner.hasHandlers("resources_discover")) { + return; + } + + const { skillPaths, promptPaths, themePaths } = await this._extensionRunner.emitResourcesDiscover( + this._cwd, + reason, + ); + + if (skillPaths.length === 0 && promptPaths.length === 0 && themePaths.length === 0) { + return; + } + + const extensionPaths: ResourceExtensionPaths = { + skillPaths: this.buildExtensionResourcePaths(skillPaths), + promptPaths: this.buildExtensionResourcePaths(promptPaths), + themePaths: this.buildExtensionResourcePaths(themePaths), + }; + + this._resourceLoader.extendResources(extensionPaths); + this._baseSystemPrompt = this._rebuildSystemPrompt(this.getActiveToolNames()); + this.agent.state.systemPrompt = this._baseSystemPrompt; + } + + private buildExtensionResourcePaths(entries: Array<{ path: string; extensionPath: string }>): Array<{ + path: string; + metadata: { source: string; scope: "temporary"; origin: "top-level"; baseDir?: string }; + }> { + return entries.map((entry) => { + const source = this.getExtensionSourceLabel(entry.extensionPath); + const baseDir = entry.extensionPath.startsWith("<") ? undefined : dirname(entry.extensionPath); + return { + path: entry.path, + metadata: { + source, + scope: "temporary", + origin: "top-level", + baseDir, + }, + }; + }); + } + + private getExtensionSourceLabel(extensionPath: string): string { + if (extensionPath.startsWith("<")) { + return `extension:${extensionPath.replace(/[<>]/g, "")}`; + } + const base = basename(extensionPath); + const name = base.replace(/\.(ts|js)$/, ""); + return `extension:${name}`; + } + + private _applyExtensionBindings(runner: ExtensionRunner): void { + runner.setUIContext(this._extensionUIContext, this._extensionMode); + runner.bindCommandContext(this._extensionCommandContextActions); + + this._extensionErrorUnsubscriber?.(); + this._extensionErrorUnsubscriber = this._extensionErrorListener + ? runner.onError(this._extensionErrorListener) + : undefined; + } + + private _refreshCurrentModelFromRegistry(): void { + const currentModel = this.model; + if (!currentModel) { + return; + } + + const refreshedModel = this._modelRegistry.find(currentModel.provider, currentModel.id); + if (!refreshedModel || refreshedModel === currentModel) { + return; + } + + this.agent.state.model = refreshedModel; + } + + private _bindExtensionCore(runner: ExtensionRunner): void { + const getCommands = (): SlashCommandInfo[] => { + const extensionCommands: SlashCommandInfo[] = runner.getRegisteredCommands().map((command) => ({ + name: command.invocationName, + description: command.description, + source: "extension", + sourceInfo: command.sourceInfo, + })); + + const templates: SlashCommandInfo[] = this.promptTemplates.map((template) => ({ + name: template.name, + description: template.description, + source: "prompt", + sourceInfo: template.sourceInfo, + })); + + const skills: SlashCommandInfo[] = this._resourceLoader.getSkills().skills.map((skill) => ({ + name: `skill:${skill.name}`, + description: skill.description, + source: "skill", + sourceInfo: skill.sourceInfo, + })); + + return [...extensionCommands, ...templates, ...skills]; + }; + + runner.bindCore( + { + sendMessage: (message, options) => { + this.sendCustomMessage(message, options).catch((err) => { + runner.emitError({ + extensionPath: "", + event: "send_message", + error: err instanceof Error ? err.message : String(err), + }); + }); + }, + sendUserMessage: (content, options) => { + this.sendUserMessage(content, options).catch((err) => { + runner.emitError({ + extensionPath: "", + event: "send_user_message", + error: err instanceof Error ? err.message : String(err), + }); + }); + }, + appendEntry: (customType, data) => { + this.sessionManager.appendCustomEntry(customType, data); + }, + setSessionName: (name) => { + this.setSessionName(name); + }, + getSessionName: () => { + return this.sessionManager.getSessionName(); + }, + setLabel: (entryId, label) => { + this.sessionManager.appendLabelChange(entryId, label); + }, + getActiveTools: () => this.getActiveToolNames(), + getAllTools: () => this.getAllTools(), + setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames), + refreshTools: () => this._refreshToolRegistry(), + getCommands, + setModel: async (model) => { + if (!this.modelRegistry.hasConfiguredAuth(model)) return false; + await this.setModel(model); + return true; + }, + getThinkingLevel: () => this.thinkingLevel, + setThinkingLevel: (level) => this.setThinkingLevel(level), + }, + { + getModel: () => this.model, + isIdle: () => !this.isStreaming, + isProjectTrusted: () => this.settingsManager.isProjectTrusted(), + getSignal: () => this.agent.signal, + abort: () => { + if (this._extensionAbortHandler) { + this._extensionAbortHandler(); + return; + } + void this.abort(); + }, + hasPendingMessages: () => this.pendingMessageCount > 0, + shutdown: () => { + this._extensionShutdownHandler?.(); + }, + getContextUsage: () => this.getContextUsage(), + compact: (options) => { + void (async () => { + try { + const result = await this.compact(options?.customInstructions); + options?.onComplete?.(result); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + options?.onError?.(err); + } + })(); + }, + getSystemPrompt: () => this.systemPrompt, + getSystemPromptOptions: () => this._baseSystemPromptOptions, + }, + { + registerProvider: (name, config) => { + this._modelRegistry.registerProvider(name, config); + this._refreshCurrentModelFromRegistry(); + }, + unregisterProvider: (name) => { + this._modelRegistry.unregisterProvider(name); + this._refreshCurrentModelFromRegistry(); + }, + }, + ); + } + + private _refreshToolRegistry(options?: { activeToolNames?: string[]; includeAllExtensionTools?: boolean }): void { + const previousRegistryNames = new Set(this._toolRegistry.keys()); + const previousActiveToolNames = this.getActiveToolNames(); + const allowedToolNames = this._allowedToolNames; + const excludedToolNames = this._excludedToolNames; + const isAllowedTool = (name: string): boolean => + (!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name); + + const registeredTools = this._extensionRunner.getAllRegisteredTools(); + const allCustomTools = [ + ...registeredTools, + ...this._customTools.map((definition) => ({ + definition, + sourceInfo: createSyntheticSourceInfo(``, { source: "sdk" }), + })), + ].filter((tool) => isAllowedTool(tool.definition.name)); + const definitionRegistry = new Map( + Array.from(this._baseToolDefinitions.entries()) + .filter(([name]) => isAllowedTool(name)) + .map(([name, definition]) => [ + name, + { + definition, + sourceInfo: createSyntheticSourceInfo(``, { source: "builtin" }), + }, + ]), + ); + for (const tool of allCustomTools) { + definitionRegistry.set(tool.definition.name, { + definition: tool.definition, + sourceInfo: tool.sourceInfo, + }); + } + this._toolDefinitions = definitionRegistry; + this._toolPromptSnippets = new Map( + Array.from(definitionRegistry.values()) + .map(({ definition }) => { + const snippet = this._normalizePromptSnippet(definition.promptSnippet); + return snippet ? ([definition.name, snippet] as const) : undefined; + }) + .filter((entry): entry is readonly [string, string] => entry !== undefined), + ); + this._toolPromptGuidelines = new Map( + Array.from(definitionRegistry.values()) + .map(({ definition }) => { + const guidelines = this._normalizePromptGuidelines(definition.promptGuidelines); + return guidelines.length > 0 ? ([definition.name, guidelines] as const) : undefined; + }) + .filter((entry): entry is readonly [string, string[]] => entry !== undefined), + ); + const runner = this._extensionRunner; + const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, runner); + const wrappedBuiltInTools = wrapRegisteredTools( + Array.from(this._baseToolDefinitions.values()) + .filter((definition) => isAllowedTool(definition.name)) + .map((definition) => ({ + definition, + sourceInfo: createSyntheticSourceInfo(``, { source: "builtin" }), + })), + runner, + ); + + const toolRegistry = new Map(wrappedBuiltInTools.map((tool) => [tool.name, tool])); + for (const tool of wrappedExtensionTools as AgentTool[]) { + toolRegistry.set(tool.name, tool); + } + this._toolRegistry = toolRegistry; + + const nextActiveToolNames = ( + options?.activeToolNames ? [...options.activeToolNames] : [...previousActiveToolNames] + ).filter((name) => isAllowedTool(name)); + + if (allowedToolNames) { + for (const toolName of this._toolRegistry.keys()) { + if (allowedToolNames.has(toolName)) { + nextActiveToolNames.push(toolName); + } + } + } else if (options?.includeAllExtensionTools) { + for (const tool of wrappedExtensionTools) { + nextActiveToolNames.push(tool.name); + } + } else if (!options?.activeToolNames) { + for (const toolName of this._toolRegistry.keys()) { + if (!previousRegistryNames.has(toolName)) { + nextActiveToolNames.push(toolName); + } + } + } + + this.setActiveToolsByName([...new Set(nextActiveToolNames)]); + } + + private _buildRuntime(options: { + activeToolNames?: string[]; + flagValues?: Map; + includeAllExtensionTools?: boolean; + }): void { + const autoResizeImages = this.settingsManager.getImageAutoResize(); + const shellCommandPrefix = this.settingsManager.getShellCommandPrefix(); + const shellPath = this.settingsManager.getShellPath(); + const baseToolDefinitions = this._baseToolsOverride + ? Object.fromEntries( + Object.entries(this._baseToolsOverride).map(([name, tool]) => [ + name, + createToolDefinitionFromAgentTool(tool), + ]), + ) + : createAllToolDefinitions(this._cwd, { + read: { autoResizeImages }, + bash: { commandPrefix: shellCommandPrefix, shellPath }, + }); + + this._baseToolDefinitions = new Map( + Object.entries(baseToolDefinitions).map(([name, tool]) => [name, tool as ToolDefinition]), + ); + + const extensionsResult = this._resourceLoader.getExtensions(); + if (options.flagValues) { + for (const [name, value] of options.flagValues) { + extensionsResult.runtime.flagValues.set(name, value); + } + } + + this._extensionRunner = new ExtensionRunner( + extensionsResult.extensions, + extensionsResult.runtime, + this._cwd, + this.sessionManager, + this._modelRegistry, + ); + if (this._extensionRunnerRef) { + this._extensionRunnerRef.current = this._extensionRunner; + } + this._bindExtensionCore(this._extensionRunner); + this._applyExtensionBindings(this._extensionRunner); + + const defaultActiveToolNames = this._baseToolsOverride + ? Object.keys(this._baseToolsOverride) + : ["read", "bash", "edit", "write"]; + const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames; + this._refreshToolRegistry({ + activeToolNames: baseActiveToolNames, + includeAllExtensionTools: options.includeAllExtensionTools, + }); + } + + async reload(options?: { beforeSessionStart?: () => void | Promise }): Promise { + const previousFlagValues = this._extensionRunner.getFlagValues(); + await emitSessionShutdownEvent(this._extensionRunner, { type: "session_shutdown", reason: "reload" }); + await this.settingsManager.reload(); + this.syncQueueModesFromSettings(); + resetApiProviders(); + await this._resourceLoader.reload(); + this._buildRuntime({ + activeToolNames: this.getActiveToolNames(), + flagValues: previousFlagValues, + includeAllExtensionTools: true, + }); + + const hasBindings = + this._extensionUIContext || + this._extensionCommandContextActions || + this._extensionShutdownHandler || + this._extensionErrorListener; + if (hasBindings) { + await options?.beforeSessionStart?.(); + await this._extensionRunner.emit({ type: "session_start", reason: "reload" }); + await this.extendResourcesFromExtensions("reload"); + } + } + + // ========================================================================= + // Auto-Retry + // ========================================================================= + + private _isNonRetryableProviderLimitError(errorMessage: string): boolean { + return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test( + errorMessage, + ); + } + + /** + * Check if an error is retryable (overloaded, rate limit, server errors). + * Context overflow errors are NOT retryable (handled by compaction instead). + */ + private _isRetryableError(message: AssistantMessage): boolean { + if (message.stopReason !== "error" || !message.errorMessage) return false; + + // Context overflow is handled by compaction, not retry + const contextWindow = this.model?.contextWindow ?? 0; + if (isContextOverflow(message, contextWindow)) return false; + + const err = message.errorMessage; + if (this._isNonRetryableProviderLimitError(err)) return false; + // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504, service unavailable, network/connection errors (including connection lost), WebSocket transport closes/errors, fetch failed, premature stream endings, HTTP/2 closed before response, terminated, retry delay exceeded + return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i.test( + err, + ); + } + + /** + * Prepare a retryable error for continuation with exponential backoff. + * @returns true if the caller should continue the agent, false otherwise + */ + private async _prepareRetry(message: AssistantMessage): Promise { + const settings = this.settingsManager.getRetrySettings(); + if (!settings.enabled) { + return false; + } + + this._retryAttempt++; + + if (this._retryAttempt > settings.maxRetries) { + // Preserve the completed attempt count so post-run handling can emit the final failure. + this._retryAttempt--; + return false; + } + + const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1); + + this._emit({ + type: "auto_retry_start", + attempt: this._retryAttempt, + maxAttempts: settings.maxRetries, + delayMs, + errorMessage: message.errorMessage || "Unknown error", + }); + + // Remove error message from agent state (keep in session for history) + const messages = this.agent.state.messages; + if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { + this.agent.state.messages = messages.slice(0, -1); + } + + // Wait with exponential backoff (abortable) + this._retryAbortController = new AbortController(); + try { + await sleep(delayMs, this._retryAbortController.signal); + } catch { + // Aborted during sleep - emit end event so UI can clean up + const attempt = this._retryAttempt; + this._retryAttempt = 0; + this._emit({ + type: "auto_retry_end", + success: false, + attempt, + finalError: "Retry cancelled", + }); + return false; + } finally { + this._retryAbortController = undefined; + } + + return true; + } + + /** + * Cancel in-progress retry. + */ + abortRetry(): void { + this._retryAbortController?.abort(); + } + + /** Whether auto-retry is currently in progress */ + get isRetrying(): boolean { + return this._retryAbortController !== undefined; + } + + /** Whether auto-retry is enabled */ + get autoRetryEnabled(): boolean { + return this.settingsManager.getRetryEnabled(); + } + + /** + * Toggle auto-retry setting. + */ + setAutoRetryEnabled(enabled: boolean): void { + this.settingsManager.setRetryEnabled(enabled); + } + + // ========================================================================= + // Bash Execution + // ========================================================================= + + /** + * Execute a bash command. + * Adds result to agent context and session. + * @param command The bash command to execute + * @param onChunk Optional streaming callback for output + * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) + * @param options.operations Custom BashOperations for remote execution + */ + async executeBash( + command: string, + onChunk?: (chunk: string) => void, + options?: { excludeFromContext?: boolean; operations?: BashOperations }, + ): Promise { + this._bashAbortController = new AbortController(); + + // Apply command prefix if configured (e.g., "shopt -s expand_aliases" for alias support) + const prefix = this.settingsManager.getShellCommandPrefix(); + const shellPath = this.settingsManager.getShellPath(); + const resolvedCommand = prefix ? `${prefix}\n${command}` : command; + + try { + const result = await executeBashWithOperations( + resolvedCommand, + this.sessionManager.getCwd(), + options?.operations ?? createLocalBashOperations({ shellPath }), + { + onChunk, + signal: this._bashAbortController.signal, + }, + ); + + this.recordBashResult(command, result, options); + return result; + } finally { + this._bashAbortController = undefined; + } + } + + /** + * Record a bash execution result in session history. + * Used by executeBash and by extensions that handle bash execution themselves. + */ + recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void { + const bashMessage: BashExecutionMessage = { + role: "bashExecution", + command, + output: result.output, + exitCode: result.exitCode, + cancelled: result.cancelled, + truncated: result.truncated, + fullOutputPath: result.fullOutputPath, + timestamp: Date.now(), + excludeFromContext: options?.excludeFromContext, + }; + + // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering + if (this.isStreaming) { + // Queue for later - will be flushed on agent_end + this._pendingBashMessages.push(bashMessage); + } else { + // Add to agent state immediately + this.agent.state.messages.push(bashMessage); + + // Save to session + this.sessionManager.appendMessage(bashMessage); + } + } + + /** + * Cancel running bash command. + */ + abortBash(): void { + this._bashAbortController?.abort(); + } + + /** Whether a bash command is currently running */ + get isBashRunning(): boolean { + return this._bashAbortController !== undefined; + } + + /** Whether there are pending bash messages waiting to be flushed */ + get hasPendingBashMessages(): boolean { + return this._pendingBashMessages.length > 0; + } + + /** + * Flush pending bash messages to agent state and session. + * Called after agent turn completes to maintain proper message ordering. + */ + private _flushPendingBashMessages(): void { + if (this._pendingBashMessages.length === 0) return; + + for (const bashMessage of this._pendingBashMessages) { + // Add to agent state + this.agent.state.messages.push(bashMessage); + + // Save to session + this.sessionManager.appendMessage(bashMessage); + } + + this._pendingBashMessages = []; + } + + // ========================================================================= + // Session Management + // ========================================================================= + + /** + * Set a display name for the current session. + */ + setSessionName(name: string): void { + this.sessionManager.appendSessionInfo(name); + this._emit({ type: "session_info_changed", name: this.sessionManager.getSessionName() }); + } + + // ========================================================================= + // Tree Navigation + // ========================================================================= + + /** + * Navigate to a different node in the session tree. + * Unlike fork() which creates a new session file, this stays in the same file. + * + * @param targetId The entry ID to navigate to + * @param options.summarize Whether user wants to summarize abandoned branch + * @param options.customInstructions Custom instructions for summarizer + * @param options.replaceInstructions If true, customInstructions replaces the default prompt + * @param options.label Label to attach to the branch summary entry + * @returns Result with editorText (if user message) and cancelled status + */ + async navigateTree( + targetId: string, + options: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string } = {}, + ): Promise<{ editorText?: string; cancelled: boolean; aborted?: boolean; summaryEntry?: BranchSummaryEntry }> { + const oldLeafId = this.sessionManager.getLeafId(); + + // No-op if already at target + if (targetId === oldLeafId) { + return { cancelled: false }; + } + + // Model required for summarization + if (options.summarize && !this.model) { + throw new Error("No model available for summarization"); + } + + const targetEntry = this.sessionManager.getEntry(targetId); + if (!targetEntry) { + throw new Error(`Entry ${targetId} not found`); + } + + // Collect entries to summarize (from old leaf to common ancestor) + const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary( + this.sessionManager, + oldLeafId, + targetId, + ); + + // Prepare event data - mutable so extensions can override + let customInstructions = options.customInstructions; + let replaceInstructions = options.replaceInstructions; + let label = options.label; + + const preparation: TreePreparation = { + targetId, + oldLeafId, + commonAncestorId, + entriesToSummarize, + userWantsSummary: options.summarize ?? false, + customInstructions, + replaceInstructions, + label, + }; + + // Set up abort controller for summarization + this._branchSummaryAbortController = new AbortController(); + + try { + let extensionSummary: { summary: string; details?: unknown } | undefined; + let fromExtension = false; + + // Emit session_before_tree event + if (this._extensionRunner.hasHandlers("session_before_tree")) { + const result = (await this._extensionRunner.emit({ + type: "session_before_tree", + preparation, + signal: this._branchSummaryAbortController.signal, + })) as SessionBeforeTreeResult | undefined; + + if (result?.cancel) { + return { cancelled: true }; + } + + if (result?.summary && options.summarize) { + extensionSummary = result.summary; + fromExtension = true; + } + + // Allow extensions to override instructions and label + if (result?.customInstructions !== undefined) { + customInstructions = result.customInstructions; + } + if (result?.replaceInstructions !== undefined) { + replaceInstructions = result.replaceInstructions; + } + if (result?.label !== undefined) { + label = result.label; + } + } + + // Run default summarizer if needed + let summaryText: string | undefined; + let summaryDetails: unknown; + if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) { + const model = this.model!; + const { apiKey, headers, env } = await this._getRequiredRequestAuth(model); + const branchSummarySettings = this.settingsManager.getBranchSummarySettings(); + const result = await generateBranchSummary(entriesToSummarize, { + model, + apiKey, + headers, + env, + signal: this._branchSummaryAbortController.signal, + customInstructions, + replaceInstructions, + reserveTokens: branchSummarySettings.reserveTokens, + streamFn: this.agent.streamFn, + }); + if (result.aborted) { + return { cancelled: true, aborted: true }; + } + if (result.error) { + throw new Error(result.error); + } + summaryText = result.summary; + summaryDetails = { + readFiles: result.readFiles || [], + modifiedFiles: result.modifiedFiles || [], + }; + } else if (extensionSummary) { + summaryText = extensionSummary.summary; + summaryDetails = extensionSummary.details; + } + + // Determine the new leaf position based on target type + let newLeafId: string | null; + let editorText: string | undefined; + + if (targetEntry.type === "message" && targetEntry.message.role === "user") { + // User message: leaf = parent (null if root), text goes to editor + newLeafId = targetEntry.parentId; + editorText = this._extractUserMessageText(targetEntry.message.content); + } else if (targetEntry.type === "custom_message") { + // Custom message: leaf = parent (null if root), text goes to editor + newLeafId = targetEntry.parentId; + editorText = + typeof targetEntry.content === "string" + ? targetEntry.content + : targetEntry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } else { + // Non-user message: leaf = selected node + newLeafId = targetId; + } + + // Switch leaf (with or without summary) + // Summary is attached at the navigation target position (newLeafId), not the old branch + let summaryEntry: BranchSummaryEntry | undefined; + if (summaryText) { + // Create summary at target position (can be null for root) + const summaryId = this.sessionManager.branchWithSummary( + newLeafId, + summaryText, + summaryDetails, + fromExtension, + ); + summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; + + // Attach label to the summary entry + if (label) { + this.sessionManager.appendLabelChange(summaryId, label); + } + } else if (newLeafId === null) { + // No summary, navigating to root - reset leaf + this.sessionManager.resetLeaf(); + } else { + // No summary, navigating to non-root + this.sessionManager.branch(newLeafId); + } + + // Attach label to target entry when not summarizing (no summary entry to label) + if (label && !summaryText) { + this.sessionManager.appendLabelChange(targetId, label); + } + + // Update agent state + const sessionContext = this.sessionManager.buildSessionContext(); + this.agent.state.messages = sessionContext.messages; + + // Emit session_tree event + await this._extensionRunner.emit({ + type: "session_tree", + newLeafId: this.sessionManager.getLeafId(), + oldLeafId, + summaryEntry, + fromExtension: summaryText ? fromExtension : undefined, + }); + + // Emit to custom tools + + return { editorText, cancelled: false, summaryEntry }; + } finally { + this._branchSummaryAbortController = undefined; + } + } + + /** + * Get all user messages from session for fork selector. + */ + getUserMessagesForForking(): Array<{ entryId: string; text: string }> { + const entries = this.sessionManager.getEntries(); + const result: Array<{ entryId: string; text: string }> = []; + + for (const entry of entries) { + if (entry.type !== "message") continue; + if (entry.message.role !== "user") continue; + + const text = this._extractUserMessageText(entry.message.content); + if (text) { + result.push({ entryId: entry.id, text }); + } + } + + return result; + } + + private _extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + } + return ""; + } + + /** + * Get session statistics. + */ + getSessionStats(): SessionStats { + const state = this.state; + const userMessages = state.messages.filter((m) => m.role === "user").length; + const assistantMessages = state.messages.filter((m) => m.role === "assistant").length; + const toolResults = state.messages.filter((m) => m.role === "toolResult").length; + + let toolCalls = 0; + let totalInput = 0; + let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheWrite = 0; + let totalCost = 0; + + for (const message of state.messages) { + if (message.role === "assistant") { + const assistantMsg = message as AssistantMessage; + toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length; + totalInput += assistantMsg.usage.input; + totalOutput += assistantMsg.usage.output; + totalCacheRead += assistantMsg.usage.cacheRead; + totalCacheWrite += assistantMsg.usage.cacheWrite; + totalCost += assistantMsg.usage.cost.total; + } + } + + return { + sessionFile: this.sessionFile, + sessionId: this.sessionId, + userMessages, + assistantMessages, + toolCalls, + toolResults, + totalMessages: state.messages.length, + tokens: { + input: totalInput, + output: totalOutput, + cacheRead: totalCacheRead, + cacheWrite: totalCacheWrite, + total: totalInput + totalOutput + totalCacheRead + totalCacheWrite, + }, + cost: totalCost, + contextUsage: this.getContextUsage(), + }; + } + + getContextUsage(): ContextUsage | undefined { + const model = this.model; + if (!model) return undefined; + + const contextWindow = model.contextWindow ?? 0; + if (contextWindow <= 0) return undefined; + + // After compaction, the last assistant usage reflects pre-compaction context size. + // We can only trust usage from an assistant that responded after the latest compaction. + // If no such assistant exists, context token count is unknown until the next LLM response. + const branchEntries = this.sessionManager.getBranch(); + const latestCompaction = getLatestCompactionEntry(branchEntries); + + if (latestCompaction) { + // Check if there's a valid assistant usage after the compaction boundary + const compactionIndex = branchEntries.lastIndexOf(latestCompaction); + let hasPostCompactionUsage = false; + for (let i = branchEntries.length - 1; i > compactionIndex; i--) { + const entry = branchEntries[i]; + if (entry.type === "message" && entry.message.role === "assistant") { + const assistant = entry.message; + if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") { + const contextTokens = calculateContextTokens(assistant.usage); + if (contextTokens > 0) { + hasPostCompactionUsage = true; + break; + } + } + } + } + + if (!hasPostCompactionUsage) { + return { tokens: null, contextWindow, percent: null }; + } + } + + const estimate = estimateContextTokens(this.messages); + const percent = (estimate.tokens / contextWindow) * 100; + + return { + tokens: estimate.tokens, + contextWindow, + percent, + }; + } + + /** + * Export session to HTML. + * @param outputPath Optional output path (defaults to session directory) + * @returns Path to exported file + */ + /** + * Export the current session branch to a JSONL file. + * Writes the session header followed by all entries on the current branch path. + * @param outputPath Target file path. If omitted, generates a timestamped file in cwd. + * @returns The resolved output file path. + */ + exportToJsonl(outputPath?: string): string { + const filePath = resolvePath( + outputPath ?? `session-${new Date().toISOString().replace(/[:.]/g, "-")}.jsonl`, + process.cwd(), + ); + const dir = dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: this.sessionManager.getSessionId(), + timestamp: new Date().toISOString(), + cwd: this.sessionManager.getCwd(), + }; + + const branchEntries = this.sessionManager.getBranch(); + const lines = [JSON.stringify(header)]; + + // Re-chain parentIds to form a linear sequence + let prevId: string | null = null; + for (const entry of branchEntries) { + const linear = { ...entry, parentId: prevId }; + lines.push(JSON.stringify(linear)); + prevId = entry.id; + } + + writeFileSync(filePath, `${lines.join("\n")}\n`); + return filePath; + } + + // ========================================================================= + // Utilities + // ========================================================================= + + /** + * Get text content of last assistant message. + * Useful for /copy command. + * @returns Text content, or undefined if no assistant message exists + */ + getLastAssistantText(): string | undefined { + const lastAssistant = this.messages + .slice() + .reverse() + .find((m) => { + if (m.role !== "assistant") return false; + const msg = m as AssistantMessage; + // Skip aborted messages with no content + if (msg.stopReason === "aborted" && msg.content.length === 0) return false; + return true; + }); + + if (!lastAssistant) return undefined; + + let text = ""; + for (const content of (lastAssistant as AssistantMessage).content) { + if (content.type === "text") { + text += content.text; + } + } + + return text.trim() || undefined; + } + + // ========================================================================= + // Extension System + // ========================================================================= + + createReplacedSessionContext(): ReplacedSessionContext { + const context = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(this._extensionRunner.createCommandContext()), + ) as ReplacedSessionContext; + context.sendMessage = (message, options) => this.sendCustomMessage(message, options); + context.sendUserMessage = (content, options) => this.sendUserMessage(content, options); + return context; + } + + /** + * Check if extensions have handlers for a specific event type. + */ + hasExtensionHandlers(eventType: string): boolean { + return this._extensionRunner.hasHandlers(eventType); + } + + /** + * Get the extension runner (for setting UI context and error handlers). + */ + get extensionRunner(): ExtensionRunner { + return this._extensionRunner; + } +} diff --git a/cactus-code/packages/coding-agent/src/core/auth-guidance.ts b/cactus-code/packages/coding-agent/src/core/auth-guidance.ts new file mode 100644 index 000000000..9782cdaff --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/auth-guidance.ts @@ -0,0 +1,25 @@ +import { join } from "node:path"; +import { getDocsPath } from "../config.ts"; + +const UNKNOWN_PROVIDER = "unknown"; + +export function getProviderLoginHelp(): string { + return [ + "Use /login to log into a provider via OAuth or API key. See:", + ` ${join(getDocsPath(), "providers.md")}`, + ` ${join(getDocsPath(), "models.md")}`, + ].join("\n"); +} + +export function formatNoModelsAvailableMessage(): string { + return `No models available. ${getProviderLoginHelp()}`; +} + +export function formatNoModelSelectedMessage(): string { + return `No model selected.\n\n${getProviderLoginHelp()}\n\nThen use /model to select a model.`; +} + +export function formatNoApiKeyFoundMessage(provider: string): string { + const providerDisplay = provider === UNKNOWN_PROVIDER ? "the selected model" : provider; + return `No API key found for ${providerDisplay}.\n\n${getProviderLoginHelp()}`; +} diff --git a/cactus-code/packages/coding-agent/src/core/auth-storage.ts b/cactus-code/packages/coding-agent/src/core/auth-storage.ts new file mode 100644 index 000000000..31a0ec9c3 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/auth-storage.ts @@ -0,0 +1,528 @@ +/** + * Credential storage for API keys and OAuth tokens. + * Handles loading, saving, and refreshing credentials from auth.json. + * + * Uses file locking to prevent race conditions when multiple pi instances + * try to refresh tokens simultaneously. + */ + +import { + findEnvKeys, + getEnvApiKey, + type OAuthCredentials, + type OAuthLoginCallbacks, + type OAuthProviderId, +} from "@earendil-works/pi-ai/compat"; +import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-works/pi-ai/oauth"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname, join } from "path"; +import lockfile from "proper-lockfile"; +import { getAgentDir } from "../config.ts"; +import { normalizePath } from "../utils/paths.ts"; +import { resolveConfigValue } from "./resolve-config-value.ts"; + +export type ApiKeyCredential = { + type: "api_key"; + key: string; + env?: Record; +}; + +export type OAuthCredential = { + type: "oauth"; +} & OAuthCredentials; + +export type AuthCredential = ApiKeyCredential | OAuthCredential; + +export type AuthStorageData = Record; + +export type AuthStatus = { + configured: boolean; + source?: "stored" | "runtime" | "environment" | "fallback" | "models_json_key" | "models_json_command"; + label?: string; +}; + +export interface GetApiKeyOptions { + includeFallback?: boolean; +} + +type LockResult = { + result: T; + next?: string; +}; + +const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 } as const; + +export interface AuthStorageBackend { + withLock(fn: (current: string | undefined) => LockResult): T; + withLockAsync(fn: (current: string | undefined) => Promise>): Promise; +} + +export class FileAuthStorageBackend implements AuthStorageBackend { + private authPath: string; + + constructor(authPath: string = join(getAgentDir(), "auth.json")) { + this.authPath = normalizePath(authPath); + } + + private ensureParentDir(): void { + const dir = dirname(this.authPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + } + + private ensureFileExists(): void { + if (!existsSync(this.authPath)) { + writeFileSync(this.authPath, "{}", AUTH_FILE_WRITE_OPTIONS); + chmodSync(this.authPath, 0o600); + } + } + + private acquireLockSyncWithRetry(path: string): () => void { + const maxAttempts = 10; + const delayMs = 20; + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return lockfile.lockSync(path, { realpath: false }); + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code !== "ELOCKED" || attempt === maxAttempts) { + throw error; + } + lastError = error; + const start = Date.now(); + while (Date.now() - start < delayMs) { + // Sleep synchronously to avoid changing callers to async. + } + } + } + + throw (lastError as Error) ?? new Error("Failed to acquire auth storage lock"); + } + + withLock(fn: (current: string | undefined) => LockResult): T { + this.ensureParentDir(); + this.ensureFileExists(); + + let release: (() => void) | undefined; + try { + release = this.acquireLockSyncWithRetry(this.authPath); + const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined; + const { result, next } = fn(current); + if (next !== undefined) { + writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); + chmodSync(this.authPath, 0o600); + } + return result; + } finally { + if (release) { + release(); + } + } + } + + async withLockAsync(fn: (current: string | undefined) => Promise>): Promise { + this.ensureParentDir(); + this.ensureFileExists(); + + let release: (() => Promise) | undefined; + let lockCompromised = false; + let lockCompromisedError: Error | undefined; + const throwIfCompromised = () => { + if (lockCompromised) { + throw lockCompromisedError ?? new Error("Auth storage lock was compromised"); + } + }; + + try { + release = await lockfile.lock(this.authPath, { + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 10000, + randomize: true, + }, + stale: 30000, + onCompromised: (err) => { + lockCompromised = true; + lockCompromisedError = err; + }, + }); + + throwIfCompromised(); + const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined; + const { result, next } = await fn(current); + throwIfCompromised(); + if (next !== undefined) { + writeFileSync(this.authPath, next, AUTH_FILE_WRITE_OPTIONS); + chmodSync(this.authPath, 0o600); + } + throwIfCompromised(); + return result; + } finally { + if (release) { + try { + await release(); + } catch { + // Ignore unlock errors when lock is compromised. + } + } + } + } +} + +export class InMemoryAuthStorageBackend implements AuthStorageBackend { + private value: string | undefined; + + withLock(fn: (current: string | undefined) => LockResult): T { + const { result, next } = fn(this.value); + if (next !== undefined) { + this.value = next; + } + return result; + } + + async withLockAsync(fn: (current: string | undefined) => Promise>): Promise { + const { result, next } = await fn(this.value); + if (next !== undefined) { + this.value = next; + } + return result; + } +} + +/** + * Credential storage backed by a JSON file. + */ +export class AuthStorage { + private data: AuthStorageData = {}; + private runtimeOverrides: Map = new Map(); + private loadError: Error | null = null; + private errors: Error[] = []; + private storage: AuthStorageBackend; + + private constructor(storage: AuthStorageBackend) { + this.storage = storage; + this.reload(); + } + + static create(authPath?: string): AuthStorage { + return new AuthStorage(new FileAuthStorageBackend(authPath ?? join(getAgentDir(), "auth.json"))); + } + + static fromStorage(storage: AuthStorageBackend): AuthStorage { + return new AuthStorage(storage); + } + + static inMemory(data: AuthStorageData = {}): AuthStorage { + const storage = new InMemoryAuthStorageBackend(); + storage.withLock(() => ({ result: undefined, next: JSON.stringify(data, null, 2) })); + return AuthStorage.fromStorage(storage); + } + + /** + * Set a runtime API key override (not persisted to disk). + * Used for CLI --api-key flag. + */ + setRuntimeApiKey(provider: string, apiKey: string): void { + this.runtimeOverrides.set(provider, apiKey); + } + + /** + * Remove a runtime API key override. + */ + removeRuntimeApiKey(provider: string): void { + this.runtimeOverrides.delete(provider); + } + + private recordError(error: unknown): void { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + this.errors.push(normalizedError); + } + + private parseStorageData(content: string | undefined): AuthStorageData { + if (!content) { + return {}; + } + return JSON.parse(content) as AuthStorageData; + } + + /** + * Reload credentials from storage. + */ + reload(): void { + let content: string | undefined; + try { + this.storage.withLock((current) => { + content = current; + return { result: undefined }; + }); + this.data = this.parseStorageData(content); + this.loadError = null; + } catch (error) { + this.loadError = error as Error; + this.recordError(error); + } + } + + private persistProviderChange(provider: string, credential: AuthCredential | undefined): void { + if (this.loadError) { + return; + } + + try { + this.storage.withLock((current) => { + const currentData = this.parseStorageData(current); + const merged: AuthStorageData = { ...currentData }; + if (credential) { + merged[provider] = credential; + } else { + delete merged[provider]; + } + return { result: undefined, next: JSON.stringify(merged, null, 2) }; + }); + } catch (error) { + this.recordError(error); + } + } + + /** + * Get credential for a provider. + */ + get(provider: string): AuthCredential | undefined { + return this.data[provider] ?? undefined; + } + + /** + * Get provider-scoped environment values for an API key credential. + */ + getProviderEnv(provider: string): Record | undefined { + const cred = this.data[provider]; + return cred?.type === "api_key" && cred.env ? { ...cred.env } : undefined; + } + + /** + * Set credential for a provider. + */ + set(provider: string, credential: AuthCredential): void { + this.data[provider] = credential; + this.persistProviderChange(provider, credential); + } + + /** + * Remove credential for a provider. + */ + remove(provider: string): void { + delete this.data[provider]; + this.persistProviderChange(provider, undefined); + } + + /** + * List all providers with credentials. + */ + list(): string[] { + return Object.keys(this.data); + } + + /** + * Check if credentials exist for a provider in auth.json. + */ + has(provider: string): boolean { + return provider in this.data; + } + + /** + * Check if any form of auth is configured for a provider. + * Unlike getApiKey(), this doesn't refresh OAuth tokens. + */ + hasAuth(provider: string): boolean { + if (this.runtimeOverrides.has(provider)) return true; + if (this.data[provider]) return true; + if (getEnvApiKey(provider)) return true; + return false; + } + + /** + * Return auth status without exposing credential values or refreshing tokens. + */ + getAuthStatus(provider: string): AuthStatus { + if (this.data[provider]) { + return { configured: true, source: "stored" }; + } + + if (this.runtimeOverrides.has(provider)) { + return { configured: false, source: "runtime", label: "--api-key" }; + } + + const envKeys = findEnvKeys(provider); + if (envKeys?.[0]) { + return { configured: false, source: "environment", label: envKeys[0] }; + } + + return { configured: false }; + } + + /** + * Get all credentials (for passing to getOAuthApiKey). + */ + getAll(): AuthStorageData { + return { ...this.data }; + } + + drainErrors(): Error[] { + const drained = [...this.errors]; + this.errors = []; + return drained; + } + + /** + * Login to an OAuth provider. + */ + async login(providerId: OAuthProviderId, callbacks: OAuthLoginCallbacks): Promise { + const provider = getOAuthProvider(providerId); + if (!provider) { + throw new Error(`Unknown OAuth provider: ${providerId}`); + } + + const credentials = await provider.login(callbacks); + this.set(providerId, { type: "oauth", ...credentials }); + } + + /** + * Logout from a provider. + */ + logout(provider: string): void { + this.remove(provider); + } + + /** + * Refresh OAuth token with backend locking to prevent race conditions. + * Multiple pi instances may try to refresh simultaneously when tokens expire. + */ + private async refreshOAuthTokenWithLock( + providerId: OAuthProviderId, + ): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { + const provider = getOAuthProvider(providerId); + if (!provider) { + return null; + } + + const result = await this.storage.withLockAsync(async (current) => { + const currentData = this.parseStorageData(current); + this.data = currentData; + this.loadError = null; + + const cred = currentData[providerId]; + if (cred?.type !== "oauth") { + return { result: null }; + } + + if (Date.now() < cred.expires) { + return { result: { apiKey: provider.getApiKey(cred), newCredentials: cred } }; + } + + const oauthCreds: Record = {}; + for (const [key, value] of Object.entries(currentData)) { + if (value.type === "oauth") { + oauthCreds[key] = value; + } + } + + const refreshed = await getOAuthApiKey(providerId, oauthCreds); + if (!refreshed) { + return { result: null }; + } + + const merged: AuthStorageData = { + ...currentData, + [providerId]: { type: "oauth", ...refreshed.newCredentials }, + }; + this.data = merged; + this.loadError = null; + return { result: refreshed, next: JSON.stringify(merged, null, 2) }; + }); + + return result; + } + + /** + * Get API key for a provider. + * Priority: + * 1. Runtime override (CLI --api-key) + * 2. API key from auth.json + * 3. OAuth token from auth.json (auto-refreshed with locking) + * 4. Environment variable + */ + async getApiKey(providerId: string, options: GetApiKeyOptions = {}): Promise { + // Runtime override takes highest priority + const runtimeKey = this.runtimeOverrides.get(providerId); + if (runtimeKey) { + return runtimeKey; + } + + const cred = this.data[providerId]; + + if (cred?.type === "api_key") { + return resolveConfigValue(cred.key, cred.env); + } + + if (cred?.type === "oauth") { + const provider = getOAuthProvider(providerId); + if (!provider) { + // Unknown OAuth provider, can't get API key + return undefined; + } + + // Check if token needs refresh + const needsRefresh = Date.now() >= cred.expires; + + if (needsRefresh) { + // Use locked refresh to prevent race conditions + try { + const result = await this.refreshOAuthTokenWithLock(providerId); + if (result) { + return result.apiKey; + } + } catch (error) { + this.recordError(error); + // Refresh failed - re-read file to check if another instance succeeded + this.reload(); + const updatedCred = this.data[providerId]; + + if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) { + // Another instance refreshed successfully, use those credentials + return provider.getApiKey(updatedCred); + } + + // Refresh truly failed - return undefined so model discovery skips this provider + // User can /login to re-authenticate (credentials preserved for retry) + return undefined; + } + } else { + // Token not expired, use current access token + return provider.getApiKey(cred); + } + } + + if (options.includeFallback === false) return undefined; + + // Fall back to environment variable + const envKey = getEnvApiKey(providerId); + if (envKey) return envKey; + + return undefined; + } + + /** + * Get all registered OAuth providers + */ + getOAuthProviders() { + return getOAuthProviders(); + } +} diff --git a/cactus-code/packages/coding-agent/src/core/bash-executor.ts b/cactus-code/packages/coding-agent/src/core/bash-executor.ts new file mode 100644 index 000000000..875a1556d --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/bash-executor.ts @@ -0,0 +1,156 @@ +/** + * Bash command execution with streaming support and cancellation. + * + * This module provides a unified bash execution implementation used by: + * - AgentSession.executeBash() for interactive and RPC modes + * - Direct calls from modes that need bash execution + */ + +import { randomBytes } from "node:crypto"; +import { createWriteStream, type WriteStream } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stripAnsi } from "../utils/ansi.ts"; +import { sanitizeBinaryOutput } from "../utils/shell.ts"; +import type { BashOperations } from "./tools/bash.ts"; +import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate.ts"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface BashExecutorOptions { + /** Callback for streaming output chunks (already sanitized) */ + onChunk?: (chunk: string) => void; + /** AbortSignal for cancellation */ + signal?: AbortSignal; +} + +export interface BashResult { + /** Combined stdout + stderr output (sanitized, possibly truncated) */ + output: string; + /** Process exit code (undefined if killed/cancelled) */ + exitCode: number | undefined; + /** Whether the command was cancelled via signal */ + cancelled: boolean; + /** Whether the output was truncated */ + truncated: boolean; + /** Path to temp file containing full output (if output exceeded truncation threshold) */ + fullOutputPath?: string; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Execute a bash command using custom BashOperations. + * Used for remote execution (SSH, containers, etc.). + */ +export async function executeBashWithOperations( + command: string, + cwd: string, + operations: BashOperations, + options?: BashExecutorOptions, +): Promise { + const outputChunks: string[] = []; + let outputBytes = 0; + const maxOutputBytes = DEFAULT_MAX_BYTES * 2; + + let tempFilePath: string | undefined; + let tempFileStream: WriteStream | undefined; + let totalBytes = 0; + + const ensureTempFile = () => { + if (tempFilePath) { + return; + } + const id = randomBytes(8).toString("hex"); + tempFilePath = join(tmpdir(), `cactus-bash-${id}.log`); + tempFileStream = createWriteStream(tempFilePath); + for (const chunk of outputChunks) { + tempFileStream.write(chunk); + } + }; + + const decoder = new TextDecoder(); + + const onData = (data: Buffer) => { + totalBytes += data.length; + + // Sanitize: strip ANSI, replace binary garbage, normalize newlines + const text = sanitizeBinaryOutput(stripAnsi(decoder.decode(data, { stream: true }))).replace(/\r/g, ""); + + // Start writing to temp file if exceeds threshold + if (totalBytes > DEFAULT_MAX_BYTES) { + ensureTempFile(); + } + + if (tempFileStream) { + tempFileStream.write(text); + } + + // Keep rolling buffer + outputChunks.push(text); + outputBytes += text.length; + while (outputBytes > maxOutputBytes && outputChunks.length > 1) { + const removed = outputChunks.shift()!; + outputBytes -= removed.length; + } + + // Stream to callback + if (options?.onChunk) { + options.onChunk(text); + } + }; + + try { + const result = await operations.exec(command, cwd, { + onData, + signal: options?.signal, + }); + + const fullOutput = outputChunks.join(""); + const truncationResult = truncateTail(fullOutput); + if (truncationResult.truncated) { + ensureTempFile(); + } + if (tempFileStream) { + tempFileStream.end(); + } + const cancelled = options?.signal?.aborted ?? false; + + return { + output: truncationResult.truncated ? truncationResult.content : fullOutput, + exitCode: cancelled ? undefined : (result.exitCode ?? undefined), + cancelled, + truncated: truncationResult.truncated, + fullOutputPath: tempFilePath, + }; + } catch (err) { + // Check if it was an abort + if (options?.signal?.aborted) { + const fullOutput = outputChunks.join(""); + const truncationResult = truncateTail(fullOutput); + if (truncationResult.truncated) { + ensureTempFile(); + } + if (tempFileStream) { + tempFileStream.end(); + } + return { + output: truncationResult.truncated ? truncationResult.content : fullOutput, + exitCode: undefined, + cancelled: true, + truncated: truncationResult.truncated, + fullOutputPath: tempFilePath, + }; + } + + if (tempFileStream) { + tempFileStream.end(); + } + + throw err; + } +} diff --git a/cactus-code/packages/coding-agent/src/core/compaction/branch-summarization.ts b/cactus-code/packages/coding-agent/src/core/compaction/branch-summarization.ts new file mode 100644 index 000000000..3f557c01b --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -0,0 +1,371 @@ +/** + * Branch summarization for tree navigation. + * + * When navigating to a different point in the session tree, this generates + * a summary of the branch being left so context isn't lost. + */ + +import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; +import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai/compat"; +import { completeSimple } from "@earendil-works/pi-ai/compat"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages.ts"; +import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts"; +import { estimateTokens } from "./compaction.ts"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + SUMMARIZATION_SYSTEM_PROMPT, + serializeConversation, +} from "./utils.ts"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface BranchSummaryResult { + summary?: string; + readFiles?: string[]; + modifiedFiles?: string[]; + aborted?: boolean; + error?: string; +} + +/** Details stored in BranchSummaryEntry.details for file tracking */ +export interface BranchSummaryDetails { + readFiles: string[]; + modifiedFiles: string[]; +} + +export type { FileOperations } from "./utils.ts"; + +export interface BranchPreparation { + /** Messages extracted for summarization, in chronological order */ + messages: AgentMessage[]; + /** File operations extracted from tool calls */ + fileOps: FileOperations; + /** Total estimated tokens in messages */ + totalTokens: number; +} + +export interface CollectEntriesResult { + /** Entries to summarize, in chronological order */ + entries: SessionEntry[]; + /** Common ancestor between old and new position, if any */ + commonAncestorId: string | null; +} + +export interface GenerateBranchSummaryOptions { + /** Model to use for summarization */ + model: Model; + /** API key for the model */ + apiKey: string; + /** Request headers for the model */ + headers?: Record; + /** Provider-scoped environment values for the model */ + env?: Record; + /** Abort signal for cancellation */ + signal: AbortSignal; + /** Optional custom instructions for summarization */ + customInstructions?: string; + /** If true, customInstructions replaces the default prompt instead of being appended */ + replaceInstructions?: boolean; + /** Tokens reserved for prompt + LLM response (default 16384) */ + reserveTokens?: number; + /** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */ + streamFn?: StreamFn; +} + +// ============================================================================ +// Entry Collection +// ============================================================================ + +/** + * Collect entries that should be summarized when navigating from one position to another. + * + * Walks from oldLeafId back to the common ancestor with targetId, collecting entries + * along the way. Does NOT stop at compaction boundaries - those are included and their + * summaries become context. + * + * @param session - Session manager (read-only access) + * @param oldLeafId - Current position (where we're navigating from) + * @param targetId - Target position (where we're navigating to) + * @returns Entries to summarize and the common ancestor + */ +export function collectEntriesForBranchSummary( + session: ReadonlySessionManager, + oldLeafId: string | null, + targetId: string, +): CollectEntriesResult { + // If no old position, nothing to summarize + if (!oldLeafId) { + return { entries: [], commonAncestorId: null }; + } + + // Find common ancestor (deepest node that's on both paths) + const oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id)); + const targetPath = session.getBranch(targetId); + + // targetPath is root-first, so iterate backwards to find deepest common ancestor + let commonAncestorId: string | null = null; + for (let i = targetPath.length - 1; i >= 0; i--) { + if (oldPath.has(targetPath[i].id)) { + commonAncestorId = targetPath[i].id; + break; + } + } + + // Collect entries from old leaf back to common ancestor + const entries: SessionEntry[] = []; + let current: string | null = oldLeafId; + + while (current && current !== commonAncestorId) { + const entry = session.getEntry(current); + if (!entry) break; + entries.push(entry); + current = entry.parentId; + } + + // Reverse to get chronological order + entries.reverse(); + + return { entries, commonAncestorId }; +} + +// ============================================================================ +// Entry to Message Conversion +// ============================================================================ + +/** + * Extract AgentMessage from a session entry. + * Similar to getMessageFromEntry in compaction.ts but also handles compaction entries. + */ +function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { + switch (entry.type) { + case "message": + // Skip tool results - context is in assistant's tool call + if (entry.message.role === "toolResult") return undefined; + return entry.message; + + case "custom_message": + return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); + + case "branch_summary": + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + + case "compaction": + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + + // These don't contribute to conversation content + case "thinking_level_change": + case "model_change": + case "custom": + case "label": + case "session_info": + return undefined; + } +} + +/** + * Prepare entries for summarization with token budget. + * + * Walks entries from NEWEST to OLDEST, adding messages until we hit the token budget. + * This ensures we keep the most recent context when the branch is too long. + * + * Also collects file operations from: + * - Tool calls in assistant messages + * - Existing branch_summary entries' details (for cumulative tracking) + * + * @param entries - Entries in chronological order + * @param tokenBudget - Maximum tokens to include (0 = no limit) + */ +export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: number = 0): BranchPreparation { + const messages: AgentMessage[] = []; + const fileOps = createFileOps(); + let totalTokens = 0; + + // First pass: collect file ops from ALL entries (even if they don't fit in token budget) + // This ensures we capture cumulative file tracking from nested branch summaries + // Only extract from pi-generated summaries (fromHook !== true), not extension-generated ones + for (const entry of entries) { + if (entry.type === "branch_summary" && !entry.fromHook && entry.details) { + const details = entry.details as BranchSummaryDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) fileOps.read.add(f); + } + if (Array.isArray(details.modifiedFiles)) { + // Modified files go into both edited and written for proper deduplication + for (const f of details.modifiedFiles) { + fileOps.edited.add(f); + } + } + } + } + + // Second pass: walk from newest to oldest, adding messages until token budget + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + const message = getMessageFromEntry(entry); + if (!message) continue; + + // Extract file ops from assistant messages (tool calls) + extractFileOpsFromMessage(message, fileOps); + + const tokens = estimateTokens(message); + + // Check budget before adding + if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) { + // If this is a summary entry, try to fit it anyway as it's important context + if (entry.type === "compaction" || entry.type === "branch_summary") { + if (totalTokens < tokenBudget * 0.9) { + messages.unshift(message); + totalTokens += tokens; + } + } + // Stop - we've hit the budget + break; + } + + messages.unshift(message); + totalTokens += tokens; + } + + return { messages, fileOps, totalTokens }; +} + +// ============================================================================ +// Summary Generation +// ============================================================================ + +const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here. +Summary of that exploration: + +`; + +const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later. + +Use this EXACT format: + +## Goal +[What was the user trying to accomplish in this branch?] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Work that was started but not finished] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [What should happen next to continue this work] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +/** + * Generate a summary of abandoned branch entries. + * + * @param entries - Session entries to summarize (chronological order) + * @param options - Generation options + */ +export async function generateBranchSummary( + entries: SessionEntry[], + options: GenerateBranchSummaryOptions, +): Promise { + const { + model, + apiKey, + headers, + env, + signal, + customInstructions, + replaceInstructions, + reserveTokens = 16384, + streamFn, + } = options; + + // Token budget = context window minus reserved space for prompt + response + const contextWindow = model.contextWindow || 128000; + const tokenBudget = contextWindow - reserveTokens; + + const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget); + + if (messages.length === 0) { + return { summary: "No content to summarize" }; + } + + // Transform to LLM-compatible messages, then serialize to text + // Serialization prevents the model from treating it as a conversation to continue + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + + // Build prompt + let instructions: string; + if (replaceInstructions && customInstructions) { + instructions = customInstructions; + } else if (customInstructions) { + instructions = `${BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: ${customInstructions}`; + } else { + instructions = BRANCH_SUMMARY_PROMPT; + } + const promptText = `\n${conversationText}\n\n\n${instructions}`; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + // Call LLM for summarization. Prefer the session stream function so SDK + // request behavior (timeouts, retries, attribution headers) stays consistent + // without running through agent state/events. + const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }; + const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 }; + const response = streamFn + ? await (await streamFn(model, context, requestOptions)).result() + : await completeSimple(model, context, requestOptions); + + // Check if aborted or errored + if (response.stopReason === "aborted") { + return { aborted: true }; + } + if (response.stopReason === "error") { + return { error: response.errorMessage || "Summarization failed" }; + } + + let summary = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + // Prepend preamble to provide context about the branch summary + summary = BRANCH_SUMMARY_PREAMBLE + summary; + + // Compute file lists and append to summary + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + return { + summary: summary || "No summary generated", + readFiles, + modifiedFiles, + }; +} diff --git a/cactus-code/packages/coding-agent/src/core/compaction/compaction.ts b/cactus-code/packages/coding-agent/src/core/compaction/compaction.ts new file mode 100644 index 000000000..8d1877183 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/compaction/compaction.ts @@ -0,0 +1,905 @@ +/** + * Context compaction for long sessions. + * + * Pure functions for compaction logic. The session manager handles I/O, + * and after compaction the session is reloaded. + */ + +import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat"; +import { completeSimple } from "@earendil-works/pi-ai/compat"; +import { + convertToLlm, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "../messages.ts"; +import { buildSessionContext, type CompactionEntry, type SessionEntry } from "../session-manager.ts"; +import { + computeFileLists, + createFileOps, + extractFileOpsFromMessage, + type FileOperations, + formatFileOperations, + SUMMARIZATION_SYSTEM_PROMPT, + serializeConversation, +} from "./utils.ts"; + +// ============================================================================ +// File Operation Tracking +// ============================================================================ + +/** Details stored in CompactionEntry.details for file tracking */ +export interface CompactionDetails { + readFiles: string[]; + modifiedFiles: string[]; +} + +/** + * Extract file operations from messages and previous compaction entries. + */ +function extractFileOperations( + messages: AgentMessage[], + entries: SessionEntry[], + prevCompactionIndex: number, +): FileOperations { + const fileOps = createFileOps(); + + // Collect from previous compaction's details (if pi-generated) + if (prevCompactionIndex >= 0) { + const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; + if (!prevCompaction.fromHook && prevCompaction.details) { + // fromHook field kept for session file compatibility + const details = prevCompaction.details as CompactionDetails; + if (Array.isArray(details.readFiles)) { + for (const f of details.readFiles) fileOps.read.add(f); + } + if (Array.isArray(details.modifiedFiles)) { + for (const f of details.modifiedFiles) fileOps.edited.add(f); + } + } + } + + // Extract from tool calls in messages + for (const msg of messages) { + extractFileOpsFromMessage(msg, fileOps); + } + + return fileOps; +} + +// ============================================================================ +// Message Extraction +// ============================================================================ + +/** + * Extract AgentMessage from an entry if it produces one. + * Returns undefined for entries that don't contribute to LLM context. + */ +function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined { + if (entry.type === "message") { + return entry.message; + } + if (entry.type === "custom_message") { + return createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp); + } + if (entry.type === "branch_summary") { + return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp); + } + if (entry.type === "compaction") { + return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp); + } + return undefined; +} + +function getMessageFromEntryForCompaction(entry: SessionEntry): AgentMessage | undefined { + if (entry.type === "compaction") { + return undefined; + } + return getMessageFromEntry(entry); +} + +/** Result from compact() - SessionManager adds uuid/parentUuid when saving */ +export interface CompactionResult { + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + estimatedTokensAfter?: number; + /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + details?: T; +} + +// ============================================================================ +// Types +// ============================================================================ + +export interface CompactionSettings { + enabled: boolean; + reserveTokens: number; + keepRecentTokens: number; +} + +export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = { + enabled: true, + reserveTokens: 16384, + keepRecentTokens: 20000, +}; + +// ============================================================================ +// Token calculation +// ============================================================================ + +/** + * Calculate total context tokens from usage. + * Uses the native totalTokens field when available, falls back to computing from components. + */ +export function calculateContextTokens(usage: Usage): number { + return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; +} + +/** + * Get usage from an assistant message if available. + * Skips aborted, error, and all-zero usage messages as they don't have valid usage data. + */ +function getAssistantUsage(msg: AgentMessage): Usage | undefined { + if (msg.role === "assistant" && "usage" in msg) { + const assistantMsg = msg as AssistantMessage; + if ( + assistantMsg.stopReason !== "aborted" && + assistantMsg.stopReason !== "error" && + assistantMsg.usage && + calculateContextTokens(assistantMsg.usage) > 0 + ) { + return assistantMsg.usage; + } + } + return undefined; +} + +/** + * Find the last valid assistant message usage from session entries. + */ +export function getLastAssistantUsage(entries: SessionEntry[]): Usage | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "message") { + const usage = getAssistantUsage(entry.message); + if (usage) return usage; + } + } + return undefined; +} + +export interface ContextUsageEstimate { + tokens: number; + usageTokens: number; + trailingTokens: number; + lastUsageIndex: number | null; +} + +function getLastAssistantUsageInfo(messages: AgentMessage[]): { usage: Usage; index: number } | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const usage = getAssistantUsage(messages[i]); + if (usage) return { usage, index: i }; + } + return undefined; +} + +/** + * Estimate context tokens from messages, using the last assistant usage when available. + * If there are messages after the last usage, estimate their tokens with estimateTokens. + */ +export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate { + const usageInfo = getLastAssistantUsageInfo(messages); + + if (!usageInfo) { + let estimated = 0; + for (const message of messages) { + estimated += estimateTokens(message); + } + return { + tokens: estimated, + usageTokens: 0, + trailingTokens: estimated, + lastUsageIndex: null, + }; + } + + const usageTokens = calculateContextTokens(usageInfo.usage); + let trailingTokens = 0; + for (let i = usageInfo.index + 1; i < messages.length; i++) { + trailingTokens += estimateTokens(messages[i]); + } + + return { + tokens: usageTokens + trailingTokens, + usageTokens, + trailingTokens, + lastUsageIndex: usageInfo.index, + }; +} + +export function resolveCompactionSettings(settings: CompactionSettings, contextWindow: number): CompactionSettings { + if (!Number.isFinite(contextWindow) || contextWindow <= 0) { + return { ...settings, enabled: false }; + } + const reserveTokens = Math.max(256, Math.min(settings.reserveTokens, Math.floor(contextWindow * 0.3))); + const keepRecentTokens = Math.max(256, Math.min(settings.keepRecentTokens, Math.floor(contextWindow * 0.4))); + if (reserveTokens >= contextWindow) { + return { ...settings, enabled: false }; + } + return { ...settings, reserveTokens, keepRecentTokens }; +} + +/** + * Check if compaction should trigger based on context usage. + */ +export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean { + if (!settings.enabled) return false; + return contextTokens > contextWindow - settings.reserveTokens; +} + +// ============================================================================ +// Cut point detection +// ============================================================================ + +const ESTIMATED_IMAGE_CHARS = 4800; + +function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number { + if (typeof content === "string") { + return content.length; + } + + let chars = 0; + for (const block of content) { + if (block.type === "text" && block.text) { + chars += block.text.length; + } else if (block.type === "image") { + chars += ESTIMATED_IMAGE_CHARS; + } + } + return chars; +} + +/** + * Estimate token count for a message using chars/4 heuristic. + * This is conservative (overestimates tokens). + */ +export function estimateTokens(message: AgentMessage): number { + let chars = 0; + + switch (message.role) { + case "user": { + chars = estimateTextAndImageContentChars( + (message as { content: string | Array<{ type: string; text?: string }> }).content, + ); + return Math.ceil(chars / 4); + } + case "assistant": { + const assistant = message as AssistantMessage; + for (const block of assistant.content) { + if (block.type === "text") { + chars += block.text.length; + } else if (block.type === "thinking") { + chars += block.thinking.length; + } else if (block.type === "toolCall") { + chars += block.name.length + JSON.stringify(block.arguments).length; + } + } + return Math.ceil(chars / 4); + } + case "custom": + case "toolResult": { + chars = estimateTextAndImageContentChars(message.content); + return Math.ceil(chars / 4); + } + case "bashExecution": { + chars = message.command.length + message.output.length; + return Math.ceil(chars / 4); + } + case "branchSummary": + case "compactionSummary": { + chars = message.summary.length; + return Math.ceil(chars / 4); + } + } + + return 0; +} + +/** + * Find valid cut points: indices of user, assistant, custom, or bashExecution messages. + * Never cut at tool results (they must follow their tool call). + * When we cut at an assistant message with tool calls, its tool results follow it + * and will be kept. + * BashExecutionMessage is treated like a user message (user-initiated context). + */ +function findValidCutPoints(entries: SessionEntry[], startIndex: number, endIndex: number): number[] { + const cutPoints: number[] = []; + for (let i = startIndex; i < endIndex; i++) { + const entry = entries[i]; + switch (entry.type) { + case "message": { + const role = entry.message.role; + switch (role) { + case "bashExecution": + case "custom": + case "branchSummary": + case "compactionSummary": + case "user": + case "assistant": + cutPoints.push(i); + break; + case "toolResult": + break; + } + break; + } + case "thinking_level_change": + case "model_change": + case "compaction": + case "branch_summary": + case "custom": + case "custom_message": + case "label": + case "session_info": + break; + } + + // branch_summary and custom_message are user-role messages, valid cut points + if (entry.type === "branch_summary" || entry.type === "custom_message") { + cutPoints.push(i); + } + } + return cutPoints; +} + +/** + * Find the user message (or bashExecution) that starts the turn containing the given entry index. + * Returns -1 if no turn start found before the index. + * BashExecutionMessage is treated like a user message for turn boundaries. + */ +export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number { + for (let i = entryIndex; i >= startIndex; i--) { + const entry = entries[i]; + // branch_summary and custom_message are user-role messages, can start a turn + if (entry.type === "branch_summary" || entry.type === "custom_message") { + return i; + } + if (entry.type === "message") { + const role = entry.message.role; + if (role === "user" || role === "bashExecution") { + return i; + } + } + } + return -1; +} + +export interface CutPointResult { + /** Index of first entry to keep */ + firstKeptEntryIndex: number; + /** Index of user message that starts the turn being split, or -1 if not splitting */ + turnStartIndex: number; + /** Whether this cut splits a turn (cut point is not a user message) */ + isSplitTurn: boolean; +} + +/** + * Find the cut point in session entries that keeps approximately `keepRecentTokens`. + * + * Algorithm: Walk backwards from newest, accumulating estimated message sizes. + * Stop when we've accumulated >= keepRecentTokens. Cut at that point. + * + * Can cut at user OR assistant messages (never tool results). When cutting at an + * assistant message with tool calls, its tool results come after and will be kept. + * + * Returns CutPointResult with: + * - firstKeptEntryIndex: the entry index to start keeping from + * - turnStartIndex: if cutting mid-turn, the user message that started that turn + * - isSplitTurn: whether we're cutting in the middle of a turn + * + * Only considers entries between `startIndex` and `endIndex` (exclusive). + */ +export function findCutPoint( + entries: SessionEntry[], + startIndex: number, + endIndex: number, + keepRecentTokens: number, +): CutPointResult { + const cutPoints = findValidCutPoints(entries, startIndex, endIndex); + + if (cutPoints.length === 0) { + return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false }; + } + + // Walk backwards from newest, accumulating estimated message sizes + let accumulatedTokens = 0; + let cutIndex = cutPoints[0]; // Default: keep from first message (not header) + + for (let i = endIndex - 1; i >= startIndex; i--) { + const entry = entries[i]; + if (entry.type !== "message") continue; + + // Estimate this message's size + const messageTokens = estimateTokens(entry.message); + accumulatedTokens += messageTokens; + + // Check if we've exceeded the budget + if (accumulatedTokens >= keepRecentTokens) { + // Find the closest valid cut point at or after this entry + for (let c = 0; c < cutPoints.length; c++) { + if (cutPoints[c] >= i) { + cutIndex = cutPoints[c]; + break; + } + } + break; + } + } + + // Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.) + while (cutIndex > startIndex) { + const prevEntry = entries[cutIndex - 1]; + // Stop at session header or compaction boundaries + if (prevEntry.type === "compaction") { + break; + } + if (prevEntry.type === "message") { + // Stop if we hit any message + break; + } + // Include this non-message entry (bash, settings change, etc.) + cutIndex--; + } + + // Determine if this is a split turn + const cutEntry = entries[cutIndex]; + const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user"; + const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex); + + return { + firstKeptEntryIndex: cutIndex, + turnStartIndex, + isSplitTurn: !isUserMessage && turnStartIndex !== -1, + }; +} + +// ============================================================================ +// Summarization +// ============================================================================ + +const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. + +Use this EXACT format: + +## Goal +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. + +Update the existing structured summary with new information. RULES: +- PRESERVE all existing information from the previous summary +- ADD new progress, decisions, and context from the new messages +- UPDATE the Progress section: move items from "In Progress" to "Done" when completed +- UPDATE "Next Steps" based on what was accomplished +- PRESERVE exact file paths, function names, and error messages +- If something is no longer relevant, you may remove it + +Use this EXACT format: + +## Goal +[Preserve existing goals, add new ones if the task expanded] + +## Constraints & Preferences +- [Preserve existing, add new ones discovered] + +## Progress +### Done +- [x] [Include previously done items AND newly completed items] + +### In Progress +- [ ] [Current work - update based on progress] + +### Blocked +- [Current blockers - remove if resolved] + +## Key Decisions +- **[Decision]**: [Brief rationale] (preserve all previous, add new) + +## Next Steps +1. [Update based on current state] + +## Critical Context +- [Preserve important context, add new if needed] + +Keep each section concise. Preserve exact file paths, function names, and error messages.`; + +function createSummarizationOptions( + model: Model, + maxTokens: number, + apiKey: string | undefined, + headers: Record | undefined, + env: Record | undefined, + signal: AbortSignal | undefined, + thinkingLevel: ThinkingLevel | undefined, +): SimpleStreamOptions { + const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env }; + if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { + options.reasoning = thinkingLevel; + } + return options; +} + +async function completeSummarization( + model: Model, + context: Context, + options: SimpleStreamOptions, + streamFn?: StreamFn, +): Promise { + if (!streamFn) { + return completeSimple(model, context, options); + } + const stream = await streamFn(model, context, options); + return stream.result(); +} + +/** + * Generate a summary of the conversation using the LLM. + * If previousSummary is provided, uses the update prompt to merge. + */ +export async function generateSummary( + currentMessages: AgentMessage[], + model: Model, + reserveTokens: number, + apiKey: string | undefined, + headers?: Record, + signal?: AbortSignal, + customInstructions?: string, + previousSummary?: string, + thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, + env?: Record, +): Promise { + const maxTokens = Math.min( + Math.floor(0.8 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); + + // Use update prompt if we have a previous summary, otherwise initial prompt + let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT; + if (customInstructions) { + basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`; + } + + // Serialize conversation to text so model doesn't try to continue it + // Convert to LLM messages first (handles custom types like bashExecution, custom, etc.) + const llmMessages = convertToLlm(currentMessages); + const conversationText = serializeConversation(llmMessages); + + // Build the prompt with conversation wrapped in tags + let promptText = `\n${conversationText}\n\n\n`; + if (previousSummary) { + promptText += `\n${previousSummary}\n\n\n`; + } + promptText += basePrompt; + + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel); + + const response = await completeSummarization( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + completionOptions, + streamFn, + ); + + if (response.stopReason === "error") { + throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); + } + + const textContent = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); + + return textContent; +} + +// ============================================================================ +// Compaction Preparation (for extensions) +// ============================================================================ + +export interface CompactionPreparation { + /** UUID of first entry to keep */ + firstKeptEntryId: string; + /** Messages that will be summarized and discarded */ + messagesToSummarize: AgentMessage[]; + /** Messages that will be turned into turn prefix summary (if splitting) */ + turnPrefixMessages: AgentMessage[]; + /** Whether this is a split turn (cut point in middle of turn) */ + isSplitTurn: boolean; + tokensBefore: number; + /** Summary from previous compaction, for iterative update */ + previousSummary?: string; + /** File operations extracted from messagesToSummarize */ + fileOps: FileOperations; + /** Compaction settions from settings.jsonl */ + settings: CompactionSettings; +} + +export function prepareCompaction( + pathEntries: SessionEntry[], + settings: CompactionSettings, +): CompactionPreparation | undefined { + if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") { + return undefined; + } + + let prevCompactionIndex = -1; + for (let i = pathEntries.length - 1; i >= 0; i--) { + if (pathEntries[i].type === "compaction") { + prevCompactionIndex = i; + break; + } + } + + let previousSummary: string | undefined; + let boundaryStart = 0; + if (prevCompactionIndex >= 0) { + const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry; + previousSummary = prevCompaction.summary; + const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId); + boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1; + } + const boundaryEnd = pathEntries.length; + + const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens; + + const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens); + + // Get UUID of first kept entry + const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex]; + if (!firstKeptEntry?.id) { + return undefined; // Session needs migration + } + const firstKeptEntryId = firstKeptEntry.id; + + const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; + + // Messages to summarize (will be discarded after summary) + const messagesToSummarize: AgentMessage[] = []; + for (let i = boundaryStart; i < historyEnd; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) messagesToSummarize.push(msg); + } + + // Messages for turn prefix summary (if splitting a turn) + const turnPrefixMessages: AgentMessage[] = []; + if (cutPoint.isSplitTurn) { + for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) turnPrefixMessages.push(msg); + } + } + + if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) { + return undefined; + } + + // Extract file operations from messages and previous compaction + const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); + + // Also extract file ops from turn prefix if splitting + if (cutPoint.isSplitTurn) { + for (const msg of turnPrefixMessages) { + extractFileOpsFromMessage(msg, fileOps); + } + } + + return { + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn: cutPoint.isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + }; +} + +// ============================================================================ +// Main compaction function +// ============================================================================ + +const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. + +Summarize the prefix to provide context for the retained suffix: + +## Original Request +[What did the user ask for in this turn?] + +## Early Progress +- [Key decisions and work done in the prefix] + +## Context for Suffix +- [Information needed to understand the retained recent work] + +Be concise. Focus on what's needed to understand the kept suffix.`; + +/** + * Generate summaries for compaction using prepared data. + * Returns CompactionResult - SessionManager adds uuid/parentUuid when saving. + * + * @param preparation - Pre-calculated preparation from prepareCompaction() + * @param customInstructions - Optional custom focus for the summary + */ +export async function compact( + preparation: CompactionPreparation, + model: Model, + apiKey: string | undefined, + headers?: Record, + customInstructions?: string, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, + env?: Record, +): Promise { + const { + firstKeptEntryId, + messagesToSummarize, + turnPrefixMessages, + isSplitTurn, + tokensBefore, + previousSummary, + fileOps, + settings, + } = preparation; + + // Generate summaries (can be parallel if both needed) and merge into one + let summary: string; + + if (isSplitTurn && turnPrefixMessages.length > 0) { + // Generate both summaries in parallel + const [historyResult, turnPrefixResult] = await Promise.all([ + messagesToSummarize.length > 0 + ? generateSummary( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + streamFn, + env, + ) + : Promise.resolve("No prior history."), + generateTurnPrefixSummary( + turnPrefixMessages, + model, + settings.reserveTokens, + apiKey, + headers, + env, + signal, + thinkingLevel, + streamFn, + ), + ]); + // Merge into single summary + summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`; + } else { + // Just generate history summary + summary = await generateSummary( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + streamFn, + env, + ); + } + + // Compute file lists and append to summary + const { readFiles, modifiedFiles } = computeFileLists(fileOps); + summary += formatFileOperations(readFiles, modifiedFiles); + + if (!firstKeptEntryId) { + throw new Error("First kept entry has no UUID - session may need migration"); + } + + return { + summary, + firstKeptEntryId, + tokensBefore, + details: { readFiles, modifiedFiles } as CompactionDetails, + }; +} + +/** + * Generate a summary for a turn prefix (when splitting a turn). + */ +async function generateTurnPrefixSummary( + messages: AgentMessage[], + model: Model, + reserveTokens: number, + apiKey: string | undefined, + headers?: Record, + env?: Record, + signal?: AbortSignal, + thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, +): Promise { + const maxTokens = Math.min( + Math.floor(0.5 * reserveTokens), + model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, + ); // Smaller budget for turn prefix + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const promptText = `\n${conversationText}\n\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`; + const summarizationMessages = [ + { + role: "user" as const, + content: [{ type: "text" as const, text: promptText }], + timestamp: Date.now(), + }, + ]; + + const response = await completeSummarization( + model, + { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, + createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel), + streamFn, + ); + + if (response.stopReason === "error") { + throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); + } + + return response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n"); +} diff --git a/cactus-code/packages/coding-agent/src/core/compaction/index.ts b/cactus-code/packages/coding-agent/src/core/compaction/index.ts new file mode 100644 index 000000000..7fae5f2c3 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/compaction/index.ts @@ -0,0 +1,7 @@ +/** + * Compaction and summarization utilities. + */ + +export * from "./branch-summarization.ts"; +export * from "./compaction.ts"; +export * from "./utils.ts"; diff --git a/cactus-code/packages/coding-agent/src/core/compaction/utils.ts b/cactus-code/packages/coding-agent/src/core/compaction/utils.ts new file mode 100644 index 000000000..6cfc16227 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/compaction/utils.ts @@ -0,0 +1,170 @@ +/** + * Shared utilities for compaction and branch summarization. + */ + +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { Message } from "@earendil-works/pi-ai"; + +// ============================================================================ +// File Operation Tracking +// ============================================================================ + +export interface FileOperations { + read: Set; + written: Set; + edited: Set; +} + +export function createFileOps(): FileOperations { + return { + read: new Set(), + written: new Set(), + edited: new Set(), + }; +} + +/** + * Extract file operations from tool calls in an assistant message. + */ +export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void { + if (message.role !== "assistant") return; + if (!("content" in message) || !Array.isArray(message.content)) return; + + for (const block of message.content) { + if (typeof block !== "object" || block === null) continue; + if (!("type" in block) || block.type !== "toolCall") continue; + if (!("arguments" in block) || !("name" in block)) continue; + + const args = block.arguments as Record | undefined; + if (!args) continue; + + const path = typeof args.path === "string" ? args.path : undefined; + if (!path) continue; + + switch (block.name) { + case "read": + fileOps.read.add(path); + break; + case "write": + fileOps.written.add(path); + break; + case "edit": + fileOps.edited.add(path); + break; + } + } +} + +/** + * Compute final file lists from file operations. + * Returns readFiles (files only read, not modified) and modifiedFiles. + */ +export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } { + const modified = new Set([...fileOps.edited, ...fileOps.written]); + const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).sort(); + const modifiedFiles = [...modified].sort(); + return { readFiles: readOnly, modifiedFiles }; +} + +/** + * Format file operations as XML tags for summary. + */ +export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string { + const sections: string[] = []; + if (readFiles.length > 0) { + sections.push(`\n${readFiles.join("\n")}\n`); + } + if (modifiedFiles.length > 0) { + sections.push(`\n${modifiedFiles.join("\n")}\n`); + } + if (sections.length === 0) return ""; + return `\n\n${sections.join("\n\n")}`; +} + +// ============================================================================ +// Message Serialization +// ============================================================================ + +/** Maximum characters for a tool result in serialized summaries. */ +const TOOL_RESULT_MAX_CHARS = 2000; + +/** + * Truncate text to a maximum character length for summarization. + * Keeps the beginning and appends a truncation marker. + */ +function truncateForSummary(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + const truncatedChars = text.length - maxChars; + return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`; +} + +/** + * Serialize LLM messages to text for summarization. + * This prevents the model from treating it as a conversation to continue. + * Call convertToLlm() first to handle custom message types. + * + * Tool results are truncated to keep the summarization request within + * reasonable token budgets. Full content is not needed for summarization. + */ +export function serializeConversation(messages: Message[]): string { + const parts: string[] = []; + + for (const msg of messages) { + if (msg.role === "user") { + const content = + typeof msg.content === "string" + ? msg.content + : msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) parts.push(`[User]: ${content}`); + } else if (msg.role === "assistant") { + const textParts: string[] = []; + const thinkingParts: string[] = []; + const toolCalls: string[] = []; + + for (const block of msg.content) { + if (block.type === "text") { + textParts.push(block.text); + } else if (block.type === "thinking") { + thinkingParts.push(block.thinking); + } else if (block.type === "toolCall") { + const args = block.arguments as Record; + const argsStr = Object.entries(args) + .map(([k, v]) => `${k}=${JSON.stringify(v)}`) + .join(", "); + toolCalls.push(`${block.name}(${argsStr})`); + } + } + + if (thinkingParts.length > 0) { + parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); + } + if (textParts.length > 0) { + parts.push(`[Assistant]: ${textParts.join("\n")}`); + } + if (toolCalls.length > 0) { + parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); + } + } else if (msg.role === "toolResult") { + const content = msg.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + if (content) { + parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); + } + } + } + + return parts.join("\n\n"); +} + +// ============================================================================ +// Summarization System Prompt +// ============================================================================ + +export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified. + +Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`; diff --git a/cactus-code/packages/coding-agent/src/core/defaults.ts b/cactus-code/packages/coding-agent/src/core/defaults.ts new file mode 100644 index 000000000..fddc7d14f --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/defaults.ts @@ -0,0 +1,3 @@ +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; + +export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; diff --git a/cactus-code/packages/coding-agent/src/core/diagnostics.ts b/cactus-code/packages/coding-agent/src/core/diagnostics.ts new file mode 100644 index 000000000..20fb80243 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/diagnostics.ts @@ -0,0 +1,15 @@ +export interface ResourceCollision { + resourceType: "extension" | "skill" | "prompt" | "theme"; + name: string; // skill name, command/tool/flag name, prompt name, theme name + winnerPath: string; + loserPath: string; + winnerSource?: string; // e.g., "npm:foo", "git:...", "local" + loserSource?: string; +} + +export interface ResourceDiagnostic { + type: "warning" | "error" | "collision"; + message: string; + path?: string; + collision?: ResourceCollision; +} diff --git a/cactus-code/packages/coding-agent/src/core/event-bus.ts b/cactus-code/packages/coding-agent/src/core/event-bus.ts new file mode 100644 index 000000000..a4c87b9f0 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/event-bus.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from "node:events"; + +export interface EventBus { + emit(channel: string, data: unknown): void; + on(channel: string, handler: (data: unknown) => void): () => void; +} + +export interface EventBusController extends EventBus { + clear(): void; +} + +export function createEventBus(): EventBusController { + const emitter = new EventEmitter(); + return { + emit: (channel, data) => { + emitter.emit(channel, data); + }, + on: (channel, handler) => { + const safeHandler = async (data: unknown) => { + try { + await handler(data); + } catch (err) { + console.error(`Event handler error (${channel}):`, err); + } + }; + emitter.on(channel, safeHandler); + return () => emitter.off(channel, safeHandler); + }, + clear: () => { + emitter.removeAllListeners(); + }, + }; +} diff --git a/cactus-code/packages/coding-agent/src/core/exec.ts b/cactus-code/packages/coding-agent/src/core/exec.ts new file mode 100644 index 000000000..5afe9ec36 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/exec.ts @@ -0,0 +1,107 @@ +/** + * Shared command execution utilities for extensions and custom tools. + */ + +import { spawn } from "node:child_process"; +import { waitForChildProcess } from "../utils/child-process.ts"; + +/** + * Options for executing shell commands. + */ +export interface ExecOptions { + /** AbortSignal to cancel the command */ + signal?: AbortSignal; + /** Timeout in milliseconds */ + timeout?: number; + /** Working directory */ + cwd?: string; +} + +/** + * Result of executing a shell command. + */ +export interface ExecResult { + stdout: string; + stderr: string; + code: number; + killed: boolean; +} + +/** + * Execute a shell command and return stdout/stderr/code. + * Supports timeout and abort signal. + */ +export async function execCommand( + command: string, + args: string[], + cwd: string, + options?: ExecOptions, +): Promise { + return new Promise((resolve) => { + const proc = spawn(command, args, { + cwd, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let killed = false; + let timeoutId: NodeJS.Timeout | undefined; + + const killProcess = () => { + if (!killed) { + killed = true; + proc.kill("SIGTERM"); + // Force kill after 5 seconds if SIGTERM doesn't work + setTimeout(() => { + if (!proc.killed) { + proc.kill("SIGKILL"); + } + }, 5000); + } + }; + + // Handle abort signal + if (options?.signal) { + if (options.signal.aborted) { + killProcess(); + } else { + options.signal.addEventListener("abort", killProcess, { once: true }); + } + } + + // Handle timeout + if (options?.timeout && options.timeout > 0) { + timeoutId = setTimeout(() => { + killProcess(); + }, options.timeout); + } + + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + + // Wait for process termination without hanging on inherited stdio handles + // held open by detached descendants. + waitForChildProcess(proc) + .then((code) => { + if (timeoutId) clearTimeout(timeoutId); + if (options?.signal) { + options.signal.removeEventListener("abort", killProcess); + } + resolve({ stdout, stderr, code: code ?? 0, killed }); + }) + .catch((_err) => { + if (timeoutId) clearTimeout(timeoutId); + if (options?.signal) { + options.signal.removeEventListener("abort", killProcess); + } + resolve({ stdout, stderr, code: 1, killed }); + }); + }); +} diff --git a/cactus-code/packages/coding-agent/src/core/experimental.ts b/cactus-code/packages/coding-agent/src/core/experimental.ts new file mode 100644 index 000000000..12d33c74c --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/experimental.ts @@ -0,0 +1,3 @@ +export function areExperimentalFeaturesEnabled(): boolean { + return process.env.PI_EXPERIMENTAL === "1"; +} diff --git a/cactus-code/packages/coding-agent/src/core/extensions/index.ts b/cactus-code/packages/coding-agent/src/core/extensions/index.ts new file mode 100644 index 000000000..3ed7a6625 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/extensions/index.ts @@ -0,0 +1,178 @@ +/** + * Extension system for lifecycle events and custom tools. + */ + +export type { SlashCommandInfo, SlashCommandSource } from "../slash-commands.ts"; +export type { SourceInfo } from "../source-info.ts"; +export { + createExtensionRuntime, + discoverAndLoadExtensions, + loadExtensionFromFactory, + loadExtensions, +} from "./loader.ts"; +export type { + ExtensionErrorListener, + ForkHandler, + NavigateTreeHandler, + NewSessionHandler, + ShutdownHandler, + SwitchSessionHandler, +} from "./runner.ts"; +export { ExtensionRunner } from "./runner.ts"; +export type { + AfterProviderResponseEvent, + AgentEndEvent, + AgentStartEvent, + // Re-exports + AgentToolResult, + AgentToolUpdateCallback, + AppendEntryHandler, + // App keybindings (for custom editors) + AppKeybinding, + AutocompleteProviderFactory, + // Events - Tool (ToolCallEvent types) + BashToolCallEvent, + BashToolResultEvent, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, + BeforeProviderRequestEvent, + BeforeProviderRequestEventResult, + BuildSystemPromptOptions, + // Context + CompactOptions, + // Events - Agent + ContextEvent, + // Event Results + ContextEventResult, + ContextUsage, + CustomToolCallEvent, + CustomToolResultEvent, + EditorFactory, + EditToolCallEvent, + EditToolResultEvent, + ExecOptions, + ExecResult, + Extension, + ExtensionActions, + // API + ExtensionAPI, + ExtensionCommandContext, + ExtensionCommandContextActions, + ExtensionContext, + ExtensionContextActions, + // Errors + ExtensionError, + ExtensionEvent, + ExtensionFactory, + ExtensionFlag, + ExtensionHandler, + ExtensionMode, + // Runtime + ExtensionRuntime, + ExtensionShortcut, + ExtensionUIContext, + ExtensionUIDialogOptions, + ExtensionWidgetOptions, + FindToolCallEvent, + FindToolResultEvent, + GetActiveToolsHandler, + GetAllToolsHandler, + GetCommandsHandler, + GetThinkingLevelHandler, + GrepToolCallEvent, + GrepToolResultEvent, + // Events - Input + InputEvent, + InputEventResult, + InputSource, + KeybindingsManager, + LoadExtensionsResult, + LsToolCallEvent, + LsToolResultEvent, + // Events - Message + MessageEndEvent, + // Message Rendering + MessageRenderer, + MessageRenderOptions, + MessageStartEvent, + MessageUpdateEvent, + ModelSelectEvent, + ModelSelectSource, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventDecision, + ProjectTrustEventResult, + ProjectTrustHandler, + // Provider Registration + ProviderConfig, + ProviderModelConfig, + ReadToolCallEvent, + ReadToolResultEvent, + // Commands + RegisteredCommand, + RegisteredTool, + ReplacedSessionContext, + ResolvedCommand, + // Events - Resources + ResourcesDiscoverEvent, + ResourcesDiscoverResult, + SendMessageHandler, + SendUserMessageHandler, + SessionBeforeCompactEvent, + SessionBeforeCompactResult, + SessionBeforeForkEvent, + SessionBeforeForkResult, + SessionBeforeSwitchEvent, + SessionBeforeSwitchResult, + SessionBeforeTreeEvent, + SessionBeforeTreeResult, + SessionCompactEvent, + SessionEvent, + SessionShutdownEvent, + // Events - Session + SessionStartEvent, + SessionTreeEvent, + SetActiveToolsHandler, + SetLabelHandler, + SetModelHandler, + SetThinkingLevelHandler, + TerminalInputHandler, + // Events - Tool + ToolCallEvent, + ToolCallEventResult, + // Tools + ToolDefinition, + // Events - Tool Execution + ToolExecutionEndEvent, + // Tool execution mode + ToolExecutionMode, + ToolExecutionStartEvent, + ToolExecutionUpdateEvent, + ToolInfo, + ToolRenderResultOptions, + ToolResultEvent, + ToolResultEventResult, + TreePreparation, + TurnEndEvent, + TurnStartEvent, + // Events - User Bash + UserBashEvent, + UserBashEventResult, + WidgetPlacement, + WorkingIndicatorOptions, + WriteToolCallEvent, + WriteToolResultEvent, +} from "./types.ts"; +// Type guards +export { + defineTool, + isBashToolResult, + isEditToolResult, + isFindToolResult, + isGrepToolResult, + isLsToolResult, + isReadToolResult, + isToolCallEventType, + isWriteToolResult, +} from "./types.ts"; +export { wrapRegisteredTool, wrapRegisteredTools } from "./wrapper.ts"; diff --git a/cactus-code/packages/coding-agent/src/core/extensions/loader.ts b/cactus-code/packages/coding-agent/src/core/extensions/loader.ts new file mode 100644 index 000000000..a93f7c852 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/extensions/loader.ts @@ -0,0 +1,687 @@ +/** + * Extension loader - loads TypeScript extension modules using jiti. + * + */ + +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as _bundledPiAgentCore from "@earendil-works/pi-agent-core"; +import * as _bundledPiAiCompat from "@earendil-works/pi-ai/compat"; +import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth"; +import type { KeyId } from "@earendil-works/pi-tui"; +import * as _bundledPiTui from "@earendil-works/pi-tui"; +import { createJiti } from "jiti/static"; +// Static imports of packages that extensions may use. +// These MUST be static so Bun bundles them into the compiled binary. +// The virtualModules option then makes them available to extensions. +import * as _bundledTypebox from "typebox"; +import * as _bundledTypeboxCompile from "typebox/compile"; +import * as _bundledTypeboxValue from "typebox/value"; +import { CONFIG_DIR_NAME, getAgentDir, isBunBinary } from "../../config.ts"; +// NOTE: This import works because loader.ts exports are NOT re-exported from index.ts, +// avoiding a circular dependency. Extensions can import from @earendil-works/pi-coding-agent. +import * as _bundledPiCodingAgent from "../../index.ts"; +import { resolvePath } from "../../utils/paths.ts"; +import { createEventBus, type EventBus } from "../event-bus.ts"; +import type { ExecOptions } from "../exec.ts"; +import { execCommand } from "../exec.ts"; +import { createSyntheticSourceInfo } from "../source-info.ts"; +import type { + Extension, + ExtensionAPI, + ExtensionFactory, + ExtensionRuntime, + LoadExtensionsResult, + MessageRenderer, + ProviderConfig, + RegisteredCommand, + ToolDefinition, +} from "./types.ts"; + +/** Modules available to extensions via virtualModules (for compiled Bun binary) */ +const VIRTUAL_MODULES: Record = { + typebox: _bundledTypebox, + "typebox/compile": _bundledTypeboxCompile, + "typebox/value": _bundledTypeboxValue, + "@sinclair/typebox": _bundledTypebox, + "@sinclair/typebox/compile": _bundledTypeboxCompile, + "@sinclair/typebox/value": _bundledTypeboxValue, + "@earendil-works/pi-agent-core": _bundledPiAgentCore, + "@earendil-works/pi-tui": _bundledPiTui, + // Extensions resolve the pi-ai root to the compat entrypoint (a strict + // superset of the core entrypoint): existing extensions using the old + // global API keep working at runtime until compat is removed. + "@earendil-works/pi-ai": _bundledPiAiCompat, + "@earendil-works/pi-ai/compat": _bundledPiAiCompat, + "@earendil-works/pi-ai/oauth": _bundledPiAiOauth, + "@earendil-works/pi-coding-agent": _bundledPiCodingAgent, + "@mariozechner/pi-agent-core": _bundledPiAgentCore, + "@mariozechner/pi-tui": _bundledPiTui, + "@mariozechner/pi-ai": _bundledPiAiCompat, + "@mariozechner/pi-ai/compat": _bundledPiAiCompat, + "@mariozechner/pi-ai/oauth": _bundledPiAiOauth, + "@mariozechner/pi-coding-agent": _bundledPiCodingAgent, +}; + +const require = createRequire(import.meta.url); + +/** + * Get aliases for jiti (used in Node.js/development mode). + * In Bun binary mode, virtualModules is used instead. + */ +let _aliases: Record | null = null; + +function getAliases(): Record { + if (_aliases) return _aliases; + + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const packageIndex = path.resolve(__dirname, "../..", "index.js"); + + const typeboxEntry = require.resolve("typebox"); + const typeboxCompileEntry = require.resolve("typebox/compile"); + const typeboxValueEntry = require.resolve("typebox/value"); + + const packagesRoot = path.resolve(__dirname, "../../../../"); + const resolveWorkspaceOrImport = (workspaceRelativePath: string, specifier: string): string => { + const workspacePath = path.join(packagesRoot, workspaceRelativePath); + if (fs.existsSync(workspacePath)) { + return workspacePath; + } + return fileURLToPath(import.meta.resolve(specifier)); + }; + + const piCodingAgentEntry = packageIndex; + const piAgentCoreEntry = resolveWorkspaceOrImport("agent/dist/index.js", "@earendil-works/pi-agent-core"); + const piTuiEntry = resolveWorkspaceOrImport("tui/dist/index.js", "@earendil-works/pi-tui"); + // Extensions resolve the pi-ai root to the compat entrypoint (a strict + // superset of the core entrypoint): existing extensions using the old + // global API keep working at runtime until compat is removed. + const piAiCompatEntry = resolveWorkspaceOrImport("ai/dist/compat.js", "@earendil-works/pi-ai/compat"); + const piAiOauthEntry = resolveWorkspaceOrImport("ai/dist/oauth.js", "@earendil-works/pi-ai/oauth"); + + _aliases = { + "@earendil-works/pi-coding-agent": piCodingAgentEntry, + "@earendil-works/pi-agent-core": piAgentCoreEntry, + "@earendil-works/pi-tui": piTuiEntry, + "@earendil-works/pi-ai": piAiCompatEntry, + "@earendil-works/pi-ai/compat": piAiCompatEntry, + "@earendil-works/pi-ai/oauth": piAiOauthEntry, + "@mariozechner/pi-coding-agent": piCodingAgentEntry, + "@mariozechner/pi-agent-core": piAgentCoreEntry, + "@mariozechner/pi-tui": piTuiEntry, + "@mariozechner/pi-ai": piAiCompatEntry, + "@mariozechner/pi-ai/compat": piAiCompatEntry, + "@mariozechner/pi-ai/oauth": piAiOauthEntry, + typebox: typeboxEntry, + "typebox/compile": typeboxCompileEntry, + "typebox/value": typeboxValueEntry, + "@sinclair/typebox": typeboxEntry, + "@sinclair/typebox/compile": typeboxCompileEntry, + "@sinclair/typebox/value": typeboxValueEntry, + }; + + return _aliases; +} + +type HandlerFn = (...args: unknown[]) => Promise; + +let extensionCacheCwd: string | undefined; +let extensionCacheGeneration = 0; +const extensionCache = new Map(); + +interface ExtensionCacheToken { + cwd: string; + generation: number; +} + +export function clearExtensionCache(): void { + extensionCache.clear(); + extensionCacheCwd = undefined; + extensionCacheGeneration++; +} + +function useExtensionCacheCwd(cwd: string): ExtensionCacheToken { + const resolvedCwd = resolvePath(cwd); + if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) { + clearExtensionCache(); + } + extensionCacheCwd = resolvedCwd; + return { cwd: resolvedCwd, generation: extensionCacheGeneration }; +} + +/** + * Create a runtime with throwing stubs for action methods. + * Runner.bindCore() replaces these with real implementations. + */ +export function createExtensionRuntime(): ExtensionRuntime { + const notInitialized = () => { + throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading."); + }; + const state: { staleMessage?: string } = {}; + const assertActive = () => { + if (state.staleMessage) { + throw new Error(state.staleMessage); + } + }; + + const runtime: ExtensionRuntime = { + sendMessage: notInitialized, + sendUserMessage: notInitialized, + appendEntry: notInitialized, + setSessionName: notInitialized, + getSessionName: notInitialized, + setLabel: notInitialized, + getActiveTools: notInitialized, + getAllTools: notInitialized, + setActiveTools: notInitialized, + // registerTool() is valid during extension load; refresh is only needed post-bind. + refreshTools: () => {}, + getCommands: notInitialized, + setModel: () => Promise.reject(new Error("Extension runtime not initialized")), + getThinkingLevel: notInitialized, + setThinkingLevel: notInitialized, + flagValues: new Map(), + pendingProviderRegistrations: [], + assertActive, + invalidate: (message) => { + state.staleMessage ??= + message ?? + "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload()."; + }, + // Pre-bind: queue registrations so bindCore() can flush them once the + // model registry is available. bindCore() replaces both with direct calls. + registerProvider: (name, config, extensionPath = "") => { + runtime.pendingProviderRegistrations.push({ name, config, extensionPath }); + }, + unregisterProvider: (name) => { + runtime.pendingProviderRegistrations = runtime.pendingProviderRegistrations.filter((r) => r.name !== name); + }, + }; + + return runtime; +} + +/** + * Create the ExtensionAPI for an extension. + * Registration methods write to the extension object. + * Action methods delegate to the shared runtime. + */ +function createExtensionAPI( + extension: Extension, + runtime: ExtensionRuntime, + cwd: string, + eventBus: EventBus, +): ExtensionAPI { + const api = { + // Registration methods - write to extension + on(event: string, handler: HandlerFn): void { + runtime.assertActive(); + const list = extension.handlers.get(event) ?? []; + list.push(handler); + extension.handlers.set(event, list); + }, + + registerTool(tool: ToolDefinition): void { + runtime.assertActive(); + extension.tools.set(tool.name, { + definition: tool, + sourceInfo: extension.sourceInfo, + }); + runtime.refreshTools(); + }, + + registerCommand(name: string, options: Omit): void { + runtime.assertActive(); + extension.commands.set(name, { + name, + sourceInfo: extension.sourceInfo, + ...options, + }); + }, + + registerShortcut( + shortcut: KeyId, + options: { + description?: string; + handler: (ctx: import("./types.ts").ExtensionContext) => Promise | void; + }, + ): void { + runtime.assertActive(); + extension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options }); + }, + + registerFlag( + name: string, + options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, + ): void { + runtime.assertActive(); + extension.flags.set(name, { name, extensionPath: extension.path, ...options }); + if (options.default !== undefined && !runtime.flagValues.has(name)) { + runtime.flagValues.set(name, options.default); + } + }, + + registerMessageRenderer(customType: string, renderer: MessageRenderer): void { + runtime.assertActive(); + extension.messageRenderers.set(customType, renderer as MessageRenderer); + }, + + // Flag access - checks extension registered it, reads from runtime + getFlag(name: string): boolean | string | undefined { + runtime.assertActive(); + if (!extension.flags.has(name)) return undefined; + return runtime.flagValues.get(name); + }, + + // Action methods - delegate to shared runtime + sendMessage(message, options): void { + runtime.assertActive(); + runtime.sendMessage(message, options); + }, + + sendUserMessage(content, options): void { + runtime.assertActive(); + runtime.sendUserMessage(content, options); + }, + + appendEntry(customType: string, data?: unknown): void { + runtime.assertActive(); + runtime.appendEntry(customType, data); + }, + + setSessionName(name: string): void { + runtime.assertActive(); + runtime.setSessionName(name); + }, + + getSessionName(): string | undefined { + runtime.assertActive(); + return runtime.getSessionName(); + }, + + setLabel(entryId: string, label: string | undefined): void { + runtime.assertActive(); + runtime.setLabel(entryId, label); + }, + + exec(command: string, args: string[], options?: ExecOptions) { + runtime.assertActive(); + return execCommand(command, args, options?.cwd ?? cwd, options); + }, + + getActiveTools(): string[] { + runtime.assertActive(); + return runtime.getActiveTools(); + }, + + getAllTools() { + runtime.assertActive(); + return runtime.getAllTools(); + }, + + setActiveTools(toolNames: string[]): void { + runtime.assertActive(); + runtime.setActiveTools(toolNames); + }, + + getCommands() { + runtime.assertActive(); + return runtime.getCommands(); + }, + + setModel(model) { + runtime.assertActive(); + return runtime.setModel(model); + }, + + getThinkingLevel() { + runtime.assertActive(); + return runtime.getThinkingLevel(); + }, + + setThinkingLevel(level) { + runtime.assertActive(); + runtime.setThinkingLevel(level); + }, + + registerProvider(name: string, config: ProviderConfig) { + runtime.assertActive(); + runtime.registerProvider(name, config, extension.path); + }, + + unregisterProvider(name: string) { + runtime.assertActive(); + runtime.unregisterProvider(name, extension.path); + }, + + events: eventBus, + } as ExtensionAPI; + + return api; +} + +function isCurrentCacheToken(cacheToken: ExtensionCacheToken | undefined): cacheToken is ExtensionCacheToken { + return ( + cacheToken !== undefined && + extensionCacheCwd === cacheToken.cwd && + extensionCacheGeneration === cacheToken.generation + ); +} + +async function loadExtensionModule(extensionPath: string, cacheToken?: ExtensionCacheToken) { + if (isCurrentCacheToken(cacheToken)) { + const cachedFactory = extensionCache.get(extensionPath); + if (cachedFactory) { + return cachedFactory; + } + } + + const jiti = createJiti(import.meta.url, { + moduleCache: false, + // In Bun binary: use virtualModules for bundled packages (no filesystem resolution) + // Also disable tryNative so jiti handles ALL imports (not just the entry point) + // In Node.js/dev: use aliases to resolve to node_modules paths + ...(isBunBinary ? { virtualModules: VIRTUAL_MODULES, tryNative: false } : { alias: getAliases() }), + }); + + const module = await jiti.import(extensionPath, { default: true }); + const factory = module as ExtensionFactory; + if (typeof factory !== "function") { + return undefined; + } + if (isCurrentCacheToken(cacheToken)) { + extensionCache.set(extensionPath, factory); + } + return factory; +} + +/** + * Create an Extension object with empty collections. + */ +function createExtension(extensionPath: string, resolvedPath: string): Extension { + const source = + extensionPath.startsWith("<") && extensionPath.endsWith(">") + ? extensionPath.slice(1, -1).split(":")[0] || "temporary" + : "local"; + const baseDir = extensionPath.startsWith("<") ? undefined : path.dirname(resolvedPath); + + return { + path: extensionPath, + resolvedPath, + sourceInfo: createSyntheticSourceInfo(extensionPath, { source, baseDir }), + handlers: new Map(), + tools: new Map(), + messageRenderers: new Map(), + commands: new Map(), + flags: new Map(), + shortcuts: new Map(), + }; +} + +async function loadExtension( + extensionPath: string, + cwd: string, + eventBus: EventBus, + runtime: ExtensionRuntime, + cacheToken?: ExtensionCacheToken, +): Promise<{ extension: Extension | null; error: string | null }> { + const resolvedPath = resolvePath(extensionPath, cwd, { normalizeUnicodeSpaces: true }); + + try { + const factory = await loadExtensionModule(resolvedPath, cacheToken); + if (!factory) { + return { extension: null, error: `Extension does not export a valid factory function: ${extensionPath}` }; + } + + const extension = createExtension(extensionPath, resolvedPath); + const api = createExtensionAPI(extension, runtime, cwd, eventBus); + await factory(api); + + return { extension, error: null }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { extension: null, error: `Failed to load extension: ${message}` }; + } +} + +/** + * Create an Extension from an inline factory function. + */ +export async function loadExtensionFromFactory( + factory: ExtensionFactory, + cwd: string, + eventBus: EventBus, + runtime: ExtensionRuntime, + extensionPath = "", +): Promise { + const extension = createExtension(extensionPath, extensionPath); + const resolvedCwd = resolvePath(cwd); + const api = createExtensionAPI(extension, runtime, resolvedCwd, eventBus); + await factory(api); + return extension; +} + +/** + * Load extensions from paths. + */ +async function loadExtensionsInternal( + paths: string[], + cwd: string, + eventBus?: EventBus, + runtime?: ExtensionRuntime, + useCache = false, +): Promise { + const extensions: Extension[] = []; + const errors: Array<{ path: string; error: string }> = []; + const cacheToken = useCache ? useExtensionCacheCwd(cwd) : undefined; + const resolvedCwd = cacheToken?.cwd ?? resolvePath(cwd); + const resolvedEventBus = eventBus ?? createEventBus(); + const resolvedRuntime = runtime ?? createExtensionRuntime(); + + for (const extPath of paths) { + const { extension, error } = await loadExtension( + extPath, + resolvedCwd, + resolvedEventBus, + resolvedRuntime, + cacheToken, + ); + + if (error) { + errors.push({ path: extPath, error }); + continue; + } + + if (extension) { + extensions.push(extension); + } + } + + return { + extensions, + errors, + runtime: resolvedRuntime, + }; +} + +export async function loadExtensions( + paths: string[], + cwd: string, + eventBus?: EventBus, + runtime?: ExtensionRuntime, +): Promise { + return loadExtensionsInternal(paths, cwd, eventBus, runtime); +} + +export async function loadExtensionsCached( + paths: string[], + cwd: string, + eventBus?: EventBus, + runtime?: ExtensionRuntime, +): Promise { + return loadExtensionsInternal(paths, cwd, eventBus, runtime, true); +} + +interface PiManifest { + extensions?: string[]; + themes?: string[]; + skills?: string[]; + prompts?: string[]; +} + +function readPiManifest(packageJsonPath: string): PiManifest | null { + try { + const content = fs.readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content); + if (pkg.pi && typeof pkg.pi === "object") { + return pkg.pi as PiManifest; + } + return null; + } catch { + return null; + } +} + +function isExtensionFile(name: string): boolean { + return name.endsWith(".ts") || name.endsWith(".js"); +} + +/** + * Resolve extension entry points from a directory. + * + * Checks for: + * 1. package.json with "pi.extensions" field -> returns declared paths + * 2. index.ts or index.js -> returns the index file + * + * Returns resolved paths or null if no entry points found. + */ +function resolveExtensionEntries(dir: string): string[] | null { + // Check for package.json with "pi" field first + const packageJsonPath = path.join(dir, "package.json"); + if (fs.existsSync(packageJsonPath)) { + const manifest = readPiManifest(packageJsonPath); + if (manifest?.extensions?.length) { + const entries: string[] = []; + for (const extPath of manifest.extensions) { + const resolvedExtPath = path.resolve(dir, extPath); + if (fs.existsSync(resolvedExtPath)) { + entries.push(resolvedExtPath); + } + } + if (entries.length > 0) { + return entries; + } + } + } + + // Check for index.ts or index.js + const indexTs = path.join(dir, "index.ts"); + const indexJs = path.join(dir, "index.js"); + if (fs.existsSync(indexTs)) { + return [indexTs]; + } + if (fs.existsSync(indexJs)) { + return [indexJs]; + } + + return null; +} + +/** + * Discover extensions in a directory. + * + * Discovery rules: + * 1. Direct files: `extensions/*.ts` or `*.js` → load + * 2. Subdirectory with index: `extensions/* /index.ts` or `index.js` → load + * 3. Subdirectory with package.json: `extensions/* /package.json` with "pi" field → load what it declares + * + * No recursion beyond one level. Complex packages must use package.json manifest. + */ +function discoverExtensionsInDir(dir: string): string[] { + if (!fs.existsSync(dir)) { + return []; + } + + const discovered: string[] = []; + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + + // 1. Direct files: *.ts or *.js + if ((entry.isFile() || entry.isSymbolicLink()) && isExtensionFile(entry.name)) { + discovered.push(entryPath); + continue; + } + + // 2 & 3. Subdirectories + if (entry.isDirectory() || entry.isSymbolicLink()) { + const entries = resolveExtensionEntries(entryPath); + if (entries) { + discovered.push(...entries); + } + } + } + } catch { + return []; + } + + return discovered; +} + +/** + * Discover and load extensions from standard locations. + */ +export async function discoverAndLoadExtensions( + configuredPaths: string[], + cwd: string, + agentDir: string = getAgentDir(), + eventBus?: EventBus, +): Promise { + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + const allPaths: string[] = []; + const seen = new Set(); + + const addPaths = (paths: string[]) => { + for (const p of paths) { + const resolved = path.resolve(p); + if (!seen.has(resolved)) { + seen.add(resolved); + allPaths.push(p); + } + } + }; + + // 1. Project-local extensions: cwd/${CONFIG_DIR_NAME}/extensions/ + const localExtDir = path.join(resolvedCwd, CONFIG_DIR_NAME, "extensions"); + addPaths(discoverExtensionsInDir(localExtDir)); + + // 2. Global extensions: agentDir/extensions/ + const globalExtDir = path.join(resolvedAgentDir, "extensions"); + addPaths(discoverExtensionsInDir(globalExtDir)); + + // 3. Explicitly configured paths + for (const p of configuredPaths) { + const resolved = resolvePath(p, resolvedCwd, { normalizeUnicodeSpaces: true }); + if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) { + // Check for package.json with pi manifest or index.ts + const entries = resolveExtensionEntries(resolved); + if (entries) { + addPaths(entries); + continue; + } + // No explicit entries - discover individual files in directory + addPaths(discoverExtensionsInDir(resolved)); + continue; + } + + addPaths([resolved]); + } + + return loadExtensions(allPaths, resolvedCwd, eventBus); +} diff --git a/cactus-code/packages/coding-agent/src/core/extensions/runner.ts b/cactus-code/packages/coding-agent/src/core/extensions/runner.ts new file mode 100644 index 000000000..9cb0e6570 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/extensions/runner.ts @@ -0,0 +1,1135 @@ +/** + * Extension runner - executes extensions and manages their lifecycle. + */ + +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { ImageContent, Model } from "@earendil-works/pi-ai"; +import type { KeyId } from "@earendil-works/pi-tui"; +import { type Theme, theme } from "../../modes/interactive/theme/theme.ts"; +import type { ResourceDiagnostic } from "../diagnostics.ts"; +import type { KeybindingsConfig } from "../keybindings.ts"; +import type { ModelRegistry } from "../model-registry.ts"; +import type { SessionManager } from "../session-manager.ts"; +import type { BuildSystemPromptOptions } from "../system-prompt.ts"; +import type { + BeforeAgentStartEvent, + BeforeAgentStartEventResult, + BeforeProviderRequestEvent, + CompactOptions, + ContextEvent, + ContextEventResult, + ContextUsage, + Extension, + ExtensionActions, + ExtensionCommandContext, + ExtensionCommandContextActions, + ExtensionContext, + ExtensionContextActions, + ExtensionError, + ExtensionEvent, + ExtensionFlag, + ExtensionMode, + ExtensionRuntime, + ExtensionShortcut, + ExtensionUIContext, + InputEvent, + InputEventResult, + InputSource, + LoadExtensionsResult, + MessageEndEvent, + MessageEndEventResult, + MessageRenderer, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventResult, + ProviderConfig, + RegisteredCommand, + RegisteredTool, + ReplacedSessionContext, + ResolvedCommand, + ResourcesDiscoverEvent, + ResourcesDiscoverResult, + SessionBeforeCompactResult, + SessionBeforeForkResult, + SessionBeforeSwitchResult, + SessionBeforeTreeResult, + SessionShutdownEvent, + ToolCallEvent, + ToolCallEventResult, + ToolResultEvent, + ToolResultEventResult, + UserBashEvent, + UserBashEventResult, +} from "./types.ts"; + +// Extension shortcuts compete with canonical keybinding ids from keybindings.json. +// Only editor-global shortcuts are reserved here. Picker-specific bindings are not. +const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ + "app.interrupt", + "app.clear", + "app.exit", + "app.suspend", + "app.thinking.cycle", + "app.model.cycleForward", + "app.model.cycleBackward", + "app.model.select", + "app.tools.expand", + "app.thinking.toggle", + "app.editor.external", + "app.message.followUp", + "tui.input.submit", + "tui.select.confirm", + "tui.select.cancel", + "tui.input.copy", + "tui.editor.deleteToLineEnd", +] as const; + +type BuiltInKeyBindings = Partial>; + +const buildBuiltinKeybindings = (resolvedKeybindings: KeybindingsConfig): BuiltInKeyBindings => { + const builtinKeybindings = {} as BuiltInKeyBindings; + for (const [keybinding, keys] of Object.entries(resolvedKeybindings)) { + if (keys === undefined) continue; + const keyList = Array.isArray(keys) ? keys : [keys]; + const restrictOverride = (RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS as readonly string[]).includes(keybinding); + for (const key of keyList) { + const normalizedKey = key.toLowerCase() as KeyId; + // If multiple actions bind the same key, the reserved action wins so extensions + // remain blocked by reserved shortcuts regardless of iteration order. + const existing = builtinKeybindings[normalizedKey]; + if (existing?.restrictOverride && !restrictOverride) continue; + builtinKeybindings[normalizedKey] = { + keybinding, + restrictOverride, + }; + } + } + return builtinKeybindings; +}; + +/** Combined result from all before_agent_start handlers */ +interface BeforeAgentStartCombinedResult { + messages?: NonNullable[]; + systemPrompt?: string; +} + +/** + * Events handled by the generic emit() method. + * Events with dedicated emitXxx() methods are excluded for stronger type safety. + */ +type RunnerEmitEvent = Exclude< + ExtensionEvent, + | ToolCallEvent + | ProjectTrustEvent + | ToolResultEvent + | UserBashEvent + | ContextEvent + | BeforeProviderRequestEvent + | BeforeAgentStartEvent + | MessageEndEvent + | ResourcesDiscoverEvent + | InputEvent +>; + +type SessionBeforeEvent = Extract< + RunnerEmitEvent, + { type: "session_before_switch" | "session_before_fork" | "session_before_compact" | "session_before_tree" } +>; + +type SessionBeforeEventResult = + | SessionBeforeSwitchResult + | SessionBeforeForkResult + | SessionBeforeCompactResult + | SessionBeforeTreeResult; + +type RunnerEmitResult = TEvent extends { type: "session_before_switch" } + ? SessionBeforeSwitchResult | undefined + : TEvent extends { type: "session_before_fork" } + ? SessionBeforeForkResult | undefined + : TEvent extends { type: "session_before_compact" } + ? SessionBeforeCompactResult | undefined + : TEvent extends { type: "session_before_tree" } + ? SessionBeforeTreeResult | undefined + : undefined; + +export type ExtensionErrorListener = (error: ExtensionError) => void; + +export type NewSessionHandler = (options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + withSession?: (ctx: ReplacedSessionContext) => Promise; +}) => Promise<{ cancelled: boolean }>; + +export type ForkHandler = ( + entryId: string, + options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise }, +) => Promise<{ cancelled: boolean }>; + +export type NavigateTreeHandler = ( + targetId: string, + options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, +) => Promise<{ cancelled: boolean }>; + +export type SwitchSessionHandler = ( + sessionPath: string, + options?: { withSession?: (ctx: ReplacedSessionContext) => Promise }, +) => Promise<{ cancelled: boolean }>; + +export type ReloadHandler = () => Promise; + +export type ShutdownHandler = () => void; + +/** + * Helper function to emit session_shutdown event to extensions. + * Returns true if the event was emitted, false if there were no handlers. + */ +export async function emitSessionShutdownEvent( + extensionRunner: ExtensionRunner, + event: SessionShutdownEvent, +): Promise { + if (extensionRunner.hasHandlers("session_shutdown")) { + await extensionRunner.emit(event); + return true; + } + return false; +} + +export async function emitProjectTrustEvent( + extensionsResult: LoadExtensionsResult, + event: ProjectTrustEvent, + ctx: ProjectTrustContext, +): Promise<{ result?: ProjectTrustEventResult; errors: ExtensionError[] }> { + const errors: ExtensionError[] = []; + for (const ext of extensionsResult.extensions) { + // A single extension may register multiple handlers for the same event. + // The first project_trust handler that returns yes/no wins; undecided falls through. + const handlers = ext.handlers.get("project_trust"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const handlerResult = (await handler(event, ctx)) as ProjectTrustEventResult; + if (handlerResult.trusted === "undecided") { + continue; + } + return { result: handlerResult, errors }; + } catch (error) { + errors.push({ + extensionPath: ext.path, + event: event.type, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } + } + } + return { errors }; +} + +const noOpUIContext: ExtensionUIContext = { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + onTerminalInput: () => () => {}, + setStatus: () => {}, + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setWidget: () => {}, + setFooter: () => {}, + setHeader: () => {}, + setTitle: () => {}, + custom: async () => undefined as never, + pasteToEditor: () => {}, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + addAutocompleteProvider: () => {}, + setEditorComponent: () => {}, + getEditorComponent: () => undefined, + get theme() { + return theme; + }, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: (_theme: string | Theme) => ({ success: false, error: "UI not available" }), + getToolsExpanded: () => false, + setToolsExpanded: () => {}, +}; + +export class ExtensionRunner { + private extensions: Extension[]; + private runtime: ExtensionRuntime; + private uiContext: ExtensionUIContext; + private mode: ExtensionMode = "print"; + private cwd: string; + private sessionManager: SessionManager; + private modelRegistry: ModelRegistry; + private errorListeners: Set = new Set(); + private getModel: () => Model | undefined = () => undefined; + private isIdleFn: () => boolean = () => true; + private isProjectTrustedFn: () => boolean = () => true; + private getSignalFn: () => AbortSignal | undefined = () => undefined; + private waitForIdleFn: () => Promise = async () => {}; + private abortFn: () => void = () => {}; + private hasPendingMessagesFn: () => boolean = () => false; + private getContextUsageFn: () => ContextUsage | undefined = () => undefined; + private compactFn: (options?: CompactOptions) => void = () => {}; + private getSystemPromptFn: () => string = () => ""; + private getSystemPromptOptionsFn: () => BuildSystemPromptOptions = () => ({ cwd: this.cwd }); + private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); + private forkHandler: ForkHandler = async () => ({ cancelled: false }); + private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); + private switchSessionHandler: SwitchSessionHandler = async () => ({ cancelled: false }); + private reloadHandler: ReloadHandler = async () => {}; + private shutdownHandler: ShutdownHandler = () => {}; + private shortcutDiagnostics: ResourceDiagnostic[] = []; + private commandDiagnostics: ResourceDiagnostic[] = []; + private staleMessage: string | undefined; + + constructor( + extensions: Extension[], + runtime: ExtensionRuntime, + cwd: string, + sessionManager: SessionManager, + modelRegistry: ModelRegistry, + ) { + this.extensions = extensions; + this.runtime = runtime; + this.uiContext = noOpUIContext; + this.cwd = cwd; + this.sessionManager = sessionManager; + this.modelRegistry = modelRegistry; + } + + bindCore( + actions: ExtensionActions, + contextActions: ExtensionContextActions, + providerActions?: { + registerProvider?: (name: string, config: ProviderConfig) => void; + unregisterProvider?: (name: string) => void; + }, + ): void { + // Copy actions into the shared runtime (all extension APIs reference this) + this.runtime.sendMessage = actions.sendMessage; + this.runtime.sendUserMessage = actions.sendUserMessage; + this.runtime.appendEntry = actions.appendEntry; + this.runtime.setSessionName = actions.setSessionName; + this.runtime.getSessionName = actions.getSessionName; + this.runtime.setLabel = actions.setLabel; + this.runtime.getActiveTools = actions.getActiveTools; + this.runtime.getAllTools = actions.getAllTools; + this.runtime.setActiveTools = actions.setActiveTools; + this.runtime.refreshTools = actions.refreshTools; + this.runtime.getCommands = actions.getCommands; + this.runtime.setModel = actions.setModel; + this.runtime.getThinkingLevel = actions.getThinkingLevel; + this.runtime.setThinkingLevel = actions.setThinkingLevel; + + // Context actions (required) + this.getModel = contextActions.getModel; + this.isIdleFn = contextActions.isIdle; + this.isProjectTrustedFn = contextActions.isProjectTrusted; + this.getSignalFn = contextActions.getSignal; + this.abortFn = contextActions.abort; + this.hasPendingMessagesFn = contextActions.hasPendingMessages; + this.shutdownHandler = contextActions.shutdown; + this.getContextUsageFn = contextActions.getContextUsage; + this.compactFn = contextActions.compact; + this.getSystemPromptFn = contextActions.getSystemPrompt; + this.getSystemPromptOptionsFn = contextActions.getSystemPromptOptions ?? (() => ({ cwd: this.cwd })); + + // Flush provider registrations queued during extension loading + for (const { name, config, extensionPath } of this.runtime.pendingProviderRegistrations) { + try { + if (providerActions?.registerProvider) { + providerActions.registerProvider(name, config); + } else { + this.modelRegistry.registerProvider(name, config); + } + } catch (err) { + this.emitError({ + extensionPath, + event: "register_provider", + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + } + } + this.runtime.pendingProviderRegistrations = []; + + // From this point on, provider registration/unregistration takes effect immediately + // without requiring a /reload. + this.runtime.registerProvider = (name, config) => { + if (providerActions?.registerProvider) { + providerActions.registerProvider(name, config); + return; + } + this.modelRegistry.registerProvider(name, config); + }; + this.runtime.unregisterProvider = (name) => { + if (providerActions?.unregisterProvider) { + providerActions.unregisterProvider(name); + return; + } + this.modelRegistry.unregisterProvider(name); + }; + } + + bindCommandContext(actions?: ExtensionCommandContextActions): void { + if (actions) { + this.waitForIdleFn = actions.waitForIdle; + this.newSessionHandler = actions.newSession; + this.forkHandler = actions.fork; + this.navigateTreeHandler = actions.navigateTree; + this.switchSessionHandler = actions.switchSession; + this.reloadHandler = actions.reload; + return; + } + + this.waitForIdleFn = async () => {}; + this.newSessionHandler = async () => ({ cancelled: false }); + this.forkHandler = async () => ({ cancelled: false }); + this.navigateTreeHandler = async () => ({ cancelled: false }); + this.switchSessionHandler = async () => ({ cancelled: false }); + this.reloadHandler = async () => {}; + } + + setUIContext(uiContext?: ExtensionUIContext, mode: ExtensionMode = "print"): void { + this.uiContext = uiContext ?? noOpUIContext; + this.mode = mode; + } + + getUIContext(): ExtensionUIContext { + return this.uiContext; + } + + hasUI(): boolean { + return this.uiContext !== noOpUIContext; + } + + getExtensionPaths(): string[] { + return this.extensions.map((e) => e.path); + } + + /** Get all registered tools from all extensions (first registration per name wins). */ + getAllRegisteredTools(): RegisteredTool[] { + const toolsByName = new Map(); + for (const ext of this.extensions) { + for (const tool of ext.tools.values()) { + if (!toolsByName.has(tool.definition.name)) { + toolsByName.set(tool.definition.name, tool); + } + } + } + return Array.from(toolsByName.values()); + } + + /** Get a tool definition by name. Returns undefined if not found. */ + getToolDefinition(toolName: string): RegisteredTool["definition"] | undefined { + for (const ext of this.extensions) { + const tool = ext.tools.get(toolName); + if (tool) { + return tool.definition; + } + } + return undefined; + } + + getFlags(): Map { + const allFlags = new Map(); + for (const ext of this.extensions) { + for (const [name, flag] of ext.flags) { + if (!allFlags.has(name)) { + allFlags.set(name, flag); + } + } + } + return allFlags; + } + + setFlagValue(name: string, value: boolean | string): void { + this.runtime.flagValues.set(name, value); + } + + getFlagValues(): Map { + return new Map(this.runtime.flagValues); + } + + getShortcuts(resolvedKeybindings: KeybindingsConfig): Map { + this.shortcutDiagnostics = []; + const builtinKeybindings = buildBuiltinKeybindings(resolvedKeybindings); + const extensionShortcuts = new Map(); + + const addDiagnostic = (message: string, extensionPath: string) => { + this.shortcutDiagnostics.push({ type: "warning", message, path: extensionPath }); + if (!this.hasUI()) { + console.warn(message); + } + }; + + for (const ext of this.extensions) { + for (const [key, shortcut] of ext.shortcuts) { + const normalizedKey = key.toLowerCase() as KeyId; + + const builtInKeybinding = builtinKeybindings[normalizedKey]; + if (builtInKeybinding?.restrictOverride === true) { + addDiagnostic( + `Extension shortcut '${key}' from ${shortcut.extensionPath} conflicts with built-in shortcut. Skipping.`, + shortcut.extensionPath, + ); + continue; + } + + if (builtInKeybinding?.restrictOverride === false) { + addDiagnostic( + `Extension shortcut conflict: '${key}' is built-in shortcut for ${builtInKeybinding.keybinding} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, + shortcut.extensionPath, + ); + } + + const existingExtensionShortcut = extensionShortcuts.get(normalizedKey); + if (existingExtensionShortcut) { + addDiagnostic( + `Extension shortcut conflict: '${key}' registered by both ${existingExtensionShortcut.extensionPath} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, + shortcut.extensionPath, + ); + } + extensionShortcuts.set(normalizedKey, shortcut); + } + } + return extensionShortcuts; + } + + getShortcutDiagnostics(): ResourceDiagnostic[] { + return this.shortcutDiagnostics; + } + + invalidate( + message = "This extension ctx is stale after session replacement or reload. Do not use a captured pi or command ctx after ctx.newSession(), ctx.fork(), ctx.switchSession(), or ctx.reload(). For newSession, fork, and switchSession, move post-replacement work into withSession and use the ctx passed to withSession. For reload, do not use the old ctx after await ctx.reload().", + ): void { + if (!this.staleMessage) { + this.staleMessage = message; + this.runtime.invalidate(message); + } + } + + private assertActive(): void { + if (this.staleMessage) { + throw new Error(this.staleMessage); + } + } + + onError(listener: ExtensionErrorListener): () => void { + this.errorListeners.add(listener); + return () => this.errorListeners.delete(listener); + } + + emitError(error: ExtensionError): void { + for (const listener of this.errorListeners) { + listener(error); + } + } + + hasHandlers(eventType: string): boolean { + for (const ext of this.extensions) { + const handlers = ext.handlers.get(eventType); + if (handlers && handlers.length > 0) { + return true; + } + } + return false; + } + + getMessageRenderer(customType: string): MessageRenderer | undefined { + for (const ext of this.extensions) { + const renderer = ext.messageRenderers.get(customType); + if (renderer) { + return renderer; + } + } + return undefined; + } + + private resolveRegisteredCommands(): ResolvedCommand[] { + const commands: RegisteredCommand[] = []; + const counts = new Map(); + + for (const ext of this.extensions) { + for (const command of ext.commands.values()) { + commands.push(command); + counts.set(command.name, (counts.get(command.name) ?? 0) + 1); + } + } + + const seen = new Map(); + const takenInvocationNames = new Set(); + + return commands.map((command) => { + const occurrence = (seen.get(command.name) ?? 0) + 1; + seen.set(command.name, occurrence); + + let invocationName = (counts.get(command.name) ?? 0) > 1 ? `${command.name}:${occurrence}` : command.name; + + if (takenInvocationNames.has(invocationName)) { + let suffix = occurrence; + do { + suffix++; + invocationName = `${command.name}:${suffix}`; + } while (takenInvocationNames.has(invocationName)); + } + + takenInvocationNames.add(invocationName); + return { + ...command, + invocationName, + }; + }); + } + + getRegisteredCommands(): ResolvedCommand[] { + this.commandDiagnostics = []; + return this.resolveRegisteredCommands(); + } + + getCommandDiagnostics(): ResourceDiagnostic[] { + return this.commandDiagnostics; + } + + getCommand(name: string): ResolvedCommand | undefined { + return this.resolveRegisteredCommands().find((command) => command.invocationName === name); + } + + /** + * Request a graceful shutdown. Called by extension tools and event handlers. + * The actual shutdown behavior is provided by the mode via bindExtensions(). + */ + shutdown(): void { + this.shutdownHandler(); + } + + /** + * Create an ExtensionContext for use in event handlers and tool execution. + * Context values are resolved at call time, so changes via bindCore/bindUI are reflected. + */ + createContext(): ExtensionContext { + const runner = this; + const getModel = this.getModel; + return { + get ui() { + runner.assertActive(); + return runner.uiContext; + }, + get mode() { + runner.assertActive(); + return runner.mode; + }, + get hasUI() { + runner.assertActive(); + return runner.hasUI(); + }, + get cwd() { + runner.assertActive(); + return runner.cwd; + }, + get sessionManager() { + runner.assertActive(); + return runner.sessionManager; + }, + get modelRegistry() { + runner.assertActive(); + return runner.modelRegistry; + }, + get model() { + runner.assertActive(); + return getModel(); + }, + isIdle: () => { + runner.assertActive(); + return runner.isIdleFn(); + }, + isProjectTrusted: () => { + runner.assertActive(); + return runner.isProjectTrustedFn(); + }, + get signal() { + runner.assertActive(); + return runner.getSignalFn(); + }, + abort: () => { + runner.assertActive(); + runner.abortFn(); + }, + hasPendingMessages: () => { + runner.assertActive(); + return runner.hasPendingMessagesFn(); + }, + shutdown: () => { + runner.assertActive(); + runner.shutdownHandler(); + }, + getContextUsage: () => { + runner.assertActive(); + return runner.getContextUsageFn(); + }, + compact: (options) => { + runner.assertActive(); + runner.compactFn(options); + }, + getSystemPrompt: () => { + runner.assertActive(); + return runner.getSystemPromptFn(); + }, + }; + } + + createCommandContext(): ExtensionCommandContext { + // Use property descriptors instead of object spread so the guarded getters from + // createContext() stay lazy. A spread would eagerly read them once and freeze the + // old values into the returned object, bypassing stale-instance checks. + const context = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(this.createContext()), + ) as ExtensionCommandContext; + context.getSystemPromptOptions = () => { + this.assertActive(); + return this.getSystemPromptOptionsFn(); + }; + context.waitForIdle = () => { + this.assertActive(); + return this.waitForIdleFn(); + }; + context.newSession = (options) => { + this.assertActive(); + return this.newSessionHandler(options); + }; + context.fork = (entryId, options) => { + this.assertActive(); + return this.forkHandler(entryId, options); + }; + context.navigateTree = (targetId, options) => { + this.assertActive(); + return this.navigateTreeHandler(targetId, options); + }; + context.switchSession = (sessionPath, options) => { + this.assertActive(); + return this.switchSessionHandler(sessionPath, options); + }; + context.reload = () => { + this.assertActive(); + return this.reloadHandler(); + }; + return context; + } + + private isSessionBeforeEvent(event: RunnerEmitEvent): event is SessionBeforeEvent { + return ( + event.type === "session_before_switch" || + event.type === "session_before_fork" || + event.type === "session_before_compact" || + event.type === "session_before_tree" + ); + } + + async emit(event: TEvent): Promise> { + const ctx = this.createContext(); + let result: SessionBeforeEventResult | undefined; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get(event.type); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const handlerResult = await handler(event, ctx); + + if (this.isSessionBeforeEvent(event) && handlerResult) { + result = handlerResult as SessionBeforeEventResult; + if (result.cancel) { + return result as RunnerEmitResult; + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: event.type, + error: message, + stack, + }); + } + } + } + + return result as RunnerEmitResult; + } + + async emitMessageEnd(event: MessageEndEvent): Promise { + const ctx = this.createContext(); + let currentMessage = event.message; + let modified = false; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("message_end"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const currentEvent: MessageEndEvent = { ...event, message: currentMessage }; + const handlerResult = (await handler(currentEvent, ctx)) as MessageEndEventResult | undefined; + if (!handlerResult?.message) continue; + + if (handlerResult.message.role !== currentMessage.role) { + this.emitError({ + extensionPath: ext.path, + event: "message_end", + error: "message_end handlers must return a message with the same role", + }); + continue; + } + + currentMessage = handlerResult.message; + modified = true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "message_end", + error: message, + stack, + }); + } + } + } + + return modified ? currentMessage : undefined; + } + + async emitToolResult(event: ToolResultEvent): Promise { + const ctx = this.createContext(); + const currentEvent: ToolResultEvent = { ...event }; + let modified = false; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("tool_result"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const handlerResult = (await handler(currentEvent, ctx)) as ToolResultEventResult | undefined; + if (!handlerResult) continue; + + if (handlerResult.content !== undefined) { + currentEvent.content = handlerResult.content; + modified = true; + } + if (handlerResult.details !== undefined) { + currentEvent.details = handlerResult.details; + modified = true; + } + if (handlerResult.isError !== undefined) { + currentEvent.isError = handlerResult.isError; + modified = true; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "tool_result", + error: message, + stack, + }); + } + } + } + + if (!modified) { + return undefined; + } + + return { + content: currentEvent.content, + details: currentEvent.details, + isError: currentEvent.isError, + }; + } + + async emitToolCall(event: ToolCallEvent): Promise { + const ctx = this.createContext(); + let result: ToolCallEventResult | undefined; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("tool_call"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + const handlerResult = await handler(event, ctx); + + if (handlerResult) { + result = handlerResult as ToolCallEventResult; + if (result.block) { + return result; + } + } + } + } + + return result; + } + + async emitUserBash(event: UserBashEvent): Promise { + const ctx = this.createContext(); + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("user_bash"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const handlerResult = await handler(event, ctx); + if (handlerResult) { + return handlerResult as UserBashEventResult; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "user_bash", + error: message, + stack, + }); + } + } + } + + return undefined; + } + + async emitContext(messages: AgentMessage[]): Promise { + const ctx = this.createContext(); + let currentMessages = structuredClone(messages); + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("context"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const event: ContextEvent = { type: "context", messages: currentMessages }; + const handlerResult = await handler(event, ctx); + + if (handlerResult && (handlerResult as ContextEventResult).messages) { + currentMessages = (handlerResult as ContextEventResult).messages!; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "context", + error: message, + stack, + }); + } + } + } + + return currentMessages; + } + + async emitBeforeProviderRequest(payload: unknown): Promise { + const ctx = this.createContext(); + let currentPayload = payload; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("before_provider_request"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const event: BeforeProviderRequestEvent = { + type: "before_provider_request", + payload: currentPayload, + }; + const handlerResult = await handler(event, ctx); + if (handlerResult !== undefined) { + currentPayload = handlerResult; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "before_provider_request", + error: message, + stack, + }); + } + } + } + + return currentPayload; + } + + async emitBeforeAgentStart( + prompt: string, + images: ImageContent[] | undefined, + systemPrompt: string, + systemPromptOptions: BuildSystemPromptOptions, + ): Promise { + let currentSystemPrompt = systemPrompt; + const ctx = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(this.createContext()), + ) as ExtensionContext; + ctx.getSystemPrompt = () => { + this.assertActive(); + return currentSystemPrompt; + }; + const messages: NonNullable[] = []; + let systemPromptModified = false; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("before_agent_start"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const event: BeforeAgentStartEvent = { + type: "before_agent_start", + prompt, + images, + systemPrompt: currentSystemPrompt, + systemPromptOptions, + }; + const handlerResult = await handler(event, ctx); + + if (handlerResult) { + const result = handlerResult as BeforeAgentStartEventResult; + if (result.message) { + messages.push(result.message); + } + if (result.systemPrompt !== undefined) { + currentSystemPrompt = result.systemPrompt; + systemPromptModified = true; + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "before_agent_start", + error: message, + stack, + }); + } + } + } + + if (messages.length > 0 || systemPromptModified) { + return { + messages: messages.length > 0 ? messages : undefined, + systemPrompt: systemPromptModified ? currentSystemPrompt : undefined, + }; + } + + return undefined; + } + + async emitResourcesDiscover( + cwd: string, + reason: ResourcesDiscoverEvent["reason"], + ): Promise<{ + skillPaths: Array<{ path: string; extensionPath: string }>; + promptPaths: Array<{ path: string; extensionPath: string }>; + themePaths: Array<{ path: string; extensionPath: string }>; + }> { + const ctx = this.createContext(); + const skillPaths: Array<{ path: string; extensionPath: string }> = []; + const promptPaths: Array<{ path: string; extensionPath: string }> = []; + const themePaths: Array<{ path: string; extensionPath: string }> = []; + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("resources_discover"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const event: ResourcesDiscoverEvent = { type: "resources_discover", cwd, reason }; + const handlerResult = await handler(event, ctx); + const result = handlerResult as ResourcesDiscoverResult | undefined; + + if (result?.skillPaths?.length) { + skillPaths.push(...result.skillPaths.map((path) => ({ path, extensionPath: ext.path }))); + } + if (result?.promptPaths?.length) { + promptPaths.push(...result.promptPaths.map((path) => ({ path, extensionPath: ext.path }))); + } + if (result?.themePaths?.length) { + themePaths.push(...result.themePaths.map((path) => ({ path, extensionPath: ext.path }))); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + this.emitError({ + extensionPath: ext.path, + event: "resources_discover", + error: message, + stack, + }); + } + } + } + + return { skillPaths, promptPaths, themePaths }; + } + + /** Emit input event. Transforms chain, "handled" short-circuits. */ + async emitInput( + text: string, + images: ImageContent[] | undefined, + source: InputSource, + streamingBehavior?: "steer" | "followUp", + ): Promise { + const ctx = this.createContext(); + let currentText = text; + let currentImages = images; + + for (const ext of this.extensions) { + for (const handler of ext.handlers.get("input") ?? []) { + try { + const event: InputEvent = { + type: "input", + text: currentText, + images: currentImages, + source, + streamingBehavior, + }; + const result = (await handler(event, ctx)) as InputEventResult | undefined; + if (result?.action === "handled") return result; + if (result?.action === "transform") { + currentText = result.text; + currentImages = result.images ?? currentImages; + } + } catch (err) { + this.emitError({ + extensionPath: ext.path, + event: "input", + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + } + } + } + return currentText !== text || currentImages !== images + ? { action: "transform", text: currentText, images: currentImages } + : { action: "continue" }; + } +} diff --git a/cactus-code/packages/coding-agent/src/core/extensions/types.ts b/cactus-code/packages/coding-agent/src/core/extensions/types.ts new file mode 100644 index 000000000..7234d4e4f --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/extensions/types.ts @@ -0,0 +1,1614 @@ +/** + * Extension system types. + * + * Extensions are TypeScript modules that can: + * - Subscribe to agent lifecycle events + * - Register LLM-callable tools + * - Register commands, keyboard shortcuts, and CLI flags + * - Interact with the user via UI primitives + */ + +import type { + AgentMessage, + AgentToolResult, + AgentToolUpdateCallback, + ThinkingLevel, + ToolExecutionMode, +} from "@earendil-works/pi-agent-core"; +import type { + Api, + AssistantMessageEvent, + AssistantMessageEventStream, + Context, + ImageContent, + Model, + OAuthCredentials, + OAuthLoginCallbacks, + SimpleStreamOptions, + TextContent, + ToolResultMessage, +} from "@earendil-works/pi-ai"; +import type { + AutocompleteItem, + AutocompleteProvider, + Component, + EditorComponent, + EditorTheme, + KeyId, + OverlayHandle, + OverlayOptions, + TUI, +} from "@earendil-works/pi-tui"; +import type { Static, TSchema } from "typebox"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import type { BashResult } from "../bash-executor.ts"; +import type { CompactionPreparation, CompactionResult } from "../compaction/index.ts"; +import type { EventBus } from "../event-bus.ts"; +import type { ExecOptions, ExecResult } from "../exec.ts"; +import type { ReadonlyFooterDataProvider } from "../footer-data-provider.ts"; +import type { KeybindingsManager } from "../keybindings.ts"; +import type { CustomMessage } from "../messages.ts"; +import type { ModelRegistry } from "../model-registry.ts"; +import type { + BranchSummaryEntry, + CompactionEntry, + ReadonlySessionManager, + SessionEntry, + SessionManager, +} from "../session-manager.ts"; +import type { SlashCommandInfo } from "../slash-commands.ts"; +import type { SourceInfo } from "../source-info.ts"; +import type { BuildSystemPromptOptions } from "../system-prompt.ts"; +import type { BashOperations } from "../tools/bash.ts"; +import type { EditToolDetails } from "../tools/edit.ts"; +import type { + BashToolDetails, + BashToolInput, + EditToolInput, + FindToolDetails, + FindToolInput, + GrepToolDetails, + GrepToolInput, + LsToolDetails, + LsToolInput, + ReadToolDetails, + ReadToolInput, + WriteToolInput, +} from "../tools/index.ts"; + +export type { ExecOptions, ExecResult } from "../exec.ts"; +export type { BuildSystemPromptOptions } from "../system-prompt.ts"; +export type { AgentToolResult, AgentToolUpdateCallback, ToolExecutionMode }; +export type { AppKeybinding, KeybindingsManager } from "../keybindings.ts"; + +// ============================================================================ +// UI Context +// ============================================================================ + +/** Options for extension UI dialogs. */ +export interface ExtensionUIDialogOptions { + /** AbortSignal to programmatically dismiss the dialog. */ + signal?: AbortSignal; + /** Timeout in milliseconds. Dialog auto-dismisses with live countdown display. */ + timeout?: number; +} + +/** Placement for extension widgets. */ +export type WidgetPlacement = "aboveEditor" | "belowEditor"; + +/** Options for extension widgets. */ +export interface ExtensionWidgetOptions { + /** Where the widget is rendered. Defaults to "aboveEditor". */ + placement?: WidgetPlacement; +} + +/** Raw terminal input listener for extensions. */ +export type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined; + +/** Working indicator configuration for the interactive streaming loader. */ +export interface WorkingIndicatorOptions { + /** Animation frames. Use an empty array to hide the indicator entirely. Custom frames are rendered verbatim. */ + frames?: string[]; + /** Frame interval in milliseconds for animated indicators. */ + intervalMs?: number; +} + +/** Wrap the current autocomplete provider with additional behavior. */ +export type AutocompleteProviderFactory = (current: AutocompleteProvider) => AutocompleteProvider; +export type EditorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => EditorComponent; + +/** + * UI context for extensions to request interactive UI. + * Each mode (interactive, RPC, print) provides its own implementation. + */ +export interface ExtensionUIContext { + /** Show a selector and return the user's choice. */ + select(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise; + + /** Show a confirmation dialog. */ + confirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise; + + /** Show a text input dialog. */ + input(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise; + + /** Show a notification to the user. */ + notify(message: string, type?: "info" | "warning" | "error"): void; + + /** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */ + onTerminalInput(handler: TerminalInputHandler): () => void; + + /** Set status text in the footer/status bar. Pass undefined to clear. */ + setStatus(key: string, text: string | undefined): void; + + /** Set the working/loading message shown during streaming. Call with no argument to restore default. */ + setWorkingMessage(message?: string): void; + + /** Show or hide the built-in interactive working loader row during streaming. */ + setWorkingVisible(visible: boolean): void; + + /** + * Configure the interactive working indicator shown during streaming. + * + * - Omit the argument to restore the default animated spinner. + * - Use `frames: ["●"]` for a static indicator. + * - Use `frames: []` to hide the indicator entirely. + * - Custom frames are rendered as provided, so extensions must add their own colors. + */ + setWorkingIndicator(options?: WorkingIndicatorOptions): void; + + /** Set the label shown for hidden thinking blocks. Call with no argument to restore default. */ + setHiddenThinkingLabel(label?: string): void; + + /** Set a widget to display above or below the editor. Accepts string array or component factory. */ + setWidget(key: string, content: string[] | undefined, options?: ExtensionWidgetOptions): void; + setWidget( + key: string, + content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined, + options?: ExtensionWidgetOptions, + ): void; + + /** Set a custom footer component, or undefined to restore the built-in footer. + * + * The factory receives a FooterDataProvider for data not otherwise accessible: + * git branch and extension statuses from setStatus(). Token stats, model info, + * etc. are available via ctx.sessionManager and ctx.model. + */ + setFooter( + factory: + | ((tui: TUI, theme: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void }) + | undefined, + ): void; + + /** Set a custom header component (shown at startup, above chat), or undefined to restore the built-in header. */ + setHeader(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void; + + /** Set the terminal window/tab title. */ + setTitle(title: string): void; + + /** Show a custom component with keyboard focus. */ + custom( + factory: ( + tui: TUI, + theme: Theme, + keybindings: KeybindingsManager, + done: (result: T) => void, + ) => (Component & { dispose?(): void }) | Promise, + options?: { + overlay?: boolean; + /** Overlay positioning/sizing options. Can be static or a function for dynamic updates. */ + overlayOptions?: OverlayOptions | (() => OverlayOptions); + /** Called with the overlay handle after the overlay is shown. Use to control visibility. */ + onHandle?: (handle: OverlayHandle) => void; + }, + ): Promise; + + /** Paste text into the editor, triggering paste handling (collapse for large content). */ + pasteToEditor(text: string): void; + + /** Set the text in the core input editor. */ + setEditorText(text: string): void; + + /** Get the current text from the core input editor. */ + getEditorText(): string; + + /** Show a multi-line editor for text editing. */ + editor(title: string, prefill?: string): Promise; + + /** Stack additional autocomplete behavior on top of the built-in provider. */ + addAutocompleteProvider(factory: AutocompleteProviderFactory): void; + + /** + * Set a custom editor component via factory function. + * Pass undefined to restore the default editor. + * + * The factory receives: + * - `theme`: EditorTheme for styling borders and autocomplete + * - `keybindings`: KeybindingsManager for app-level keybindings + * + * For full app keybinding support (escape, ctrl+d, model switching, etc.), + * extend `CustomEditor` from `@earendil-works/pi-coding-agent` and call + * `super.handleInput(data)` for keys you don't handle. + * + * @example + * ```ts + * import { CustomEditor } from "@earendil-works/pi-coding-agent"; + * + * class VimEditor extends CustomEditor { + * private mode: "normal" | "insert" = "insert"; + * + * handleInput(data: string): void { + * if (this.mode === "normal") { + * // Handle vim normal mode keys... + * if (data === "i") { this.mode = "insert"; return; } + * } + * super.handleInput(data); // App keybindings + text editing + * } + * } + * + * ctx.ui.setEditorComponent((tui, theme, keybindings) => + * new VimEditor(tui, theme, keybindings) + * ); + * ``` + */ + setEditorComponent(factory: EditorFactory | undefined): void; + + /** Get the currently configured custom editor factory, or undefined when using the default editor. */ + getEditorComponent(): EditorFactory | undefined; + + /** Get the current theme for styling. */ + readonly theme: Theme; + + /** Get all available themes with their names and file paths. */ + getAllThemes(): { name: string; path: string | undefined }[]; + + /** Load a theme by name without switching to it. Returns undefined if not found. */ + getTheme(name: string): Theme | undefined; + + /** Set the current theme by name or Theme object. */ + setTheme(theme: string | Theme): { success: boolean; error?: string }; + + /** Get current tool output expansion state. */ + getToolsExpanded(): boolean; + + /** Set tool output expansion state. */ + setToolsExpanded(expanded: boolean): void; +} + +// ============================================================================ +// Extension Context +// ============================================================================ + +export interface ContextUsage { + /** Estimated context tokens, or null if unknown (e.g. right after compaction, before next LLM response). */ + tokens: number | null; + contextWindow: number; + /** Context usage as percentage of context window, or null if tokens is unknown. */ + percent: number | null; +} + +export interface CompactOptions { + customInstructions?: string; + onComplete?: (result: CompactionResult) => void; + onError?: (error: Error) => void; +} + +/** + * Context passed to extension event handlers. + */ +export type ExtensionMode = "tui" | "rpc" | "json" | "print"; + +export interface ExtensionContext { + /** UI methods for user interaction */ + ui: ExtensionUIContext; + /** Current run mode. Use "tui" to guard terminal-only UI such as custom components. */ + mode: ExtensionMode; + /** Whether dialog-capable UI is available (true in TUI and RPC modes) */ + hasUI: boolean; + /** Current working directory */ + cwd: string; + /** Session manager (read-only) */ + sessionManager: ReadonlySessionManager; + /** Model registry for API key resolution */ + modelRegistry: ModelRegistry; + /** Current model (may be undefined) */ + model: Model | undefined; + /** Whether the agent is idle (not streaming) */ + isIdle(): boolean; + /** Whether project-local trust is active for this context. */ + isProjectTrusted(): boolean; + /** The current abort signal, or undefined when the agent is not streaming. */ + signal: AbortSignal | undefined; + /** Abort the current agent operation */ + abort(): void; + /** Whether there are queued messages waiting */ + hasPendingMessages(): boolean; + /** Gracefully shutdown pi and exit. Available in all contexts. */ + shutdown(): void; + /** Get current context usage for the active model. */ + getContextUsage(): ContextUsage | undefined; + /** Trigger compaction without awaiting completion. */ + compact(options?: CompactOptions): void; + /** Get the current effective system prompt. */ + getSystemPrompt(): string; +} + +/** + * Extended context for command handlers. + * Includes session control methods only safe in user-initiated commands. + */ +export interface ExtensionCommandContext extends ExtensionContext { + /** Get the current base system-prompt construction options. */ + getSystemPromptOptions(): BuildSystemPromptOptions; + + /** Wait for the agent to finish streaming */ + waitForIdle(): Promise; + + /** Start a new session, optionally with initialization. */ + newSession(options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }): Promise<{ cancelled: boolean }>; + + /** Fork from a specific entry, creating a new session file. */ + fork( + entryId: string, + options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise }, + ): Promise<{ cancelled: boolean }>; + + /** Navigate to a different point in the session tree. */ + navigateTree( + targetId: string, + options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, + ): Promise<{ cancelled: boolean }>; + + /** Switch to a different session file. */ + switchSession( + sessionPath: string, + options?: { withSession?: (ctx: ReplacedSessionContext) => Promise }, + ): Promise<{ cancelled: boolean }>; + + /** Reload extensions, skills, prompts, and themes. */ + reload(): Promise; +} + +/** + * Fresh command-capable context bound to the replacement session after a session switch. + * + * This is passed to `withSession()` callbacks on `newSession()`, `fork()`, and `switchSession()`. + */ +export interface ReplacedSessionContext extends ExtensionCommandContext { + sendMessage( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, + ): Promise; + + sendUserMessage( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, + ): Promise; +} + +// ============================================================================ +// Tool Types +// ============================================================================ + +/** Rendering options for tool results */ +export interface ToolRenderResultOptions { + /** Whether the result view is expanded */ + expanded: boolean; + /** Whether this is a partial/streaming result */ + isPartial: boolean; +} + +/** Context passed to tool renderers. */ +export interface ToolRenderContext { + /** Current tool call arguments. Shared across call/result renders for the same tool call. */ + args: TArgs; + /** Unique id for this tool execution. Stable across call/result renders for the same tool call. */ + toolCallId: string; + /** Invalidate just this tool execution component for redraw. */ + invalidate: () => void; + /** Previously returned component for this render slot, if any. */ + lastComponent: Component | undefined; + /** Shared renderer state for this tool row. Initialized by tool-execution.ts. */ + state: TState; + /** Working directory for this tool execution. */ + cwd: string; + /** Whether the tool execution has started. */ + executionStarted: boolean; + /** Whether the tool call arguments are complete. */ + argsComplete: boolean; + /** Whether the tool result is partial/streaming. */ + isPartial: boolean; + /** Whether the result view is expanded. */ + expanded: boolean; + /** Whether inline images are currently shown in the TUI. */ + showImages: boolean; + /** Whether the current result is an error. */ + isError: boolean; +} + +/** + * Tool definition for registerTool(). + */ +export interface ToolDefinition { + /** Tool name (used in LLM tool calls) */ + name: string; + /** Human-readable label for UI */ + label: string; + /** Description for LLM */ + description: string; + /** Optional one-line snippet for the Available tools section in the default system prompt. Custom tools are omitted from that section when this is not provided. */ + promptSnippet?: string; + /** Optional guideline bullets appended to the default system prompt Guidelines section when this tool is active. */ + promptGuidelines?: string[]; + /** Parameter schema (TypeBox) */ + parameters: TParams; + /** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */ + renderShell?: "default" | "self"; + + /** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */ + prepareArguments?: (args: unknown) => Static; + + /** + * Per-tool execution mode override. + * - "sequential": this tool must execute one at a time with other tool calls. + * - "parallel": this tool can execute concurrently with other tool calls. + * + * If omitted, the default execution mode applies. + */ + executionMode?: ToolExecutionMode; + + /** Execute the tool. */ + execute( + toolCallId: string, + params: Static, + signal: AbortSignal | undefined, + onUpdate: AgentToolUpdateCallback | undefined, + ctx: ExtensionContext, + ): Promise>; + + /** Custom rendering for tool call display */ + renderCall?: (args: Static, theme: Theme, context: ToolRenderContext>) => Component; + + /** Custom rendering for tool result display */ + renderResult?: ( + result: AgentToolResult, + options: ToolRenderResultOptions, + theme: Theme, + context: ToolRenderContext>, + ) => Component; +} + +type AnyToolDefinition = ToolDefinition; + +/** + * Preserve parameter inference for standalone tool definitions. + * + * Use this when assigning a tool to a variable or passing it through arrays such + * as `customTools`, where contextual typing would otherwise widen params to + * `unknown`. + */ +export function defineTool( + tool: ToolDefinition, +): ToolDefinition & AnyToolDefinition { + return tool as ToolDefinition & AnyToolDefinition; +} + +// ============================================================================ +// Startup/Resource Events +// ============================================================================ + +export interface ProjectTrustEvent { + type: "project_trust"; + cwd: string; +} + +export type ProjectTrustEventDecision = "yes" | "no" | "undecided"; + +export interface ProjectTrustEventResult { + trusted: ProjectTrustEventDecision; + remember?: boolean; +} + +export interface ProjectTrustContext { + cwd: string; + mode: ExtensionMode; + hasUI: boolean; + ui: Pick; +} + +export type ProjectTrustHandler = ( + event: ProjectTrustEvent, + ctx: ProjectTrustContext, +) => Promise | ProjectTrustEventResult; + +/** Fired after session_start to allow extensions to provide additional resource paths. */ +export interface ResourcesDiscoverEvent { + type: "resources_discover"; + cwd: string; + reason: "startup" | "reload"; +} + +/** Result from resources_discover event handler */ +export interface ResourcesDiscoverResult { + skillPaths?: string[]; + promptPaths?: string[]; + themePaths?: string[]; +} + +// ============================================================================ +// Session Events +// ============================================================================ + +/** Fired when a session is started, loaded, or reloaded */ +export interface SessionStartEvent { + type: "session_start"; + /** Why this session start happened. */ + reason: "startup" | "reload" | "new" | "resume" | "fork"; + /** Previously active session file. Present for "new", "resume", and "fork". */ + previousSessionFile?: string; +} + +/** Fired before switching to another session (can be cancelled) */ +export interface SessionBeforeSwitchEvent { + type: "session_before_switch"; + reason: "new" | "resume"; + targetSessionFile?: string; +} + +/** Fired before forking a session (can be cancelled) */ +export interface SessionBeforeForkEvent { + type: "session_before_fork"; + entryId: string; + position: "before" | "at"; +} + +/** Fired before context compaction (can be cancelled or customized) */ +export interface SessionBeforeCompactEvent { + type: "session_before_compact"; + preparation: CompactionPreparation; + branchEntries: SessionEntry[]; + customInstructions?: string; + /** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */ + reason: "manual" | "threshold" | "overflow"; + /** True when the aborted turn is retried after this compaction (overflow recovery) */ + willRetry: boolean; + signal: AbortSignal; +} + +/** Fired after context compaction */ +export interface SessionCompactEvent { + type: "session_compact"; + compactionEntry: CompactionEntry; + fromExtension: boolean; + /** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */ + reason: "manual" | "threshold" | "overflow"; + /** True when the aborted turn is retried after this compaction (overflow recovery) */ + willRetry: boolean; +} + +/** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */ +export interface SessionShutdownEvent { + type: "session_shutdown"; + reason: "quit" | "reload" | "new" | "resume" | "fork"; + /** Destination session file when shutting down due to session replacement. */ + targetSessionFile?: string; +} + +/** Preparation data for tree navigation */ +export interface TreePreparation { + targetId: string; + oldLeafId: string | null; + commonAncestorId: string | null; + entriesToSummarize: SessionEntry[]; + userWantsSummary: boolean; + /** Custom instructions for summarization */ + customInstructions?: string; + /** If true, customInstructions replaces the default prompt instead of being appended */ + replaceInstructions?: boolean; + /** Label to attach to the branch summary entry */ + label?: string; +} + +/** Fired before navigating in the session tree (can be cancelled) */ +export interface SessionBeforeTreeEvent { + type: "session_before_tree"; + preparation: TreePreparation; + signal: AbortSignal; +} + +/** Fired after navigating in the session tree */ +export interface SessionTreeEvent { + type: "session_tree"; + newLeafId: string | null; + oldLeafId: string | null; + summaryEntry?: BranchSummaryEntry; + fromExtension?: boolean; +} + +export type SessionEvent = + | SessionStartEvent + | SessionBeforeSwitchEvent + | SessionBeforeForkEvent + | SessionBeforeCompactEvent + | SessionCompactEvent + | SessionShutdownEvent + | SessionBeforeTreeEvent + | SessionTreeEvent; + +// ============================================================================ +// Agent Events +// ============================================================================ + +/** Fired before each LLM call. Can modify messages. */ +export interface ContextEvent { + type: "context"; + messages: AgentMessage[]; +} + +/** Fired before a provider request is sent. Can replace the payload. */ +export interface BeforeProviderRequestEvent { + type: "before_provider_request"; + payload: unknown; +} + +/** Fired after a provider response is received and before the response stream is consumed. */ +export interface AfterProviderResponseEvent { + type: "after_provider_response"; + status: number; + headers: Record; +} + +/** Fired after user submits prompt but before agent loop. */ +export interface BeforeAgentStartEvent { + type: "before_agent_start"; + /** The raw user prompt text (after expansion). */ + prompt: string; + /** Images attached to the user prompt, if any. */ + images?: ImageContent[]; + /** The fully assembled system prompt string. */ + systemPrompt: string; + /** Structured options used to build the system prompt. Extensions can inspect this to understand what Pi loaded without re-discovering resources. */ + systemPromptOptions: BuildSystemPromptOptions; +} + +/** Fired when an agent loop starts */ +export interface AgentStartEvent { + type: "agent_start"; +} + +/** Fired when an agent loop ends */ +export interface AgentEndEvent { + type: "agent_end"; + messages: AgentMessage[]; +} + +/** Fired at the start of each turn */ +export interface TurnStartEvent { + type: "turn_start"; + turnIndex: number; + timestamp: number; +} + +/** Fired at the end of each turn */ +export interface TurnEndEvent { + type: "turn_end"; + turnIndex: number; + message: AgentMessage; + toolResults: ToolResultMessage[]; +} + +/** Fired when a message starts (user, assistant, or toolResult) */ +export interface MessageStartEvent { + type: "message_start"; + message: AgentMessage; +} + +/** Fired during assistant message streaming with token-by-token updates */ +export interface MessageUpdateEvent { + type: "message_update"; + message: AgentMessage; + assistantMessageEvent: AssistantMessageEvent; +} + +/** Fired when a message ends */ +export interface MessageEndEvent { + type: "message_end"; + message: AgentMessage; +} + +/** Fired when a tool starts executing */ +export interface ToolExecutionStartEvent { + type: "tool_execution_start"; + toolCallId: string; + toolName: string; + args: any; +} + +/** Fired during tool execution with partial/streaming output */ +export interface ToolExecutionUpdateEvent { + type: "tool_execution_update"; + toolCallId: string; + toolName: string; + args: any; + partialResult: any; +} + +/** Fired when a tool finishes executing */ +export interface ToolExecutionEndEvent { + type: "tool_execution_end"; + toolCallId: string; + toolName: string; + result: any; + isError: boolean; +} + +// ============================================================================ +// Model Events +// ============================================================================ + +export type ModelSelectSource = "set" | "cycle" | "restore"; + +/** Fired when a new model is selected */ +export interface ModelSelectEvent { + type: "model_select"; + model: Model; + previousModel: Model | undefined; + source: ModelSelectSource; +} + +/** Fired when a new thinking level is selected */ +export interface ThinkingLevelSelectEvent { + type: "thinking_level_select"; + level: ThinkingLevel; + previousLevel: ThinkingLevel; +} + +// ============================================================================ +// User Bash Events +// ============================================================================ + +/** Fired when user executes a bash command via ! or !! prefix */ +export interface UserBashEvent { + type: "user_bash"; + /** The command to execute */ + command: string; + /** True if !! prefix was used (excluded from LLM context) */ + excludeFromContext: boolean; + /** Current working directory */ + cwd: string; +} + +// ============================================================================ +// Input Events +// ============================================================================ + +/** Source of user input */ +export type InputSource = "interactive" | "rpc" | "extension"; + +/** Fired when user input is received, before agent processing */ +export interface InputEvent { + type: "input"; + /** The input text */ + text: string; + /** Attached images, if any */ + images?: ImageContent[]; + /** Where the input came from */ + source: InputSource; + /** How the input will be delivered during streaming, or undefined when idle */ + streamingBehavior?: "steer" | "followUp"; +} + +/** Result from input event handler */ +export type InputEventResult = + | { action: "continue" } + | { action: "transform"; text: string; images?: ImageContent[] } + | { action: "handled" }; + +// ============================================================================ +// Tool Events +// ============================================================================ + +interface ToolCallEventBase { + type: "tool_call"; + toolCallId: string; +} + +export interface BashToolCallEvent extends ToolCallEventBase { + toolName: "bash"; + input: BashToolInput; +} + +export interface ReadToolCallEvent extends ToolCallEventBase { + toolName: "read"; + input: ReadToolInput; +} + +export interface EditToolCallEvent extends ToolCallEventBase { + toolName: "edit"; + input: EditToolInput; +} + +export interface WriteToolCallEvent extends ToolCallEventBase { + toolName: "write"; + input: WriteToolInput; +} + +export interface GrepToolCallEvent extends ToolCallEventBase { + toolName: "grep"; + input: GrepToolInput; +} + +export interface FindToolCallEvent extends ToolCallEventBase { + toolName: "find"; + input: FindToolInput; +} + +export interface LsToolCallEvent extends ToolCallEventBase { + toolName: "ls"; + input: LsToolInput; +} + +export interface CustomToolCallEvent extends ToolCallEventBase { + toolName: string; + input: Record; +} + +/** + * Fired before a tool executes. Can block. + * + * `event.input` is mutable. Mutate it in place to patch tool arguments before execution. + * Later `tool_call` handlers see earlier mutations. No re-validation is performed after mutation. + */ +export type ToolCallEvent = + | BashToolCallEvent + | ReadToolCallEvent + | EditToolCallEvent + | WriteToolCallEvent + | GrepToolCallEvent + | FindToolCallEvent + | LsToolCallEvent + | CustomToolCallEvent; + +interface ToolResultEventBase { + type: "tool_result"; + toolCallId: string; + input: Record; + content: (TextContent | ImageContent)[]; + isError: boolean; +} + +export interface BashToolResultEvent extends ToolResultEventBase { + toolName: "bash"; + details: BashToolDetails | undefined; +} + +export interface ReadToolResultEvent extends ToolResultEventBase { + toolName: "read"; + details: ReadToolDetails | undefined; +} + +export interface EditToolResultEvent extends ToolResultEventBase { + toolName: "edit"; + details: EditToolDetails | undefined; +} + +export interface WriteToolResultEvent extends ToolResultEventBase { + toolName: "write"; + details: undefined; +} + +export interface GrepToolResultEvent extends ToolResultEventBase { + toolName: "grep"; + details: GrepToolDetails | undefined; +} + +export interface FindToolResultEvent extends ToolResultEventBase { + toolName: "find"; + details: FindToolDetails | undefined; +} + +export interface LsToolResultEvent extends ToolResultEventBase { + toolName: "ls"; + details: LsToolDetails | undefined; +} + +export interface CustomToolResultEvent extends ToolResultEventBase { + toolName: string; + details: unknown; +} + +/** Fired after a tool executes. Can modify result. */ +export type ToolResultEvent = + | BashToolResultEvent + | ReadToolResultEvent + | EditToolResultEvent + | WriteToolResultEvent + | GrepToolResultEvent + | FindToolResultEvent + | LsToolResultEvent + | CustomToolResultEvent; + +// Type guards for ToolResultEvent +export function isBashToolResult(e: ToolResultEvent): e is BashToolResultEvent { + return e.toolName === "bash"; +} +export function isReadToolResult(e: ToolResultEvent): e is ReadToolResultEvent { + return e.toolName === "read"; +} +export function isEditToolResult(e: ToolResultEvent): e is EditToolResultEvent { + return e.toolName === "edit"; +} +export function isWriteToolResult(e: ToolResultEvent): e is WriteToolResultEvent { + return e.toolName === "write"; +} +export function isGrepToolResult(e: ToolResultEvent): e is GrepToolResultEvent { + return e.toolName === "grep"; +} +export function isFindToolResult(e: ToolResultEvent): e is FindToolResultEvent { + return e.toolName === "find"; +} +export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent { + return e.toolName === "ls"; +} + +/** + * Type guard for narrowing ToolCallEvent by tool name. + * + * Built-in tools narrow automatically (no type params needed): + * ```ts + * if (isToolCallEventType("bash", event)) { + * event.input.command; // string + * } + * ``` + * + * Custom tools require explicit type parameters: + * ```ts + * if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) { + * event.input.action; // typed + * } + * ``` + * + * Note: Direct narrowing via `event.toolName === "bash"` doesn't work because + * CustomToolCallEvent.toolName is `string` which overlaps with all literals. + */ +export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent; +export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent; +export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent; +export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent; +export function isToolCallEventType(toolName: "grep", event: ToolCallEvent): event is GrepToolCallEvent; +export function isToolCallEventType(toolName: "find", event: ToolCallEvent): event is FindToolCallEvent; +export function isToolCallEventType(toolName: "ls", event: ToolCallEvent): event is LsToolCallEvent; +export function isToolCallEventType>( + toolName: TName, + event: ToolCallEvent, +): event is ToolCallEvent & { toolName: TName; input: TInput }; +export function isToolCallEventType(toolName: string, event: ToolCallEvent): boolean { + return event.toolName === toolName; +} + +/** Union of all event types */ +export type ExtensionEvent = + | ProjectTrustEvent + | ResourcesDiscoverEvent + | SessionEvent + | ContextEvent + | BeforeProviderRequestEvent + | AfterProviderResponseEvent + | BeforeAgentStartEvent + | AgentStartEvent + | AgentEndEvent + | TurnStartEvent + | TurnEndEvent + | MessageStartEvent + | MessageUpdateEvent + | MessageEndEvent + | ToolExecutionStartEvent + | ToolExecutionUpdateEvent + | ToolExecutionEndEvent + | ModelSelectEvent + | ThinkingLevelSelectEvent + | UserBashEvent + | InputEvent + | ToolCallEvent + | ToolResultEvent; + +// ============================================================================ +// Event Results +// ============================================================================ + +export interface ContextEventResult { + messages?: AgentMessage[]; +} + +export type BeforeProviderRequestEventResult = unknown; + +export interface ToolCallEventResult { + /** Block tool execution. To modify arguments, mutate `event.input` in place instead. */ + block?: boolean; + reason?: string; +} + +/** Result from user_bash event handler */ +export interface UserBashEventResult { + /** Custom operations to use for execution */ + operations?: BashOperations; + /** Full replacement: extension handled execution, use this result */ + result?: BashResult; +} + +export interface ToolResultEventResult { + content?: (TextContent | ImageContent)[]; + details?: unknown; + isError?: boolean; +} + +export interface MessageEndEventResult { + /** Replace the finalized message. The replacement must keep the original message role. */ + message?: AgentMessage; +} + +export interface BeforeAgentStartEventResult { + message?: Pick; + /** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */ + systemPrompt?: string; +} + +export interface SessionBeforeSwitchResult { + cancel?: boolean; +} + +export interface SessionBeforeForkResult { + cancel?: boolean; + skipConversationRestore?: boolean; +} + +export interface SessionBeforeCompactResult { + cancel?: boolean; + compaction?: CompactionResult; +} + +export interface SessionBeforeTreeResult { + cancel?: boolean; + summary?: { + summary: string; + details?: unknown; + }; + /** Override custom instructions for summarization */ + customInstructions?: string; + /** Override whether customInstructions replaces the default prompt */ + replaceInstructions?: boolean; + /** Override label to attach to the branch summary entry */ + label?: string; +} + +// ============================================================================ +// Message Rendering +// ============================================================================ + +export interface MessageRenderOptions { + expanded: boolean; +} + +export type MessageRenderer = ( + message: CustomMessage, + options: MessageRenderOptions, + theme: Theme, +) => Component | undefined; + +// ============================================================================ +// Command Registration +// ============================================================================ + +export interface RegisteredCommand { + name: string; + sourceInfo: SourceInfo; + description?: string; + getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null | Promise; + handler: (args: string, ctx: ExtensionCommandContext) => Promise; +} + +export interface ResolvedCommand extends RegisteredCommand { + invocationName: string; +} + +// ============================================================================ +// Extension API +// ============================================================================ + +/** Handler function type for events */ +// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements +export type ExtensionHandler = (event: E, ctx: ExtensionContext) => Promise | R | void; + +/** + * ExtensionAPI passed to extension factory functions. + */ +export interface ExtensionAPI { + // ========================================================================= + // Event Subscription + // ========================================================================= + + on(event: "project_trust", handler: ProjectTrustHandler): void; + on(event: "resources_discover", handler: ExtensionHandler): void; + on(event: "session_start", handler: ExtensionHandler): void; + on( + event: "session_before_switch", + handler: ExtensionHandler, + ): void; + on(event: "session_before_fork", handler: ExtensionHandler): void; + on( + event: "session_before_compact", + handler: ExtensionHandler, + ): void; + on(event: "session_compact", handler: ExtensionHandler): void; + on(event: "session_shutdown", handler: ExtensionHandler): void; + on(event: "session_before_tree", handler: ExtensionHandler): void; + on(event: "session_tree", handler: ExtensionHandler): void; + on(event: "context", handler: ExtensionHandler): void; + on( + event: "before_provider_request", + handler: ExtensionHandler, + ): void; + on(event: "after_provider_response", handler: ExtensionHandler): void; + on(event: "before_agent_start", handler: ExtensionHandler): void; + on(event: "agent_start", handler: ExtensionHandler): void; + on(event: "agent_end", handler: ExtensionHandler): void; + on(event: "turn_start", handler: ExtensionHandler): void; + on(event: "turn_end", handler: ExtensionHandler): void; + on(event: "message_start", handler: ExtensionHandler): void; + on(event: "message_update", handler: ExtensionHandler): void; + on(event: "message_end", handler: ExtensionHandler): void; + on(event: "tool_execution_start", handler: ExtensionHandler): void; + on(event: "tool_execution_update", handler: ExtensionHandler): void; + on(event: "tool_execution_end", handler: ExtensionHandler): void; + on(event: "model_select", handler: ExtensionHandler): void; + on(event: "thinking_level_select", handler: ExtensionHandler): void; + on(event: "tool_call", handler: ExtensionHandler): void; + on(event: "tool_result", handler: ExtensionHandler): void; + on(event: "user_bash", handler: ExtensionHandler): void; + on(event: "input", handler: ExtensionHandler): void; + + // ========================================================================= + // Tool Registration + // ========================================================================= + + /** Register a tool that the LLM can call. */ + registerTool( + tool: ToolDefinition, + ): void; + + // ========================================================================= + // Command, Shortcut, Flag Registration + // ========================================================================= + + /** Register a custom command. */ + registerCommand(name: string, options: Omit): void; + + /** Register a keyboard shortcut. */ + registerShortcut( + shortcut: KeyId, + options: { + description?: string; + handler: (ctx: ExtensionContext) => Promise | void; + }, + ): void; + + /** Register a CLI flag. */ + registerFlag( + name: string, + options: { + description?: string; + type: "boolean" | "string"; + default?: boolean | string; + }, + ): void; + + /** Get the value of a registered CLI flag. */ + getFlag(name: string): boolean | string | undefined; + + // ========================================================================= + // Message Rendering + // ========================================================================= + + /** Register a custom renderer for CustomMessageEntry. */ + registerMessageRenderer(customType: string, renderer: MessageRenderer): void; + + // ========================================================================= + // Actions + // ========================================================================= + + /** Send a custom message to the session. */ + sendMessage( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, + ): void; + + /** + * Send a user message to the agent. Always triggers a turn. + * When the agent is streaming, use deliverAs to specify how to queue the message. + */ + sendUserMessage( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, + ): void; + + /** Append a custom entry to the session for state persistence (not sent to LLM). */ + appendEntry(customType: string, data?: T): void; + + // ========================================================================= + // Session Metadata + // ========================================================================= + + /** Set the session display name (shown in session selector). */ + setSessionName(name: string): void; + + /** Get the current session name, if set. */ + getSessionName(): string | undefined; + + /** Set or clear a label on an entry. Labels are user-defined markers for bookmarking/navigation. */ + setLabel(entryId: string, label: string | undefined): void; + + /** Execute a shell command. */ + exec(command: string, args: string[], options?: ExecOptions): Promise; + + /** Get the list of currently active tool names. */ + getActiveTools(): string[]; + + /** Get all configured tools with parameter schema, prompt guidelines, and source metadata. */ + getAllTools(): ToolInfo[]; + + /** Set the active tools by name. */ + setActiveTools(toolNames: string[]): void; + + /** Get available slash commands in the current session. */ + getCommands(): SlashCommandInfo[]; + + // ========================================================================= + // Model and Thinking Level + // ========================================================================= + + /** Set the current model. Returns false if no API key available. */ + setModel(model: Model): Promise; + + /** Get current thinking level. */ + getThinkingLevel(): ThinkingLevel; + + /** Set thinking level (clamped to model capabilities). */ + setThinkingLevel(level: ThinkingLevel): void; + + // ========================================================================= + // Provider Registration + // ========================================================================= + + /** + * Register or override a model provider. + * + * If `models` is provided: replaces all existing models for this provider. + * If only `baseUrl` is provided: overrides the URL for existing models. + * If `oauth` is provided: registers OAuth provider for /login support. + * If `streamSimple` is provided: registers a custom API stream handler. + * + * During initial extension load this call is queued and applied once the + * runner has bound its context. After that it takes effect immediately, so + * it is safe to call from command handlers or event callbacks without + * requiring a `/reload`. + * + * @example + * // Register a new provider with custom models + * pi.registerProvider("my-proxy", { + * baseUrl: "https://proxy.example.com", + * apiKey: "$PROXY_API_KEY", + * api: "anthropic-messages", + * models: [ + * { + * id: "claude-sonnet-4-20250514", + * name: "Claude 4 Sonnet (proxy)", + * reasoning: false, + * input: ["text", "image"], + * cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + * contextWindow: 200000, + * maxTokens: 16384 + * } + * ] + * }); + * + * @example + * // Override baseUrl for an existing provider + * pi.registerProvider("anthropic", { + * baseUrl: "https://proxy.example.com" + * }); + * + * @example + * // Register provider with OAuth support + * pi.registerProvider("corporate-ai", { + * baseUrl: "https://ai.corp.com", + * api: "openai-responses", + * models: [...], + * oauth: { + * name: "Corporate AI (SSO)", + * async login(callbacks) { ... }, + * async refreshToken(credentials) { ... }, + * getApiKey(credentials) { return credentials.access; } + * } + * }); + */ + registerProvider(name: string, config: ProviderConfig): void; + + /** + * Unregister a previously registered provider. + * + * Removes all models belonging to the named provider and restores any + * built-in models that were overridden by it. Has no effect if the provider + * is not currently registered. + * + * Like `registerProvider`, this takes effect immediately when called after + * the initial load phase. + * + * @example + * pi.unregisterProvider("my-proxy"); + */ + unregisterProvider(name: string): void; + + /** Shared event bus for extension communication. */ + events: EventBus; +} + +// ============================================================================ +// Provider Registration Types +// ============================================================================ + +/** Configuration for registering a provider via pi.registerProvider(). */ +export interface ProviderConfig { + /** Display name for the provider in UI. */ + name?: string; + /** Base URL for the API endpoint. Required when defining models. */ + baseUrl?: string; + /** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or leading !command. Required when defining models (unless oauth provided). */ + apiKey?: string; + /** API type. Required at provider or model level when defining models. */ + api?: Api; + /** Optional streamSimple handler for custom APIs. */ + streamSimple?: (model: Model, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream; + /** Custom headers to include in requests. */ + headers?: Record; + /** If true, adds Authorization: Bearer header with the resolved API key. */ + authHeader?: boolean; + /** Models to register. If provided, replaces all existing models for this provider. */ + models?: ProviderModelConfig[]; + /** OAuth provider for /login support. The `id` is set automatically from the provider name. */ + oauth?: { + /** Display name for the provider in login UI. */ + name: string; + /** Run the login flow, return credentials to persist. */ + login(callbacks: OAuthLoginCallbacks): Promise; + /** Refresh expired credentials, return updated credentials to persist. */ + refreshToken(credentials: OAuthCredentials): Promise; + /** Convert credentials to API key string for the provider. */ + getApiKey(credentials: OAuthCredentials): string; + /** Optional: modify models for this provider (e.g., update baseUrl based on credentials). */ + modifyModels?(models: Model[], credentials: OAuthCredentials): Model[]; + }; +} + +/** Configuration for a model within a provider. */ +export interface ProviderModelConfig { + /** Model ID (e.g., "claude-sonnet-4-20250514"). */ + id: string; + /** Display name (e.g., "Claude 4 Sonnet"). */ + name: string; + /** API type override for this model. */ + api?: Api; + /** API endpoint URL override for this model. */ + baseUrl?: string; + /** Whether the model supports extended thinking. */ + reasoning: boolean; + /** Maps pi thinking levels to provider/model-specific values; null marks a level unsupported. */ + thinkingLevelMap?: Model["thinkingLevelMap"]; + /** Supported input types. */ + input: ("text" | "image")[]; + /** Cost per token (for tracking, can be 0). */ + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + /** Maximum context window size in tokens. */ + contextWindow: number; + /** Maximum output tokens. */ + maxTokens: number; + /** Custom headers for this model. */ + headers?: Record; + /** OpenAI compatibility settings. */ + compat?: Model["compat"]; +} + +/** Extension factory function type. Supports both sync and async initialization. */ +export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise; + +// ============================================================================ +// Loaded Extension Types +// ============================================================================ + +export interface RegisteredTool { + definition: ToolDefinition; + sourceInfo: SourceInfo; +} + +export interface ExtensionFlag { + name: string; + description?: string; + type: "boolean" | "string"; + default?: boolean | string; + extensionPath: string; +} + +export interface ExtensionShortcut { + shortcut: KeyId; + description?: string; + handler: (ctx: ExtensionContext) => Promise | void; + extensionPath: string; +} + +type HandlerFn = (...args: unknown[]) => Promise; + +export type SendMessageHandler = ( + message: Pick, "customType" | "content" | "display" | "details">, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, +) => void; + +export type SendUserMessageHandler = ( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, +) => void; + +export type AppendEntryHandler = (customType: string, data?: T) => void; + +export type SetSessionNameHandler = (name: string) => void; + +export type GetSessionNameHandler = () => string | undefined; + +export type GetActiveToolsHandler = () => string[]; + +/** Tool info with name, description, parameter schema, prompt guidelines, and source metadata. */ +export type ToolInfo = Pick & { + sourceInfo: SourceInfo; +}; + +export type GetAllToolsHandler = () => ToolInfo[]; + +export type GetCommandsHandler = () => SlashCommandInfo[]; + +export type SetActiveToolsHandler = (toolNames: string[]) => void; + +export type RefreshToolsHandler = () => void; + +export type SetModelHandler = (model: Model) => Promise; + +export type GetThinkingLevelHandler = () => ThinkingLevel; + +export type SetThinkingLevelHandler = (level: ThinkingLevel) => void; + +export type SetLabelHandler = (entryId: string, label: string | undefined) => void; + +/** + * Shared state created by loader, used during registration and runtime. + * Contains flag values (defaults set during registration, CLI values set after). + */ +export interface ExtensionRuntimeState { + flagValues: Map; + /** Provider registrations queued during extension loading, processed when runner binds */ + pendingProviderRegistrations: Array<{ name: string; config: ProviderConfig; extensionPath: string }>; + /** Throws when this extension instance is stale after runtime replacement. */ + assertActive: () => void; + /** Marks this extension instance as stale after runtime replacement or reload. */ + invalidate: (message?: string) => void; + /** + * Register or unregister a provider. + * + * Before bindCore(): queues registrations / removes from queue. + * After bindCore(): calls ModelRegistry directly for immediate effect. + */ + registerProvider: (name: string, config: ProviderConfig, extensionPath?: string) => void; + unregisterProvider: (name: string, extensionPath?: string) => void; +} + +/** + * Action implementations for pi.* API methods. + * Provided to runner.initialize(), copied into the shared runtime. + */ +export interface ExtensionActions { + sendMessage: SendMessageHandler; + sendUserMessage: SendUserMessageHandler; + appendEntry: AppendEntryHandler; + setSessionName: SetSessionNameHandler; + getSessionName: GetSessionNameHandler; + setLabel: SetLabelHandler; + getActiveTools: GetActiveToolsHandler; + getAllTools: GetAllToolsHandler; + setActiveTools: SetActiveToolsHandler; + refreshTools: RefreshToolsHandler; + getCommands: GetCommandsHandler; + setModel: SetModelHandler; + getThinkingLevel: GetThinkingLevelHandler; + setThinkingLevel: SetThinkingLevelHandler; +} + +/** + * Actions for ExtensionContext (ctx.* in event handlers). + * Required by all modes. + */ +export interface ExtensionContextActions { + getModel: () => Model | undefined; + isIdle: () => boolean; + isProjectTrusted: () => boolean; + getSignal: () => AbortSignal | undefined; + abort: () => void; + hasPendingMessages: () => boolean; + shutdown: () => void; + getContextUsage: () => ContextUsage | undefined; + compact: (options?: CompactOptions) => void; + getSystemPrompt: () => string; + getSystemPromptOptions?: () => BuildSystemPromptOptions; +} + +/** + * Actions for ExtensionCommandContext (ctx.* in command handlers). + * Only needed for interactive mode where extension commands are invokable. + */ +export interface ExtensionCommandContextActions { + waitForIdle: () => Promise; + newSession: (options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + withSession?: (ctx: ReplacedSessionContext) => Promise; + }) => Promise<{ cancelled: boolean }>; + fork: ( + entryId: string, + options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise }, + ) => Promise<{ cancelled: boolean }>; + navigateTree: ( + targetId: string, + options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }, + ) => Promise<{ cancelled: boolean }>; + switchSession: ( + sessionPath: string, + options?: { withSession?: (ctx: ReplacedSessionContext) => Promise }, + ) => Promise<{ cancelled: boolean }>; + reload: () => Promise; +} + +/** + * Full runtime = state + actions. + * Created by loader with throwing action stubs, completed by runner.initialize(). + */ +export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionActions {} + +/** Loaded extension with all registered items. */ +export interface Extension { + path: string; + resolvedPath: string; + sourceInfo: SourceInfo; + handlers: Map; + tools: Map; + messageRenderers: Map; + commands: Map; + flags: Map; + shortcuts: Map; +} + +/** Result of loading extensions. */ +export interface LoadExtensionsResult { + extensions: Extension[]; + errors: Array<{ path: string; error: string }>; + /** Shared runtime - actions are throwing stubs until runner.initialize() */ + runtime: ExtensionRuntime; +} + +// ============================================================================ +// Extension Error +// ============================================================================ + +export interface ExtensionError { + extensionPath: string; + event: string; + error: string; + stack?: string; +} diff --git a/cactus-code/packages/coding-agent/src/core/extensions/wrapper.ts b/cactus-code/packages/coding-agent/src/core/extensions/wrapper.ts new file mode 100644 index 000000000..6a84038c2 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/extensions/wrapper.ts @@ -0,0 +1,30 @@ +/** + * Tool wrappers for extension-registered tools. + * + * These wrappers only adapt tool execution so extension tools receive the runner context. + * Tool call and tool result interception is handled by AgentSession via agent-core hooks. + */ + +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.ts"; +import type { ExtensionRunner } from "./runner.ts"; +import type { RegisteredTool } from "./types.ts"; + +/** + * Wrap a RegisteredTool into an AgentTool. + * Uses the runner's createContext() for consistent context across tools and event handlers. + */ +export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: ExtensionRunner): AgentTool { + return wrapToolDefinition(registeredTool.definition, () => runner.createContext()); +} + +/** + * Wrap all registered tools into AgentTools. + * Uses the runner's createContext() for consistent context across tools and event handlers. + */ +export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: ExtensionRunner): AgentTool[] { + return wrapToolDefinitions( + registeredTools.map((registeredTool) => registeredTool.definition), + () => runner.createContext(), + ); +} diff --git a/cactus-code/packages/coding-agent/src/core/footer-data-provider.ts b/cactus-code/packages/coding-agent/src/core/footer-data-provider.ts new file mode 100644 index 000000000..f15001c2e --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/footer-data-provider.ts @@ -0,0 +1,388 @@ +import { type ExecFileException, execFile, spawnSync } from "child_process"; +import { existsSync, type FSWatcher, readFileSync, type Stats, statSync, unwatchFile, watchFile } from "fs"; +import { dirname, join, resolve } from "path"; +import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.ts"; + +type GitPaths = { + repoDir: string; + commonGitDir: string; + headPath: string; +}; + +/** + * Find git metadata paths by walking up from cwd. + * Handles both regular git repos (.git is a directory) and worktrees (.git is a file). + */ +function findGitPaths(cwd: string): GitPaths | null { + let dir = cwd; + while (true) { + const gitPath = join(dir, ".git"); + if (existsSync(gitPath)) { + try { + const stat = statSync(gitPath); + if (stat.isFile()) { + const content = readFileSync(gitPath, "utf8").trim(); + if (content.startsWith("gitdir: ")) { + const gitDir = resolve(dir, content.slice(8).trim()); + const headPath = join(gitDir, "HEAD"); + if (!existsSync(headPath)) return null; + const commonDirPath = join(gitDir, "commondir"); + const commonGitDir = existsSync(commonDirPath) + ? resolve(gitDir, readFileSync(commonDirPath, "utf8").trim()) + : gitDir; + return { repoDir: dir, commonGitDir, headPath }; + } + } else if (stat.isDirectory()) { + const headPath = join(gitPath, "HEAD"); + if (!existsSync(headPath)) return null; + return { repoDir: dir, commonGitDir: gitPath, headPath }; + } + } catch { + return null; + } + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** Ask git for the current branch. Returns null on detached HEAD or if git is unavailable. */ +function resolveBranchWithGitSync(repoDir: string): string | null { + const result = spawnSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], { + cwd: repoDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const branch = result.status === 0 ? result.stdout.trim() : ""; + return branch || null; +} + +/** Ask git for the current branch asynchronously. Returns null on detached HEAD or if git is unavailable. */ +function resolveBranchWithGitAsync(repoDir: string): Promise { + return new Promise((resolvePromise) => { + execFile( + "git", + ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], + { + cwd: repoDir, + encoding: "utf8", + }, + (error: ExecFileException | null, stdout: string) => { + if (error) { + resolvePromise(null); + return; + } + const branch = stdout.trim(); + resolvePromise(branch || null); + }, + ); + }); +} + +function isWslEnvironment(): boolean { + return process.platform === "linux" && !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP); +} + +function isWindowsMountedRepoPath(repoDir: string): boolean { + return /^\/mnt\/[a-z](?:\/|$)/i.test(repoDir); +} + +function shouldPollGitHead(repoDir: string): boolean { + return isWslEnvironment() && isWindowsMountedRepoPath(repoDir); +} + +/** + * Provides git branch and extension statuses - data not otherwise accessible to extensions. + * Token stats, model info available via ctx.sessionManager and ctx.model. + */ +export class FooterDataProvider { + private cwd: string; + private static readonly WATCH_DEBOUNCE_MS = 500; + + private extensionStatuses = new Map(); + private cachedBranch: string | null | undefined = undefined; + private gitPaths: GitPaths | null | undefined = undefined; + private headWatcher: FSWatcher | null = null; + private headWatchFilePath: string | null = null; + private headWatchFileListener: ((current: Stats, previous: Stats) => void) | null = null; + private reftableWatcher: FSWatcher | null = null; + private reftableTablesListWatcher: FSWatcher | null = null; + private reftableTablesListPath: string | null = null; + private branchChangeCallbacks = new Set<() => void>(); + private availableProviderCount = 0; + private refreshTimer: ReturnType | null = null; + private gitWatcherRetryTimer: ReturnType | null = null; + private refreshInFlight = false; + private refreshPending = false; + private disposed = false; + + constructor(cwd: string) { + this.cwd = cwd; + this.gitPaths = findGitPaths(cwd); + this.setupGitWatcher(); + } + + /** Current git branch, null if not in repo, "detached" if detached HEAD */ + getGitBranch(): string | null { + if (this.cachedBranch === undefined) { + this.cachedBranch = this.resolveGitBranchSync(); + } + return this.cachedBranch; + } + + /** Extension status texts set via ctx.ui.setStatus() */ + getExtensionStatuses(): ReadonlyMap { + return this.extensionStatuses; + } + + /** Subscribe to git branch changes. Returns unsubscribe function. */ + onBranchChange(callback: () => void): () => void { + this.branchChangeCallbacks.add(callback); + return () => this.branchChangeCallbacks.delete(callback); + } + + /** Internal: set extension status */ + setExtensionStatus(key: string, text: string | undefined): void { + if (text === undefined) { + this.extensionStatuses.delete(key); + } else { + this.extensionStatuses.set(key, text); + } + } + + /** Internal: clear extension statuses */ + clearExtensionStatuses(): void { + this.extensionStatuses.clear(); + } + + /** Number of unique providers with available models (for footer display) */ + getAvailableProviderCount(): number { + return this.availableProviderCount; + } + + /** Internal: update available provider count */ + setAvailableProviderCount(count: number): void { + this.availableProviderCount = count; + } + + setCwd(cwd: string): void { + if (this.cwd === cwd) { + return; + } + + this.cwd = cwd; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.clearGitWatchers(); + this.cachedBranch = undefined; + this.gitPaths = findGitPaths(cwd); + this.setupGitWatcher(); + this.notifyBranchChange(); + } + + /** Internal: cleanup */ + dispose(): void { + this.disposed = true; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.clearGitWatchers(); + this.branchChangeCallbacks.clear(); + } + + private notifyBranchChange(): void { + for (const cb of this.branchChangeCallbacks) cb(); + } + + private scheduleRefresh(): void { + if (this.disposed || this.refreshTimer) return; + if (this.refreshInFlight) { + this.refreshPending = true; + return; + } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.refreshGitBranchAsync(); + }, FooterDataProvider.WATCH_DEBOUNCE_MS); + } + + private async refreshGitBranchAsync(): Promise { + if (this.disposed) return; + if (this.refreshInFlight) { + this.refreshPending = true; + return; + } + + this.refreshInFlight = true; + try { + const nextBranch = await this.resolveGitBranchAsync(); + if (this.disposed) return; + if (this.cachedBranch !== undefined && this.cachedBranch !== nextBranch) { + this.cachedBranch = nextBranch; + this.notifyBranchChange(); + return; + } + this.cachedBranch = nextBranch; + } finally { + this.refreshInFlight = false; + if (this.refreshPending && !this.disposed) { + this.refreshPending = false; + this.scheduleRefresh(); + } + } + } + + private resolveGitBranchSync(): string | null { + try { + if (!this.gitPaths) return null; + const content = readFileSync(this.gitPaths.headPath, "utf8").trim(); + if (content.startsWith("ref: refs/heads/")) { + const branch = content.slice(16); + return branch === ".invalid" ? (resolveBranchWithGitSync(this.gitPaths.repoDir) ?? "detached") : branch; + } + return "detached"; + } catch { + return null; + } + } + + private async resolveGitBranchAsync(): Promise { + try { + if (!this.gitPaths) return null; + const content = readFileSync(this.gitPaths.headPath, "utf8").trim(); + if (content.startsWith("ref: refs/heads/")) { + const branch = content.slice(16); + return branch === ".invalid" + ? ((await resolveBranchWithGitAsync(this.gitPaths.repoDir)) ?? "detached") + : branch; + } + return "detached"; + } catch { + return null; + } + } + + private clearGitWatchers(): void { + closeWatcher(this.headWatcher); + this.headWatcher = null; + if (this.headWatchFilePath && this.headWatchFileListener) { + unwatchFile(this.headWatchFilePath, this.headWatchFileListener); + this.headWatchFilePath = null; + this.headWatchFileListener = null; + } + closeWatcher(this.reftableWatcher); + this.reftableWatcher = null; + closeWatcher(this.reftableTablesListWatcher); + this.reftableTablesListWatcher = null; + if (this.reftableTablesListPath) { + unwatchFile(this.reftableTablesListPath); + this.reftableTablesListPath = null; + } + if (this.gitWatcherRetryTimer) { + clearTimeout(this.gitWatcherRetryTimer); + this.gitWatcherRetryTimer = null; + } + } + + private scheduleGitWatcherRetry(): void { + if (this.disposed || this.gitWatcherRetryTimer) { + return; + } + + this.gitWatcherRetryTimer = setTimeout(() => { + this.gitWatcherRetryTimer = null; + this.setupGitWatcher(); + }, FS_WATCH_RETRY_DELAY_MS); + } + + private handleGitWatcherError(): void { + this.clearGitWatchers(); + this.scheduleGitWatcherRetry(); + } + + private setupGitWatcher(): void { + this.clearGitWatchers(); + if (!this.gitPaths) return; + + const pollGitHead = shouldPollGitHead(this.gitPaths.repoDir); + + // Watch the directory containing HEAD, not HEAD itself. + // Git uses atomic writes (write temp, rename over HEAD), which changes the inode. + // fs.watch on a file stops working after the inode changes. + this.headWatcher = watchWithErrorHandler( + dirname(this.gitPaths.headPath), + (_eventType, filename) => { + if (!filename || filename === "HEAD") { + this.scheduleRefresh(); + } + }, + () => this.handleGitWatcherError(), + ); + if (pollGitHead) { + this.headWatchFilePath = this.gitPaths.headPath; + this.headWatchFileListener = (current, previous) => { + if ( + current.mtimeMs !== previous.mtimeMs || + current.ctimeMs !== previous.ctimeMs || + current.size !== previous.size + ) { + this.scheduleRefresh(); + } + }; + watchFile(this.headWatchFilePath, { interval: 1000 }, this.headWatchFileListener); + } + if (!this.headWatcher && !pollGitHead) { + return; + } + + // In reftable repos, branch switches update files in the reftable directory + // instead of HEAD. Watch it separately so the footer picks up those changes. + const reftableDir = join(this.gitPaths.commonGitDir, "reftable"); + if (existsSync(reftableDir)) { + this.reftableWatcher = watchWithErrorHandler( + reftableDir, + () => { + this.scheduleRefresh(); + }, + () => this.handleGitWatcherError(), + ); + if (!this.reftableWatcher) { + return; + } + + const tablesListPath = join(reftableDir, "tables.list"); + if (existsSync(tablesListPath)) { + this.reftableTablesListPath = tablesListPath; + this.reftableTablesListWatcher = watchWithErrorHandler( + tablesListPath, + () => { + this.scheduleRefresh(); + }, + () => this.handleGitWatcherError(), + ); + if (!this.reftableTablesListWatcher) { + return; + } + watchFile(tablesListPath, { interval: 250 }, (current, previous) => { + if ( + current.mtimeMs !== previous.mtimeMs || + current.ctimeMs !== previous.ctimeMs || + current.size !== previous.size + ) { + this.scheduleRefresh(); + } + }); + } + } + } +} + +/** Read-only view for extensions - excludes setExtensionStatus, setAvailableProviderCount and dispose */ +export type ReadonlyFooterDataProvider = Pick< + FooterDataProvider, + "getGitBranch" | "getExtensionStatuses" | "getAvailableProviderCount" | "onBranchChange" +>; diff --git a/cactus-code/packages/coding-agent/src/core/http-dispatcher.ts b/cactus-code/packages/coding-agent/src/core/http-dispatcher.ts new file mode 100644 index 000000000..0910f4d6c --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/http-dispatcher.ts @@ -0,0 +1,73 @@ +import * as undici from "undici"; + +export const DEFAULT_HTTP_IDLE_TIMEOUT_MS = 300_000; + +export const HTTP_IDLE_TIMEOUT_CHOICES = [ + { label: "30 sec", timeoutMs: 30_000 }, + { label: "1 min", timeoutMs: 60_000 }, + { label: "2 min", timeoutMs: 120_000 }, + { label: "5 min", timeoutMs: 300_000 }, + { label: "disabled", timeoutMs: 0 }, +] as const; + +const originalGlobalFetch = globalThis.fetch; +let installedGlobalFetch: typeof globalThis.fetch | undefined; + +export function parseHttpIdleTimeoutMs(value: unknown): number | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.toLowerCase() === "disabled") { + return 0; + } + if (trimmed.length === 0) { + return undefined; + } + return parseHttpIdleTimeoutMs(Number(trimmed)); + } + + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.floor(value); +} + +export function formatHttpIdleTimeoutMs(timeoutMs: number): string { + const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.timeoutMs === timeoutMs); + if (choice) { + return choice.label; + } + return `${timeoutMs / 1000} sec`; +} + +export function applyHttpProxySettings(httpProxy: string | undefined): void { + const proxy = httpProxy?.trim(); + if (!proxy) return; + process.env.HTTP_PROXY ??= proxy; + process.env.HTTPS_PROXY ??= proxy; +} + +export function configureHttpDispatcher(timeoutMs: number = DEFAULT_HTTP_IDLE_TIMEOUT_MS): void { + const normalizedTimeoutMs = parseHttpIdleTimeoutMs(timeoutMs); + if (normalizedTimeoutMs === undefined) { + throw new Error(`Invalid HTTP idle timeout: ${String(timeoutMs)}`); + } + undici.setGlobalDispatcher( + new undici.EnvHttpProxyAgent({ + allowH2: false, + bodyTimeout: normalizedTimeoutMs, + headersTimeout: normalizedTimeoutMs, + }), + ); + // Keep fetch and the dispatcher on the same undici implementation. Node 26.0's + // bundled fetch can otherwise consume compressed responses through npm undici's + // dispatcher without decompressing them, causing response.json() failures. + // If a caller replaced fetch after module load, preserve that deliberate override. + const shouldInstallGlobals = + installedGlobalFetch === undefined + ? globalThis.fetch === originalGlobalFetch + : globalThis.fetch === installedGlobalFetch; + if (shouldInstallGlobals) { + undici.install?.(); + installedGlobalFetch = globalThis.fetch; + } +} diff --git a/cactus-code/packages/coding-agent/src/core/index.ts b/cactus-code/packages/coding-agent/src/core/index.ts new file mode 100644 index 000000000..b7654f422 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/index.ts @@ -0,0 +1,78 @@ +/** + * Core modules shared between all run modes. + */ + +export { + AgentSession, + type AgentSessionConfig, + type AgentSessionEvent, + type AgentSessionEventListener, + type ModelCycleResult, + type PromptOptions, + type SessionStats, +} from "./agent-session.ts"; +export { + AgentSessionRuntime, + type CreateAgentSessionRuntimeFactory, + type CreateAgentSessionRuntimeResult, + createAgentSessionRuntime, +} from "./agent-session-runtime.ts"; +export { + type AgentSessionRuntimeDiagnostic, + type AgentSessionServices, + type CreateAgentSessionFromServicesOptions, + type CreateAgentSessionServicesOptions, + createAgentSessionFromServices, + createAgentSessionServices, +} from "./agent-session-services.ts"; +export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts"; +export type { CompactionResult } from "./compaction/index.ts"; +export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts"; +export { areExperimentalFeaturesEnabled } from "./experimental.ts"; +// Extensions system +export { + type AgentEndEvent, + type AgentStartEvent, + type AgentToolResult, + type AgentToolUpdateCallback, + type BeforeAgentStartEvent, + type BeforeAgentStartEventResult, + type BuildSystemPromptOptions, + type ContextEvent, + defineTool, + discoverAndLoadExtensions, + type ExecOptions, + type ExecResult, + type Extension, + type ExtensionAPI, + type ExtensionCommandContext, + type ExtensionContext, + type ExtensionError, + type ExtensionEvent, + type ExtensionFactory, + type ExtensionFlag, + type ExtensionHandler, + ExtensionRunner, + type ExtensionShortcut, + type ExtensionUIContext, + type LoadExtensionsResult, + type MessageRenderer, + type RegisteredCommand, + type SessionBeforeCompactEvent, + type SessionBeforeForkEvent, + type SessionBeforeSwitchEvent, + type SessionBeforeTreeEvent, + type SessionCompactEvent, + type SessionShutdownEvent, + type SessionStartEvent, + type SessionTreeEvent, + type ToolCallEvent, + type ToolCallEventResult, + type ToolDefinition, + type ToolRenderResultOptions, + type ToolResultEvent, + type TurnEndEvent, + type TurnStartEvent, + type WorkingIndicatorOptions, +} from "./extensions/index.ts"; +export { createSyntheticSourceInfo } from "./source-info.ts"; diff --git a/cactus-code/packages/coding-agent/src/core/keybindings.ts b/cactus-code/packages/coding-agent/src/core/keybindings.ts new file mode 100644 index 000000000..7c0de6431 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/keybindings.ts @@ -0,0 +1,370 @@ +import { + type Keybinding, + type KeybindingDefinitions, + type KeybindingsConfig, + type KeyId, + TUI_KEYBINDINGS, + KeybindingsManager as TuiKeybindingsManager, +} from "@earendil-works/pi-tui"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { getAgentDir } from "../config.ts"; + +export interface AppKeybindings { + "app.interrupt": true; + "app.clear": true; + "app.exit": true; + "app.suspend": true; + "app.thinking.cycle": true; + "app.model.cycleForward": true; + "app.model.cycleBackward": true; + "app.model.select": true; + "app.tools.expand": true; + "app.thinking.toggle": true; + "app.session.toggleNamedFilter": true; + "app.editor.external": true; + "app.message.followUp": true; + "app.message.dequeue": true; + "app.clipboard.pasteImage": true; + "app.session.new": true; + "app.session.tree": true; + "app.session.fork": true; + "app.session.resume": true; + "app.tree.foldOrUp": true; + "app.tree.unfoldOrDown": true; + "app.tree.editLabel": true; + "app.tree.toggleLabelTimestamp": true; + "app.session.togglePath": true; + "app.session.toggleSort": true; + "app.session.rename": true; + "app.session.delete": true; + "app.session.deleteNoninvasive": true; + "app.models.save": true; + "app.models.enableAll": true; + "app.models.clearAll": true; + "app.models.toggleProvider": true; + "app.models.reorderUp": true; + "app.models.reorderDown": true; + "app.tree.filter.default": true; + "app.tree.filter.noTools": true; + "app.tree.filter.userOnly": true; + "app.tree.filter.labeledOnly": true; + "app.tree.filter.all": true; + "app.tree.filter.cycleForward": true; + "app.tree.filter.cycleBackward": true; +} + +export type AppKeybinding = keyof AppKeybindings; + +declare module "@earendil-works/pi-tui" { + interface Keybindings extends AppKeybindings {} +} + +export const KEYBINDINGS = { + ...TUI_KEYBINDINGS, + "app.interrupt": { defaultKeys: "escape", description: "Cancel or abort" }, + "app.clear": { defaultKeys: "ctrl+c", description: "Clear editor" }, + "app.exit": { defaultKeys: "ctrl+d", description: "Exit when editor is empty" }, + "app.suspend": { + defaultKeys: process.platform === "win32" ? [] : "ctrl+z", + description: "Suspend to background", + }, + "app.thinking.cycle": { + defaultKeys: "shift+tab", + description: "Cycle thinking level", + }, + "app.model.cycleForward": { + defaultKeys: "ctrl+p", + description: "Cycle to next model", + }, + "app.model.cycleBackward": { + defaultKeys: "shift+ctrl+p", + description: "Cycle to previous model", + }, + "app.model.select": { defaultKeys: "ctrl+l", description: "Open model selector" }, + "app.tools.expand": { defaultKeys: "ctrl+o", description: "Toggle tool output" }, + "app.thinking.toggle": { + defaultKeys: "ctrl+t", + description: "Toggle thinking blocks", + }, + "app.session.toggleNamedFilter": { + defaultKeys: "ctrl+n", + description: "Toggle named session filter", + }, + "app.editor.external": { + defaultKeys: "ctrl+g", + description: "Open external editor", + }, + "app.message.followUp": { + defaultKeys: "alt+enter", + description: "Queue follow-up message", + }, + "app.message.dequeue": { + defaultKeys: "alt+up", + description: "Restore queued messages", + }, + "app.clipboard.pasteImage": { + defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", + description: "Paste image from clipboard", + }, + "app.session.new": { defaultKeys: [], description: "Start a new session" }, + "app.session.tree": { defaultKeys: [], description: "Open session tree" }, + "app.session.fork": { defaultKeys: [], description: "Fork current session" }, + "app.session.resume": { defaultKeys: [], description: "Resume a session" }, + "app.tree.foldOrUp": { + defaultKeys: ["ctrl+left", "alt+left"], + description: "Fold tree branch or move up", + }, + "app.tree.unfoldOrDown": { + defaultKeys: ["ctrl+right", "alt+right"], + description: "Unfold tree branch or move down", + }, + "app.tree.editLabel": { + defaultKeys: "shift+l", + description: "Edit tree label", + }, + "app.tree.toggleLabelTimestamp": { + defaultKeys: "shift+t", + description: "Toggle tree label timestamps", + }, + "app.session.togglePath": { + defaultKeys: "ctrl+p", + description: "Toggle session path display", + }, + "app.session.toggleSort": { + defaultKeys: "ctrl+s", + description: "Toggle session sort mode", + }, + "app.session.rename": { + defaultKeys: "ctrl+r", + description: "Rename session", + }, + "app.session.delete": { + defaultKeys: "ctrl+d", + description: "Delete session", + }, + "app.session.deleteNoninvasive": { + defaultKeys: "ctrl+backspace", + description: "Delete session when query is empty", + }, + "app.models.save": { + defaultKeys: "ctrl+s", + description: "Save model selection", + }, + "app.models.enableAll": { + defaultKeys: "ctrl+a", + description: "Enable all models", + }, + "app.models.clearAll": { + defaultKeys: "ctrl+x", + description: "Clear all models", + }, + "app.models.toggleProvider": { + defaultKeys: "ctrl+p", + description: "Toggle all models for provider", + }, + "app.models.reorderUp": { + defaultKeys: "alt+up", + description: "Move model up in order", + }, + "app.models.reorderDown": { + defaultKeys: "alt+down", + description: "Move model down in order", + }, + "app.tree.filter.default": { + defaultKeys: "ctrl+d", + description: "Tree filter: default view", + }, + "app.tree.filter.noTools": { + defaultKeys: "ctrl+t", + description: "Tree filter: hide tool results", + }, + "app.tree.filter.userOnly": { + defaultKeys: "ctrl+u", + description: "Tree filter: user messages only", + }, + "app.tree.filter.labeledOnly": { + defaultKeys: "ctrl+l", + description: "Tree filter: labeled entries only", + }, + "app.tree.filter.all": { + defaultKeys: "ctrl+a", + description: "Tree filter: show all entries", + }, + "app.tree.filter.cycleForward": { + defaultKeys: "ctrl+o", + description: "Tree filter: cycle forward", + }, + "app.tree.filter.cycleBackward": { + defaultKeys: "shift+ctrl+o", + description: "Tree filter: cycle backward", + }, +} as const satisfies KeybindingDefinitions; + +const KEYBINDING_NAME_MIGRATIONS = { + cursorUp: "tui.editor.cursorUp", + cursorDown: "tui.editor.cursorDown", + cursorLeft: "tui.editor.cursorLeft", + cursorRight: "tui.editor.cursorRight", + cursorWordLeft: "tui.editor.cursorWordLeft", + cursorWordRight: "tui.editor.cursorWordRight", + cursorLineStart: "tui.editor.cursorLineStart", + cursorLineEnd: "tui.editor.cursorLineEnd", + jumpForward: "tui.editor.jumpForward", + jumpBackward: "tui.editor.jumpBackward", + pageUp: "tui.editor.pageUp", + pageDown: "tui.editor.pageDown", + deleteCharBackward: "tui.editor.deleteCharBackward", + deleteCharForward: "tui.editor.deleteCharForward", + deleteWordBackward: "tui.editor.deleteWordBackward", + deleteWordForward: "tui.editor.deleteWordForward", + deleteToLineStart: "tui.editor.deleteToLineStart", + deleteToLineEnd: "tui.editor.deleteToLineEnd", + yank: "tui.editor.yank", + yankPop: "tui.editor.yankPop", + undo: "tui.editor.undo", + newLine: "tui.input.newLine", + submit: "tui.input.submit", + tab: "tui.input.tab", + copy: "tui.input.copy", + selectUp: "tui.select.up", + selectDown: "tui.select.down", + selectPageUp: "tui.select.pageUp", + selectPageDown: "tui.select.pageDown", + selectConfirm: "tui.select.confirm", + selectCancel: "tui.select.cancel", + interrupt: "app.interrupt", + clear: "app.clear", + exit: "app.exit", + suspend: "app.suspend", + cycleThinkingLevel: "app.thinking.cycle", + cycleModelForward: "app.model.cycleForward", + cycleModelBackward: "app.model.cycleBackward", + selectModel: "app.model.select", + expandTools: "app.tools.expand", + toggleThinking: "app.thinking.toggle", + toggleSessionNamedFilter: "app.session.toggleNamedFilter", + externalEditor: "app.editor.external", + followUp: "app.message.followUp", + dequeue: "app.message.dequeue", + pasteImage: "app.clipboard.pasteImage", + newSession: "app.session.new", + tree: "app.session.tree", + fork: "app.session.fork", + resume: "app.session.resume", + treeFoldOrUp: "app.tree.foldOrUp", + treeUnfoldOrDown: "app.tree.unfoldOrDown", + treeEditLabel: "app.tree.editLabel", + treeToggleLabelTimestamp: "app.tree.toggleLabelTimestamp", + toggleSessionPath: "app.session.togglePath", + toggleSessionSort: "app.session.toggleSort", + renameSession: "app.session.rename", + deleteSession: "app.session.delete", + deleteSessionNoninvasive: "app.session.deleteNoninvasive", +} as const satisfies Record; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS { + return key in KEYBINDING_NAME_MIGRATIONS; +} + +function toKeybindingsConfig(value: unknown): KeybindingsConfig { + if (!isRecord(value)) return {}; + + const config: KeybindingsConfig = {}; + for (const [key, binding] of Object.entries(value)) { + if (typeof binding === "string") { + config[key] = binding as KeyId; + continue; + } + if (Array.isArray(binding) && binding.every((entry) => typeof entry === "string")) { + config[key] = binding as KeyId[]; + } + } + return config; +} + +export function migrateKeybindingsConfig(rawConfig: Record): { + config: Record; + migrated: boolean; +} { + const config: Record = {}; + let migrated = false; + + for (const [key, value] of Object.entries(rawConfig)) { + const nextKey = isLegacyKeybindingName(key) ? KEYBINDING_NAME_MIGRATIONS[key] : key; + if (nextKey !== key) { + migrated = true; + } + if (key !== nextKey && Object.hasOwn(rawConfig, nextKey)) { + migrated = true; + continue; + } + config[nextKey] = value; + } + + return { config: orderKeybindingsConfig(config), migrated }; +} + +function orderKeybindingsConfig(config: Record): Record { + const ordered: Record = {}; + for (const keybinding of Object.keys(KEYBINDINGS)) { + if (Object.hasOwn(config, keybinding)) { + ordered[keybinding] = config[keybinding]; + } + } + + const extras = Object.keys(config) + .filter((key) => !Object.hasOwn(ordered, key)) + .sort(); + for (const key of extras) { + ordered[key] = config[key]; + } + + return ordered; +} + +function loadRawConfig(path: string): Record | undefined { + if (!existsSync(path)) return undefined; + try { + const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +export class KeybindingsManager extends TuiKeybindingsManager { + private configPath: string | undefined; + + constructor(userBindings: KeybindingsConfig = {}, configPath?: string) { + super(KEYBINDINGS, userBindings); + this.configPath = configPath; + } + + static create(agentDir: string = getAgentDir()): KeybindingsManager { + const configPath = join(agentDir, "keybindings.json"); + const userBindings = KeybindingsManager.loadFromFile(configPath); + return new KeybindingsManager(userBindings, configPath); + } + + reload(): void { + if (!this.configPath) return; + this.setUserBindings(KeybindingsManager.loadFromFile(this.configPath)); + } + + getEffectiveConfig(): KeybindingsConfig { + return this.getResolvedBindings(); + } + + private static loadFromFile(path: string): KeybindingsConfig { + const rawConfig = loadRawConfig(path); + if (!rawConfig) return {}; + return toKeybindingsConfig(migrateKeybindingsConfig(rawConfig).config); + } +} + +export type { Keybinding, KeyId, KeybindingsConfig }; diff --git a/cactus-code/packages/coding-agent/src/core/messages.ts b/cactus-code/packages/coding-agent/src/core/messages.ts new file mode 100644 index 000000000..81ffabb58 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/messages.ts @@ -0,0 +1,195 @@ +/** + * Custom message types and transformers for the coding agent. + * + * Extends the base AgentMessage type with coding-agent specific message types, + * and provides a transformer to convert them to LLM-compatible messages. + */ + +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; + +export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: + + +`; + +export const COMPACTION_SUMMARY_SUFFIX = ` +`; + +export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from: + + +`; + +export const BRANCH_SUMMARY_SUFFIX = ``; + +/** + * Message type for bash executions via the ! command. + */ +export interface BashExecutionMessage { + role: "bashExecution"; + command: string; + output: string; + exitCode: number | undefined; + cancelled: boolean; + truncated: boolean; + fullOutputPath?: string; + timestamp: number; + /** If true, this message is excluded from LLM context (!! prefix) */ + excludeFromContext?: boolean; +} + +/** + * Message type for extension-injected messages via sendMessage(). + * These are custom messages that extensions can inject into the conversation. + */ +export interface CustomMessage { + role: "custom"; + customType: string; + content: string | (TextContent | ImageContent)[]; + display: boolean; + details?: T; + timestamp: number; +} + +export interface BranchSummaryMessage { + role: "branchSummary"; + summary: string; + fromId: string; + timestamp: number; +} + +export interface CompactionSummaryMessage { + role: "compactionSummary"; + summary: string; + tokensBefore: number; + timestamp: number; +} + +// Extend CustomAgentMessages via declaration merging +declare module "@earendil-works/pi-agent-core" { + interface CustomAgentMessages { + bashExecution: BashExecutionMessage; + custom: CustomMessage; + branchSummary: BranchSummaryMessage; + compactionSummary: CompactionSummaryMessage; + } +} + +/** + * Convert a BashExecutionMessage to user message text for LLM context. + */ +export function bashExecutionToText(msg: BashExecutionMessage): string { + let text = `Ran \`${msg.command}\`\n`; + if (msg.output) { + text += `\`\`\`\n${msg.output}\n\`\`\``; + } else { + text += "(no output)"; + } + if (msg.cancelled) { + text += "\n\n(command cancelled)"; + } else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) { + text += `\n\nCommand exited with code ${msg.exitCode}`; + } + if (msg.truncated && msg.fullOutputPath) { + text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`; + } + return text; +} + +export function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage { + return { + role: "branchSummary", + summary, + fromId, + timestamp: new Date(timestamp).getTime(), + }; +} + +export function createCompactionSummaryMessage( + summary: string, + tokensBefore: number, + timestamp: string, +): CompactionSummaryMessage { + return { + role: "compactionSummary", + summary: summary, + tokensBefore, + timestamp: new Date(timestamp).getTime(), + }; +} + +/** Convert CustomMessageEntry to AgentMessage format */ +export function createCustomMessage( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details: unknown | undefined, + timestamp: string, +): CustomMessage { + return { + role: "custom", + customType, + content, + display, + details, + timestamp: new Date(timestamp).getTime(), + }; +} + +/** + * Transform AgentMessages (including custom types) to LLM-compatible Messages. + * + * This is used by: + * - Agent's transormToLlm option (for prompt calls and queued messages) + * - Compaction's generateSummary (for summarization) + * - Custom extensions and tools + */ +export function convertToLlm(messages: AgentMessage[]): Message[] { + return messages + .map((m): Message | undefined => { + switch (m.role) { + case "bashExecution": + // Skip messages excluded from context (!! prefix) + if (m.excludeFromContext) { + return undefined; + } + return { + role: "user", + content: [{ type: "text", text: bashExecutionToText(m) }], + timestamp: m.timestamp, + }; + case "custom": { + const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; + return { + role: "user", + content, + timestamp: m.timestamp, + }; + } + case "branchSummary": + return { + role: "user", + content: [{ type: "text" as const, text: BRANCH_SUMMARY_PREFIX + m.summary + BRANCH_SUMMARY_SUFFIX }], + timestamp: m.timestamp, + }; + case "compactionSummary": + return { + role: "user", + content: [ + { type: "text" as const, text: COMPACTION_SUMMARY_PREFIX + m.summary + COMPACTION_SUMMARY_SUFFIX }, + ], + timestamp: m.timestamp, + }; + case "user": + case "assistant": + case "toolResult": + return m; + default: + // biome-ignore lint/correctness/noSwitchDeclarations: fine + const _exhaustiveCheck: never = m; + return undefined; + } + }) + .filter((m) => m !== undefined); +} diff --git a/cactus-code/packages/coding-agent/src/core/model-registry.ts b/cactus-code/packages/coding-agent/src/core/model-registry.ts new file mode 100644 index 000000000..70f393948 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/model-registry.ts @@ -0,0 +1,990 @@ +/** + * Model registry - manages built-in and custom models, provides API key resolution. + */ + +import { + type AnthropicMessagesCompat, + type Api, + type AssistantMessageEventStream, + type Context, + getModels, + getProviders, + type KnownProvider, + type Model, + type OAuthProviderInterface, + type OpenAICompletionsCompat, + type OpenAIResponsesCompat, + registerApiProvider, + resetApiProviders, + type SimpleStreamOptions, +} from "@earendil-works/pi-ai/compat"; +import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { type Static, Type } from "typebox"; +import { Compile } from "typebox/compile"; +import type { TLocalizedValidationError } from "typebox/error"; +import { getAgentDir } from "../config.ts"; +import { stripJsonComments } from "../utils/json.ts"; +import { normalizePath } from "../utils/paths.ts"; +import type { AuthStatus, AuthStorage } from "./auth-storage.ts"; +import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "./provider-display-names.ts"; +import { + clearConfigValueCache, + getConfigValueEnvVarNames, + isCommandConfigValue, + isConfigValueConfigured, + resolveConfigValueOrThrow, + resolveConfigValueUncached, + resolveHeadersOrThrow, +} from "./resolve-config-value.ts"; + +// Schema for OpenRouter routing preferences +const PercentileCutoffsSchema = Type.Object({ + p50: Type.Optional(Type.Number()), + p75: Type.Optional(Type.Number()), + p90: Type.Optional(Type.Number()), + p99: Type.Optional(Type.Number()), +}); + +const OpenRouterRoutingSchema = Type.Object({ + allow_fallbacks: Type.Optional(Type.Boolean()), + require_parameters: Type.Optional(Type.Boolean()), + data_collection: Type.Optional(Type.Union([Type.Literal("deny"), Type.Literal("allow")])), + zdr: Type.Optional(Type.Boolean()), + enforce_distillable_text: Type.Optional(Type.Boolean()), + order: Type.Optional(Type.Array(Type.String())), + only: Type.Optional(Type.Array(Type.String())), + ignore: Type.Optional(Type.Array(Type.String())), + quantizations: Type.Optional(Type.Array(Type.String())), + sort: Type.Optional( + Type.Union([ + Type.String(), + Type.Object({ + by: Type.Optional(Type.String()), + partition: Type.Optional(Type.Union([Type.String(), Type.Null()])), + }), + ]), + ), + max_price: Type.Optional( + Type.Object({ + prompt: Type.Optional(Type.Union([Type.Number(), Type.String()])), + completion: Type.Optional(Type.Union([Type.Number(), Type.String()])), + image: Type.Optional(Type.Union([Type.Number(), Type.String()])), + audio: Type.Optional(Type.Union([Type.Number(), Type.String()])), + request: Type.Optional(Type.Union([Type.Number(), Type.String()])), + }), + ), + preferred_min_throughput: Type.Optional(Type.Union([Type.Number(), PercentileCutoffsSchema])), + preferred_max_latency: Type.Optional(Type.Union([Type.Number(), PercentileCutoffsSchema])), +}); + +// Schema for Vercel AI Gateway routing preferences +const VercelGatewayRoutingSchema = Type.Object({ + only: Type.Optional(Type.Array(Type.String())), + order: Type.Optional(Type.Array(Type.String())), +}); + +// Schema for thinking level support and provider-specific values +const ThinkingLevelMapValueSchema = Type.Union([Type.String(), Type.Null()]); +const ThinkingLevelMapSchema = Type.Object({ + off: Type.Optional(ThinkingLevelMapValueSchema), + minimal: Type.Optional(ThinkingLevelMapValueSchema), + low: Type.Optional(ThinkingLevelMapValueSchema), + medium: Type.Optional(ThinkingLevelMapValueSchema), + high: Type.Optional(ThinkingLevelMapValueSchema), + xhigh: Type.Optional(ThinkingLevelMapValueSchema), +}); + +const ChatTemplateKwargScalarSchema = Type.Union([Type.String(), Type.Number(), Type.Boolean(), Type.Null()]); +const ChatTemplateKwargVariableSchema = Type.Object({ + $var: Type.Union([Type.Literal("thinking.enabled"), Type.Literal("thinking.effort")]), + omitWhenOff: Type.Optional(Type.Boolean()), +}); +const ChatTemplateKwargSchema = Type.Union([ChatTemplateKwargScalarSchema, ChatTemplateKwargVariableSchema]); + +const OpenAICompletionsCompatSchema = Type.Object({ + supportsStore: Type.Optional(Type.Boolean()), + supportsDeveloperRole: Type.Optional(Type.Boolean()), + supportsReasoningEffort: Type.Optional(Type.Boolean()), + supportsUsageInStreaming: Type.Optional(Type.Boolean()), + maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])), + requiresToolResultName: Type.Optional(Type.Boolean()), + requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), + requiresThinkingAsText: Type.Optional(Type.Boolean()), + requiresReasoningContentOnAssistantMessages: Type.Optional(Type.Boolean()), + thinkingFormat: Type.Optional( + Type.Union([ + Type.Literal("openai"), + Type.Literal("openrouter"), + Type.Literal("together"), + Type.Literal("deepseek"), + Type.Literal("zai"), + Type.Literal("qwen"), + Type.Literal("chat-template"), + Type.Literal("qwen-chat-template"), + Type.Literal("string-thinking"), + Type.Literal("ant-ling"), + ]), + ), + chatTemplateKwargs: Type.Optional(Type.Record(Type.String(), ChatTemplateKwargSchema)), + cacheControlFormat: Type.Optional(Type.Literal("anthropic")), + openRouterRouting: Type.Optional(OpenRouterRoutingSchema), + vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), + supportsStrictMode: Type.Optional(Type.Boolean()), + supportsLongCacheRetention: Type.Optional(Type.Boolean()), +}); + +const OpenAIResponsesCompatSchema = Type.Object({ + supportsDeveloperRole: Type.Optional(Type.Boolean()), + sendSessionIdHeader: Type.Optional(Type.Boolean()), + supportsLongCacheRetention: Type.Optional(Type.Boolean()), +}); + +const AnthropicMessagesCompatSchema = Type.Object({ + supportsEagerToolInputStreaming: Type.Optional(Type.Boolean()), + supportsLongCacheRetention: Type.Optional(Type.Boolean()), + sendSessionAffinityHeaders: Type.Optional(Type.Boolean()), + supportsCacheControlOnTools: Type.Optional(Type.Boolean()), + forceAdaptiveThinking: Type.Optional(Type.Boolean()), +}); + +const ProviderCompatSchema = Type.Union([ + OpenAICompletionsCompatSchema, + OpenAIResponsesCompatSchema, + AnthropicMessagesCompatSchema, +]); + +// Schema for custom model definition +// Most fields are optional with sensible defaults for local models (Ollama, LM Studio, etc.) +const ModelDefinitionSchema = Type.Object({ + id: Type.String({ minLength: 1 }), + name: Type.Optional(Type.String({ minLength: 1 })), + api: Type.Optional(Type.String({ minLength: 1 })), + baseUrl: Type.Optional(Type.String({ minLength: 1 })), + reasoning: Type.Optional(Type.Boolean()), + thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema), + input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))), + cost: Type.Optional( + Type.Object({ + input: Type.Number(), + output: Type.Number(), + cacheRead: Type.Number(), + cacheWrite: Type.Number(), + }), + ), + contextWindow: Type.Optional(Type.Number()), + maxTokens: Type.Optional(Type.Number()), + headers: Type.Optional(Type.Record(Type.String(), Type.String())), + compat: Type.Optional(ProviderCompatSchema), +}); + +// Schema for per-model overrides (all fields optional, merged with built-in model) +const ModelOverrideSchema = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1 })), + reasoning: Type.Optional(Type.Boolean()), + thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema), + input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))), + cost: Type.Optional( + Type.Object({ + input: Type.Optional(Type.Number()), + output: Type.Optional(Type.Number()), + cacheRead: Type.Optional(Type.Number()), + cacheWrite: Type.Optional(Type.Number()), + }), + ), + contextWindow: Type.Optional(Type.Number()), + maxTokens: Type.Optional(Type.Number()), + headers: Type.Optional(Type.Record(Type.String(), Type.String())), + compat: Type.Optional(ProviderCompatSchema), +}); + +type ModelOverride = Static; + +const ProviderConfigSchema = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1 })), + baseUrl: Type.Optional(Type.String({ minLength: 1 })), + apiKey: Type.Optional(Type.String({ minLength: 1 })), + api: Type.Optional(Type.String({ minLength: 1 })), + headers: Type.Optional(Type.Record(Type.String(), Type.String())), + compat: Type.Optional(ProviderCompatSchema), + authHeader: Type.Optional(Type.Boolean()), + models: Type.Optional(Type.Array(ModelDefinitionSchema)), + modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)), +}); + +const ModelsConfigSchema = Type.Object({ + providers: Type.Record(Type.String(), ProviderConfigSchema), +}); + +const validateModelsConfig = Compile(ModelsConfigSchema); + +type ModelsConfig = Static; + +function formatValidationPath(error: TLocalizedValidationError): string { + if (error.keyword === "required") { + const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties; + const requiredProperty = requiredProperties?.[0]; + if (requiredProperty) { + const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, "."); + return basePath ? `${basePath}.${requiredProperty}` : requiredProperty; + } + } + const path = error.instancePath.replace(/^\//, "").replace(/\//g, "."); + return path || "root"; +} + +/** Provider override config (baseUrl, compat) without request auth/headers */ +interface ProviderOverride { + baseUrl?: string; + compat?: Model["compat"]; +} + +interface ProviderRequestConfig { + apiKey?: string; + headers?: Record; + authHeader?: boolean; +} + +export type ResolvedRequestAuth = + | { + ok: true; + apiKey?: string; + headers?: Record; + env?: Record; + } + | { + ok: false; + error: string; + }; + +/** Result of loading custom models from models.json */ +interface CustomModelsResult { + models: Model[]; + /** Providers with baseUrl/headers/apiKey overrides for built-in models */ + overrides: Map; + /** Per-model overrides: provider -> modelId -> override */ + modelOverrides: Map>; + error: string | undefined; +} + +function emptyCustomModelsResult(error?: string): CustomModelsResult { + return { models: [], overrides: new Map(), modelOverrides: new Map(), error }; +} + +function mergeCompat( + baseCompat: Model["compat"], + overrideCompat: ModelOverride["compat"], +): Model["compat"] | undefined { + if (!overrideCompat) return baseCompat; + + const base = baseCompat as OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat | undefined; + const override = overrideCompat as OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat; + const merged = { ...base, ...override } as OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat; + + const baseCompletions = base as OpenAICompletionsCompat | undefined; + const overrideCompletions = override as OpenAICompletionsCompat; + const mergedCompletions = merged as OpenAICompletionsCompat; + + if (baseCompletions?.openRouterRouting || overrideCompletions.openRouterRouting) { + mergedCompletions.openRouterRouting = { + ...baseCompletions?.openRouterRouting, + ...overrideCompletions.openRouterRouting, + }; + } + + if (baseCompletions?.vercelGatewayRouting || overrideCompletions.vercelGatewayRouting) { + mergedCompletions.vercelGatewayRouting = { + ...baseCompletions?.vercelGatewayRouting, + ...overrideCompletions.vercelGatewayRouting, + }; + } + + if (baseCompletions?.chatTemplateKwargs || overrideCompletions.chatTemplateKwargs) { + mergedCompletions.chatTemplateKwargs = { + ...baseCompletions?.chatTemplateKwargs, + ...overrideCompletions.chatTemplateKwargs, + }; + } + + return merged as Model["compat"]; +} + +/** + * Deep merge a model override into a model. + * Handles nested objects (cost, compat) by merging rather than replacing. + */ +function applyModelOverride(model: Model, override: ModelOverride): Model { + const result = { ...model }; + + // Simple field overrides + if (override.name !== undefined) result.name = override.name; + if (override.reasoning !== undefined) result.reasoning = override.reasoning; + if (override.thinkingLevelMap !== undefined) { + result.thinkingLevelMap = { ...model.thinkingLevelMap, ...override.thinkingLevelMap }; + } + if (override.input !== undefined) result.input = override.input as ("text" | "image")[]; + if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow; + if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens; + + // Merge cost (partial override) + if (override.cost) { + result.cost = { + input: override.cost.input ?? model.cost.input, + output: override.cost.output ?? model.cost.output, + cacheRead: override.cost.cacheRead ?? model.cost.cacheRead, + cacheWrite: override.cost.cacheWrite ?? model.cost.cacheWrite, + }; + } + + // Deep merge compat + result.compat = mergeCompat(model.compat, override.compat); + + return result; +} + +/** Clear the config value command cache. Exported for testing. */ +export const clearApiKeyCache = clearConfigValueCache; + +/** + * Model registry - loads and manages models, resolves API keys via AuthStorage. + */ +export class ModelRegistry { + private models: Model[] = []; + private providerRequestConfigs: Map = new Map(); + private modelRequestHeaders: Map> = new Map(); + private registeredProviders: Map = new Map(); + private loadError: string | undefined = undefined; + readonly authStorage: AuthStorage; + private modelsJsonPath: string | undefined; + + private constructor(authStorage: AuthStorage, modelsJsonPath: string | undefined) { + this.authStorage = authStorage; + this.modelsJsonPath = modelsJsonPath ? normalizePath(modelsJsonPath) : undefined; + this.loadModels(); + } + + static create(authStorage: AuthStorage, modelsJsonPath: string = join(getAgentDir(), "models.json")): ModelRegistry { + return new ModelRegistry(authStorage, modelsJsonPath); + } + + static inMemory(authStorage: AuthStorage): ModelRegistry { + return new ModelRegistry(authStorage, undefined); + } + + /** + * Reload models from disk (built-in + custom from models.json). + */ + refresh(): void { + this.providerRequestConfigs.clear(); + this.modelRequestHeaders.clear(); + this.loadError = undefined; + + // Ensure dynamic API/OAuth registrations are rebuilt from current provider state. + resetApiProviders(); + resetOAuthProviders(); + + this.loadModels(); + + for (const [providerName, config] of this.registeredProviders.entries()) { + this.applyProviderConfig(providerName, config); + } + } + + /** + * Get any error from loading models.json (undefined if no error). + */ + getError(): string | undefined { + return this.loadError; + } + + private loadModels(): void { + // Load custom models and overrides from models.json + const { + models: customModels, + overrides, + modelOverrides, + error, + } = this.modelsJsonPath ? this.loadCustomModels(this.modelsJsonPath) : emptyCustomModelsResult(); + + if (error) { + this.loadError = error; + // Keep built-in models even if custom models failed to load + } + + const builtInModels = this.loadBuiltInModels(overrides, modelOverrides); + let combined = this.mergeCustomModels(builtInModels, customModels); + + // Let OAuth providers modify their models (e.g., update baseUrl) + for (const oauthProvider of this.authStorage.getOAuthProviders()) { + const cred = this.authStorage.get(oauthProvider.id); + if (cred?.type === "oauth" && oauthProvider.modifyModels) { + combined = oauthProvider.modifyModels(combined, cred); + } + } + + this.models = combined; + } + + /** Load built-in models and apply provider/model overrides */ + private loadBuiltInModels( + overrides: Map, + modelOverrides: Map>, + ): Model[] { + return getProviders().flatMap((provider) => { + const models = getModels(provider as KnownProvider) as Model[]; + const providerOverride = overrides.get(provider); + const perModelOverrides = modelOverrides.get(provider); + + return models.map((m) => { + let model = m; + + // Apply provider-level baseUrl/headers/compat override + if (providerOverride) { + model = { + ...model, + baseUrl: providerOverride.baseUrl ?? model.baseUrl, + compat: mergeCompat(model.compat, providerOverride.compat), + }; + } + + // Apply per-model override + const modelOverride = perModelOverrides?.get(m.id); + if (modelOverride) { + model = applyModelOverride(model, modelOverride); + } + + return model; + }); + }); + } + + /** Merge custom models into built-in list by provider+id (custom wins on conflicts). */ + private mergeCustomModels(builtInModels: Model[], customModels: Model[]): Model[] { + const merged = [...builtInModels]; + for (const customModel of customModels) { + const existingIndex = merged.findIndex((m) => m.provider === customModel.provider && m.id === customModel.id); + if (existingIndex >= 0) { + merged[existingIndex] = customModel; + } else { + merged.push(customModel); + } + } + return merged; + } + + private loadCustomModels(modelsJsonPath: string): CustomModelsResult { + if (!existsSync(modelsJsonPath)) { + return emptyCustomModelsResult(); + } + + try { + const content = readFileSync(modelsJsonPath, "utf-8"); + const parsed = JSON.parse(stripJsonComments(content)) as unknown; + + if (!validateModelsConfig.Check(parsed)) { + const errors = + validateModelsConfig + .Errors(parsed) + .map((error) => ` - ${formatValidationPath(error)}: ${error.message}`) + .join("\n") || "Unknown schema error"; + return emptyCustomModelsResult(`Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`); + } + + const config = parsed as ModelsConfig; + + // Additional validation + this.validateConfig(config); + + const overrides = new Map(); + const modelOverrides = new Map>(); + + for (const [providerName, providerConfig] of Object.entries(config.providers)) { + if (providerConfig.baseUrl || providerConfig.compat) { + overrides.set(providerName, { + baseUrl: providerConfig.baseUrl, + compat: providerConfig.compat, + }); + } + + this.storeProviderRequestConfig(providerName, providerConfig); + + if (providerConfig.modelOverrides) { + modelOverrides.set(providerName, new Map(Object.entries(providerConfig.modelOverrides))); + for (const [modelId, modelOverride] of Object.entries(providerConfig.modelOverrides)) { + this.storeModelHeaders(providerName, modelId, modelOverride.headers); + } + } + } + + return { models: this.parseModels(config), overrides, modelOverrides, error: undefined }; + } catch (error) { + if (error instanceof SyntaxError) { + return emptyCustomModelsResult(`Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`); + } + return emptyCustomModelsResult( + `Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsJsonPath}`, + ); + } + } + + private validateConfig(config: ModelsConfig): void { + const builtInProviders = new Set(getProviders()); + + for (const [providerName, providerConfig] of Object.entries(config.providers)) { + const isBuiltIn = builtInProviders.has(providerName); + const hasProviderApi = !!providerConfig.api; + const models = providerConfig.models ?? []; + const hasModelOverrides = + providerConfig.modelOverrides && Object.keys(providerConfig.modelOverrides).length > 0; + + if (models.length === 0) { + // Override-only config: needs baseUrl, headers, compat, modelOverrides, or some combination. + if (!providerConfig.baseUrl && !providerConfig.headers && !providerConfig.compat && !hasModelOverrides) { + throw new Error( + `Provider ${providerName}: must specify "baseUrl", "headers", "compat", "modelOverrides", or "models".`, + ); + } + } else if (!isBuiltIn) { + // Non-built-in providers with custom models require an endpoint. + // Auth can come from auth.json, --api-key, or provider request config. + if (!providerConfig.baseUrl) { + throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`); + } + } + // Built-in providers with custom models: baseUrl/apiKey/api are optional, + // inherited from built-in models. Auth comes from env vars / auth storage. + + for (const modelDef of models) { + const hasModelApi = !!modelDef.api; + + if (!hasProviderApi && !hasModelApi && !isBuiltIn) { + throw new Error( + `Provider ${providerName}, model ${modelDef.id}: no "api" specified. Set at provider or model level.`, + ); + } + // For built-in providers, api is optional — inherited from built-in models. + + if (!modelDef.id) throw new Error(`Provider ${providerName}: model missing "id"`); + // Validate contextWindow/maxTokens only if provided (they have defaults) + if (modelDef.contextWindow !== undefined && modelDef.contextWindow <= 0) + throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`); + if (modelDef.maxTokens !== undefined && modelDef.maxTokens <= 0) + throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`); + } + } + } + + private parseModels(config: ModelsConfig): Model[] { + const models: Model[] = []; + const builtInProviders = new Set(getProviders()); + + // Cache built-in defaults (api, baseUrl) per provider, extracted from first model. + const builtInDefaultsCache = new Map(); + const getBuiltInDefaults = (providerName: string): { api: string; baseUrl: string } | undefined => { + if (!builtInProviders.has(providerName)) return undefined; + if (builtInDefaultsCache.has(providerName)) return builtInDefaultsCache.get(providerName); + const builtIn = getModels(providerName as KnownProvider) as Model[]; + if (builtIn.length === 0) return undefined; + const defaults = { api: builtIn[0].api, baseUrl: builtIn[0].baseUrl }; + builtInDefaultsCache.set(providerName, defaults); + return defaults; + }; + + for (const [providerName, providerConfig] of Object.entries(config.providers)) { + const modelDefs = providerConfig.models ?? []; + if (modelDefs.length === 0) continue; // Override-only, no custom models + + const builtInDefaults = getBuiltInDefaults(providerName); + + for (const modelDef of modelDefs) { + const api = modelDef.api ?? providerConfig.api ?? builtInDefaults?.api; + if (!api) continue; + + const baseUrl = modelDef.baseUrl ?? providerConfig.baseUrl ?? builtInDefaults?.baseUrl; + if (!baseUrl) continue; + + const compat = mergeCompat(providerConfig.compat, modelDef.compat); + this.storeModelHeaders(providerName, modelDef.id, modelDef.headers); + + const defaultCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + models.push({ + id: modelDef.id, + name: modelDef.name ?? modelDef.id, + api: api as Api, + provider: providerName, + baseUrl, + reasoning: modelDef.reasoning ?? false, + thinkingLevelMap: modelDef.thinkingLevelMap, + input: (modelDef.input ?? ["text"]) as ("text" | "image")[], + cost: modelDef.cost ?? defaultCost, + contextWindow: modelDef.contextWindow ?? 128000, + maxTokens: modelDef.maxTokens ?? 16384, + headers: undefined, + compat, + } as Model); + } + } + + return models; + } + + /** + * Get all models (built-in + custom). + * If models.json had errors, returns only built-in models. + */ + getAll(): Model[] { + return this.models; + } + + /** + * Get only models that have auth configured. + * This is a fast check that doesn't refresh OAuth tokens. + */ + getAvailable(): Model[] { + return this.models.filter((m) => this.hasConfiguredAuth(m)); + } + + /** + * Find a model by provider and ID. + */ + find(provider: string, modelId: string): Model | undefined { + return this.models.find((m) => m.provider === provider && m.id === modelId); + } + + /** + * Get API key for a model. + */ + hasConfiguredAuth(model: Model): boolean { + const providerApiKey = this.providerRequestConfigs.get(model.provider)?.apiKey; + return ( + this.authStorage.hasAuth(model.provider) || + (providerApiKey !== undefined && isConfigValueConfigured(providerApiKey)) + ); + } + + private getModelRequestKey(provider: string, modelId: string): string { + return `${provider}:${modelId}`; + } + + private storeProviderRequestConfig( + providerName: string, + config: { + apiKey?: string; + headers?: Record; + authHeader?: boolean; + }, + ): void { + if (!config.apiKey && !config.headers && !config.authHeader) { + return; + } + + this.providerRequestConfigs.set(providerName, { + apiKey: config.apiKey, + headers: config.headers, + authHeader: config.authHeader, + }); + } + + private storeModelHeaders(providerName: string, modelId: string, headers?: Record): void { + const key = this.getModelRequestKey(providerName, modelId); + if (!headers || Object.keys(headers).length === 0) { + this.modelRequestHeaders.delete(key); + return; + } + this.modelRequestHeaders.set(key, headers); + } + + /** + * Get API key and request headers for a model. + */ + async getApiKeyAndHeaders(model: Model): Promise { + try { + const providerConfig = this.providerRequestConfigs.get(model.provider); + const providerEnv = this.authStorage.getProviderEnv(model.provider); + const apiKeyFromAuthStorage = await this.authStorage.getApiKey(model.provider, { includeFallback: false }); + const apiKey = + apiKeyFromAuthStorage ?? + (providerConfig?.apiKey + ? resolveConfigValueOrThrow( + providerConfig.apiKey, + `API key for provider "${model.provider}"`, + providerEnv, + ) + : undefined); + + const providerHeaders = resolveHeadersOrThrow( + providerConfig?.headers, + `provider "${model.provider}"`, + providerEnv, + ); + const modelHeaders = resolveHeadersOrThrow( + this.modelRequestHeaders.get(this.getModelRequestKey(model.provider, model.id)), + `model "${model.provider}/${model.id}"`, + providerEnv, + ); + + let headers = + model.headers || providerHeaders || modelHeaders + ? { ...model.headers, ...providerHeaders, ...modelHeaders } + : undefined; + + if (providerConfig?.authHeader) { + if (!apiKey) { + return { ok: false, error: `No API key found for "${model.provider}"` }; + } + headers = { ...headers, Authorization: `Bearer ${apiKey}` }; + } + + return { + ok: true, + apiKey, + headers: headers && Object.keys(headers).length > 0 ? headers : undefined, + env: providerEnv && Object.keys(providerEnv).length > 0 ? providerEnv : undefined, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Return auth status for a provider, including request auth configured in models.json. + * This intentionally does not execute command-backed config values. + */ + getProviderAuthStatus(provider: string): AuthStatus { + const authStatus = this.authStorage.getAuthStatus(provider); + if (authStatus.source) { + return authStatus; + } + + const providerApiKey = this.providerRequestConfigs.get(provider)?.apiKey; + if (!providerApiKey) { + return authStatus; + } + + if (isCommandConfigValue(providerApiKey)) { + return { configured: true, source: "models_json_command" }; + } + + const envVarNames = getConfigValueEnvVarNames(providerApiKey); + if (envVarNames.length > 0) { + return isConfigValueConfigured(providerApiKey) + ? { configured: true, source: "environment", label: envVarNames.join(", ") } + : { configured: false }; + } + + return { configured: true, source: "models_json_key" }; + } + + /** + * Get display name for a provider. + */ + getProviderDisplayName(provider: string): string { + const registeredProvider = this.registeredProviders.get(provider); + const oauthProvider = this.authStorage.getOAuthProviders().find((p) => p.id === provider); + + return ( + registeredProvider?.name ?? + registeredProvider?.oauth?.name ?? + oauthProvider?.name ?? + BUILT_IN_PROVIDER_DISPLAY_NAMES[provider] ?? + provider + ); + } + + /** + * Get API key for a provider. + */ + async getApiKeyForProvider(provider: string): Promise { + const apiKey = await this.authStorage.getApiKey(provider); + if (apiKey !== undefined) { + return apiKey; + } + + const providerApiKey = this.providerRequestConfigs.get(provider)?.apiKey; + return providerApiKey + ? resolveConfigValueUncached(providerApiKey, this.authStorage.getProviderEnv(provider)) + : undefined; + } + + /** + * Check if a model is using OAuth credentials (subscription). + */ + isUsingOAuth(model: Model): boolean { + const cred = this.authStorage.get(model.provider); + return cred?.type === "oauth"; + } + + /** + * Register a provider dynamically (from extensions). + * + * If provider has models: replaces all existing models for this provider. + * If provider has only baseUrl/headers: overrides existing models' URLs. + * If provider has oauth: registers OAuth provider for /login support. + */ + registerProvider(providerName: string, config: ProviderConfigInput): void { + this.validateProviderConfig(providerName, config); + this.applyProviderConfig(providerName, config); + this.upsertRegisteredProvider(providerName, config); + } + + /** + * Unregister a previously registered provider. + * + * Removes the provider from the registry and reloads models from disk so that + * built-in models overridden by this provider are restored to their original state. + * Also resets dynamic OAuth and API stream registrations before reapplying + * remaining dynamic providers. + * Has no effect if the provider was never registered. + */ + unregisterProvider(providerName: string): void { + if (!this.registeredProviders.has(providerName)) return; + this.registeredProviders.delete(providerName); + this.refresh(); + } + + /** + * Upsert a provider config into registeredProviders. + * If the provider is already registered, defined values in the incoming config + * override existing ones; undefined values are preserved from the stored config. + * If the provider is not registered, the incoming config is stored as-is. + */ + private upsertRegisteredProvider(providerName: string, config: ProviderConfigInput): void { + const existing = this.registeredProviders.get(providerName); + if (!existing) { + this.registeredProviders.set(providerName, config); + return; + } + for (const k of Object.keys(config) as (keyof ProviderConfigInput)[]) { + if (config[k] !== undefined) { + (existing as Record)[k] = config[k]; + } + } + } + + private validateProviderConfig(providerName: string, config: ProviderConfigInput): void { + if (config.streamSimple && !config.api) { + throw new Error(`Provider ${providerName}: "api" is required when registering streamSimple.`); + } + + if (!config.models || config.models.length === 0) { + return; + } + + if (!config.baseUrl) { + throw new Error(`Provider ${providerName}: "baseUrl" is required when defining models.`); + } + if (!config.apiKey && !config.oauth) { + throw new Error(`Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`); + } + + for (const modelDef of config.models) { + const api = modelDef.api || config.api; + if (!api) { + throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`); + } + } + } + + private applyProviderConfig(providerName: string, config: ProviderConfigInput): void { + // Register OAuth provider if provided + if (config.oauth) { + // Ensure the OAuth provider ID matches the provider name + const oauthProvider: OAuthProviderInterface = { + ...config.oauth, + id: providerName, + }; + registerOAuthProvider(oauthProvider); + } + + if (config.streamSimple) { + const streamSimple = config.streamSimple; + registerApiProvider( + { + api: config.api!, + stream: (model, context, options) => streamSimple(model, context, options as SimpleStreamOptions), + streamSimple, + }, + `provider:${providerName}`, + ); + } + + this.storeProviderRequestConfig(providerName, config); + + if (config.models && config.models.length > 0) { + // Full replacement: remove existing models for this provider + this.models = this.models.filter((m) => m.provider !== providerName); + + // Parse and add new models + for (const modelDef of config.models) { + const api = modelDef.api || config.api; + this.storeModelHeaders(providerName, modelDef.id, modelDef.headers); + + this.models.push({ + id: modelDef.id, + name: modelDef.name, + api: api as Api, + provider: providerName, + baseUrl: modelDef.baseUrl ?? config.baseUrl!, + reasoning: modelDef.reasoning, + thinkingLevelMap: modelDef.thinkingLevelMap, + input: modelDef.input as ("text" | "image")[], + cost: modelDef.cost, + contextWindow: modelDef.contextWindow, + maxTokens: modelDef.maxTokens, + headers: undefined, + compat: modelDef.compat, + } as Model); + } + + // Apply OAuth modifyModels if credentials exist (e.g., to update baseUrl) + if (config.oauth?.modifyModels) { + const cred = this.authStorage.get(providerName); + if (cred?.type === "oauth") { + this.models = config.oauth.modifyModels(this.models, cred); + } + } + } else if (config.baseUrl || config.headers) { + // Override-only: update baseUrl for existing models. Request headers are resolved per request. + this.models = this.models.map((m) => { + if (m.provider !== providerName) return m; + return { + ...m, + baseUrl: config.baseUrl ?? m.baseUrl, + }; + }); + } + } +} + +/** + * Input type for registerProvider API. + */ +export interface ProviderConfigInput { + name?: string; + baseUrl?: string; + apiKey?: string; + api?: Api; + streamSimple?: (model: Model, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream; + headers?: Record; + authHeader?: boolean; + /** OAuth provider for /login support */ + oauth?: Omit; + models?: Array<{ + id: string; + name: string; + api?: Api; + baseUrl?: string; + reasoning: boolean; + thinkingLevelMap?: Model["thinkingLevelMap"]; + input: ("text" | "image")[]; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + contextWindow: number; + maxTokens: number; + headers?: Record; + compat?: Model["compat"]; + }>; +} diff --git a/cactus-code/packages/coding-agent/src/core/model-resolver.ts b/cactus-code/packages/coding-agent/src/core/model-resolver.ts new file mode 100644 index 000000000..5616bc12f --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/model-resolver.ts @@ -0,0 +1,647 @@ +/** + * Model resolution, scoping, and initial selection + */ + +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@earendil-works/pi-ai"; +import chalk from "chalk"; +import { minimatch } from "minimatch"; +import { isValidThinkingLevel } from "../cli/args.ts"; +import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import type { ModelRegistry } from "./model-registry.ts"; + +/** Default model IDs for each known provider */ +export const defaultModelPerProvider: Record = { + cactus: "cactus", +}; + +export interface ScopedModel { + model: Model; + /** Thinking level if explicitly specified in pattern (e.g., "model:high"), undefined otherwise */ + thinkingLevel?: ThinkingLevel; +} + +/** + * Helper to check if a model ID looks like an alias (no date suffix) + * Dates are typically in format: -20241022 or -20250929 + */ +function isAlias(id: string): boolean { + // Check if ID ends with -latest + if (id.endsWith("-latest")) return true; + + // Check if ID ends with a date pattern (-YYYYMMDD) + const datePattern = /-\d{8}$/; + return !datePattern.test(id); +} + +/** + * Find an exact model reference match. + * Supports either a bare model id or a canonical provider/modelId reference. + * When matching by bare id, ambiguous matches across providers are rejected. + */ +export function findExactModelReferenceMatch( + modelReference: string, + availableModels: Model[], +): Model | undefined { + const trimmedReference = modelReference.trim(); + if (!trimmedReference) { + return undefined; + } + + const normalizedReference = trimmedReference.toLowerCase(); + + const canonicalMatches = availableModels.filter( + (model) => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference, + ); + if (canonicalMatches.length === 1) { + return canonicalMatches[0]; + } + if (canonicalMatches.length > 1) { + return undefined; + } + + const slashIndex = trimmedReference.indexOf("/"); + if (slashIndex !== -1) { + const provider = trimmedReference.substring(0, slashIndex).trim(); + const modelId = trimmedReference.substring(slashIndex + 1).trim(); + if (provider && modelId) { + const providerMatches = availableModels.filter( + (model) => + model.provider.toLowerCase() === provider.toLowerCase() && + model.id.toLowerCase() === modelId.toLowerCase(), + ); + if (providerMatches.length === 1) { + return providerMatches[0]; + } + if (providerMatches.length > 1) { + return undefined; + } + } + } + + const idMatches = availableModels.filter((model) => model.id.toLowerCase() === normalizedReference); + return idMatches.length === 1 ? idMatches[0] : undefined; +} + +/** + * Try to match a pattern to a model from the available models list. + * Returns the matched model or undefined if no match found. + */ +function tryMatchModel(modelPattern: string, availableModels: Model[]): Model | undefined { + const exactMatch = findExactModelReferenceMatch(modelPattern, availableModels); + if (exactMatch) { + return exactMatch; + } + + // No exact match - fall back to partial matching + const matches = availableModels.filter( + (m) => + m.id.toLowerCase().includes(modelPattern.toLowerCase()) || + m.name?.toLowerCase().includes(modelPattern.toLowerCase()), + ); + + if (matches.length === 0) { + return undefined; + } + + // Separate into aliases and dated versions + const aliases = matches.filter((m) => isAlias(m.id)); + const datedVersions = matches.filter((m) => !isAlias(m.id)); + + if (aliases.length > 0) { + // Prefer alias - if multiple aliases, pick the one that sorts highest + aliases.sort((a, b) => b.id.localeCompare(a.id)); + return aliases[0]; + } else { + // No alias found, pick latest dated version + datedVersions.sort((a, b) => b.id.localeCompare(a.id)); + return datedVersions[0]; + } +} + +export interface ParsedModelResult { + model: Model | undefined; + /** Thinking level if explicitly specified in pattern, undefined otherwise */ + thinkingLevel?: ThinkingLevel; + warning: string | undefined; +} + +function buildFallbackModel(provider: string, modelId: string, availableModels: Model[]): Model | undefined { + const providerModels = availableModels.filter((m) => m.provider === provider); + if (providerModels.length === 0) return undefined; + + const defaultId = defaultModelPerProvider[provider as KnownProvider]; + const baseModel = defaultId + ? (providerModels.find((m) => m.id === defaultId) ?? providerModels[0]) + : providerModels[0]; + + return { + ...baseModel, + id: modelId, + name: modelId, + }; +} + +/** + * Parse a pattern to extract model and thinking level. + * Handles models with colons in their IDs (e.g., OpenRouter's :exacto suffix). + * + * Algorithm: + * 1. Try to match full pattern as a model + * 2. If found, return it with "off" thinking level + * 3. If not found and has colons, split on last colon: + * - If suffix is valid thinking level, use it and recurse on prefix + * - If suffix is invalid, warn and recurse on prefix with "off" + * + * @internal Exported for testing + */ +export function parseModelPattern( + pattern: string, + availableModels: Model[], + options?: { allowInvalidThinkingLevelFallback?: boolean }, +): ParsedModelResult { + // Try exact match first + const exactMatch = tryMatchModel(pattern, availableModels); + if (exactMatch) { + return { model: exactMatch, thinkingLevel: undefined, warning: undefined }; + } + + // No match - try splitting on last colon if present + const lastColonIndex = pattern.lastIndexOf(":"); + if (lastColonIndex === -1) { + // No colons, pattern simply doesn't match any model + return { model: undefined, thinkingLevel: undefined, warning: undefined }; + } + + const prefix = pattern.substring(0, lastColonIndex); + const suffix = pattern.substring(lastColonIndex + 1); + + if (isValidThinkingLevel(suffix)) { + // Valid thinking level - recurse on prefix and use this level + const result = parseModelPattern(prefix, availableModels, options); + if (result.model) { + // Only use this thinking level if no warning from inner recursion + return { + model: result.model, + thinkingLevel: result.warning ? undefined : suffix, + warning: result.warning, + }; + } + return result; + } else { + // Invalid suffix + const allowFallback = options?.allowInvalidThinkingLevelFallback ?? true; + if (!allowFallback) { + // In strict mode (CLI --model parsing), treat it as part of the model id and fail. + // This avoids accidentally resolving to a different model. + return { model: undefined, thinkingLevel: undefined, warning: undefined }; + } + + // Scope mode: recurse on prefix and warn + const result = parseModelPattern(prefix, availableModels, options); + if (result.model) { + return { + model: result.model, + thinkingLevel: undefined, + warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using default instead.`, + }; + } + return result; + } +} + +/** + * Resolve model patterns to actual Model objects with optional thinking levels + * Format: "pattern:level" where :level is optional + * For each pattern, finds all matching models and picks the best version: + * 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929) + * 2. If no alias, pick the latest dated version + * + * Supports models with colons in their IDs (e.g., OpenRouter's model:exacto). + * The algorithm tries to match the full pattern first, then progressively + * strips colon-suffixes to find a match. + */ +export async function resolveModelScope(patterns: string[], modelRegistry: ModelRegistry): Promise { + const availableModels = await modelRegistry.getAvailable(); + const scopedModels: ScopedModel[] = []; + + for (const pattern of patterns) { + // Check if pattern contains glob characters + if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) { + // Extract optional thinking level suffix (e.g., "provider/*:high") + const colonIdx = pattern.lastIndexOf(":"); + let globPattern = pattern; + let thinkingLevel: ThinkingLevel | undefined; + + if (colonIdx !== -1) { + const suffix = pattern.substring(colonIdx + 1); + if (isValidThinkingLevel(suffix)) { + thinkingLevel = suffix; + globPattern = pattern.substring(0, colonIdx); + } + } + + // Match against "provider/modelId" format OR just model ID + // This allows "*sonnet*" to match without requiring "anthropic/*sonnet*" + const matchingModels = availableModels.filter((m) => { + const fullId = `${m.provider}/${m.id}`; + return minimatch(fullId, globPattern, { nocase: true }) || minimatch(m.id, globPattern, { nocase: true }); + }); + + if (matchingModels.length === 0) { + console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`)); + continue; + } + + for (const model of matchingModels) { + if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) { + scopedModels.push({ model, thinkingLevel }); + } + } + continue; + } + + const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels); + + if (warning) { + console.warn(chalk.yellow(`Warning: ${warning}`)); + } + + if (!model) { + console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`)); + continue; + } + + // Avoid duplicates + if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) { + scopedModels.push({ model, thinkingLevel }); + } + } + + return scopedModels; +} + +export interface ResolveCliModelResult { + model: Model | undefined; + thinkingLevel?: ThinkingLevel; + warning: string | undefined; + /** + * Error message suitable for CLI display. + * When set, model will be undefined. + */ + error: string | undefined; +} + +/** + * Resolve a single model from CLI flags. + * + * Supports: + * - --provider --model + * - --model / + * - Fuzzy matching (same rules as model scoping: exact id, then partial id/name) + * + * Note: This does not apply the thinking level by itself, but it may *parse* and + * return a thinking level from ":" so the caller can apply it. + */ +export function resolveCliModel(options: { + cliProvider?: string; + cliModel?: string; + cliThinking?: ThinkingLevel; + modelRegistry: ModelRegistry; +}): ResolveCliModelResult { + const { cliProvider, cliModel, cliThinking, modelRegistry } = options; + + if (!cliModel) { + return { model: undefined, warning: undefined, error: undefined }; + } + + // Important: use *all* models here, not just models with pre-configured auth. + // This allows "--api-key" to be used for first-time setup. + const availableModels = modelRegistry.getAll(); + if (availableModels.length === 0) { + return { + model: undefined, + warning: undefined, + error: "No models available. Check your installation or add models to models.json.", + }; + } + + // Build canonical provider lookup (case-insensitive) + const providerMap = new Map(); + for (const m of availableModels) { + providerMap.set(m.provider.toLowerCase(), m.provider); + } + + let provider = cliProvider ? providerMap.get(cliProvider.toLowerCase()) : undefined; + if (cliProvider && !provider) { + return { + model: undefined, + warning: undefined, + error: `Unknown provider "${cliProvider}". Use --list-models to see available providers/models.`, + }; + } + + // If no explicit --provider, try to interpret "provider/model" format first. + // When the prefix before the first slash matches a known provider, prefer that + // interpretation over matching models whose IDs literally contain slashes + // (e.g. "zai/glm-5" should resolve to provider=zai, model=glm-5, not to a + // vercel-ai-gateway model with id "zai/glm-5"). + let pattern = cliModel; + let inferredProvider = false; + + if (!provider) { + const slashIndex = cliModel.indexOf("/"); + if (slashIndex !== -1) { + const maybeProvider = cliModel.substring(0, slashIndex); + const canonical = providerMap.get(maybeProvider.toLowerCase()); + if (canonical) { + provider = canonical; + pattern = cliModel.substring(slashIndex + 1); + inferredProvider = true; + } + } + } + + // If no provider was inferred from the slash, try exact matches without provider inference. + // This handles models whose IDs naturally contain slashes (e.g. OpenRouter-style IDs). + if (!provider) { + const lower = cliModel.toLowerCase(); + const exact = availableModels.find( + (m) => m.id.toLowerCase() === lower || `${m.provider}/${m.id}`.toLowerCase() === lower, + ); + if (exact) { + return { model: exact, warning: undefined, thinkingLevel: undefined, error: undefined }; + } + } + + if (cliProvider && provider) { + // If both were provided, tolerate --model / by stripping the provider prefix + const prefix = `${provider}/`; + if (cliModel.toLowerCase().startsWith(prefix.toLowerCase())) { + pattern = cliModel.substring(prefix.length); + } + } + + const candidates = provider ? availableModels.filter((m) => m.provider === provider) : availableModels; + const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, { + allowInvalidThinkingLevelFallback: false, + }); + + if (model) { + // If provider inference matched an unauthenticated provider/model pair, prefer + // one exact raw model-id match that is authenticated. This keeps + // "provider/model" syntax preferred when usable, but handles models whose + // literal id starts with a known provider name (for example + // commandcode model id "xiaomi/mimo-v2.5-pro"). + if (inferredProvider) { + const rawExactMatches = availableModels.filter( + (m) => m.id.toLowerCase() === cliModel.toLowerCase() && !modelsAreEqual(m, model), + ); + if (rawExactMatches.length > 0 && !modelRegistry.hasConfiguredAuth(model)) { + const authenticatedRawMatches = rawExactMatches.filter((m) => modelRegistry.hasConfiguredAuth(m)); + if (authenticatedRawMatches.length === 1) { + return { + model: authenticatedRawMatches[0], + thinkingLevel: undefined, + warning: undefined, + error: undefined, + }; + } + } + } + return { model, thinkingLevel, warning, error: undefined }; + } + + // If we inferred a provider from the slash but found no match within that provider, + // fall back to matching the full input as a raw model id across all models. + // This handles OpenRouter-style IDs like "openai/gpt-4o:extended" where "openai" + // looks like a provider but the full string is actually a model id on openrouter. + if (inferredProvider) { + const lower = cliModel.toLowerCase(); + const exact = availableModels.find( + (m) => m.id.toLowerCase() === lower || `${m.provider}/${m.id}`.toLowerCase() === lower, + ); + if (exact) { + return { model: exact, warning: undefined, thinkingLevel: undefined, error: undefined }; + } + // Also try parseModelPattern on the full input against all models + const fallback = parseModelPattern(cliModel, availableModels, { + allowInvalidThinkingLevelFallback: false, + }); + if (fallback.model) { + return { + model: fallback.model, + thinkingLevel: fallback.thinkingLevel, + warning: fallback.warning, + error: undefined, + }; + } + } + + if (provider) { + // Parse thinking level suffix from the pattern before building the fallback model, + // but only when --thinking is not explicitly provided. + // e.g. "zai-org/GLM-5.1-FP8:high" → modelId="zai-org/GLM-5.1-FP8", fallbackThinking="high" + let fallbackPattern = pattern; + let fallbackThinking: ThinkingLevel | undefined; + if (!cliThinking) { + const lastColon = pattern.lastIndexOf(":"); + if (lastColon !== -1) { + const suffix = pattern.substring(lastColon + 1); + if (isValidThinkingLevel(suffix)) { + fallbackPattern = pattern.substring(0, lastColon); + fallbackThinking = suffix; + } + } + } + + const fallbackModel = buildFallbackModel(provider, fallbackPattern, availableModels); + if (fallbackModel) { + const requestedThinking = cliThinking ?? fallbackThinking; + const model = + requestedThinking && requestedThinking !== "off" ? { ...fallbackModel, reasoning: true } : fallbackModel; + const fallbackWarning = warning + ? `${warning} Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.` + : `Model "${fallbackPattern}" not found for provider "${provider}". Using custom model id.`; + return { model, thinkingLevel: fallbackThinking, warning: fallbackWarning, error: undefined }; + } + } + + const display = provider ? `${provider}/${pattern}` : cliModel; + return { + model: undefined, + thinkingLevel: undefined, + warning, + error: `Model "${display}" not found. Use --list-models to see available models.`, + }; +} + +export interface InitialModelResult { + model: Model | undefined; + thinkingLevel: ThinkingLevel; + fallbackMessage: string | undefined; +} + +/** + * Find the initial model to use based on priority: + * 1. CLI args (provider + model) + * 2. First model from scoped models (if not continuing/resuming) + * 3. Restored from session (if continuing/resuming) + * 4. Saved default from settings + * 5. First available model with valid API key + */ +export async function findInitialModel(options: { + cliProvider?: string; + cliModel?: string; + scopedModels: ScopedModel[]; + isContinuing: boolean; + defaultProvider?: string; + defaultModelId?: string; + defaultThinkingLevel?: ThinkingLevel; + modelRegistry: ModelRegistry; +}): Promise { + const { + cliProvider, + cliModel, + scopedModels, + isContinuing, + defaultProvider, + defaultModelId, + defaultThinkingLevel, + modelRegistry, + } = options; + + let model: Model | undefined; + let thinkingLevel: ThinkingLevel = DEFAULT_THINKING_LEVEL; + + // 1. CLI args take priority + if (cliProvider && cliModel) { + const resolved = resolveCliModel({ + cliProvider, + cliModel, + modelRegistry, + }); + if (resolved.error) { + console.error(chalk.red(resolved.error)); + process.exit(1); + } + if (resolved.model) { + return { model: resolved.model, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined }; + } + } + + // 2. Use first model from scoped models (skip if continuing/resuming) + if (scopedModels.length > 0 && !isContinuing) { + return { + model: scopedModels[0].model, + thinkingLevel: scopedModels[0].thinkingLevel ?? defaultThinkingLevel ?? DEFAULT_THINKING_LEVEL, + fallbackMessage: undefined, + }; + } + + // 3. Try saved default from settings + if (defaultProvider && defaultModelId) { + const found = modelRegistry.find(defaultProvider, defaultModelId); + if (found) { + model = found; + if (defaultThinkingLevel) { + thinkingLevel = defaultThinkingLevel; + } + return { model, thinkingLevel, fallbackMessage: undefined }; + } + } + + // 4. Try first available model with valid API key + const availableModels = await modelRegistry.getAvailable(); + + if (availableModels.length > 0) { + // Try to find a default model from known providers + for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { + const defaultId = defaultModelPerProvider[provider]; + const match = availableModels.find((m) => m.provider === provider && m.id === defaultId); + if (match) { + return { model: match, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined }; + } + } + + // If no default found, use first available + return { model: availableModels[0], thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined }; + } + + // 5. No model found + return { model: undefined, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined }; +} + +/** + * Restore model from session, with fallback to available models + */ +export async function restoreModelFromSession( + savedProvider: string, + savedModelId: string, + currentModel: Model | undefined, + shouldPrintMessages: boolean, + modelRegistry: ModelRegistry, +): Promise<{ model: Model | undefined; fallbackMessage: string | undefined }> { + const restoredModel = modelRegistry.find(savedProvider, savedModelId); + + // Check if restored model exists and still has auth configured + const hasConfiguredAuth = restoredModel ? modelRegistry.hasConfiguredAuth(restoredModel) : false; + + if (restoredModel && hasConfiguredAuth) { + if (shouldPrintMessages) { + console.log(chalk.dim(`Restored model: ${savedProvider}/${savedModelId}`)); + } + return { model: restoredModel, fallbackMessage: undefined }; + } + + // Model not found or no API key - fall back + const reason = !restoredModel ? "model no longer exists" : "no auth configured"; + + if (shouldPrintMessages) { + console.error(chalk.yellow(`Warning: Could not restore model ${savedProvider}/${savedModelId} (${reason}).`)); + } + + // If we already have a model, use it as fallback + if (currentModel) { + if (shouldPrintMessages) { + console.log(chalk.dim(`Falling back to: ${currentModel.provider}/${currentModel.id}`)); + } + return { + model: currentModel, + fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${currentModel.provider}/${currentModel.id}.`, + }; + } + + // Try to find any available model + const availableModels = await modelRegistry.getAvailable(); + + if (availableModels.length > 0) { + // Try to find a default model from known providers + let fallbackModel: Model | undefined; + for (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) { + const defaultId = defaultModelPerProvider[provider]; + const match = availableModels.find((m) => m.provider === provider && m.id === defaultId); + if (match) { + fallbackModel = match; + break; + } + } + + // If no default found, use first available + if (!fallbackModel) { + fallbackModel = availableModels[0]; + } + + if (shouldPrintMessages) { + console.log(chalk.dim(`Falling back to: ${fallbackModel.provider}/${fallbackModel.id}`)); + } + + return { + model: fallbackModel, + fallbackMessage: `Could not restore model ${savedProvider}/${savedModelId} (${reason}). Using ${fallbackModel.provider}/${fallbackModel.id}.`, + }; + } + + // No models available + return { model: undefined, fallbackMessage: undefined }; +} diff --git a/cactus-code/packages/coding-agent/src/core/output-guard.ts b/cactus-code/packages/coding-agent/src/core/output-guard.ts new file mode 100644 index 000000000..8781a51dc --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/output-guard.ts @@ -0,0 +1,108 @@ +interface StdoutTakeoverState { + rawStdoutWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean; + rawStderrWrite: (chunk: string, callback?: (error?: Error | null) => void) => boolean; + originalStdoutWrite: typeof process.stdout.write; +} + +let stdoutTakeoverState: StdoutTakeoverState | undefined; + +const RAW_STDOUT_RETRY_DELAY_MS = 10; + +let rawStdoutWriteTail: Promise = Promise.resolve(); + +function getRawStdoutWrite(): StdoutTakeoverState["rawStdoutWrite"] { + if (stdoutTakeoverState) { + return stdoutTakeoverState.rawStdoutWrite; + } + return process.stdout.write.bind(process.stdout) as StdoutTakeoverState["rawStdoutWrite"]; +} + +async function writeRawStdoutChunk(text: string): Promise { + while (true) { + try { + await new Promise((resolve, reject) => { + try { + getRawStdoutWrite()(text, (error) => { + if (error) reject(error); + else resolve(); + }); + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + return; + } catch (error) { + const writeError = error instanceof Error ? error : new Error(String(error)); + const code = (writeError as Error & { code?: unknown }).code; + if (code !== "ENOBUFS" && code !== "EAGAIN" && code !== "EWOULDBLOCK") { + throw writeError; + } + await new Promise((resolve) => setTimeout(resolve, RAW_STDOUT_RETRY_DELAY_MS)); + } + } +} + +export function takeOverStdout(): void { + if (stdoutTakeoverState) { + return; + } + + const rawStdoutWrite = process.stdout.write.bind(process.stdout) as StdoutTakeoverState["rawStdoutWrite"]; + const rawStderrWrite = process.stderr.write.bind(process.stderr) as StdoutTakeoverState["rawStderrWrite"]; + const originalStdoutWrite = process.stdout.write; + + process.stdout.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, + ): boolean => { + if (typeof encodingOrCallback === "function") { + return rawStderrWrite(String(chunk), encodingOrCallback); + } + return rawStderrWrite(String(chunk), callback); + }) as typeof process.stdout.write; + + stdoutTakeoverState = { + rawStdoutWrite, + rawStderrWrite, + originalStdoutWrite, + }; +} + +export function restoreStdout(): void { + if (!stdoutTakeoverState) { + return; + } + + process.stdout.write = stdoutTakeoverState.originalStdoutWrite; + stdoutTakeoverState = undefined; +} + +export function isStdoutTakenOver(): boolean { + return stdoutTakeoverState !== undefined; +} + +export function writeRawStdout(text: string): void { + if (text.length === 0) { + return; + } + rawStdoutWriteTail = rawStdoutWriteTail.then(() => writeRawStdoutChunk(text)); + void rawStdoutWriteTail.catch(() => { + process.exit(1); + }); +} + +export async function waitForRawStdoutBackpressure(): Promise { + while (true) { + const tail = rawStdoutWriteTail; + await tail; + if (tail === rawStdoutWriteTail) { + return; + } + } +} + +export async function flushRawStdout(): Promise { + await waitForRawStdoutBackpressure(); + await writeRawStdoutChunk(""); +} diff --git a/cactus-code/packages/coding-agent/src/core/package-manager.ts b/cactus-code/packages/coding-agent/src/core/package-manager.ts new file mode 100644 index 000000000..ae81663d0 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/package-manager.ts @@ -0,0 +1,2561 @@ +import type { ChildProcess, ChildProcessByStdio } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; + +function getEnv(): NodeJS.ProcessEnv { + if (process.platform !== "linux" || Object.keys(process.env).length > 0) { + return process.env; + } + try { + const data = readFileSync("/proc/self/environ", "utf-8"); + const env: NodeJS.ProcessEnv = {}; + for (const entry of data.split("\0")) { + const idx = entry.indexOf("="); + if (idx > 0) { + env[entry.slice(0, idx)] = entry.slice(idx + 1); + } + } + return env; + } catch { + return process.env; + } +} + +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import type { Readable } from "node:stream"; +import { globSync } from "glob"; +import ignore from "ignore"; +import { minimatch } from "minimatch"; +import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; +import { CONFIG_DIR_NAME } from "../config.ts"; +import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; +import { type GitSource, parseGitUrl } from "../utils/git.ts"; +import { canonicalizePath, isLocalPath, markPathIgnoredByCloudSync, resolvePath } from "../utils/paths.ts"; +import { isStdoutTakenOver } from "./output-guard.ts"; +import type { PackageSource, SettingsManager } from "./settings-manager.ts"; + +const NETWORK_TIMEOUT_MS = 10000; +const UPDATE_CHECK_CONCURRENCY = 4; +const GIT_UPDATE_CONCURRENCY = 4; + +function isOfflineModeEnabled(): boolean { + const value = process.env.PI_OFFLINE; + if (!value) return false; + return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; +} + +function isExactNpmVersion(version: string | undefined): boolean { + return valid(version ?? "") !== null; +} + +function getNpmVersionRange(version: string | undefined): string | undefined { + return version ? (validRange(version) ?? undefined) : undefined; +} + +export interface PathMetadata { + source: string; + scope: SourceScope; + origin: "package" | "top-level"; + baseDir?: string; +} + +export interface ResolvedResource { + path: string; + enabled: boolean; + metadata: PathMetadata; +} + +export interface ResolvedPaths { + extensions: ResolvedResource[]; + skills: ResolvedResource[]; + prompts: ResolvedResource[]; + themes: ResolvedResource[]; +} + +export type MissingSourceAction = "install" | "skip" | "error"; + +export interface ProgressEvent { + type: "start" | "progress" | "complete" | "error"; + action: "install" | "remove" | "update" | "clone" | "pull"; + source: string; + message?: string; +} + +export type ProgressCallback = (event: ProgressEvent) => void; + +export interface PackageUpdate { + source: string; + displayName: string; + type: "npm" | "git"; + scope: Exclude; +} + +export interface ConfiguredPackage { + source: string; + scope: "user" | "project"; + filtered: boolean; + installedPath?: string; +} + +export interface PackageManager { + resolve(onMissing?: (source: string) => Promise): Promise; + install(source: string, options?: { local?: boolean }): Promise; + installAndPersist(source: string, options?: { local?: boolean }): Promise; + remove(source: string, options?: { local?: boolean }): Promise; + removeAndPersist(source: string, options?: { local?: boolean }): Promise; + update(source?: string): Promise; + listConfiguredPackages(): ConfiguredPackage[]; + resolveExtensionSources( + sources: string[], + options?: { local?: boolean; temporary?: boolean }, + ): Promise; + addSourceToSettings(source: string, options?: { local?: boolean }): boolean; + removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean; + setProgressCallback(callback: ProgressCallback | undefined): void; + getInstalledPath(source: string, scope: "user" | "project"): string | undefined; +} + +interface PackageManagerOptions { + cwd: string; + agentDir: string; + settingsManager: SettingsManager; +} + +type SourceScope = "user" | "project" | "temporary"; + +type NpmSource = { + type: "npm"; + spec: string; + name: string; + version?: string; + range?: string; + pinned: boolean; +}; + +type LocalSource = { + type: "local"; + path: string; +}; + +type ParsedSource = NpmSource | GitSource | LocalSource; + +type InstalledSourceScope = Exclude; + +interface ConfiguredUpdateSource { + source: string; + scope: InstalledSourceScope; +} + +interface NpmUpdateTarget extends ConfiguredUpdateSource { + parsed: NpmSource; +} + +interface GitUpdateTarget extends ConfiguredUpdateSource { + parsed: GitSource; +} + +interface PiManifest { + extensions?: string[]; + skills?: string[]; + prompts?: string[]; + themes?: string[]; +} + +interface ResourceAccumulator { + extensions: Map; + skills: Map; + prompts: Map; + themes: Map; +} + +/** + * Compute a numeric precedence rank for a resource based on its metadata. + * Lower rank = higher precedence. Used to sort resolved resources so that + * name-collision resolution ("first wins") produces the correct outcome. + * + * Precedence (highest to lowest): + * 0 project + settings entry (source: "local", scope: "project") + * 1 project + auto-discovered (source: "auto", scope: "project") + * 2 user + settings entry (source: "local", scope: "user") + * 3 user + auto-discovered (source: "auto", scope: "user") + * 4 package resource (origin: "package") + */ +function resourcePrecedenceRank(m: PathMetadata): number { + if (m.origin === "package") return 4; + const scopeBase = m.scope === "project" ? 0 : 2; + return scopeBase + (m.source === "local" ? 0 : 1); +} + +interface PackageFilter { + extensions?: string[]; + skills?: string[]; + prompts?: string[]; + themes?: string[]; +} + +type ResourceType = "extensions" | "skills" | "prompts" | "themes"; + +const RESOURCE_TYPES: ResourceType[] = ["extensions", "skills", "prompts", "themes"]; + +const FILE_PATTERNS: Record = { + extensions: /\.(ts|js)$/, + skills: /\.md$/, + prompts: /\.md$/, + themes: /\.json$/, +}; + +const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; + +type IgnoreMatcher = ReturnType; + +function toPosixPath(p: string): string { + return p.split(sep).join("/"); +} + +function getHomeDir(): string { + return process.env.HOME || homedir(); +} + +export function getExtensionTempFolder(agentDir: string): string { + const tempFolder = join(agentDir, "tmp", "extensions"); + mkdirSync(tempFolder, { recursive: true, mode: 0o700 }); + chmodSync(tempFolder, 0o700); + return tempFolder; +} + +function prefixIgnorePattern(line: string, prefix: string): string | null { + const trimmed = line.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null; + + let pattern = line; + let negated = false; + + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + + if (pattern.startsWith("/")) { + pattern = pattern.slice(1); + } + + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + return negated ? `!${prefixed}` : prefixed; +} + +function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void { + const relativeDir = relative(rootDir, dir); + const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : ""; + + for (const filename of IGNORE_FILE_NAMES) { + const ignorePath = join(dir, filename); + if (!existsSync(ignorePath)) continue; + try { + const content = readFileSync(ignorePath, "utf-8"); + const patterns = content + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) { + ig.add(patterns); + } + } catch {} + } +} + +function isPattern(s: string): boolean { + return s.startsWith("!") || s.startsWith("+") || s.startsWith("-") || s.includes("*") || s.includes("?"); +} + +function isOverridePattern(s: string): boolean { + return s.startsWith("!") || s.startsWith("+") || s.startsWith("-"); +} + +function hasGlobPattern(s: string): boolean { + return s.includes("*") || s.includes("?"); +} + +function splitPatterns(entries: string[]): { plain: string[]; patterns: string[] } { + const plain: string[] = []; + const patterns: string[] = []; + for (const entry of entries) { + if (isPattern(entry)) { + patterns.push(entry); + } else { + plain.push(entry); + } + } + return { plain, patterns }; +} + +function collectFiles( + dir: string, + filePattern: RegExp, + skipNodeModules = true, + ignoreMatcher?: IgnoreMatcher, + rootDir?: string, +): string[] { + const files: string[] = []; + if (!existsSync(dir)) return files; + + const root = rootDir ?? dir; + const ig = ignoreMatcher ?? ignore(); + addIgnoreRules(ig, dir, root); + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + if (skipNodeModules && entry.name === "node_modules") continue; + + const fullPath = join(dir, entry.name); + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + const ignorePath = isDir ? `${relPath}/` : relPath; + if (ig.ignores(ignorePath)) continue; + + if (isDir) { + files.push(...collectFiles(fullPath, filePattern, skipNodeModules, ig, root)); + } else if (isFile && filePattern.test(entry.name)) { + files.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return files; +} + +type SkillDiscoveryMode = "pi" | "agents"; + +function collectSkillEntries( + dir: string, + mode: SkillDiscoveryMode, + ignoreMatcher?: IgnoreMatcher, + rootDir?: string, +): string[] { + const entries: string[] = []; + if (!existsSync(dir)) return entries; + + const root = rootDir ?? dir; + const ig = ignoreMatcher ?? ignore(); + addIgnoreRules(ig, dir, root); + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of dirEntries) { + if (entry.name !== "SKILL.md") { + continue; + } + + const fullPath = join(dir, entry.name); + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + if (isFile && !ig.ignores(relPath)) { + entries.push(fullPath); + return entries; + } + } + + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) continue; + if (entry.name === "node_modules") continue; + + const fullPath = join(dir, entry.name); + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + if (mode === "pi" && dir === root && isFile && entry.name.endsWith(".md") && !ig.ignores(relPath)) { + entries.push(fullPath); + continue; + } + + if (!isDir) continue; + if (ig.ignores(`${relPath}/`)) continue; + + entries.push(...collectSkillEntries(fullPath, mode, ig, root)); + } + } catch { + // Ignore errors + } + + return entries; +} + +function collectAutoSkillEntries(dir: string, mode: SkillDiscoveryMode): string[] { + return collectSkillEntries(dir, mode); +} + +function findGitRepoRoot(startDir: string): string | null { + let dir = resolve(startDir); + while (true) { + if (existsSync(join(dir, ".git"))) { + return dir; + } + const parent = dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +function collectAncestorAgentsSkillDirs(startDir: string): string[] { + const skillDirs: string[] = []; + const resolvedStartDir = resolve(startDir); + const gitRepoRoot = findGitRepoRoot(resolvedStartDir); + + let dir = resolvedStartDir; + while (true) { + skillDirs.push(join(dir, ".agents", "skills")); + if (gitRepoRoot && dir === gitRepoRoot) { + break; + } + const parent = dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + + return skillDirs; +} + +function collectAutoPromptEntries(dir: string): string[] { + const entries: string[] = []; + if (!existsSync(dir)) return entries; + + const ig = ignore(); + addIgnoreRules(ig, dir, dir); + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) continue; + if (entry.name === "node_modules") continue; + + const fullPath = join(dir, entry.name); + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(dir, fullPath)); + if (ig.ignores(relPath)) continue; + + if (isFile && entry.name.endsWith(".md")) { + entries.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return entries; +} + +function collectAutoThemeEntries(dir: string): string[] { + const entries: string[] = []; + if (!existsSync(dir)) return entries; + + const ig = ignore(); + addIgnoreRules(ig, dir, dir); + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) continue; + if (entry.name === "node_modules") continue; + + const fullPath = join(dir, entry.name); + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(dir, fullPath)); + if (ig.ignores(relPath)) continue; + + if (isFile && entry.name.endsWith(".json")) { + entries.push(fullPath); + } + } + } catch { + // Ignore errors + } + + return entries; +} + +function readPiManifestFile(packageJsonPath: string): PiManifest | null { + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content) as { pi?: PiManifest }; + return pkg.pi ?? null; + } catch { + return null; + } +} + +function resolveExtensionEntries(dir: string): string[] | null { + const packageJsonPath = join(dir, "package.json"); + if (existsSync(packageJsonPath)) { + const manifest = readPiManifestFile(packageJsonPath); + if (manifest?.extensions?.length) { + const entries: string[] = []; + for (const extPath of manifest.extensions) { + const resolvedExtPath = resolve(dir, extPath); + if (existsSync(resolvedExtPath)) { + entries.push(resolvedExtPath); + } + } + if (entries.length > 0) { + return entries; + } + } + } + + const indexTs = join(dir, "index.ts"); + const indexJs = join(dir, "index.js"); + if (existsSync(indexTs)) { + return [indexTs]; + } + if (existsSync(indexJs)) { + return [indexJs]; + } + + return null; +} + +function collectAutoExtensionEntries(dir: string): string[] { + const entries: string[] = []; + if (!existsSync(dir)) return entries; + + // First check if this directory itself has explicit extension entries (package.json or index) + const rootEntries = resolveExtensionEntries(dir); + if (rootEntries) { + return rootEntries; + } + + // Otherwise, discover extensions from directory contents + const ig = ignore(); + addIgnoreRules(ig, dir, dir); + + try { + const dirEntries = readdirSync(dir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (entry.name.startsWith(".")) continue; + if (entry.name === "node_modules") continue; + + const fullPath = join(dir, entry.name); + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDir = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(dir, fullPath)); + const ignorePath = isDir ? `${relPath}/` : relPath; + if (ig.ignores(ignorePath)) continue; + + if (isFile && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { + entries.push(fullPath); + } else if (isDir) { + const resolvedEntries = resolveExtensionEntries(fullPath); + if (resolvedEntries) { + entries.push(...resolvedEntries); + } + } + } + } catch { + // Ignore errors + } + + return entries; +} + +/** + * Collect resource files from a directory based on resource type. + * Extensions use smart discovery (index.ts in subdirs), others use recursive collection. + */ +function collectResourceFiles(dir: string, resourceType: ResourceType): string[] { + if (resourceType === "skills") { + return collectSkillEntries(dir, "pi"); + } + if (resourceType === "extensions") { + return collectAutoExtensionEntries(dir); + } + return collectFiles(dir, FILE_PATTERNS[resourceType]); +} + +function matchesAnyPattern(filePath: string, patterns: string[], baseDir: string): boolean { + const rel = toPosixPath(relative(baseDir, filePath)); + const name = basename(filePath); + const filePathPosix = toPosixPath(filePath); + const isSkillFile = name === "SKILL.md"; + const parentDir = isSkillFile ? dirname(filePath) : undefined; + const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined; + const parentName = isSkillFile ? basename(parentDir!) : undefined; + const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined; + + return patterns.some((pattern) => { + const normalizedPattern = toPosixPath(pattern); + if ( + minimatch(rel, normalizedPattern) || + minimatch(name, normalizedPattern) || + minimatch(filePathPosix, normalizedPattern) + ) { + return true; + } + if (!isSkillFile) return false; + return ( + minimatch(parentRel!, normalizedPattern) || + minimatch(parentName!, normalizedPattern) || + minimatch(parentDirPosix!, normalizedPattern) + ); + }); +} + +function normalizeExactPattern(pattern: string): string { + const normalized = pattern.startsWith("./") || pattern.startsWith(".\\") ? pattern.slice(2) : pattern; + return toPosixPath(normalized); +} + +function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean { + if (patterns.length === 0) return false; + const rel = toPosixPath(relative(baseDir, filePath)); + const name = basename(filePath); + const filePathPosix = toPosixPath(filePath); + const isSkillFile = name === "SKILL.md"; + const parentDir = isSkillFile ? dirname(filePath) : undefined; + const parentRel = isSkillFile ? toPosixPath(relative(baseDir, parentDir!)) : undefined; + const parentDirPosix = isSkillFile ? toPosixPath(parentDir!) : undefined; + + return patterns.some((pattern) => { + const normalized = normalizeExactPattern(pattern); + if (normalized === rel || normalized === filePathPosix) { + return true; + } + if (!isSkillFile) return false; + return normalized === parentRel || normalized === parentDirPosix; + }); +} + +function getOverridePatterns(entries: string[]): string[] { + return entries.filter((pattern) => pattern.startsWith("!") || pattern.startsWith("+") || pattern.startsWith("-")); +} + +function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string): boolean { + const overrides = getOverridePatterns(patterns); + const excludes = overrides.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1)); + const forceIncludes = overrides.filter((pattern) => pattern.startsWith("+")).map((pattern) => pattern.slice(1)); + const forceExcludes = overrides.filter((pattern) => pattern.startsWith("-")).map((pattern) => pattern.slice(1)); + + let enabled = true; + if (excludes.length > 0 && matchesAnyPattern(filePath, excludes, baseDir)) { + enabled = false; + } + if (forceIncludes.length > 0 && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) { + enabled = true; + } + if (forceExcludes.length > 0 && matchesAnyExactPattern(filePath, forceExcludes, baseDir)) { + enabled = false; + } + return enabled; +} + +/** + * Apply patterns to paths and return a Set of enabled paths. + * Pattern types: + * - Plain patterns: include matching paths + * - `!pattern`: exclude matching paths + * - `+path`: force-include exact path (overrides exclusions) + * - `-path`: force-exclude exact path (overrides force-includes) + */ +function applyPatterns(allPaths: string[], patterns: string[], baseDir: string): Set { + const includes: string[] = []; + const excludes: string[] = []; + const forceIncludes: string[] = []; + const forceExcludes: string[] = []; + + for (const p of patterns) { + if (p.startsWith("+")) { + forceIncludes.push(p.slice(1)); + } else if (p.startsWith("-")) { + forceExcludes.push(p.slice(1)); + } else if (p.startsWith("!")) { + excludes.push(p.slice(1)); + } else { + includes.push(p); + } + } + + // Step 1: Apply includes (or all if no includes) + let result: string[]; + if (includes.length === 0) { + result = [...allPaths]; + } else { + result = allPaths.filter((filePath) => matchesAnyPattern(filePath, includes, baseDir)); + } + + // Step 2: Apply excludes + if (excludes.length > 0) { + result = result.filter((filePath) => !matchesAnyPattern(filePath, excludes, baseDir)); + } + + // Step 3: Force-include (add back from allPaths, overriding exclusions) + if (forceIncludes.length > 0) { + for (const filePath of allPaths) { + if (!result.includes(filePath) && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) { + result.push(filePath); + } + } + } + + // Step 4: Force-exclude (remove even if included or force-included) + if (forceExcludes.length > 0) { + result = result.filter((filePath) => !matchesAnyExactPattern(filePath, forceExcludes, baseDir)); + } + + return new Set(result); +} + +export class DefaultPackageManager implements PackageManager { + private cwd: string; + private agentDir: string; + private settingsManager: SettingsManager; + private globalNpmRoot: string | undefined; + private globalNpmRootCommandKey: string | undefined; + private progressCallback: ProgressCallback | undefined; + + constructor(options: PackageManagerOptions) { + this.cwd = resolvePath(options.cwd); + this.agentDir = resolvePath(options.agentDir); + this.settingsManager = options.settingsManager; + } + + setProgressCallback(callback: ProgressCallback | undefined): void { + this.progressCallback = callback; + } + + addSourceToSettings(source: string, options?: { local?: boolean }): boolean { + const scope: SourceScope = options?.local ? "project" : "user"; + const currentSettings = + scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); + const currentPackages = currentSettings.packages ?? []; + const normalizedSource = this.normalizePackageSourceForSettings(source, scope); + const matchIndex = currentPackages.findIndex((existing) => this.packageSourcesMatch(existing, source, scope)); + if (matchIndex !== -1) { + const existing = currentPackages[matchIndex]; + if (this.getPackageSourceString(existing) === normalizedSource) { + return false; + } + const nextPackages = [...currentPackages]; + nextPackages[matchIndex] = + typeof existing === "string" ? normalizedSource : { ...existing, source: normalizedSource }; + if (scope === "project") { + this.settingsManager.setProjectPackages(nextPackages); + } else { + this.settingsManager.setPackages(nextPackages); + } + return true; + } + const nextPackages = [...currentPackages, normalizedSource]; + if (scope === "project") { + this.settingsManager.setProjectPackages(nextPackages); + } else { + this.settingsManager.setPackages(nextPackages); + } + return true; + } + + removeSourceFromSettings(source: string, options?: { local?: boolean }): boolean { + const scope: SourceScope = options?.local ? "project" : "user"; + const currentSettings = + scope === "project" ? this.settingsManager.getProjectSettings() : this.settingsManager.getGlobalSettings(); + const currentPackages = currentSettings.packages ?? []; + const nextPackages = currentPackages.filter((existing) => !this.packageSourcesMatch(existing, source, scope)); + const changed = nextPackages.length !== currentPackages.length; + if (!changed) { + return false; + } + if (scope === "project") { + this.settingsManager.setProjectPackages(nextPackages); + } else { + this.settingsManager.setPackages(nextPackages); + } + return true; + } + + getInstalledPath(source: string, scope: "user" | "project"): string | undefined { + const parsed = this.parseSource(source); + if (parsed.type === "npm") { + const path = this.getNpmInstallPath(parsed, scope); + return existsSync(path) ? path : undefined; + } + if (parsed.type === "git") { + const path = this.getGitInstallPath(parsed, scope); + return existsSync(path) ? path : undefined; + } + if (parsed.type === "local") { + const baseDir = this.getBaseDirForScope(scope); + const path = this.resolvePathFromBase(parsed.path, baseDir); + return existsSync(path) ? path : undefined; + } + return undefined; + } + + private emitProgress(event: ProgressEvent): void { + this.progressCallback?.(event); + } + + private async withProgress( + action: ProgressEvent["action"], + source: string, + message: string, + operation: () => Promise, + ): Promise { + this.emitProgress({ type: "start", action, source, message }); + try { + await operation(); + this.emitProgress({ type: "complete", action, source }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.emitProgress({ type: "error", action, source, message: errorMessage }); + throw error; + } + } + + async resolve(onMissing?: (source: string) => Promise): Promise { + const accumulator = this.createAccumulator(); + const globalSettings = this.settingsManager.getGlobalSettings(); + const projectSettings = this.settingsManager.getProjectSettings(); + + // Collect all packages with scope (project first so cwd resources win collisions) + const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = []; + for (const pkg of projectSettings.packages ?? []) { + allPackages.push({ pkg, scope: "project" }); + } + for (const pkg of globalSettings.packages ?? []) { + allPackages.push({ pkg, scope: "user" }); + } + + // Dedupe: project scope wins over global for same package identity + const packageSources = this.dedupePackages(allPackages); + await this.resolvePackageSources(packageSources, accumulator, onMissing); + + const globalBaseDir = this.agentDir; + const projectBaseDir = join(this.cwd, CONFIG_DIR_NAME); + + for (const resourceType of RESOURCE_TYPES) { + const target = this.getTargetMap(accumulator, resourceType); + const globalEntries = (globalSettings[resourceType] ?? []) as string[]; + const projectEntries = (projectSettings[resourceType] ?? []) as string[]; + this.resolveLocalEntries( + projectEntries, + resourceType, + target, + { + source: "local", + scope: "project", + origin: "top-level", + }, + projectBaseDir, + ); + this.resolveLocalEntries( + globalEntries, + resourceType, + target, + { + source: "local", + scope: "user", + origin: "top-level", + }, + globalBaseDir, + ); + } + + this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir); + + return this.toResolvedPaths(accumulator); + } + + async resolveExtensionSources( + sources: string[], + options?: { local?: boolean; temporary?: boolean }, + ): Promise { + const accumulator = this.createAccumulator(); + const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "user"; + const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope })); + await this.resolvePackageSources(packageSources, accumulator); + return this.toResolvedPaths(accumulator); + } + + listConfiguredPackages(): ConfiguredPackage[] { + const globalSettings = this.settingsManager.getGlobalSettings(); + const projectSettings = this.settingsManager.getProjectSettings(); + const configuredPackages: ConfiguredPackage[] = []; + + for (const pkg of globalSettings.packages ?? []) { + const source = typeof pkg === "string" ? pkg : pkg.source; + configuredPackages.push({ + source, + scope: "user", + filtered: typeof pkg === "object", + installedPath: this.getInstalledPath(source, "user"), + }); + } + + for (const pkg of projectSettings.packages ?? []) { + const source = typeof pkg === "string" ? pkg : pkg.source; + configuredPackages.push({ + source, + scope: "project", + filtered: typeof pkg === "object", + installedPath: this.getInstalledPath(source, "project"), + }); + } + + return configuredPackages; + } + + async install(source: string, options?: { local?: boolean }): Promise { + const parsed = this.parseSource(source); + const scope: SourceScope = options?.local ? "project" : "user"; + this.assertProjectTrustedForScope(scope); + await this.withProgress("install", source, `Installing ${source}...`, async () => { + if (parsed.type === "npm") { + await this.installNpm(parsed, scope, false); + return; + } + if (parsed.type === "git") { + await this.installGit(parsed, scope); + return; + } + if (parsed.type === "local") { + const resolved = this.resolvePath(parsed.path); + if (!existsSync(resolved)) { + throw new Error(`Path does not exist: ${resolved}`); + } + return; + } + throw new Error(`Unsupported install source: ${source}`); + }); + } + + async installAndPersist(source: string, options?: { local?: boolean }): Promise { + await this.install(source, options); + this.addSourceToSettings(source, options); + } + + async remove(source: string, options?: { local?: boolean }): Promise { + const parsed = this.parseSource(source); + const scope: SourceScope = options?.local ? "project" : "user"; + this.assertProjectTrustedForScope(scope); + await this.withProgress("remove", source, `Removing ${source}...`, async () => { + if (parsed.type === "npm") { + await this.uninstallNpm(parsed, scope); + return; + } + if (parsed.type === "git") { + await this.removeGit(parsed, scope); + return; + } + if (parsed.type === "local") { + return; + } + throw new Error(`Unsupported remove source: ${source}`); + }); + } + + async removeAndPersist(source: string, options?: { local?: boolean }): Promise { + await this.remove(source, options); + return this.removeSourceFromSettings(source, options); + } + + async update(source?: string): Promise { + const globalSettings = this.settingsManager.getGlobalSettings(); + const projectSettings = this.settingsManager.getProjectSettings(); + const identity = source ? this.getPackageIdentity(source) : undefined; + let matched = false; + const updateSources: ConfiguredUpdateSource[] = []; + + for (const pkg of globalSettings.packages ?? []) { + const sourceStr = typeof pkg === "string" ? pkg : pkg.source; + if (identity && this.getPackageIdentity(sourceStr, "user") !== identity) continue; + matched = true; + updateSources.push({ source: sourceStr, scope: "user" }); + } + for (const pkg of projectSettings.packages ?? []) { + const sourceStr = typeof pkg === "string" ? pkg : pkg.source; + if (identity && this.getPackageIdentity(sourceStr, "project") !== identity) continue; + matched = true; + updateSources.push({ source: sourceStr, scope: "project" }); + } + + if (source && !matched) { + throw new Error( + this.buildNoMatchingPackageMessage(source, [ + ...(globalSettings.packages ?? []), + ...(projectSettings.packages ?? []), + ]), + ); + } + + await this.updateConfiguredSources(updateSources); + } + + private async updateConfiguredSources(sources: ConfiguredUpdateSource[]): Promise { + if (isOfflineModeEnabled() || sources.length === 0) { + return; + } + + const npmCandidates: NpmUpdateTarget[] = []; + const gitCandidates: GitUpdateTarget[] = []; + + for (const entry of sources) { + const parsed = this.parseSource(entry.source); + // Pinned npm versions are fixed. Pinned git refs are configured checkout targets, + // so include them to reconcile an existing clone when the configured ref changes. + if (parsed.type === "npm") { + if (!parsed.pinned) { + npmCandidates.push({ ...entry, parsed }); + } + } else if (parsed.type === "git") { + gitCandidates.push({ ...entry, parsed }); + } + } + + const npmCheckTasks = npmCandidates.map((entry) => async () => ({ + entry, + shouldUpdate: await this.shouldUpdateNpmSource(entry.parsed, entry.scope), + })); + const npmCheckResults = await this.runWithConcurrency(npmCheckTasks, UPDATE_CHECK_CONCURRENCY); + const userNpmUpdates: NpmUpdateTarget[] = []; + const projectNpmUpdates: NpmUpdateTarget[] = []; + for (const result of npmCheckResults) { + if (!result.shouldUpdate) { + continue; + } + if (result.entry.scope === "user") { + userNpmUpdates.push(result.entry); + } else { + projectNpmUpdates.push(result.entry); + } + } + + const tasks: Promise[] = []; + if (userNpmUpdates.length > 0) { + tasks.push(this.updateNpmBatch(userNpmUpdates, "user")); + } + if (projectNpmUpdates.length > 0) { + tasks.push(this.updateNpmBatch(projectNpmUpdates, "project")); + } + if (gitCandidates.length > 0) { + const gitTasks = gitCandidates.map( + (entry) => async () => + this.withProgress("update", entry.source, `Updating ${entry.source}...`, async () => { + await this.updateGit(entry.parsed, entry.scope); + }), + ); + tasks.push(this.runWithConcurrency(gitTasks, GIT_UPDATE_CONCURRENCY).then(() => {})); + } + + await Promise.all(tasks); + } + + private async shouldUpdateNpmSource(source: NpmSource, scope: InstalledSourceScope): Promise { + const installedPath = this.getManagedNpmInstallPath(source, scope); + const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined; + if (!installedVersion) { + return true; + } + + try { + const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); + return targetVersion !== installedVersion; + } catch { + // Preserve existing update behavior when version lookup fails. + return true; + } + } + + private async updateNpmBatch(sources: NpmUpdateTarget[], scope: InstalledSourceScope): Promise { + if (sources.length === 0) { + return; + } + + const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`; + const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`; + const specs = sources.map((entry) => (entry.parsed.version ? entry.parsed.spec : `${entry.parsed.name}@latest`)); + + await this.withProgress("update", sourceLabel, message, async () => { + await this.installNpmBatch(specs, scope); + }); + } + + private async installNpmBatch(_specs: string[], _scope: InstalledSourceScope): Promise { + throw new Error("Package installation is disabled in this Cactus build."); + } + + async checkForAvailableUpdates(): Promise { + if (isOfflineModeEnabled()) { + return []; + } + + const globalSettings = this.settingsManager.getGlobalSettings(); + const projectSettings = this.settingsManager.getProjectSettings(); + const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = []; + for (const pkg of projectSettings.packages ?? []) { + allPackages.push({ pkg, scope: "project" }); + } + for (const pkg of globalSettings.packages ?? []) { + allPackages.push({ pkg, scope: "user" }); + } + + const packageSources = this.dedupePackages(allPackages); + const checks = packageSources + .filter( + (entry): entry is { pkg: PackageSource; scope: Exclude } => + entry.scope !== "temporary", + ) + .map((entry) => async (): Promise => { + const source = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source; + const parsed = this.parseSource(source); + if (parsed.type === "local" || parsed.pinned) { + return undefined; + } + + if (parsed.type === "npm") { + const installedPath = this.getNpmInstallPath(parsed, entry.scope); + if (!existsSync(installedPath)) { + return undefined; + } + const hasUpdate = await this.npmHasAvailableUpdate(parsed, installedPath); + if (!hasUpdate) { + return undefined; + } + return { + source, + displayName: parsed.name, + type: "npm", + scope: entry.scope, + }; + } + + const installedPath = this.getGitInstallPath(parsed, entry.scope); + if (!existsSync(installedPath)) { + return undefined; + } + const hasUpdate = await this.gitHasAvailableUpdate(installedPath); + if (!hasUpdate) { + return undefined; + } + return { + source, + displayName: `${parsed.host}/${parsed.path}`, + type: "git", + scope: entry.scope, + }; + }); + + const results = await this.runWithConcurrency(checks, UPDATE_CHECK_CONCURRENCY); + return results.filter((result): result is PackageUpdate => result !== undefined); + } + + private async resolvePackageSources( + sources: Array<{ pkg: PackageSource; scope: SourceScope }>, + accumulator: ResourceAccumulator, + onMissing?: (source: string) => Promise, + ): Promise { + for (const { pkg, scope } of sources) { + const sourceStr = typeof pkg === "string" ? pkg : pkg.source; + const filter = typeof pkg === "object" ? pkg : undefined; + const parsed = this.parseSource(sourceStr); + const metadata: PathMetadata = { source: sourceStr, scope, origin: "package" }; + + if (parsed.type === "local") { + const baseDir = this.getBaseDirForScope(scope); + this.resolveLocalExtensionSource(parsed, accumulator, filter, metadata, baseDir); + continue; + } + + const installMissing = async (): Promise => { + if (isOfflineModeEnabled()) { + return false; + } + if (!onMissing) { + await this.installParsedSource(parsed, scope); + return true; + } + const action = await onMissing(sourceStr); + if (action === "skip") return false; + if (action === "error") throw new Error(`Missing source: ${sourceStr}`); + await this.installParsedSource(parsed, scope); + return true; + }; + + if (parsed.type === "npm") { + let installedPath = this.getNpmInstallPath(parsed, scope); + const needsInstall = + !existsSync(installedPath) || !(await this.installedNpmMatchesConfiguredVersion(parsed, installedPath)); + if (needsInstall) { + const installed = await installMissing(); + if (!installed) continue; + installedPath = this.getNpmInstallPath(parsed, scope); + } + metadata.baseDir = installedPath; + this.collectPackageResources(installedPath, accumulator, filter, metadata); + continue; + } + + if (parsed.type === "git") { + const installedPath = this.getGitInstallPath(parsed, scope); + if (!existsSync(installedPath)) { + const installed = await installMissing(); + if (!installed) continue; + } else if (scope === "temporary" && !parsed.pinned && !isOfflineModeEnabled()) { + await this.refreshTemporaryGitSource(parsed, sourceStr); + } + metadata.baseDir = installedPath; + this.collectPackageResources(installedPath, accumulator, filter, metadata); + } + } + } + + private resolveLocalExtensionSource( + source: LocalSource, + accumulator: ResourceAccumulator, + filter: PackageFilter | undefined, + metadata: PathMetadata, + baseDir: string, + ): void { + const resolved = this.resolvePathFromBase(source.path, baseDir); + if (!existsSync(resolved)) { + return; + } + + try { + const stats = statSync(resolved); + if (stats.isFile()) { + metadata.baseDir = dirname(resolved); + this.addResource(accumulator.extensions, resolved, metadata, true); + return; + } + if (stats.isDirectory()) { + metadata.baseDir = resolved; + const resources = this.collectPackageResources(resolved, accumulator, filter, metadata); + if (!resources) { + this.addResource(accumulator.extensions, resolved, metadata, true); + } + } + } catch { + return; + } + } + + private async installParsedSource(parsed: ParsedSource, scope: SourceScope): Promise { + if (parsed.type === "npm") { + await this.installNpm(parsed, scope, scope === "temporary"); + return; + } + if (parsed.type === "git") { + await this.installGit(parsed, scope); + return; + } + } + + private getPackageSourceString(pkg: PackageSource): string { + return typeof pkg === "string" ? pkg : pkg.source; + } + + private getSourceMatchKeyForInput(source: string): string { + const parsed = this.parseSource(source); + if (parsed.type === "npm") { + return `npm:${parsed.name}`; + } + if (parsed.type === "git") { + return `git:${parsed.host}/${parsed.path}`; + } + return `local:${this.resolvePath(parsed.path)}`; + } + + private getSourceMatchKeyForSettings(source: string, scope: SourceScope): string { + const parsed = this.parseSource(source); + if (parsed.type === "npm") { + return `npm:${parsed.name}`; + } + if (parsed.type === "git") { + return `git:${parsed.host}/${parsed.path}`; + } + const baseDir = this.getBaseDirForScope(scope); + return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`; + } + + private buildNoMatchingPackageMessage(source: string, configuredPackages: PackageSource[]): string { + const suggestion = this.findSuggestedConfiguredSource(source, configuredPackages); + if (!suggestion) { + return `No matching package found for ${source}`; + } + return `No matching package found for ${source}. Did you mean ${suggestion}?`; + } + + private findSuggestedConfiguredSource(source: string, configuredPackages: PackageSource[]): string | undefined { + const trimmedSource = source.trim(); + const suggestions = new Set(); + + for (const pkg of configuredPackages) { + const sourceStr = this.getPackageSourceString(pkg); + const parsed = this.parseSource(sourceStr); + if (parsed.type === "npm") { + if (trimmedSource === parsed.name || trimmedSource === parsed.spec) { + suggestions.add(sourceStr); + } + continue; + } + if (parsed.type === "git") { + const shorthand = `${parsed.host}/${parsed.path}`; + const shorthandWithRef = parsed.ref ? `${shorthand}@${parsed.ref}` : undefined; + if (trimmedSource === shorthand || (shorthandWithRef && trimmedSource === shorthandWithRef)) { + suggestions.add(sourceStr); + } + } + } + + return suggestions.values().next().value; + } + + private packageSourcesMatch(existing: PackageSource, inputSource: string, scope: SourceScope): boolean { + const left = this.getSourceMatchKeyForSettings(this.getPackageSourceString(existing), scope); + const right = this.getSourceMatchKeyForInput(inputSource); + return left === right; + } + + private normalizePackageSourceForSettings(source: string, scope: SourceScope): string { + const parsed = this.parseSource(source); + if (parsed.type !== "local") { + return source; + } + const baseDir = this.getBaseDirForScope(scope); + const resolved = this.resolvePath(parsed.path); + const rel = relative(baseDir, resolved); + return rel || "."; + } + + private parseSource(source: string): ParsedSource { + if (source.startsWith("npm:")) { + const spec = source.slice("npm:".length).trim(); + const { name, version } = this.parseNpmSpec(spec); + return { + type: "npm", + spec, + name, + version, + range: getNpmVersionRange(version), + pinned: isExactNpmVersion(version), + }; + } + + if (isLocalPath(source)) { + return { type: "local", path: source }; + } + + // Try parsing as git URL + const gitParsed = parseGitUrl(source); + if (gitParsed) { + return gitParsed; + } + + return { type: "local", path: source }; + } + + private async installedNpmMatchesConfiguredVersion(source: NpmSource, installedPath: string): Promise { + const installedVersion = this.getInstalledNpmVersion(installedPath); + if (!installedVersion) { + return false; + } + return source.range ? satisfies(installedVersion, source.range) : true; + } + + private async npmHasAvailableUpdate(source: NpmSource, installedPath: string): Promise { + if (isOfflineModeEnabled()) { + return false; + } + + const installedVersion = this.getInstalledNpmVersion(installedPath); + if (!installedVersion) { + return false; + } + + try { + const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); + return targetVersion !== installedVersion; + } catch { + return false; + } + } + + private getInstalledNpmVersion(installedPath: string): string | undefined { + const packageJsonPath = join(installedPath, "package.json"); + if (!existsSync(packageJsonPath)) return undefined; + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content) as { version?: string }; + return pkg.version; + } catch { + return undefined; + } + } + + private async getLatestNpmVersion(packageSpec: string, range?: string): Promise { + const npmCommand = this.getNpmCommand(); + const stdout = await this.runCommandCapture( + npmCommand.command, + [...npmCommand.args, "view", packageSpec, "version", "--json"], + { cwd: this.cwd, timeoutMs: NETWORK_TIMEOUT_MS }, + ); + const raw = stdout.trim(); + if (!raw) throw new Error("Empty response from npm view"); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed === "string") { + return parsed; + } + if (Array.isArray(parsed)) { + const versions = parsed.filter((value): value is string => typeof value === "string" && value.length > 0); + const latest = range ? maxSatisfying(versions, range) : [...versions].sort(rcompare)[0]; + if (latest) return latest; + } + throw new Error("Unexpected response from npm view"); + } + + private async gitHasAvailableUpdate(installedPath: string): Promise { + if (isOfflineModeEnabled()) { + return false; + } + + try { + const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], { + cwd: installedPath, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + const remoteHead = await this.getRemoteGitHead(installedPath); + return localHead.trim() !== remoteHead.trim(); + } catch { + return false; + } + } + + private async getRemoteGitHead(installedPath: string): Promise { + const upstreamRef = await this.getGitUpstreamRef(installedPath); + if (upstreamRef) { + const remoteHead = await this.runGitRemoteCommand(installedPath, ["ls-remote", "origin", upstreamRef]); + const match = remoteHead.match(/^([0-9a-f]{40})\s+/m); + if (match?.[1]) { + return match[1]; + } + } + + const remoteHead = await this.runGitRemoteCommand(installedPath, ["ls-remote", "origin", "HEAD"]); + const match = remoteHead.match(/^([0-9a-f]{40})\s+HEAD$/m); + if (!match?.[1]) { + throw new Error("Failed to determine remote HEAD"); + } + return match[1]; + } + + private async getLocalGitUpdateTarget( + installedPath: string, + ): Promise<{ ref: string; head: string; fetchArgs: string[] }> { + try { + const upstream = await this.runCommandCapture("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], { + cwd: installedPath, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + const trimmedUpstream = upstream.trim(); + if (!trimmedUpstream.startsWith("origin/")) { + throw new Error(`Unsupported upstream remote: ${trimmedUpstream}`); + } + const branch = trimmedUpstream.slice("origin/".length); + if (!branch) { + throw new Error("Missing upstream branch name"); + } + const head = await this.runCommandCapture("git", ["rev-parse", "@{upstream}"], { + cwd: installedPath, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + return { + ref: "@{upstream}", + head, + fetchArgs: [ + "fetch", + "--prune", + "--no-tags", + "origin", + `+refs/heads/${branch}:refs/remotes/origin/${branch}`, + ], + }; + } catch { + await this.runCommand("git", ["remote", "set-head", "origin", "-a"], { cwd: installedPath }).catch(() => {}); + const head = await this.runCommandCapture("git", ["rev-parse", "origin/HEAD"], { + cwd: installedPath, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + const originHeadRef = await this.runCommandCapture("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { + cwd: installedPath, + timeoutMs: NETWORK_TIMEOUT_MS, + }).catch(() => ""); + const branch = originHeadRef.trim().replace(/^refs\/remotes\/origin\//, ""); + if (branch) { + return { + ref: "origin/HEAD", + head, + fetchArgs: [ + "fetch", + "--prune", + "--no-tags", + "origin", + `+refs/heads/${branch}:refs/remotes/origin/${branch}`, + ], + }; + } + return { + ref: "origin/HEAD", + head, + fetchArgs: ["fetch", "--prune", "--no-tags", "origin", "+HEAD:refs/remotes/origin/HEAD"], + }; + } + } + + private async getGitUpstreamRef(installedPath: string): Promise { + try { + const upstream = await this.runCommandCapture("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], { + cwd: installedPath, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + const trimmed = upstream.trim(); + if (!trimmed.startsWith("origin/")) { + return undefined; + } + const branch = trimmed.slice("origin/".length); + return branch ? `refs/heads/${branch}` : undefined; + } catch { + return undefined; + } + } + + private runGitRemoteCommand(installedPath: string, args: string[]): Promise { + return this.runCommandCapture("git", args, { + cwd: installedPath, + timeoutMs: NETWORK_TIMEOUT_MS, + env: { + GIT_TERMINAL_PROMPT: "0", + }, + }); + } + + private async runWithConcurrency(tasks: Array<() => Promise>, limit: number): Promise { + if (tasks.length === 0) { + return []; + } + + const results: T[] = new Array(tasks.length); + let nextIndex = 0; + const workerCount = Math.max(1, Math.min(limit, tasks.length)); + + const worker = async () => { + while (true) { + const index = nextIndex; + nextIndex += 1; + if (index >= tasks.length) { + return; + } + results[index] = await tasks[index](); + } + }; + + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return results; + } + + /** + * Get a unique identity for a package, ignoring version/ref. + * Used to detect when the same package is in both global and project settings. + * For git packages, uses normalized host/path to ensure SSH and HTTPS URLs + * for the same repository are treated as identical. + */ + private getPackageIdentity(source: string, scope?: SourceScope): string { + const parsed = this.parseSource(source); + if (parsed.type === "npm") { + return `npm:${parsed.name}`; + } + if (parsed.type === "git") { + // Use host/path for identity to normalize SSH and HTTPS + return `git:${parsed.host}/${parsed.path}`; + } + if (scope) { + const baseDir = this.getBaseDirForScope(scope); + return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`; + } + return `local:${this.resolvePath(parsed.path)}`; + } + + /** + * Dedupe packages: if same package identity appears in both global and project, + * keep only the project one (project wins). + */ + private dedupePackages( + packages: Array<{ pkg: PackageSource; scope: SourceScope }>, + ): Array<{ pkg: PackageSource; scope: SourceScope }> { + const seen = new Map(); + + for (const entry of packages) { + const sourceStr = typeof entry.pkg === "string" ? entry.pkg : entry.pkg.source; + const identity = this.getPackageIdentity(sourceStr, entry.scope); + + const existing = seen.get(identity); + if (!existing) { + seen.set(identity, entry); + } else if (entry.scope === "project" && existing.scope === "user") { + // Project wins over user + seen.set(identity, entry); + } + // If existing is project and new is global, keep existing (project) + // If both are same scope, keep first one + } + + return Array.from(seen.values()); + } + + private parseNpmSpec(spec: string): { name: string; version?: string } { + const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/); + if (!match) { + return { name: spec }; + } + const name = match[1] ?? spec; + const version = match[2]; + return { name, version }; + } + + private assertProjectTrustedForScope(scope: SourceScope): void { + if (scope === "project" && !this.settingsManager.isProjectTrusted()) { + throw new Error("Project is not trusted; refusing to access project package storage"); + } + } + + private getNpmCommand(): { command: string; args: string[] } { + const configuredCommand = this.settingsManager.getNpmCommand(); + if (!configuredCommand || configuredCommand.length === 0) { + return { command: "npm", args: [] }; + } + const [command, ...args] = configuredCommand; + if (!command) { + throw new Error("Invalid npmCommand: first array entry must be a non-empty command"); + } + return { command, args }; + } + + private getPackageManagerName(): string { + const npmCommand = this.getNpmCommand(); + const commandParts = [npmCommand.command, ...npmCommand.args]; + const separatorIndex = commandParts.lastIndexOf("--"); + const packageManagerCommand = separatorIndex >= 0 ? commandParts[separatorIndex + 1] : npmCommand.command; + return packageManagerCommand ? basename(packageManagerCommand).replace(/\.(cmd|exe)$/i, "") : ""; + } + + private async runNpmCommand(args: string[], options?: { cwd?: string }): Promise { + const npmCommand = this.getNpmCommand(); + await this.runCommand(npmCommand.command, [...npmCommand.args, ...args], options); + } + + private getGitDependencyInstallArgs(): string[] { + const configuredCommand = this.settingsManager.getNpmCommand(); + if (configuredCommand && configuredCommand.length > 0) { + return ["install"]; + } + return ["install", "--omit=dev"]; + } + + private runNpmCommandSync(args: string[]): string { + const npmCommand = this.getNpmCommand(); + return this.runCommandSync(npmCommand.command, [...npmCommand.args, ...args]); + } + + private getNpmInstallArgs(specs: string[], installRoot: string): string[] { + const packageManagerName = this.getPackageManagerName(); + // Extension packages run inside pi and resolve pi APIs through loader aliases/virtual modules. + // Disable peer dependency resolution for managed installs (npm's --legacy-peer-deps, and + // equivalent bun/pnpm settings) so package managers do not install or solve host-provided + // @earendil-works/pi-* peers. Stale auto-installed pi peers can otherwise block updates. + if (packageManagerName === "bun") { + return ["install", ...specs, "--cwd", installRoot, "--omit=peer"]; + } + if (packageManagerName === "pnpm") { + return [ + "install", + ...specs, + "--prefix", + installRoot, + "--config.auto-install-peers=false", + "--config.strict-peer-dependencies=false", + "--config.strict-dep-builds=false", + ]; + } + return ["install", ...specs, "--prefix", installRoot, "--legacy-peer-deps"]; + } + + private async installNpm(_source: NpmSource, _scope: SourceScope, _temporary: boolean): Promise { + throw new Error("Package installation is disabled in this Cactus build."); + } + + private async uninstallNpm(source: NpmSource, scope: SourceScope): Promise { + const installRoot = this.getNpmInstallRoot(scope, false); + if (!existsSync(installRoot)) { + return; + } + if (this.getPackageManagerName() === "bun") { + await this.runNpmCommand(["uninstall", source.name, "--cwd", installRoot]); + return; + } + await this.runNpmCommand(["uninstall", source.name, "--prefix", installRoot]); + } + + private async installGit(_source: GitSource, _scope: SourceScope): Promise { + throw new Error("Package installation is disabled in this Cactus build."); + } + + private async updateGit(source: GitSource, scope: SourceScope): Promise { + const targetDir = this.getGitInstallPath(source, scope); + if (!existsSync(targetDir)) { + await this.installGit(source, scope); + return; + } + + if (source.ref) { + await this.ensureGitRef(targetDir, ["fetch", "origin", source.ref], "FETCH_HEAD"); + return; + } + + const target = await this.getLocalGitUpdateTarget(targetDir); + await this.ensureGitRef(targetDir, target.fetchArgs, target.ref); + } + + private async ensureGitRef(targetDir: string, fetchArgs: string[], ref: string): Promise { + // Fetch only the ref we will reset to, avoiding unrelated branch/tag noise. + await this.runCommand("git", fetchArgs, { cwd: targetDir }); + + const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], { + cwd: targetDir, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + const commitRef = `${ref}^{commit}`; + const targetHead = await this.runCommandCapture("git", ["rev-parse", commitRef], { + cwd: targetDir, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + if (localHead.trim() === targetHead.trim()) { + return; + } + + await this.runCommand("git", ["reset", "--hard", commitRef], { cwd: targetDir }); + + // Clean untracked files (extensions should be pristine) + await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir }); + + const packageJsonPath = join(targetDir, "package.json"); + if (existsSync(packageJsonPath)) { + await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir }); + } + } + + private async refreshTemporaryGitSource(source: GitSource, sourceStr: string): Promise { + if (isOfflineModeEnabled()) { + return; + } + try { + await this.withProgress("pull", sourceStr, `Refreshing ${sourceStr}...`, async () => { + await this.updateGit(source, "temporary"); + }); + } catch { + // Keep cached temporary checkout if refresh fails. + } + } + + private async removeGit(source: GitSource, scope: SourceScope): Promise { + const targetDir = this.getGitInstallPath(source, scope); + if (!existsSync(targetDir)) return; + rmSync(targetDir, { recursive: true, force: true }); + this.pruneEmptyGitParents(targetDir, this.getGitInstallRoot(scope)); + } + + private pruneEmptyGitParents(targetDir: string, installRoot: string | undefined): void { + if (!installRoot) return; + const resolvedRoot = resolve(installRoot); + let current = dirname(targetDir); + while (current.startsWith(resolvedRoot) && current !== resolvedRoot) { + if (!existsSync(current)) { + current = dirname(current); + continue; + } + const entries = readdirSync(current); + if (entries.length > 0) { + break; + } + try { + rmSync(current, { recursive: true, force: true }); + } catch { + break; + } + current = dirname(current); + } + } + + private ensureNpmProject(installRoot: string): void { + if (!existsSync(installRoot)) { + mkdirSync(installRoot, { recursive: true }); + } + markPathIgnoredByCloudSync(installRoot); + this.ensureGitIgnore(installRoot); + const packageJsonPath = join(installRoot, "package.json"); + if (!existsSync(packageJsonPath)) { + const pkgJson = { name: "pi-extensions", private: true }; + writeFileSync(packageJsonPath, JSON.stringify(pkgJson, null, 2), "utf-8"); + } + } + + private ensureGitIgnore(dir: string): void { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const ignorePath = join(dir, ".gitignore"); + if (!existsSync(ignorePath)) { + writeFileSync(ignorePath, "*\n!.gitignore\n", "utf-8"); + } + } + + private getNpmInstallRoot(scope: SourceScope, temporary: boolean): string { + if (temporary) { + return this.getTemporaryDir("npm"); + } + if (scope === "project") { + this.assertProjectTrustedForScope(scope); + return join(this.cwd, CONFIG_DIR_NAME, "npm"); + } + return join(this.agentDir, "npm"); + } + + private getGlobalNpmRoot(): string { + const npmCommand = this.getNpmCommand(); + const commandKey = [npmCommand.command, ...npmCommand.args].join("\0"); + if (this.globalNpmRoot && this.globalNpmRootCommandKey === commandKey) { + return this.globalNpmRoot; + } + if (this.getPackageManagerName() === "bun") { + const binDir = this.runNpmCommandSync(["pm", "bin", "-g"]).trim(); + this.globalNpmRoot = join(dirname(binDir), "install", "global", "node_modules"); + } else { + this.globalNpmRoot = this.runNpmCommandSync(["root", "-g"]).trim(); + } + this.globalNpmRootCommandKey = commandKey; + return this.globalNpmRoot; + } + + private getPnpmGlobalPackagePath(packageName: string): string | undefined { + if (this.getPackageManagerName() !== "pnpm") { + return undefined; + } + + const output = this.runNpmCommandSync(["list", "-g", "--depth", "0", "--json"]); + const entries = JSON.parse(output) as Array<{ dependencies?: Record }>; + for (const entry of entries) { + const path = entry.dependencies?.[packageName]?.path; + if (path) return path; + } + return undefined; + } + + private getManagedNpmInstallPath(source: NpmSource, scope: SourceScope): string { + if (scope === "temporary") { + return join(this.getTemporaryDir("npm"), "node_modules", source.name); + } + if (scope === "project") { + this.assertProjectTrustedForScope(scope); + return join(this.cwd, CONFIG_DIR_NAME, "npm", "node_modules", source.name); + } + return join(this.agentDir, "npm", "node_modules", source.name); + } + + private getLegacyGlobalNpmInstallPath(source: NpmSource): string | undefined { + try { + return this.getPnpmGlobalPackagePath(source.name) ?? join(this.getGlobalNpmRoot(), source.name); + } catch { + return undefined; + } + } + + private getNpmInstallPath(source: NpmSource, scope: SourceScope): string { + const managedPath = this.getManagedNpmInstallPath(source, scope); + if (scope !== "user" || existsSync(managedPath)) { + return managedPath; + } + const legacyPath = this.getLegacyGlobalNpmInstallPath(source); + return legacyPath && existsSync(legacyPath) ? legacyPath : managedPath; + } + + private getGitInstallPath(source: GitSource, scope: SourceScope): string { + if (scope === "temporary") { + return this.getTemporaryDir(`git-${source.host}`, source.path); + } + const installRoot = this.getGitInstallRoot(scope); + if (!installRoot) { + throw new Error("Missing git install root"); + } + return this.resolveManagedPath(installRoot, source.host, source.path); + } + + private getGitInstallRoot(scope: SourceScope): string | undefined { + if (scope === "temporary") { + return undefined; + } + if (scope === "project") { + this.assertProjectTrustedForScope(scope); + return join(this.cwd, CONFIG_DIR_NAME, "git"); + } + return join(this.agentDir, "git"); + } + + private getTemporaryDir(prefix: string, suffix?: string): string { + const root = this.resolveManagedPath(getExtensionTempFolder(this.agentDir), prefix); + const hash = createHash("sha256") + .update(`${prefix}-${suffix ?? ""}`) + .digest("hex") + .slice(0, 8); + return this.resolveManagedPath(root, hash, suffix ?? ""); + } + + private resolveManagedPath(root: string, ...parts: string[]): string { + const resolvedRoot = resolve(root); + const resolvedPath = resolve(resolvedRoot, ...parts); + if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${sep}`)) { + throw new Error(`Refusing to use path outside package install root: ${resolvedPath}`); + } + return resolvedPath; + } + + private getBaseDirForScope(scope: SourceScope): string { + if (scope === "project") { + this.assertProjectTrustedForScope(scope); + return join(this.cwd, CONFIG_DIR_NAME); + } + if (scope === "user") { + return this.agentDir; + } + return this.cwd; + } + + private resolvePath(input: string): string { + return resolvePath(input, this.cwd, { homeDir: getHomeDir(), trim: true }); + } + + private resolvePathFromBase(input: string, baseDir: string): string { + return resolvePath(input, baseDir, { homeDir: getHomeDir(), trim: true }); + } + + private collectPackageResources( + packageRoot: string, + accumulator: ResourceAccumulator, + filter: PackageFilter | undefined, + metadata: PathMetadata, + ): boolean { + if (filter) { + for (const resourceType of RESOURCE_TYPES) { + const patterns = filter[resourceType as keyof PackageFilter]; + const target = this.getTargetMap(accumulator, resourceType); + if (patterns !== undefined) { + this.applyPackageFilter(packageRoot, patterns, resourceType, target, metadata); + } else { + this.collectDefaultResources(packageRoot, resourceType, target, metadata); + } + } + return true; + } + + const manifest = this.readPiManifest(packageRoot); + if (manifest) { + for (const resourceType of RESOURCE_TYPES) { + const entries = manifest[resourceType as keyof PiManifest]; + this.addManifestEntries( + entries, + packageRoot, + resourceType, + this.getTargetMap(accumulator, resourceType), + metadata, + ); + } + return true; + } + + let hasAnyDir = false; + for (const resourceType of RESOURCE_TYPES) { + const dir = join(packageRoot, resourceType); + if (existsSync(dir)) { + // Collect all files from the directory (all enabled by default) + const files = collectResourceFiles(dir, resourceType); + for (const f of files) { + this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true); + } + hasAnyDir = true; + } + } + return hasAnyDir; + } + + private collectDefaultResources( + packageRoot: string, + resourceType: ResourceType, + target: Map, + metadata: PathMetadata, + ): void { + const manifest = this.readPiManifest(packageRoot); + const entries = manifest?.[resourceType as keyof PiManifest]; + if (entries) { + this.addManifestEntries(entries, packageRoot, resourceType, target, metadata); + return; + } + const dir = join(packageRoot, resourceType); + if (existsSync(dir)) { + // Collect all files from the directory (all enabled by default) + const files = collectResourceFiles(dir, resourceType); + for (const f of files) { + this.addResource(target, f, metadata, true); + } + } + } + + private applyPackageFilter( + packageRoot: string, + userPatterns: string[], + resourceType: ResourceType, + target: Map, + metadata: PathMetadata, + ): void { + const { allFiles } = this.collectManifestFiles(packageRoot, resourceType); + + if (userPatterns.length === 0) { + // Empty array explicitly disables all resources of this type + for (const f of allFiles) { + this.addResource(target, f, metadata, false); + } + return; + } + + // Apply user patterns + const enabledByUser = applyPatterns(allFiles, userPatterns, packageRoot); + + for (const f of allFiles) { + const enabled = enabledByUser.has(f); + this.addResource(target, f, metadata, enabled); + } + } + + /** + * Collect all files from a package for a resource type, applying manifest patterns. + * Returns { allFiles, enabledByManifest } where enabledByManifest is the set of files + * that pass the manifest's own patterns. + */ + private collectManifestFiles( + packageRoot: string, + resourceType: ResourceType, + ): { allFiles: string[]; enabledByManifest: Set } { + const manifest = this.readPiManifest(packageRoot); + const entries = manifest?.[resourceType as keyof PiManifest]; + if (entries && entries.length > 0) { + const allFiles = this.collectFilesFromManifestEntries(entries, packageRoot, resourceType); + const manifestPatterns = entries.filter(isOverridePattern); + const enabledByManifest = + manifestPatterns.length > 0 ? applyPatterns(allFiles, manifestPatterns, packageRoot) : new Set(allFiles); + return { allFiles: Array.from(enabledByManifest), enabledByManifest }; + } + + const conventionDir = join(packageRoot, resourceType); + if (!existsSync(conventionDir)) { + return { allFiles: [], enabledByManifest: new Set() }; + } + const allFiles = collectResourceFiles(conventionDir, resourceType); + return { allFiles, enabledByManifest: new Set(allFiles) }; + } + + private readPiManifest(packageRoot: string): PiManifest | null { + const packageJsonPath = join(packageRoot, "package.json"); + if (!existsSync(packageJsonPath)) { + return null; + } + + try { + const content = readFileSync(packageJsonPath, "utf-8"); + const pkg = JSON.parse(content) as { pi?: PiManifest }; + return pkg.pi ?? null; + } catch { + return null; + } + } + + private addManifestEntries( + entries: string[] | undefined, + root: string, + resourceType: ResourceType, + target: Map, + metadata: PathMetadata, + ): void { + if (!entries) return; + + const allFiles = this.collectFilesFromManifestEntries(entries, root, resourceType); + const patterns = entries.filter(isOverridePattern); + const enabledPaths = applyPatterns(allFiles, patterns, root); + + for (const f of allFiles) { + if (enabledPaths.has(f)) { + this.addResource(target, f, metadata, true); + } + } + } + + private collectFilesFromManifestEntries(entries: string[], root: string, resourceType: ResourceType): string[] { + const sourceEntries = entries.filter((entry) => !isOverridePattern(entry)); + const resolved = sourceEntries.flatMap((entry) => { + if (!hasGlobPattern(entry)) { + return [resolve(root, entry)]; + } + + return globSync(entry, { + cwd: root, + absolute: true, + dot: false, + nodir: false, + }).map((match) => resolve(match)); + }); + return this.collectFilesFromPaths(resolved, resourceType); + } + + private resolveLocalEntries( + entries: string[], + resourceType: ResourceType, + target: Map, + metadata: PathMetadata, + baseDir: string, + ): void { + if (entries.length === 0) return; + + // Collect all files from plain entries (non-pattern entries) + const { plain, patterns } = splitPatterns(entries); + const resolvedPlain = plain.map((p) => this.resolvePathFromBase(p, baseDir)); + const allFiles = this.collectFilesFromPaths(resolvedPlain, resourceType); + + // Determine which files are enabled based on patterns + const enabledPaths = applyPatterns(allFiles, patterns, baseDir); + + // Add all files with their enabled state + for (const f of allFiles) { + this.addResource(target, f, metadata, enabledPaths.has(f)); + } + } + + private addAutoDiscoveredResources( + accumulator: ResourceAccumulator, + globalSettings: ReturnType, + projectSettings: ReturnType, + globalBaseDir: string, + projectBaseDir: string, + ): void { + const userMetadata: PathMetadata = { + source: "auto", + scope: "user", + origin: "top-level", + baseDir: globalBaseDir, + }; + const projectMetadata: PathMetadata = { + source: "auto", + scope: "project", + origin: "top-level", + baseDir: projectBaseDir, + }; + + const userOverrides = { + extensions: (globalSettings.extensions ?? []) as string[], + skills: (globalSettings.skills ?? []) as string[], + prompts: (globalSettings.prompts ?? []) as string[], + themes: (globalSettings.themes ?? []) as string[], + }; + const projectOverrides = { + extensions: (projectSettings.extensions ?? []) as string[], + skills: (projectSettings.skills ?? []) as string[], + prompts: (projectSettings.prompts ?? []) as string[], + themes: (projectSettings.themes ?? []) as string[], + }; + + const userDirs = { + extensions: join(globalBaseDir, "extensions"), + skills: join(globalBaseDir, "skills"), + prompts: join(globalBaseDir, "prompts"), + themes: join(globalBaseDir, "themes"), + }; + const projectDirs = { + extensions: join(projectBaseDir, "extensions"), + skills: join(projectBaseDir, "skills"), + prompts: join(projectBaseDir, "prompts"), + themes: join(projectBaseDir, "themes"), + }; + const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills"); + const projectTrusted = this.settingsManager.isProjectTrusted(); + const projectAgentsSkillDirs = projectTrusted + ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir)) + : []; + + const addResources = ( + resourceType: ResourceType, + paths: string[], + metadata: PathMetadata, + overrides: string[], + baseDir: string, + ) => { + const target = this.getTargetMap(accumulator, resourceType); + for (const path of paths) { + const enabled = isEnabledByOverrides(path, overrides, baseDir); + this.addResource(target, path, metadata, enabled); + } + }; + + if (projectTrusted) { + // Project extensions from .pi/ + addResources( + "extensions", + collectAutoExtensionEntries(projectDirs.extensions), + projectMetadata, + projectOverrides.extensions, + projectBaseDir, + ); + + // Project skills from .pi/ + addResources( + "skills", + collectAutoSkillEntries(projectDirs.skills, "pi"), + projectMetadata, + projectOverrides.skills, + projectBaseDir, + ); + } + + // Project skills from .agents/ (each with its own baseDir) + for (const agentsSkillsDir of projectAgentsSkillDirs) { + const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory + const agentsMetadata: PathMetadata = { + ...projectMetadata, + baseDir: agentsBaseDir, + }; + addResources( + "skills", + collectAutoSkillEntries(agentsSkillsDir, "agents"), + agentsMetadata, + projectOverrides.skills, + agentsBaseDir, + ); + } + + if (projectTrusted) { + addResources( + "prompts", + collectAutoPromptEntries(projectDirs.prompts), + projectMetadata, + projectOverrides.prompts, + projectBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(projectDirs.themes), + projectMetadata, + projectOverrides.themes, + projectBaseDir, + ); + } + + // User extensions from ~/.pi/agent/ + addResources( + "extensions", + collectAutoExtensionEntries(userDirs.extensions), + userMetadata, + userOverrides.extensions, + globalBaseDir, + ); + + // User skills from ~/.pi/agent/ + addResources( + "skills", + collectAutoSkillEntries(userDirs.skills, "pi"), + userMetadata, + userOverrides.skills, + globalBaseDir, + ); + + // User skills from ~/.agents/ (with its own baseDir) + const userAgentsBaseDir = dirname(userAgentsSkillsDir); + const userAgentsMetadata: PathMetadata = { + ...userMetadata, + baseDir: userAgentsBaseDir, + }; + addResources( + "skills", + collectAutoSkillEntries(userAgentsSkillsDir, "agents"), + userAgentsMetadata, + userOverrides.skills, + userAgentsBaseDir, + ); + + addResources( + "prompts", + collectAutoPromptEntries(userDirs.prompts), + userMetadata, + userOverrides.prompts, + globalBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(userDirs.themes), + userMetadata, + userOverrides.themes, + globalBaseDir, + ); + } + + private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] { + const files: string[] = []; + for (const p of paths) { + if (!existsSync(p)) continue; + + try { + const stats = statSync(p); + if (stats.isFile()) { + files.push(p); + } else if (stats.isDirectory()) { + files.push(...collectResourceFiles(p, resourceType)); + } + } catch { + // Ignore errors + } + } + return files; + } + + private getTargetMap( + accumulator: ResourceAccumulator, + resourceType: ResourceType, + ): Map { + switch (resourceType) { + case "extensions": + return accumulator.extensions; + case "skills": + return accumulator.skills; + case "prompts": + return accumulator.prompts; + case "themes": + return accumulator.themes; + default: + throw new Error(`Unknown resource type: ${resourceType}`); + } + } + + private addResource( + map: Map, + path: string, + metadata: PathMetadata, + enabled: boolean, + ): void { + if (!path) return; + if (!map.has(path)) { + map.set(path, { metadata, enabled }); + } + } + + private createAccumulator(): ResourceAccumulator { + return { + extensions: new Map(), + skills: new Map(), + prompts: new Map(), + themes: new Map(), + }; + } + + private toResolvedPaths(accumulator: ResourceAccumulator): ResolvedPaths { + const mapToResolved = ( + entries: Map, + ): ResolvedResource[] => { + const resolved = Array.from(entries.entries()).map(([path, { metadata, enabled }]) => ({ + path, + enabled, + metadata, + })); + resolved.sort((a, b) => resourcePrecedenceRank(a.metadata) - resourcePrecedenceRank(b.metadata)); + + const seen = new Set(); + return resolved.filter((entry) => { + const canonicalPath = canonicalizePath(entry.path); + if (seen.has(canonicalPath)) return false; + seen.add(canonicalPath); + return true; + }); + }; + + return { + extensions: mapToResolved(accumulator.extensions), + skills: mapToResolved(accumulator.skills), + prompts: mapToResolved(accumulator.prompts), + themes: mapToResolved(accumulator.themes), + }; + } + + private spawnCommand(command: string, args: string[], options?: { cwd?: string }): ChildProcess { + const env = getEnv(); + return spawnProcess(command, args, { + cwd: options?.cwd, + stdio: isStdoutTakenOver() ? ["ignore", 2, 2] : "inherit", + env, + }); + } + + private spawnCaptureCommand( + command: string, + args: string[], + options?: { cwd?: string; env?: Record }, + ): ChildProcessByStdio { + const baseEnv = getEnv(); + const env = options?.env ? { ...baseEnv, ...options.env } : baseEnv; + return spawnProcess(command, args, { + cwd: options?.cwd, + stdio: ["ignore", "pipe", "pipe"], + env, + }); + } + + private runCommandCapture( + command: string, + args: string[], + options?: { cwd?: string; timeoutMs?: number; env?: Record }, + ): Promise { + return new Promise((resolvePromise, reject) => { + const child = this.spawnCaptureCommand(command, args, options); + let stdout = ""; + let stderr = ""; + let timedOut = false; + const timeout = + typeof options?.timeoutMs === "number" + ? setTimeout(() => { + timedOut = true; + child.kill(); + }, options.timeoutMs) + : undefined; + + child.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + child.once("error", (error) => { + if (timeout) clearTimeout(timeout); + reject(error); + }); + child.once("close", (code, signal) => { + if (timeout) clearTimeout(timeout); + if (timedOut) { + reject(new Error(`${command} ${args.join(" ")} timed out after ${options?.timeoutMs}ms`)); + return; + } + if (code === 0) { + resolvePromise(stdout.trim()); + return; + } + const exitStatus = code === null ? `signal ${signal ?? "unknown"}` : `code ${code}`; + reject(new Error(`${command} ${args.join(" ")} failed with ${exitStatus}: ${stderr || stdout}`)); + }); + }); + } + + private runCommand(command: string, args: string[], options?: { cwd?: string }): Promise { + return new Promise((resolvePromise, reject) => { + const child = this.spawnCommand(command, args, options); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolvePromise(); + } else { + reject(new Error(`${command} ${args.join(" ")} failed with code ${code}`)); + } + }); + }); + } + + private runCommandSync(command: string, args: string[]): string { + const env = getEnv(); + const result = spawnProcessSync(command, args, { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + env, + }); + if (result.error || result.status !== 0) { + throw new Error( + `Failed to run ${command} ${args.join(" ")}: ${result.error?.message || result.stderr || result.stdout}`, + ); + } + return (result.stdout || result.stderr || "").trim(); + } +} diff --git a/cactus-code/packages/coding-agent/src/core/project-trust.ts b/cactus-code/packages/coding-agent/src/core/project-trust.ts new file mode 100644 index 000000000..4b0039774 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/project-trust.ts @@ -0,0 +1,96 @@ +import { CONFIG_DIR_NAME } from "../config.ts"; +import { emitProjectTrustEvent } from "./extensions/runner.ts"; +import type { LoadExtensionsResult, ProjectTrustContext } from "./extensions/types.ts"; +import type { DefaultProjectTrust } from "./settings-manager.ts"; +import { + getProjectTrustOptions, + hasTrustRequiringProjectResources, + type ProjectTrustOption, + type ProjectTrustStore, +} from "./trust-manager.ts"; + +export type AppMode = "interactive" | "print" | "json"; + +export interface ResolveProjectTrustedOptions { + cwd: string; + trustStore: ProjectTrustStore; + trustOverride?: boolean; + defaultProjectTrust?: DefaultProjectTrust; + extensionsResult?: LoadExtensionsResult; + projectTrustContext: ProjectTrustContext; + onExtensionError?: (message: string) => void; +} + +function formatProjectTrustPrompt(cwd: string): string { + return `Trust project folder?\n${cwd}\n\nThis allows Cactus to load ${CONFIG_DIR_NAME} settings and resources (skills, prompts, themes) from this project.`; +} + +async function selectProjectTrustOption( + cwd: string, + ctx: ProjectTrustContext, +): Promise { + const options = getProjectTrustOptions(cwd, { includeSessionOnly: true }); + const selected = await ctx.ui.select( + formatProjectTrustPrompt(cwd), + options.map((option) => option.label), + ); + return options.find((option) => option.label === selected); +} + +function saveProjectTrustPromptResult(trustStore: ProjectTrustStore, result: ProjectTrustOption): void { + if (result.updates.length > 0) { + trustStore.setMany(result.updates); + } +} + +export async function resolveProjectTrusted(options: ResolveProjectTrustedOptions): Promise { + if (options.trustOverride !== undefined) { + return options.trustOverride; + } + if (!hasTrustRequiringProjectResources(options.cwd)) { + return true; + } + + if (options.extensionsResult) { + const { result, errors } = await emitProjectTrustEvent( + options.extensionsResult, + { type: "project_trust", cwd: options.cwd }, + options.projectTrustContext, + ); + for (const error of errors) { + options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`); + } + if (result) { + const trusted = result.trusted === "yes"; + if (result.remember === true) { + options.trustStore.set(options.cwd, trusted); + } + return trusted; + } + } + + const decision = options.trustStore.get(options.cwd); + if (decision !== null) { + return decision; + } + + switch (options.defaultProjectTrust ?? "ask") { + case "always": + return true; + case "never": + return false; + case "ask": + break; + } + + if (!options.projectTrustContext.hasUI) { + return false; + } + + const selected = await selectProjectTrustOption(options.cwd, options.projectTrustContext); + if (selected !== undefined) { + saveProjectTrustPromptResult(options.trustStore, selected); + return selected.trusted; + } + return false; +} diff --git a/cactus-code/packages/coding-agent/src/core/prompt-templates.ts b/cactus-code/packages/coding-agent/src/core/prompt-templates.ts new file mode 100644 index 000000000..6b5b1e24a --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/prompt-templates.ts @@ -0,0 +1,284 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "fs"; +import { basename, dirname, join, resolve, sep } from "path"; +import { CONFIG_DIR_NAME } from "../config.ts"; +import { parseFrontmatter } from "../utils/frontmatter.ts"; +import { resolvePath } from "../utils/paths.ts"; +import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; + +/** + * Represents a prompt template loaded from a markdown file + */ +export interface PromptTemplate { + name: string; + description: string; + argumentHint?: string; + content: string; + sourceInfo: SourceInfo; + filePath: string; // Absolute path to the template file +} + +/** + * Parse command arguments respecting quoted strings (bash-style) + * Returns array of arguments + */ +export function parseCommandArgs(argsString: string): string[] { + const args: string[] = []; + let current = ""; + let inQuote: string | null = null; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (inQuote) { + if (char === inQuote) { + inQuote = null; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + inQuote = char; + } else if (/\s/.test(char)) { + if (current) { + args.push(current); + current = ""; + } + } else { + current += char; + } + } + + if (current) { + args.push(current); + } + + return args; +} + +/** + * Substitute argument placeholders in template content + * Supports: + * - $1, $2, ... for positional args + * - $@ and $ARGUMENTS for all args + * - ${N:-default} for positional arg N with default when missing/empty + * - ${@:N} for args from Nth onwards (bash-style slicing) + * - ${@:N:L} for L args starting from Nth + * + * Note: Replacement happens on the template string only. Argument and default values + * containing patterns like $1, $@, or $ARGUMENTS are NOT recursively substituted. + */ +export function substituteArgs(content: string, args: string[]): string { + const allArgs = args.join(" "); + + return content.replace( + /\$\{(\d+):-([^}]*)\}|\$\{@:(\d+)(?::(\d+))?\}|\$(ARGUMENTS|@|\d+)/g, + (_match, defaultNum, defaultValue, sliceStart, sliceLength, simple) => { + if (defaultNum) { + const index = parseInt(defaultNum, 10) - 1; + const value = args[index]; + return value ? value : defaultValue; + } + + if (sliceStart) { + let start = parseInt(sliceStart, 10) - 1; // Convert to 0-indexed (user provides 1-indexed) + // Treat 0 as 1 (bash convention: args start at 1) + if (start < 0) start = 0; + + if (sliceLength) { + const length = parseInt(sliceLength, 10); + return args.slice(start, start + length).join(" "); + } + return args.slice(start).join(" "); + } + + if (simple === "ARGUMENTS" || simple === "@") { + return allArgs; + } + + const index = parseInt(simple, 10) - 1; + return args[index] ?? ""; + }, + ); +} + +function loadTemplateFromFile(filePath: string, sourceInfo: SourceInfo): PromptTemplate | null { + try { + const rawContent = readFileSync(filePath, "utf-8"); + const { frontmatter, body } = parseFrontmatter>(rawContent); + + const name = basename(filePath).replace(/\.md$/, ""); + + // Get description from frontmatter or first non-empty line + let description = frontmatter.description || ""; + if (!description) { + const firstLine = body.split("\n").find((line) => line.trim()); + if (firstLine) { + // Truncate if too long + description = firstLine.slice(0, 60); + if (firstLine.length > 60) description += "..."; + } + } + + return { + name, + description, + ...(frontmatter["argument-hint"] && { argumentHint: frontmatter["argument-hint"] }), + content: body, + sourceInfo, + filePath, + }; + } catch { + return null; + } +} + +/** + * Scan a directory for .md files (non-recursive) and load them as prompt templates. + */ +function loadTemplatesFromDir(dir: string, getSourceInfo: (filePath: string) => SourceInfo): PromptTemplate[] { + const templates: PromptTemplate[] = []; + + if (!existsSync(dir)) { + return templates; + } + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + // For symlinks, check if they point to a file + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isFile = stats.isFile(); + } catch { + // Broken symlink, skip it + continue; + } + } + + if (isFile && entry.name.endsWith(".md")) { + const template = loadTemplateFromFile(fullPath, getSourceInfo(fullPath)); + if (template) { + templates.push(template); + } + } + } + } catch { + return templates; + } + + return templates; +} + +export interface LoadPromptTemplatesOptions { + /** Working directory for project-local templates. */ + cwd: string; + /** Agent config directory for global templates. */ + agentDir: string; + /** Explicit prompt template paths (files or directories). */ + promptPaths: string[]; + /** Include default prompt directories. */ + includeDefaults: boolean; +} + +/** + * Load all prompt templates from: + * 1. Global: agentDir/prompts/ + * 2. Project: cwd/{CONFIG_DIR_NAME}/prompts/ + * 3. Explicit prompt paths + */ +export function loadPromptTemplates(options: LoadPromptTemplatesOptions): PromptTemplate[] { + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(options.agentDir); + const promptPaths = options.promptPaths; + const includeDefaults = options.includeDefaults; + + const templates: PromptTemplate[] = []; + + const globalPromptsDir = join(resolvedAgentDir, "prompts"); + const projectPromptsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "prompts"); + + const isUnderPath = (target: string, root: string): boolean => { + const normalizedRoot = resolve(root); + if (target === normalizedRoot) { + return true; + } + const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return target.startsWith(prefix); + }; + + const getSourceInfo = (resolvedPath: string): SourceInfo => { + if (isUnderPath(resolvedPath, globalPromptsDir)) { + return createSyntheticSourceInfo(resolvedPath, { + source: "local", + scope: "user", + baseDir: globalPromptsDir, + }); + } + if (isUnderPath(resolvedPath, projectPromptsDir)) { + return createSyntheticSourceInfo(resolvedPath, { + source: "local", + scope: "project", + baseDir: projectPromptsDir, + }); + } + return createSyntheticSourceInfo(resolvedPath, { + source: "local", + baseDir: statSync(resolvedPath).isDirectory() ? resolvedPath : dirname(resolvedPath), + }); + }; + + if (includeDefaults) { + templates.push(...loadTemplatesFromDir(globalPromptsDir, getSourceInfo)); + templates.push(...loadTemplatesFromDir(projectPromptsDir, getSourceInfo)); + } + + // 3. Load explicit prompt paths + for (const rawPath of promptPaths) { + const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true }); + if (!existsSync(resolvedPath)) { + continue; + } + + try { + const stats = statSync(resolvedPath); + if (stats.isDirectory()) { + templates.push(...loadTemplatesFromDir(resolvedPath, getSourceInfo)); + } else if (stats.isFile() && resolvedPath.endsWith(".md")) { + const template = loadTemplateFromFile(resolvedPath, getSourceInfo(resolvedPath)); + if (template) { + templates.push(template); + } + } + } catch { + // Ignore read failures + } + } + + return templates; +} + +/** + * Expand a prompt template if it matches a template name. + * Returns the expanded content or the original text if not a template. + */ +export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string { + if (!text.startsWith("/")) return text; + + const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + if (!match) return text; + + const templateName = match[1]; + const argsString = match[2] ?? ""; + + const template = templates.find((t) => t.name === templateName); + if (template) { + const args = parseCommandArgs(argsString); + return substituteArgs(template.content, args); + } + + return text; +} diff --git a/cactus-code/packages/coding-agent/src/core/provider-attribution.ts b/cactus-code/packages/coding-agent/src/core/provider-attribution.ts new file mode 100644 index 000000000..b26f32927 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/provider-attribution.ts @@ -0,0 +1,17 @@ +import type { Api, Model, ProviderHeaders } from "@earendil-works/pi-ai"; +import type { SettingsManager } from "./settings-manager.ts"; + +export function mergeProviderAttributionHeaders( + _model: Model, + _settingsManager: SettingsManager, + _sessionId: string | undefined, + ...headerSources: Array +): ProviderHeaders | undefined { + const merged: ProviderHeaders = {}; + for (const headers of headerSources) { + if (headers) { + Object.assign(merged, headers); + } + } + return Object.keys(merged).length > 0 ? merged : undefined; +} diff --git a/cactus-code/packages/coding-agent/src/core/provider-display-names.ts b/cactus-code/packages/coding-agent/src/core/provider-display-names.ts new file mode 100644 index 000000000..d33c3d7d6 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/provider-display-names.ts @@ -0,0 +1,35 @@ +export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { + anthropic: "Anthropic", + "amazon-bedrock": "Amazon Bedrock", + "ant-ling": "Ant Ling", + "azure-openai-responses": "Azure OpenAI Responses", + cerebras: "Cerebras", + "cloudflare-ai-gateway": "Cloudflare AI Gateway", + "cloudflare-workers-ai": "Cloudflare Workers AI", + deepseek: "DeepSeek", + fireworks: "Fireworks", + google: "Google Gemini", + "google-vertex": "Google Vertex AI", + groq: "Groq", + huggingface: "Hugging Face", + "kimi-coding": "Kimi For Coding", + mistral: "Mistral", + minimax: "MiniMax", + "minimax-cn": "MiniMax (China)", + moonshotai: "Moonshot AI", + "moonshotai-cn": "Moonshot AI (China)", + nvidia: "NVIDIA NIM", + opencode: "OpenCode Zen", + "opencode-go": "OpenCode Go", + openai: "OpenAI", + openrouter: "OpenRouter", + together: "Together AI", + "vercel-ai-gateway": "Vercel AI Gateway", + xai: "xAI", + zai: "ZAI Coding Plan (Global)", + "zai-coding-cn": "ZAI Coding Plan (China)", + xiaomi: "Xiaomi MiMo", + "xiaomi-token-plan-cn": "Xiaomi MiMo Token Plan (China)", + "xiaomi-token-plan-ams": "Xiaomi MiMo Token Plan (Amsterdam)", + "xiaomi-token-plan-sgp": "Xiaomi MiMo Token Plan (Singapore)", +}; diff --git a/cactus-code/packages/coding-agent/src/core/resolve-config-value.ts b/cactus-code/packages/coding-agent/src/core/resolve-config-value.ts new file mode 100644 index 000000000..6d75b001e --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/resolve-config-value.ts @@ -0,0 +1,287 @@ +/** + * Resolve configuration values that may be shell commands, environment variables, or literals. + * Used by auth-storage.ts and model-registry.ts. + */ + +import { execSync, spawnSync } from "child_process"; +import { getShellConfig } from "../utils/shell.ts"; + +// Cache for shell command results (persists for process lifetime) +const commandResultCache = new Map(); +const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const ENV_VAR_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/; + +type TemplatePart = { type: "literal"; value: string } | { type: "env"; name: string }; + +type ConfigValueReference = { type: "command"; config: string } | { type: "template"; parts: TemplatePart[] }; + +function appendLiteral(parts: TemplatePart[], value: string): void { + if (!value) return; + const previousPart = parts[parts.length - 1]; + if (previousPart?.type === "literal") { + previousPart.value += value; + return; + } + parts.push({ type: "literal", value }); +} + +function parseConfigValueTemplate(config: string): TemplatePart[] { + const parts: TemplatePart[] = []; + let index = 0; + + while (index < config.length) { + const dollarIndex = config.indexOf("$", index); + if (dollarIndex < 0) { + appendLiteral(parts, config.slice(index)); + break; + } + + appendLiteral(parts, config.slice(index, dollarIndex)); + const nextChar = config[dollarIndex + 1]; + + if (nextChar === "$" || nextChar === "!") { + appendLiteral(parts, nextChar); + index = dollarIndex + 2; + continue; + } + + if (nextChar === "{") { + const endIndex = config.indexOf("}", dollarIndex + 2); + if (endIndex < 0) { + appendLiteral(parts, "$"); + index = dollarIndex + 1; + continue; + } + + const name = config.slice(dollarIndex + 2, endIndex); + if (ENV_VAR_NAME_RE.test(name)) { + parts.push({ type: "env", name }); + } else { + appendLiteral(parts, config.slice(dollarIndex, endIndex + 1)); + } + index = endIndex + 1; + continue; + } + + const match = config.slice(dollarIndex + 1).match(ENV_VAR_NAME_PREFIX_RE); + if (match) { + parts.push({ type: "env", name: match[0] }); + index = dollarIndex + 1 + match[0].length; + continue; + } + + appendLiteral(parts, "$"); + index = dollarIndex + 1; + } + + return parts; +} + +function parseConfigValueReference(config: string): ConfigValueReference { + if (config.startsWith("!")) { + return { type: "command", config }; + } + + return { type: "template", parts: parseConfigValueTemplate(config) }; +} + +function resolveEnvConfigValue(name: string, env?: Record): string | undefined { + return env?.[name] || process.env[name] || undefined; +} + +function getTemplateEnvVarNames(parts: TemplatePart[]): string[] { + const names: string[] = []; + for (const part of parts) { + if (part.type !== "env" || names.includes(part.name)) continue; + names.push(part.name); + } + return names; +} + +function resolveTemplate(parts: TemplatePart[], env?: Record): string | undefined { + let resolved = ""; + for (const part of parts) { + if (part.type === "literal") { + resolved += part.value; + continue; + } + const envValue = resolveEnvConfigValue(part.name, env); + if (envValue === undefined) return undefined; + resolved += envValue; + } + return resolved; +} + +export function getConfigValueEnvVarName(config: string): string | undefined { + const reference = parseConfigValueReference(config); + if (reference.type !== "template") return undefined; + return reference.parts.length === 1 && reference.parts[0]?.type === "env" ? reference.parts[0].name : undefined; +} + +export function getConfigValueEnvVarNames(config: string): string[] { + const reference = parseConfigValueReference(config); + return reference.type === "template" ? getTemplateEnvVarNames(reference.parts) : []; +} + +export function getMissingConfigValueEnvVarNames(config: string, env?: Record): string[] { + return getConfigValueEnvVarNames(config).filter((name) => resolveEnvConfigValue(name, env) === undefined); +} + +export function isCommandConfigValue(config: string): boolean { + return parseConfigValueReference(config).type === "command"; +} + +export function isConfigValueConfigured(config: string, env?: Record): boolean { + return getMissingConfigValueEnvVarNames(config, env).length === 0; +} + +/** + * Resolve a config value (API key, header value, etc.) to an actual value. + * - If starts with "!", executes the rest as a shell command and uses stdout (cached) + * - Interpolates "$ENV_VAR" or "${ENV_VAR}" references with the named environment variable + * - In non-command values, "$$" escapes a literal "$" and "$!" escapes a literal "!" + * - Otherwise treats the value as a literal + */ +export function resolveConfigValue(config: string, env?: Record): string | undefined { + const reference = parseConfigValueReference(config); + if (reference.type === "command") { + return executeCommand(reference.config); + } + return resolveTemplate(reference.parts, env); +} + +function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } { + try { + const { shell, args, commandTransport } = getShellConfig(); + const commandFromStdin = commandTransport === "stdin"; + const result = spawnSync(shell, commandFromStdin ? args : [...args, command], { + encoding: "utf-8", + input: commandFromStdin ? command : undefined, + timeout: 10000, + stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "ignore"], + shell: false, + windowsHide: true, + }); + + if (result.error) { + const error = result.error as NodeJS.ErrnoException; + if (error.code === "ENOENT") { + return { executed: false, value: undefined }; + } + return { executed: true, value: undefined }; + } + + if (result.status !== 0) { + return { executed: true, value: undefined }; + } + + const value = (result.stdout ?? "").trim(); + return { executed: true, value: value || undefined }; + } catch { + return { executed: false, value: undefined }; + } +} + +function executeWithDefaultShell(command: string): string | undefined { + try { + const output = execSync(command, { + encoding: "utf-8", + timeout: 10000, + stdio: ["ignore", "pipe", "ignore"], + }); + return output.trim() || undefined; + } catch { + return undefined; + } +} + +function executeCommandUncached(commandConfig: string): string | undefined { + const command = commandConfig.slice(1); + return process.platform === "win32" + ? (() => { + const configuredResult = executeWithConfiguredShell(command); + return configuredResult.executed ? configuredResult.value : executeWithDefaultShell(command); + })() + : executeWithDefaultShell(command); +} + +function executeCommand(commandConfig: string): string | undefined { + if (commandResultCache.has(commandConfig)) { + return commandResultCache.get(commandConfig); + } + + const result = executeCommandUncached(commandConfig); + commandResultCache.set(commandConfig, result); + return result; +} + +/** + * Resolve all header values using the same resolution logic as API keys. + */ +export function resolveConfigValueUncached(config: string, env?: Record): string | undefined { + const reference = parseConfigValueReference(config); + if (reference.type === "command") { + return executeCommandUncached(reference.config); + } + return resolveTemplate(reference.parts, env); +} + +export function resolveConfigValueOrThrow(config: string, description: string, env?: Record): string { + const resolvedValue = resolveConfigValueUncached(config, env); + if (resolvedValue !== undefined) { + return resolvedValue; + } + + const reference = parseConfigValueReference(config); + if (reference.type === "command") { + throw new Error(`Failed to resolve ${description} from shell command: ${reference.config.slice(1)}`); + } + + if (reference.type === "template") { + const missingEnvVars = getMissingConfigValueEnvVarNames(config, env); + if (missingEnvVars.length === 1) { + throw new Error(`Failed to resolve ${description} from environment variable: ${missingEnvVars[0]}`); + } + if (missingEnvVars.length > 1) { + throw new Error(`Failed to resolve ${description} from environment variables: ${missingEnvVars.join(", ")}`); + } + } + + throw new Error(`Failed to resolve ${description}`); +} + +/** + * Resolve all header values using the same resolution logic as API keys. + */ +export function resolveHeaders( + headers: Record | undefined, + env?: Record, +): Record | undefined { + if (!headers) return undefined; + const resolved: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const resolvedValue = resolveConfigValue(value, env); + if (resolvedValue) { + resolved[key] = resolvedValue; + } + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +export function resolveHeadersOrThrow( + headers: Record | undefined, + description: string, + env?: Record, +): Record | undefined { + if (!headers) return undefined; + const resolved: Record = {}; + for (const [key, value] of Object.entries(headers)) { + resolved[key] = resolveConfigValueOrThrow(value, `${description} header "${key}"`, env); + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** Clear the config value command cache. Exported for testing. */ +export function clearConfigValueCache(): void { + commandResultCache.clear(); +} diff --git a/cactus-code/packages/coding-agent/src/core/resource-loader.ts b/cactus-code/packages/coding-agent/src/core/resource-loader.ts new file mode 100644 index 000000000..18486ead9 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/resource-loader.ts @@ -0,0 +1,1037 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join, resolve, sep } from "node:path"; +import chalk from "chalk"; +import { CONFIG_DIR_NAME } from "../config.ts"; +import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.ts"; +import type { ResourceDiagnostic } from "./diagnostics.ts"; + +export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts"; + +import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; +import { createEventBus, type EventBus } from "./event-bus.ts"; +import { + clearExtensionCache, + createExtensionRuntime, + loadExtensionFromFactory, + loadExtensionsCached, +} from "./extensions/loader.ts"; +import type { Extension, ExtensionFactory, ExtensionRuntime, LoadExtensionsResult } from "./extensions/types.ts"; +import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts"; +import type { PromptTemplate } from "./prompt-templates.ts"; +import { loadPromptTemplates } from "./prompt-templates.ts"; +import { SettingsManager } from "./settings-manager.ts"; +import type { Skill } from "./skills.ts"; +import { loadSkills } from "./skills.ts"; +import { createSourceInfo, type SourceInfo } from "./source-info.ts"; + +export interface ResourceExtensionPaths { + skillPaths?: Array<{ path: string; metadata: PathMetadata }>; + promptPaths?: Array<{ path: string; metadata: PathMetadata }>; + themePaths?: Array<{ path: string; metadata: PathMetadata }>; +} + +export interface ResourceLoaderReloadOptions { + resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise; +} + +export interface ResourceLoader { + getExtensions(): LoadExtensionsResult; + getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; + getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }; + getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; + getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> }; + getSystemPrompt(): string | undefined; + getAppendSystemPrompt(): string[]; + extendResources(paths: ResourceExtensionPaths): void; + reload(options?: ResourceLoaderReloadOptions): Promise; +} + +function resolvePromptInput(input: string | undefined, description: string): string | undefined { + if (!input) { + return undefined; + } + + if (existsSync(input)) { + try { + return readFileSync(input, "utf-8"); + } catch (error) { + console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`)); + return input; + } + } + + return input; +} + +function loadContextFileFromDir(dir: string): { path: string; content: string } | null { + const candidates = ["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]; + for (const filename of candidates) { + const filePath = join(dir, filename); + if (existsSync(filePath)) { + try { + return { + path: filePath, + content: readFileSync(filePath, "utf-8"), + }; + } catch (error) { + console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`)); + } + } + } + return null; +} + +export function loadProjectContextFiles(options: { + cwd: string; + agentDir: string; +}): Array<{ path: string; content: string }> { + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(options.agentDir); + + const contextFiles: Array<{ path: string; content: string }> = []; + const seenPaths = new Set(); + + const globalContext = loadContextFileFromDir(resolvedAgentDir); + if (globalContext) { + contextFiles.push(globalContext); + seenPaths.add(globalContext.path); + } + + const ancestorContextFiles: Array<{ path: string; content: string }> = []; + + let currentDir = resolvedCwd; + const root = resolve("/"); + + while (true) { + const contextFile = loadContextFileFromDir(currentDir); + if (contextFile && !seenPaths.has(contextFile.path)) { + ancestorContextFiles.unshift(contextFile); + seenPaths.add(contextFile.path); + } + + if (currentDir === root) break; + + const parentDir = resolve(currentDir, ".."); + if (parentDir === currentDir) break; + currentDir = parentDir; + } + + contextFiles.push(...ancestorContextFiles); + + return contextFiles; +} + +export interface DefaultResourceLoaderOptions { + cwd: string; + agentDir: string; + settingsManager?: SettingsManager; + eventBus?: EventBus; + additionalExtensionPaths?: string[]; + additionalSkillPaths?: string[]; + additionalPromptTemplatePaths?: string[]; + additionalThemePaths?: string[]; + extensionFactories?: ExtensionFactory[]; + noExtensions?: boolean; + noSkills?: boolean; + noPromptTemplates?: boolean; + noThemes?: boolean; + noContextFiles?: boolean; + systemPrompt?: string; + appendSystemPrompt?: string[]; + extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult; + skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { + skills: Skill[]; + diagnostics: ResourceDiagnostic[]; + }; + promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => { + prompts: PromptTemplate[]; + diagnostics: ResourceDiagnostic[]; + }; + themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => { + themes: Theme[]; + diagnostics: ResourceDiagnostic[]; + }; + agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => { + agentsFiles: Array<{ path: string; content: string }>; + }; + systemPromptOverride?: (base: string | undefined) => string | undefined; + appendSystemPromptOverride?: (base: string[]) => string[]; +} + +export class DefaultResourceLoader implements ResourceLoader { + private cwd: string; + private agentDir: string; + private settingsManager: SettingsManager; + private eventBus: EventBus; + private packageManager: DefaultPackageManager; + private additionalExtensionPaths: string[]; + private additionalSkillPaths: string[]; + private additionalPromptTemplatePaths: string[]; + private additionalThemePaths: string[]; + private extensionFactories: ExtensionFactory[]; + private noExtensions: boolean; + private noSkills: boolean; + private noPromptTemplates: boolean; + private noThemes: boolean; + private noContextFiles: boolean; + private systemPromptSource?: string; + private appendSystemPromptSource?: string[]; + private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult; + private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { + skills: Skill[]; + diagnostics: ResourceDiagnostic[]; + }; + private promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => { + prompts: PromptTemplate[]; + diagnostics: ResourceDiagnostic[]; + }; + private themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => { + themes: Theme[]; + diagnostics: ResourceDiagnostic[]; + }; + private agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => { + agentsFiles: Array<{ path: string; content: string }>; + }; + private systemPromptOverride?: (base: string | undefined) => string | undefined; + private appendSystemPromptOverride?: (base: string[]) => string[]; + + private extensionsResult: LoadExtensionsResult; + private skills: Skill[]; + private skillDiagnostics: ResourceDiagnostic[]; + private prompts: PromptTemplate[]; + private promptDiagnostics: ResourceDiagnostic[]; + private themes: Theme[]; + private themeDiagnostics: ResourceDiagnostic[]; + private agentsFiles: Array<{ path: string; content: string }>; + private systemPrompt?: string; + private appendSystemPrompt: string[]; + private lastSkillPaths: string[]; + private extensionSkillSourceInfos: Map; + private extensionPromptSourceInfos: Map; + private extensionThemeSourceInfos: Map; + private lastPromptPaths: string[]; + private lastThemePaths: string[]; + private loaded: boolean; + + constructor(options: DefaultResourceLoaderOptions) { + this.cwd = resolvePath(options.cwd); + this.agentDir = resolvePath(options.agentDir); + this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir); + this.eventBus = options.eventBus ?? createEventBus(); + this.packageManager = new DefaultPackageManager({ + cwd: this.cwd, + agentDir: this.agentDir, + settingsManager: this.settingsManager, + }); + this.additionalExtensionPaths = options.additionalExtensionPaths ?? []; + this.additionalSkillPaths = options.additionalSkillPaths ?? []; + this.additionalPromptTemplatePaths = options.additionalPromptTemplatePaths ?? []; + this.additionalThemePaths = options.additionalThemePaths ?? []; + this.extensionFactories = options.extensionFactories ?? []; + this.noExtensions = options.noExtensions ?? false; + this.noSkills = options.noSkills ?? false; + this.noPromptTemplates = options.noPromptTemplates ?? false; + this.noThemes = options.noThemes ?? false; + this.noContextFiles = options.noContextFiles ?? false; + this.systemPromptSource = options.systemPrompt; + this.appendSystemPromptSource = options.appendSystemPrompt; + this.extensionsOverride = options.extensionsOverride; + this.skillsOverride = options.skillsOverride; + this.promptsOverride = options.promptsOverride; + this.themesOverride = options.themesOverride; + this.agentsFilesOverride = options.agentsFilesOverride; + this.systemPromptOverride = options.systemPromptOverride; + this.appendSystemPromptOverride = options.appendSystemPromptOverride; + + this.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() }; + this.skills = []; + this.skillDiagnostics = []; + this.prompts = []; + this.promptDiagnostics = []; + this.themes = []; + this.themeDiagnostics = []; + this.agentsFiles = []; + this.appendSystemPrompt = []; + this.lastSkillPaths = []; + this.extensionSkillSourceInfos = new Map(); + this.extensionPromptSourceInfos = new Map(); + this.extensionThemeSourceInfos = new Map(); + this.lastPromptPaths = []; + this.lastThemePaths = []; + this.loaded = false; + } + + getExtensions(): LoadExtensionsResult { + return this.extensionsResult; + } + + getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } { + return { skills: this.skills, diagnostics: this.skillDiagnostics }; + } + + getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } { + return { prompts: this.prompts, diagnostics: this.promptDiagnostics }; + } + + getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } { + return { themes: this.themes, diagnostics: this.themeDiagnostics }; + } + + getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } { + return { agentsFiles: this.agentsFiles }; + } + + getSystemPrompt(): string | undefined { + return this.systemPrompt; + } + + getAppendSystemPrompt(): string[] { + return this.appendSystemPrompt; + } + + extendResources(paths: ResourceExtensionPaths): void { + const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []); + const promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []); + const themePaths = this.normalizeExtensionPaths(paths.themePaths ?? []); + + for (const entry of skillPaths) { + this.extensionSkillSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); + } + for (const entry of promptPaths) { + this.extensionPromptSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); + } + for (const entry of themePaths) { + this.extensionThemeSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); + } + + if (skillPaths.length > 0) { + this.lastSkillPaths = this.mergePaths( + this.lastSkillPaths, + skillPaths.map((entry) => entry.path), + ); + this.updateSkillsFromPaths(this.lastSkillPaths); + } + + if (promptPaths.length > 0) { + this.lastPromptPaths = this.mergePaths( + this.lastPromptPaths, + promptPaths.map((entry) => entry.path), + ); + this.updatePromptsFromPaths(this.lastPromptPaths); + } + + if (themePaths.length > 0) { + this.lastThemePaths = this.mergePaths( + this.lastThemePaths, + themePaths.map((entry) => entry.path), + ); + this.updateThemesFromPaths(this.lastThemePaths); + } + } + + async loadProjectTrustExtensions(): Promise { + // Force untrusted project settings for the bootstrap pass. This keeps project-local + // extensions/packages out while still loading user/global and temporary CLI extensions. + this.settingsManager.setProjectTrusted(false); + await this.settingsManager.reload(); + return this.loadCurrentExtensionSet({ includeInlineFactories: true }); + } + + async reload(options?: ResourceLoaderReloadOptions): Promise { + if (this.loaded) { + clearExtensionCache(); + } + + let preTrustExtensions: LoadExtensionsResult | undefined; + if (options?.resolveProjectTrust) { + preTrustExtensions = await this.loadProjectTrustExtensions(); + const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions }); + this.settingsManager.setProjectTrusted(projectTrusted); + } + + // reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state. + await this.settingsManager.reload(); + const resolvedPaths = await this.packageManager.resolve(); + const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { + temporary: true, + }); + const metadataByPath = new Map(); + + this.extensionSkillSourceInfos = new Map(); + this.extensionPromptSourceInfos = new Map(); + this.extensionThemeSourceInfos = new Map(); + + // Helper to extract enabled paths and store metadata + const getEnabledResources = (resources: ResolvedResource[]): ResolvedResource[] => { + for (const r of resources) { + if (!metadataByPath.has(r.path)) { + metadataByPath.set(r.path, r.metadata); + } + } + return resources.filter((r) => r.enabled); + }; + + const getEnabledPaths = (resources: ResolvedResource[]): string[] => + getEnabledResources(resources).map((r) => r.path); + const enabledExtensions = getEnabledPaths(resolvedPaths.extensions); + const enabledSkillResources = getEnabledResources(resolvedPaths.skills); + const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); + const enabledThemes = getEnabledPaths(resolvedPaths.themes); + + const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath)); + + // Add CLI paths metadata + for (const r of cliExtensionPaths.extensions) { + if (!metadataByPath.has(r.path)) { + metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); + } + } + for (const r of cliExtensionPaths.skills) { + if (!metadataByPath.has(r.path)) { + metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); + } + } + + const cliEnabledExtensions = getEnabledPaths(cliExtensionPaths.extensions); + const cliEnabledSkills = getEnabledPaths(cliExtensionPaths.skills); + const cliEnabledPrompts = getEnabledPaths(cliExtensionPaths.prompts); + const cliEnabledThemes = getEnabledPaths(cliExtensionPaths.themes); + + const extensionPaths = this.noExtensions + ? cliEnabledExtensions + : this.mergePaths(cliEnabledExtensions, enabledExtensions); + + const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions); + for (const p of this.additionalExtensionPaths) { + if (isLocalPath(p)) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved)) { + extensionsResult.errors.push({ path: resolved, error: `Extension path does not exist: ${resolved}` }); + } + } + } + this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; + this.applyExtensionSourceInfo(this.extensionsResult.extensions, metadataByPath); + + const skillPaths = this.noSkills + ? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths) + : this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths); + + this.lastSkillPaths = skillPaths; + this.updateSkillsFromPaths(skillPaths, metadataByPath); + for (const p of this.additionalSkillPaths) { + if (isLocalPath(p)) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) { + this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: resolved }); + } + } + } + + const promptPaths = this.noPromptTemplates + ? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths) + : this.mergePaths([...cliEnabledPrompts, ...enabledPrompts], this.additionalPromptTemplatePaths); + + this.lastPromptPaths = promptPaths; + this.updatePromptsFromPaths(promptPaths, metadataByPath); + for (const p of this.additionalPromptTemplatePaths) { + if (isLocalPath(p)) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved) && !this.promptDiagnostics.some((d) => d.path === resolved)) { + this.promptDiagnostics.push({ + type: "error", + message: "Prompt template path does not exist", + path: resolved, + }); + } + } + } + + const themePaths = this.noThemes + ? this.mergePaths(cliEnabledThemes, this.additionalThemePaths) + : this.mergePaths([...cliEnabledThemes, ...enabledThemes], this.additionalThemePaths); + + this.lastThemePaths = themePaths; + this.updateThemesFromPaths(themePaths, metadataByPath); + for (const p of this.additionalThemePaths) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved) && !this.themeDiagnostics.some((d) => d.path === resolved)) { + this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: resolved }); + } + } + + const agentsFiles = { + agentsFiles: this.noContextFiles + ? [] + : loadProjectContextFiles({ + cwd: this.cwd, + agentDir: this.agentDir, + }), + }; + const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; + this.agentsFiles = resolvedAgentsFiles.agentsFiles; + + const baseSystemPrompt = resolvePromptInput( + this.systemPromptSource ?? this.discoverSystemPromptFile(), + "system prompt", + ); + this.systemPrompt = this.systemPromptOverride ? this.systemPromptOverride(baseSystemPrompt) : baseSystemPrompt; + + const appendSources = + this.appendSystemPromptSource ?? + (this.discoverAppendSystemPromptFile() ? [this.discoverAppendSystemPromptFile()!] : []); + const baseAppend = appendSources + .map((s) => resolvePromptInput(s, "append system prompt")) + .filter((s): s is string => s !== undefined); + this.appendSystemPrompt = this.appendSystemPromptOverride + ? this.appendSystemPromptOverride(baseAppend) + : baseAppend; + this.loaded = true; + } + + private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise { + const resolvedPaths = await this.packageManager.resolve(); + const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { + temporary: true, + }); + const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path); + const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path); + const extensionPaths = this.noExtensions + ? cliEnabledExtensions + : this.mergePaths(cliEnabledExtensions, enabledExtensions); + const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); + if (!options.includeInlineFactories) { + return extensionsResult; + } + + const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); + extensionsResult.extensions.push(...inlineExtensions.extensions); + extensionsResult.errors.push(...inlineExtensions.errors); + return extensionsResult; + } + + private resolveExtensionLoadPath(path: string): string { + return resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true }); + } + + private async loadFinalExtensionSet( + extensionPaths: string[], + preTrustExtensions: LoadExtensionsResult | undefined, + ): Promise { + if (!preTrustExtensions) { + const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); + const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); + extensionsResult.extensions.push(...inlineExtensions.extensions); + extensionsResult.errors.push(...inlineExtensions.errors); + this.addExtensionConflictDiagnostics(extensionsResult); + return extensionsResult; + } + + const preloadedByPath = new Map( + preTrustExtensions.extensions + .filter((extension) => !extension.path.startsWith(" [extension.resolvedPath, extension]), + ); + const failedPreloadPaths = new Set( + preTrustExtensions.errors.map((error) => this.resolveExtensionLoadPath(error.path)), + ); + const remainingPaths = extensionPaths.filter((path) => { + const resolvedPath = this.resolveExtensionLoadPath(path); + return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath); + }); + const remainingExtensions = await loadExtensionsCached( + remainingPaths, + this.cwd, + this.eventBus, + preTrustExtensions.runtime, + ); + const loadedByPath = new Map(preloadedByPath); + for (const extension of remainingExtensions.extensions) { + loadedByPath.set(extension.resolvedPath, extension); + } + + const inlineExtensions = preTrustExtensions.extensions.filter((extension) => + extension.path.startsWith(" loadedByPath.get(this.resolveExtensionLoadPath(path))) + .filter((extension): extension is Extension => extension !== undefined); + orderedExtensions.push(...inlineExtensions); + + const extensionsResult: LoadExtensionsResult = { + extensions: orderedExtensions, + errors: [...preTrustExtensions.errors, ...remainingExtensions.errors], + runtime: preTrustExtensions.runtime, + }; + this.addExtensionConflictDiagnostics(extensionsResult); + return extensionsResult; + } + + private addExtensionConflictDiagnostics(extensionsResult: LoadExtensionsResult): void { + // Detect extension conflicts (tools, commands, flags with same names from different extensions) + // Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order. + const conflicts = this.detectExtensionConflicts(extensionsResult.extensions); + for (const conflict of conflicts) { + extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); + } + } + + private mapSkillPath(resource: ResolvedResource, metadataByPath: Map): string { + if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") { + return resource.path; + } + try { + const stats = statSync(resource.path); + if (!stats.isDirectory()) { + return resource.path; + } + } catch { + return resource.path; + } + const skillFile = join(resource.path, "SKILL.md"); + if (existsSync(skillFile)) { + if (!metadataByPath.has(skillFile)) { + metadataByPath.set(skillFile, resource.metadata); + } + return skillFile; + } + return resource.path; + } + + private normalizeExtensionPaths( + entries: Array<{ path: string; metadata: PathMetadata }>, + ): Array<{ path: string; metadata: PathMetadata }> { + return entries.map((entry) => { + const metadata = entry.metadata.baseDir + ? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) } + : entry.metadata; + return { + path: this.resolveResourcePath(entry.path), + metadata, + }; + }); + } + + private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map): void { + let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }; + if (this.noSkills && skillPaths.length === 0) { + skillsResult = { skills: [], diagnostics: [] }; + } else { + skillsResult = loadSkills({ + cwd: this.cwd, + agentDir: this.agentDir, + skillPaths, + includeDefaults: false, + }); + } + const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult; + this.skills = resolvedSkills.skills.map((skill) => ({ + ...skill, + sourceInfo: + this.findSourceInfoForPath(skill.filePath, this.extensionSkillSourceInfos, metadataByPath) ?? + skill.sourceInfo ?? + this.getDefaultSourceInfoForPath(skill.filePath), + })); + this.skillDiagnostics = resolvedSkills.diagnostics; + } + + private updatePromptsFromPaths(promptPaths: string[], metadataByPath?: Map): void { + let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }; + if (this.noPromptTemplates && promptPaths.length === 0) { + promptsResult = { prompts: [], diagnostics: [] }; + } else { + const allPrompts = loadPromptTemplates({ + cwd: this.cwd, + agentDir: this.agentDir, + promptPaths, + includeDefaults: false, + }); + promptsResult = this.dedupePrompts(allPrompts); + } + const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult; + this.prompts = resolvedPrompts.prompts.map((prompt) => ({ + ...prompt, + sourceInfo: + this.findSourceInfoForPath(prompt.filePath, this.extensionPromptSourceInfos, metadataByPath) ?? + prompt.sourceInfo ?? + this.getDefaultSourceInfoForPath(prompt.filePath), + })); + this.promptDiagnostics = resolvedPrompts.diagnostics; + } + + private updateThemesFromPaths(themePaths: string[], metadataByPath?: Map): void { + let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }; + if (this.noThemes && themePaths.length === 0) { + themesResult = { themes: [], diagnostics: [] }; + } else { + const loaded = this.loadThemes(themePaths, false); + const deduped = this.dedupeThemes(loaded.themes); + themesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] }; + } + const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult; + this.themes = resolvedThemes.themes.map((theme) => { + const sourcePath = theme.sourcePath; + theme.sourceInfo = sourcePath + ? (this.findSourceInfoForPath(sourcePath, this.extensionThemeSourceInfos, metadataByPath) ?? + theme.sourceInfo ?? + this.getDefaultSourceInfoForPath(sourcePath)) + : theme.sourceInfo; + return theme; + }); + this.themeDiagnostics = resolvedThemes.diagnostics; + } + + private applyExtensionSourceInfo(extensions: Extension[], metadataByPath: Map): void { + for (const extension of extensions) { + extension.sourceInfo = + this.findSourceInfoForPath(extension.path, undefined, metadataByPath) ?? + this.getDefaultSourceInfoForPath(extension.path); + for (const command of extension.commands.values()) { + command.sourceInfo = extension.sourceInfo; + } + for (const tool of extension.tools.values()) { + tool.sourceInfo = extension.sourceInfo; + } + } + } + + private findSourceInfoForPath( + resourcePath: string, + extraSourceInfos?: Map, + metadataByPath?: Map, + ): SourceInfo | undefined { + if (!resourcePath) { + return undefined; + } + + if (resourcePath.startsWith("<")) { + return this.getDefaultSourceInfoForPath(resourcePath); + } + + const normalizedResourcePath = resolve(resourcePath); + if (extraSourceInfos) { + for (const [sourcePath, sourceInfo] of extraSourceInfos.entries()) { + const normalizedSourcePath = resolve(sourcePath); + if ( + normalizedResourcePath === normalizedSourcePath || + normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`) + ) { + return { ...sourceInfo, path: resourcePath }; + } + } + } + + if (metadataByPath) { + const exact = metadataByPath.get(normalizedResourcePath) ?? metadataByPath.get(resourcePath); + if (exact) { + return createSourceInfo(resourcePath, exact); + } + + for (const [sourcePath, metadata] of metadataByPath.entries()) { + const normalizedSourcePath = resolve(sourcePath); + if ( + normalizedResourcePath === normalizedSourcePath || + normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`) + ) { + return createSourceInfo(resourcePath, metadata); + } + } + } + + return undefined; + } + + private getDefaultSourceInfoForPath(filePath: string): SourceInfo { + if (filePath.startsWith("<") && filePath.endsWith(">")) { + return { + path: filePath, + source: filePath.slice(1, -1).split(":")[0] || "temporary", + scope: "temporary", + origin: "top-level", + }; + } + + const normalizedPath = resolve(filePath); + const agentRoots = [ + join(this.agentDir, "skills"), + join(this.agentDir, "prompts"), + join(this.agentDir, "themes"), + join(this.agentDir, "extensions"), + ]; + const projectRoots = [ + join(this.cwd, CONFIG_DIR_NAME, "skills"), + join(this.cwd, CONFIG_DIR_NAME, "prompts"), + join(this.cwd, CONFIG_DIR_NAME, "themes"), + join(this.cwd, CONFIG_DIR_NAME, "extensions"), + ]; + + for (const root of agentRoots) { + if (this.isUnderPath(normalizedPath, root)) { + return { path: filePath, source: "local", scope: "user", origin: "top-level", baseDir: root }; + } + } + + for (const root of projectRoots) { + if (this.isUnderPath(normalizedPath, root)) { + return { path: filePath, source: "local", scope: "project", origin: "top-level", baseDir: root }; + } + } + + return { + path: filePath, + source: "local", + scope: "temporary", + origin: "top-level", + baseDir: statSync(normalizedPath).isDirectory() ? normalizedPath : resolve(normalizedPath, ".."), + }; + } + + private mergePaths(primary: string[], additional: string[]): string[] { + const merged: string[] = []; + const seen = new Set(); + + for (const p of [...primary, ...additional]) { + const resolved = this.resolveResourcePath(p); + const canonicalPath = canonicalizePath(resolved); + if (seen.has(canonicalPath)) continue; + seen.add(canonicalPath); + merged.push(resolved); + } + + return merged; + } + + private resolveResourcePath(p: string): string { + return resolvePath(p, this.cwd, { trim: true }); + } + + private loadThemes( + paths: string[], + includeDefaults: boolean = true, + ): { + themes: Theme[]; + diagnostics: ResourceDiagnostic[]; + } { + const themes: Theme[] = []; + const diagnostics: ResourceDiagnostic[] = []; + if (includeDefaults) { + const defaultDirs = [join(this.agentDir, "themes"), join(this.cwd, CONFIG_DIR_NAME, "themes")]; + + for (const dir of defaultDirs) { + this.loadThemesFromDir(dir, themes, diagnostics); + } + } + + for (const p of paths) { + const resolved = this.resolveResourcePath(p); + if (!existsSync(resolved)) { + diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved }); + continue; + } + + try { + const stats = statSync(resolved); + if (stats.isDirectory()) { + this.loadThemesFromDir(resolved, themes, diagnostics); + } else if (stats.isFile() && resolved.endsWith(".json")) { + this.loadThemeFromFile(resolved, themes, diagnostics); + } else { + diagnostics.push({ type: "warning", message: "theme path is not a json file", path: resolved }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read theme path"; + diagnostics.push({ type: "warning", message, path: resolved }); + } + } + + return { themes, diagnostics }; + } + + private loadThemesFromDir(dir: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void { + if (!existsSync(dir)) { + return; + } + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(join(dir, entry.name)).isFile(); + } catch { + continue; + } + } + if (!isFile) { + continue; + } + if (!entry.name.endsWith(".json")) { + continue; + } + this.loadThemeFromFile(join(dir, entry.name), themes, diagnostics); + } + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read theme directory"; + diagnostics.push({ type: "warning", message, path: dir }); + } + } + + private loadThemeFromFile(filePath: string, themes: Theme[], diagnostics: ResourceDiagnostic[]): void { + try { + themes.push(loadThemeFromPath(filePath)); + } catch (error) { + const message = error instanceof Error ? error.message : "failed to load theme"; + diagnostics.push({ type: "warning", message, path: filePath }); + } + } + + private async loadExtensionFactories(runtime: ExtensionRuntime): Promise<{ + extensions: Extension[]; + errors: Array<{ path: string; error: string }>; + }> { + const extensions: Extension[] = []; + const errors: Array<{ path: string; error: string }> = []; + + for (const [index, factory] of this.extensionFactories.entries()) { + const extensionPath = ``; + try { + const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath); + extensions.push(extension); + } catch (error) { + const message = error instanceof Error ? error.message : "failed to load extension"; + errors.push({ path: extensionPath, error: message }); + } + } + + return { extensions, errors }; + } + + private dedupePrompts(prompts: PromptTemplate[]): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } { + const seen = new Map(); + const diagnostics: ResourceDiagnostic[] = []; + + for (const prompt of prompts) { + const existing = seen.get(prompt.name); + if (existing) { + diagnostics.push({ + type: "collision", + message: `name "/${prompt.name}" collision`, + path: prompt.filePath, + collision: { + resourceType: "prompt", + name: prompt.name, + winnerPath: existing.filePath, + loserPath: prompt.filePath, + }, + }); + } else { + seen.set(prompt.name, prompt); + } + } + + return { prompts: Array.from(seen.values()), diagnostics }; + } + + private dedupeThemes(themes: Theme[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } { + const seen = new Map(); + const diagnostics: ResourceDiagnostic[] = []; + + for (const t of themes) { + const name = t.name ?? "unnamed"; + const existing = seen.get(name); + if (existing) { + diagnostics.push({ + type: "collision", + message: `name "${name}" collision`, + path: t.sourcePath, + collision: { + resourceType: "theme", + name, + winnerPath: existing.sourcePath ?? "", + loserPath: t.sourcePath ?? "", + }, + }); + } else { + seen.set(name, t); + } + } + + return { themes: Array.from(seen.values()), diagnostics }; + } + + private discoverSystemPromptFile(): string | undefined { + const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); + if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { + return projectPath; + } + + const globalPath = join(this.agentDir, "SYSTEM.md"); + if (existsSync(globalPath)) { + return globalPath; + } + + return undefined; + } + + private discoverAppendSystemPromptFile(): string | undefined { + const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); + if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { + return projectPath; + } + + const globalPath = join(this.agentDir, "APPEND_SYSTEM.md"); + if (existsSync(globalPath)) { + return globalPath; + } + + return undefined; + } + + private isUnderPath(target: string, root: string): boolean { + const normalizedRoot = resolve(root); + if (target === normalizedRoot) { + return true; + } + const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return target.startsWith(prefix); + } + + private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> { + const conflicts: Array<{ path: string; message: string }> = []; + + // Track which extension registered each tool and flag + const toolOwners = new Map(); + const flagOwners = new Map(); + + for (const ext of extensions) { + // Check tools + for (const toolName of ext.tools.keys()) { + const existingOwner = toolOwners.get(toolName); + if (existingOwner && existingOwner !== ext.path) { + conflicts.push({ + path: ext.path, + message: `Tool "${toolName}" conflicts with ${existingOwner}`, + }); + } else { + toolOwners.set(toolName, ext.path); + } + } + + // Check flags + for (const flagName of ext.flags.keys()) { + const existingOwner = flagOwners.get(flagName); + if (existingOwner && existingOwner !== ext.path) { + conflicts.push({ + path: ext.path, + message: `Flag "--${flagName}" conflicts with ${existingOwner}`, + }); + } else { + flagOwners.set(flagName, ext.path); + } + } + } + + return conflicts; + } +} diff --git a/cactus-code/packages/coding-agent/src/core/sdk.ts b/cactus-code/packages/coding-agent/src/core/sdk.ts new file mode 100644 index 000000000..2175c60f8 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/sdk.ts @@ -0,0 +1,399 @@ +import { join } from "node:path"; +import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat"; +import { getAgentDir } from "../config.ts"; +import { resolvePath } from "../utils/paths.ts"; +import { AgentSession } from "./agent-session.ts"; +import { formatNoModelsAvailableMessage } from "./auth-guidance.ts"; +import { AuthStorage } from "./auth-storage.ts"; +import { DEFAULT_THINKING_LEVEL } from "./defaults.ts"; +import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts"; +import { convertToLlm } from "./messages.ts"; +import { ModelRegistry } from "./model-registry.ts"; +import { findInitialModel } from "./model-resolver.ts"; +import { mergeProviderAttributionHeaders } from "./provider-attribution.ts"; +import type { ResourceLoader } from "./resource-loader.ts"; +import { DefaultResourceLoader } from "./resource-loader.ts"; +import { getDefaultSessionDir, SessionManager } from "./session-manager.ts"; +import { SettingsManager } from "./settings-manager.ts"; +import { time } from "./timings.ts"; +import { + createBashTool, + createCodingTools, + createEditTool, + createFindTool, + createGrepTool, + createLsTool, + createReadOnlyTools, + createReadTool, + createWriteTool, + type ToolName, + withFileMutationQueue, +} from "./tools/index.ts"; + +export interface CreateAgentSessionOptions { + /** Working directory for project-local discovery. Default: process.cwd() */ + cwd?: string; + /** Global config directory. Default: ~/.pi/agent */ + agentDir?: string; + + /** Auth storage for credentials. Default: AuthStorage.create(agentDir/auth.json) */ + authStorage?: AuthStorage; + /** Model registry. Default: ModelRegistry.create(authStorage, agentDir/models.json) */ + modelRegistry?: ModelRegistry; + + /** Model to use. Default: from settings, else first available */ + model?: Model; + /** Thinking level. Default: from settings, else 'medium' (clamped to model capabilities) */ + thinkingLevel?: ThinkingLevel; + /** Models available for cycling (Ctrl+P in interactive mode) */ + scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>; + + /** + * Optional default tool suppression mode when no explicit allowlist is provided. + * + * - "all": start with no tools enabled + * - "builtin": disable the default built-in tools (read, bash, edit, write) + * but keep extension/custom tools enabled + */ + noTools?: "all" | "builtin"; + /** + * Optional allowlist of tool names. + * + * When omitted, pi enables the default built-in tools (read, bash, edit, write) + * and leaves extension/custom tools enabled unless `noTools` changes that default. + * When provided, only the listed tool names are enabled. + */ + tools?: string[]; + /** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */ + excludeTools?: string[]; + /** Custom tools to register (in addition to built-in tools). */ + customTools?: ToolDefinition[]; + + /** Resource loader. When omitted, DefaultResourceLoader is used. */ + resourceLoader?: ResourceLoader; + + /** Session manager. Default: SessionManager.create(cwd) */ + sessionManager?: SessionManager; + + /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */ + settingsManager?: SettingsManager; + /** Session start event metadata for extension runtime startup. */ + sessionStartEvent?: SessionStartEvent; +} + +/** Result from createAgentSession */ +export interface CreateAgentSessionResult { + /** The created session */ + session: AgentSession; + /** Extensions result (for UI context setup in interactive mode) */ + extensionsResult: LoadExtensionsResult; + /** Warning if session was restored with a different model than saved */ + modelFallbackMessage?: string; +} + +// Re-exports + +export * from "./agent-session-runtime.ts"; +export type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, + ExtensionFactory, + SlashCommandInfo, + SlashCommandSource, + ToolDefinition, +} from "./extensions/index.ts"; +export type { PromptTemplate } from "./prompt-templates.ts"; +export type { Skill } from "./skills.ts"; +export type { Tool } from "./tools/index.ts"; + +export { + withFileMutationQueue, + // Tool factories (for custom cwd) + createCodingTools, + createReadOnlyTools, + createReadTool, + createBashTool, + createEditTool, + createWriteTool, + createGrepTool, + createFindTool, + createLsTool, +}; + +// Helper Functions + +function getDefaultAgentDir(): string { + return getAgentDir(); +} + +/** + * Create an AgentSession with the specified options. + * + * @example + * ```typescript + * // Minimal - uses defaults + * const { session } = await createAgentSession(); + * + * // With explicit model + * import { getModel } from '@earendil-works/pi-ai'; + * const { session } = await createAgentSession({ + * model: getModel('anthropic', 'claude-opus-4-5'), + * thinkingLevel: 'high', + * }); + * + * // Continue previous session + * const { session, modelFallbackMessage } = await createAgentSession({ + * continueSession: true, + * }); + * + * // Full control + * const loader = new DefaultResourceLoader({ + * cwd: process.cwd(), + * agentDir: getAgentDir(), + * settingsManager: SettingsManager.create(), + * }); + * await loader.reload(); + * const { session } = await createAgentSession({ + * model: myModel, + * tools: ["read", "bash"], + * resourceLoader: loader, + * sessionManager: SessionManager.inMemory(), + * }); + * ``` + */ +export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { + const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd()); + const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir(); + let resourceLoader = options.resourceLoader; + + // Use provided or create AuthStorage and ModelRegistry + const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined; + const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined; + const authStorage = options.authStorage ?? AuthStorage.create(authPath); + const modelRegistry = options.modelRegistry ?? ModelRegistry.create(authStorage, modelsPath); + + const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); + const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir)); + + if (!resourceLoader) { + resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager }); + await resourceLoader.reload(); + time("resourceLoader.reload"); + } + + // Check if session has existing data to restore + const existingSession = sessionManager.buildSessionContext(); + const hasExistingSession = existingSession.messages.length > 0; + const hasThinkingEntry = sessionManager.getBranch().some((entry) => entry.type === "thinking_level_change"); + + let model = options.model; + let modelFallbackMessage: string | undefined; + + // If session has data, try to restore model from it + if (!model && hasExistingSession && existingSession.model) { + const restoredModel = modelRegistry.find(existingSession.model.provider, existingSession.model.modelId); + if (restoredModel && modelRegistry.hasConfiguredAuth(restoredModel)) { + model = restoredModel; + } + if (!model) { + modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`; + } + } + + // If still no model, use findInitialModel (checks settings default, then provider defaults) + if (!model) { + const result = await findInitialModel({ + scopedModels: [], + isContinuing: hasExistingSession, + defaultProvider: settingsManager.getDefaultProvider(), + defaultModelId: settingsManager.getDefaultModel(), + defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(), + modelRegistry, + }); + model = result.model; + if (!model) { + modelFallbackMessage = formatNoModelsAvailableMessage(); + } else if (modelFallbackMessage) { + modelFallbackMessage += `. Using ${model.provider}/${model.id}`; + } + } + + let thinkingLevel = options.thinkingLevel; + + // If session has data, restore thinking level from it + if (thinkingLevel === undefined && hasExistingSession) { + thinkingLevel = hasThinkingEntry + ? (existingSession.thinkingLevel as ThinkingLevel) + : (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL); + } + + // Fall back to settings default + if (thinkingLevel === undefined) { + thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL; + } + + // Clamp to model capabilities + if (!model) { + thinkingLevel = "off"; + } else { + thinkingLevel = clampThinkingLevel(model, thinkingLevel) as ThinkingLevel; + } + + const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write", "grep", "find", "ls"]; + const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined); + const excludedToolNames = options.excludeTools; + const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined; + const initialActiveToolNames: string[] = ( + options.tools ? [...options.tools] : options.noTools ? [] : defaultActiveToolNames + ).filter((name) => !excludedToolNameSet?.has(name)); + + let agent: Agent; + + // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth) + const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => { + const converted = convertToLlm(messages); + // Check setting dynamically so mid-session changes take effect + if (!settingsManager.getBlockImages()) { + return converted; + } + // Filter out ImageContent from all messages, replacing with text placeholder + return converted.map((msg) => { + if (msg.role === "user" || msg.role === "toolResult") { + const content = msg.content; + if (Array.isArray(content)) { + const hasImages = content.some((c) => c.type === "image"); + if (hasImages) { + const filteredContent = content + .map((c) => + c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c, + ) + .filter( + (c, i, arr) => + // Dedupe consecutive "Image reading is disabled." texts + !( + c.type === "text" && + c.text === "Image reading is disabled." && + i > 0 && + arr[i - 1].type === "text" && + (arr[i - 1] as { type: "text"; text: string }).text === "Image reading is disabled." + ), + ); + return { ...msg, content: filteredContent }; + } + } + } + return msg; + }); + }; + + const extensionRunnerRef: { current?: ExtensionRunner } = {}; + + agent = new Agent({ + initialState: { + systemPrompt: "", + model, + thinkingLevel, + tools: [], + }, + convertToLlm: convertToLlmWithBlockImages, + streamFn: async (model, context, options) => { + const auth = await modelRegistry.getApiKeyAndHeaders(model); + if (!auth.ok) { + throw new Error(auth.error); + } + const env = auth.env || options?.env ? { ...(auth.env ?? {}), ...(options?.env ?? {}) } : undefined; + const providerRetrySettings = settingsManager.getProviderRetrySettings(); + const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); + // SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout". + // Use max int32 to effectively disable the timeout. + const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs; + const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs; + const websocketConnectTimeoutMs = + options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(); + return streamSimple(model, context, { + ...options, + apiKey: auth.apiKey, + env, + timeoutMs, + websocketConnectTimeoutMs, + maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries, + maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs, + headers: mergeProviderAttributionHeaders( + model, + settingsManager, + options?.sessionId, + auth.headers, + options?.headers, + ), + }); + }, + onPayload: async (payload, _model) => { + const runner = extensionRunnerRef.current; + if (!runner?.hasHandlers("before_provider_request")) { + return payload; + } + return runner.emitBeforeProviderRequest(payload); + }, + onResponse: async (response, _model) => { + const runner = extensionRunnerRef.current; + if (!runner?.hasHandlers("after_provider_response")) { + return; + } + await runner.emit({ + type: "after_provider_response", + status: response.status, + headers: response.headers, + }); + }, + sessionId: sessionManager.getSessionId(), + transformContext: async (messages) => { + const runner = extensionRunnerRef.current; + if (!runner) return messages; + return runner.emitContext(messages); + }, + steeringMode: settingsManager.getSteeringMode(), + followUpMode: settingsManager.getFollowUpMode(), + transport: settingsManager.getTransport(), + thinkingBudgets: settingsManager.getThinkingBudgets(), + maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs, + }); + + // Restore messages if session has existing data + if (hasExistingSession) { + agent.state.messages = existingSession.messages; + if (!hasThinkingEntry) { + sessionManager.appendThinkingLevelChange(thinkingLevel); + } + } else { + // Save initial model and thinking level for new sessions so they can be restored on resume + if (model) { + sessionManager.appendModelChange(model.provider, model.id); + } + sessionManager.appendThinkingLevelChange(thinkingLevel); + } + + const session = new AgentSession({ + agent, + sessionManager, + settingsManager, + cwd, + scopedModels: options.scopedModels, + resourceLoader, + customTools: options.customTools, + modelRegistry, + initialActiveToolNames, + allowedToolNames, + excludedToolNames, + extensionRunnerRef, + sessionStartEvent: options.sessionStartEvent, + }); + const extensionsResult = resourceLoader.getExtensions(); + + return { + session, + extensionsResult, + modelFallbackMessage, + }; +} diff --git a/cactus-code/packages/coding-agent/src/core/session-cwd.ts b/cactus-code/packages/coding-agent/src/core/session-cwd.ts new file mode 100644 index 000000000..79960df1f --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/session-cwd.ts @@ -0,0 +1,59 @@ +import { existsSync } from "node:fs"; + +export interface SessionCwdIssue { + sessionFile?: string; + sessionCwd: string; + fallbackCwd: string; +} + +interface SessionCwdSource { + getCwd(): string; + getSessionFile(): string | undefined; +} + +export function getMissingSessionCwdIssue( + sessionManager: SessionCwdSource, + fallbackCwd: string, +): SessionCwdIssue | undefined { + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile) { + return undefined; + } + + const sessionCwd = sessionManager.getCwd(); + if (!sessionCwd || existsSync(sessionCwd)) { + return undefined; + } + + return { + sessionFile, + sessionCwd, + fallbackCwd, + }; +} + +export function formatMissingSessionCwdError(issue: SessionCwdIssue): string { + const sessionFile = issue.sessionFile ? `\nSession file: ${issue.sessionFile}` : ""; + return `Stored session working directory does not exist: ${issue.sessionCwd}${sessionFile}\nCurrent working directory: ${issue.fallbackCwd}`; +} + +export function formatMissingSessionCwdPrompt(issue: SessionCwdIssue): string { + return `cwd from session file does not exist\n${issue.sessionCwd}\n\ncontinue in current cwd\n${issue.fallbackCwd}`; +} + +export class MissingSessionCwdError extends Error { + readonly issue: SessionCwdIssue; + + constructor(issue: SessionCwdIssue) { + super(formatMissingSessionCwdError(issue)); + this.name = "MissingSessionCwdError"; + this.issue = issue; + } +} + +export function assertSessionCwdExists(sessionManager: SessionCwdSource, fallbackCwd: string): void { + const issue = getMissingSessionCwdIssue(sessionManager, fallbackCwd); + if (issue) { + throw new MissingSessionCwdError(issue); + } +} diff --git a/cactus-code/packages/coding-agent/src/core/session-manager.ts b/cactus-code/packages/coding-agent/src/core/session-manager.ts new file mode 100644 index 000000000..e40ed0c47 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/session-manager.ts @@ -0,0 +1,1578 @@ +import { type AgentMessage, uuidv7 } from "@earendil-works/pi-agent-core"; +import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; +import { randomUUID } from "crypto"; +import { + appendFileSync, + closeSync, + createReadStream, + existsSync, + mkdirSync, + openSync, + readdirSync, + readSync, + statSync, + writeFileSync, +} from "fs"; +import { readdir, stat } from "fs/promises"; +import { join, resolve } from "path"; +import { createInterface } from "readline"; +import { StringDecoder } from "string_decoder"; +import { getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts"; +import { normalizePath, resolvePath } from "../utils/paths.ts"; +import { + type BashExecutionMessage, + type CustomMessage, + createBranchSummaryMessage, + createCompactionSummaryMessage, + createCustomMessage, +} from "./messages.ts"; + +export const CURRENT_SESSION_VERSION = 3; + +export interface SessionHeader { + type: "session"; + version?: number; // v1 sessions don't have this + id: string; + timestamp: string; + cwd: string; + parentSession?: string; +} + +export interface NewSessionOptions { + id?: string; + parentSession?: string; +} + +export interface SessionEntryBase { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} + +export interface SessionMessageEntry extends SessionEntryBase { + type: "message"; + message: AgentMessage; +} + +export interface ThinkingLevelChangeEntry extends SessionEntryBase { + type: "thinking_level_change"; + thinkingLevel: string; +} + +export interface ModelChangeEntry extends SessionEntryBase { + type: "model_change"; + provider: string; + modelId: string; +} + +export interface CompactionEntry extends SessionEntryBase { + type: "compaction"; + summary: string; + firstKeptEntryId: string; + tokensBefore: number; + /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ + details?: T; + /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ + fromHook?: boolean; +} + +export interface BranchSummaryEntry extends SessionEntryBase { + type: "branch_summary"; + fromId: string; + summary: string; + /** Extension-specific data (not sent to LLM) */ + details?: T; + /** True if generated by an extension, false if pi-generated */ + fromHook?: boolean; +} + +/** + * Custom entry for extensions to store extension-specific data in the session. + * Use customType to identify your extension's entries. + * + * Purpose: Persist extension state across session reloads. On reload, extensions can + * scan entries for their customType and reconstruct internal state. + * + * Does NOT participate in LLM context (ignored by buildSessionContext). + * For injecting content into context, see CustomMessageEntry. + */ +export interface CustomEntry extends SessionEntryBase { + type: "custom"; + customType: string; + data?: T; +} + +/** Label entry for user-defined bookmarks/markers on entries. */ +export interface LabelEntry extends SessionEntryBase { + type: "label"; + targetId: string; + label: string | undefined; +} + +/** Session metadata entry (e.g., user-defined display name). */ +export interface SessionInfoEntry extends SessionEntryBase { + type: "session_info"; + name?: string; +} + +/** + * Custom message entry for extensions to inject messages into LLM context. + * Use customType to identify your extension's entries. + * + * Unlike CustomEntry, this DOES participate in LLM context. + * The content is converted to a user message in buildSessionContext(). + * Use details for extension-specific metadata (not sent to LLM). + * + * display controls TUI rendering: + * - false: hidden entirely + * - true: rendered with distinct styling (different from user messages) + */ +export interface CustomMessageEntry extends SessionEntryBase { + type: "custom_message"; + customType: string; + content: string | (TextContent | ImageContent)[]; + details?: T; + display: boolean; +} + +/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */ +export type SessionEntry = + | SessionMessageEntry + | ThinkingLevelChangeEntry + | ModelChangeEntry + | CompactionEntry + | BranchSummaryEntry + | CustomEntry + | CustomMessageEntry + | LabelEntry + | SessionInfoEntry; + +/** Raw file entry (includes header) */ +export type FileEntry = SessionHeader | SessionEntry; + +/** Tree node for getTree() - defensive copy of session structure */ +export interface SessionTreeNode { + entry: SessionEntry; + children: SessionTreeNode[]; + /** Resolved label for this entry, if any */ + label?: string; + /** Timestamp of the latest label change for this entry, if any */ + labelTimestamp?: string; +} + +export interface SessionContext { + messages: AgentMessage[]; + thinkingLevel: string; + model: { provider: string; modelId: string } | null; +} + +export interface SessionInfo { + path: string; + id: string; + /** Working directory where the session was started. Empty string for old sessions. */ + cwd: string; + /** User-defined display name from session_info entries. */ + name?: string; + /** Path to the parent session (if this session was forked). */ + parentSessionPath?: string; + created: Date; + modified: Date; + messageCount: number; + firstMessage: string; + allMessagesText: string; +} + +export type ReadonlySessionManager = Pick< + SessionManager, + | "getCwd" + | "getSessionDir" + | "getSessionId" + | "getSessionFile" + | "getLeafId" + | "getLeafEntry" + | "getEntry" + | "getLabel" + | "getBranch" + | "getHeader" + | "getEntries" + | "getTree" + | "getSessionName" +>; + +function createSessionId(): string { + return uuidv7(); +} + +export function assertValidSessionId(id: string): void { + if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) { + throw new Error( + "Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character", + ); + } +} + +/** Generate a unique short ID (8 hex chars, collision-checked) */ +function generateId(byId: { has(id: string): boolean }): string { + for (let i = 0; i < 100; i++) { + const id = randomUUID().slice(0, 8); + if (!byId.has(id)) return id; + } + // Fallback to full UUID if somehow we have collisions + return randomUUID(); +} + +/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */ +function migrateV1ToV2(entries: FileEntry[]): void { + const ids = new Set(); + let prevId: string | null = null; + + for (const entry of entries) { + if (entry.type === "session") { + entry.version = 2; + continue; + } + + entry.id = generateId(ids); + entry.parentId = prevId; + prevId = entry.id; + + // Convert firstKeptEntryIndex to firstKeptEntryId for compaction + if (entry.type === "compaction") { + const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number }; + if (typeof comp.firstKeptEntryIndex === "number") { + const targetEntry = entries[comp.firstKeptEntryIndex]; + if (targetEntry && targetEntry.type !== "session") { + comp.firstKeptEntryId = targetEntry.id; + } + delete comp.firstKeptEntryIndex; + } + } + } +} + +/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */ +function migrateV2ToV3(entries: FileEntry[]): void { + for (const entry of entries) { + if (entry.type === "session") { + entry.version = 3; + continue; + } + + // Update message entries with hookMessage role + if (entry.type === "message") { + const msgEntry = entry as SessionMessageEntry; + if (msgEntry.message && (msgEntry.message as { role: string }).role === "hookMessage") { + (msgEntry.message as { role: string }).role = "custom"; + } + } + } +} + +/** + * Run all necessary migrations to bring entries to current version. + * Mutates entries in place. Returns true if any migration was applied. + */ +function migrateToCurrentVersion(entries: FileEntry[]): boolean { + const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; + const version = header?.version ?? 1; + + if (version >= CURRENT_SESSION_VERSION) return false; + + if (version < 2) migrateV1ToV2(entries); + if (version < 3) migrateV2ToV3(entries); + + return true; +} + +/** Exported for testing */ +export function migrateSessionEntries(entries: FileEntry[]): void { + migrateToCurrentVersion(entries); +} + +/** Exported for compaction.test.ts */ +export function parseSessionEntries(content: string): FileEntry[] { + const entries: FileEntry[] = []; + const lines = content.trim().split("\n"); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as FileEntry; + entries.push(entry); + } catch { + // Skip malformed lines + } + } + + return entries; +} + +export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null { + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].type === "compaction") { + return entries[i] as CompactionEntry; + } + } + return null; +} + +/** + * Build the session context from entries using tree traversal. + * If leafId is provided, walks from that entry to root. + * Handles compaction and branch summaries along the path. + */ +export function buildSessionContext( + entries: SessionEntry[], + leafId?: string | null, + byId?: Map, +): SessionContext { + // Build uuid index if not available + if (!byId) { + byId = new Map(); + for (const entry of entries) { + byId.set(entry.id, entry); + } + } + + // Find leaf + let leaf: SessionEntry | undefined; + if (leafId === null) { + // Explicitly null - return no messages (navigated to before first entry) + return { messages: [], thinkingLevel: "off", model: null }; + } + if (leafId) { + leaf = byId.get(leafId); + } + if (!leaf) { + // Fallback to last entry (when leafId is undefined) + leaf = entries[entries.length - 1]; + } + + if (!leaf) { + return { messages: [], thinkingLevel: "off", model: null }; + } + + // Walk from leaf to root, collecting path + const path: SessionEntry[] = []; + let current: SessionEntry | undefined = leaf; + while (current) { + path.push(current); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + path.reverse(); + + // Extract settings and find compaction + let thinkingLevel = "off"; + let model: { provider: string; modelId: string } | null = null; + let compaction: CompactionEntry | null = null; + + for (const entry of path) { + if (entry.type === "thinking_level_change") { + thinkingLevel = entry.thinkingLevel; + } else if (entry.type === "model_change") { + model = { provider: entry.provider, modelId: entry.modelId }; + } else if (entry.type === "message" && entry.message.role === "assistant") { + model = { provider: entry.message.provider, modelId: entry.message.model }; + } else if (entry.type === "compaction") { + compaction = entry; + } + } + + // Build messages and collect corresponding entries + // When there's a compaction, we need to: + // 1. Emit summary first (entry = compaction) + // 2. Emit kept messages (from firstKeptEntryId up to compaction) + // 3. Emit messages after compaction + const messages: AgentMessage[] = []; + + const appendMessage = (entry: SessionEntry) => { + if (entry.type === "message") { + messages.push(entry.message); + } else if (entry.type === "custom_message") { + messages.push( + createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp), + ); + } else if (entry.type === "branch_summary" && entry.summary) { + messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)); + } + }; + + if (compaction) { + // Emit summary first + messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp)); + + // Find compaction index in path + const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id); + + // Emit kept messages (before compaction, starting from firstKeptEntryId) + let foundFirstKept = false; + for (let i = 0; i < compactionIdx; i++) { + const entry = path[i]; + if (entry.id === compaction.firstKeptEntryId) { + foundFirstKept = true; + } + if (foundFirstKept) { + appendMessage(entry); + } + } + + // Emit messages after compaction + for (let i = compactionIdx + 1; i < path.length; i++) { + const entry = path[i]; + appendMessage(entry); + } + } else { + // No compaction - emit all messages, handle branch summaries and custom messages + for (const entry of path) { + appendMessage(entry); + } + } + + return { messages, thinkingLevel, model }; +} + +/** + * Compute the default session directory for a cwd. + * Encodes cwd into a safe directory name under ~/.pi/agent/sessions/. + */ +function getDefaultSessionDirPath(cwd: string, agentDir: string = getDefaultAgentDir()): string { + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + const safePath = `--${resolvedCwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`; + return join(resolvedAgentDir, "sessions", safePath); +} + +export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultAgentDir()): string { + const sessionDir = getDefaultSessionDirPath(cwd, agentDir); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + return sessionDir; +} + +const SESSION_READ_BUFFER_SIZE = 1024 * 1024; + +function parseSessionEntryLine(line: string): FileEntry | null { + if (!line.trim()) return null; + try { + return JSON.parse(line) as FileEntry; + } catch { + // Skip malformed lines + return null; + } +} + +/** Exported for testing */ +export function loadEntriesFromFile(filePath: string): FileEntry[] { + const resolvedFilePath = normalizePath(filePath); + if (!existsSync(resolvedFilePath)) return []; + + const entries: FileEntry[] = []; + const fd = openSync(resolvedFilePath, "r"); + try { + const decoder = new StringDecoder("utf8"); + const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE); + let pending = ""; + + while (true) { + const bytesRead = readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + + pending += decoder.write(buffer.subarray(0, bytesRead)); + let lineStart = 0; + let newlineIndex = pending.indexOf("\n", lineStart); + while (newlineIndex !== -1) { + const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex)); + if (entry) entries.push(entry); + lineStart = newlineIndex + 1; + newlineIndex = pending.indexOf("\n", lineStart); + } + pending = pending.slice(lineStart); + } + + pending += decoder.end(); + const finalEntry = parseSessionEntryLine(pending); + if (finalEntry) entries.push(finalEntry); + } finally { + closeSync(fd); + } + + // Validate session header + if (entries.length === 0) return entries; + const header = entries[0]; + if (header.type !== "session" || typeof (header as { id?: unknown }).id !== "string") { + return []; + } + + return entries; +} + +function readSessionHeader(filePath: string): SessionHeader | null { + try { + const fd = openSync(filePath, "r"); + const buffer = Buffer.alloc(512); + const bytesRead = readSync(fd, buffer, 0, 512, 0); + closeSync(fd); + const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0]; + if (!firstLine) return null; + const header = JSON.parse(firstLine) as Record; + if (header.type !== "session" || typeof header.id !== "string") { + return null; + } + return header as unknown as SessionHeader; + } catch { + return null; + } +} + +function getSessionHeaderCwd(header: SessionHeader): string | undefined { + const cwd = (header as { cwd?: unknown }).cwd; + return typeof cwd === "string" ? cwd : undefined; +} + +function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean { + return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd; +} + +/** Exported for testing */ +export function findMostRecentSession(sessionDir: string, cwd?: string): string | null { + const resolvedSessionDir = normalizePath(sessionDir); + const resolvedCwd = cwd ? resolvePath(cwd) : undefined; + try { + const files = readdirSync(resolvedSessionDir) + .filter((f) => f.endsWith(".jsonl")) + .map((f) => join(resolvedSessionDir, f)) + .map((path) => ({ path, header: readSessionHeader(path) })) + .filter( + (file): file is { path: string; header: SessionHeader } => + file.header !== null && + (!resolvedCwd || sessionCwdMatches(getSessionHeaderCwd(file.header), resolvedCwd)), + ) + .map(({ path }) => ({ path, mtime: statSync(path).mtime })) + .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + return files[0]?.path || null; + } catch { + return null; + } +} + +function isMessageWithContent(message: AgentMessage): message is Message { + return typeof (message as Message).role === "string" && "content" in message; +} + +function extractTextContent(message: Message): string { + const content = message.content; + if (typeof content === "string") { + return content; + } + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join(" "); +} + +function getMessageActivityTime(entry: SessionMessageEntry): number | undefined { + const message = entry.message; + if (!isMessageWithContent(message)) return undefined; + if (message.role !== "user" && message.role !== "assistant") return undefined; + + const msgTimestamp = (message as { timestamp?: number }).timestamp; + if (typeof msgTimestamp === "number") { + return msgTimestamp; + } + + const t = new Date(entry.timestamp).getTime(); + return Number.isNaN(t) ? undefined : t; +} + +async function buildSessionInfo(filePath: string): Promise { + try { + const stats = await stat(filePath); + let header: SessionHeader | null = null; + let messageCount = 0; + let firstMessage = ""; + const allMessages: string[] = []; + let name: string | undefined; + let lastActivityTime: number | undefined; + + const rl = createInterface({ + input: createReadStream(filePath, { encoding: "utf8" }), + crlfDelay: Infinity, + }); + + for await (const line of rl) { + const entry = parseSessionEntryLine(line); + if (!entry) continue; + + if (!header) { + if (entry.type !== "session") return null; + header = entry; + continue; + } + + // Extract session name (use latest, including explicit clears) + if (entry.type === "session_info") { + name = entry.name?.trim() || undefined; + } + + if (entry.type !== "message") continue; + messageCount++; + + const activityTime = getMessageActivityTime(entry); + if (typeof activityTime === "number") { + lastActivityTime = Math.max(lastActivityTime ?? 0, activityTime); + } + + const message = entry.message; + if (!isMessageWithContent(message)) continue; + if (message.role !== "user" && message.role !== "assistant") continue; + + const textContent = extractTextContent(message); + if (!textContent) continue; + + allMessages.push(textContent); + if (!firstMessage && message.role === "user") { + firstMessage = textContent; + } + } + + if (!header) return null; + + const cwd = typeof header.cwd === "string" ? header.cwd : ""; + const parentSessionPath = header.parentSession; + const headerTime = typeof header.timestamp === "string" ? new Date(header.timestamp).getTime() : NaN; + const modified = + typeof lastActivityTime === "number" && lastActivityTime > 0 + ? new Date(lastActivityTime) + : !Number.isNaN(headerTime) + ? new Date(headerTime) + : stats.mtime; + + return { + path: filePath, + id: header.id, + cwd, + name, + parentSessionPath, + created: new Date(header.timestamp), + modified, + messageCount, + firstMessage: firstMessage || "(no messages)", + allMessagesText: allMessages.join(" "), + }; + } catch { + return null; + } +} + +export type SessionListProgress = (loaded: number, total: number) => void; + +const MAX_CONCURRENT_SESSION_INFO_LOADS = 10; + +async function buildSessionInfosWithConcurrency( + files: string[], + onLoaded: () => void, +): Promise<(SessionInfo | null)[]> { + const results: (SessionInfo | null)[] = new Array(files.length).fill(null); + const inFlight = new Set>(); + let nextIndex = 0; + + const startNext = (): void => { + const index = nextIndex++; + const file = files[index]; + if (!file) return; + + let task: Promise; + task = buildSessionInfo(file) + .then((info) => { + results[index] = info; + }) + .catch(() => { + results[index] = null; + }) + .finally(() => { + inFlight.delete(task); + onLoaded(); + }); + inFlight.add(task); + }; + + while (nextIndex < files.length || inFlight.size > 0) { + while (nextIndex < files.length && inFlight.size < MAX_CONCURRENT_SESSION_INFO_LOADS) { + startNext(); + } + if (inFlight.size > 0) { + await Promise.race(inFlight); + } + } + + return results; +} + +async function listSessionsFromDir( + dir: string, + onProgress?: SessionListProgress, + progressOffset = 0, + progressTotal?: number, +): Promise { + const sessions: SessionInfo[] = []; + if (!existsSync(dir)) { + return sessions; + } + + try { + const dirEntries = await readdir(dir); + const files = dirEntries.filter((f) => f.endsWith(".jsonl")).map((f) => join(dir, f)); + const total = progressTotal ?? files.length; + + let loaded = 0; + const results = await buildSessionInfosWithConcurrency(files, () => { + loaded++; + onProgress?.(progressOffset + loaded, total); + }); + for (const info of results) { + if (info) { + sessions.push(info); + } + } + } catch { + // Return empty list on error + } + + return sessions; +} + +/** + * Manages conversation sessions as append-only trees stored in JSONL files. + * + * Each session entry has an id and parentId forming a tree structure. The "leaf" + * pointer tracks the current position. Appending creates a child of the current leaf. + * Branching moves the leaf to an earlier entry, allowing new branches without + * modifying history. + * + * Use buildSessionContext() to get the resolved message list for the LLM, which + * handles compaction summaries and follows the path from root to current leaf. + */ +export class SessionManager { + private sessionId: string = ""; + private sessionFile: string | undefined; + private sessionDir: string; + private cwd: string; + private persist: boolean; + private flushed: boolean = false; + private fileEntries: FileEntry[] = []; + private byId: Map = new Map(); + private labelsById: Map = new Map(); + private labelTimestampsById: Map = new Map(); + private leafId: string | null = null; + + private constructor( + cwd: string, + sessionDir: string, + sessionFile: string | undefined, + persist: boolean, + newSessionOptions?: NewSessionOptions, + ) { + this.cwd = resolvePath(cwd); + this.sessionDir = normalizePath(sessionDir); + this.persist = persist; + if (persist && this.sessionDir && !existsSync(this.sessionDir)) { + mkdirSync(this.sessionDir, { recursive: true }); + } + + if (sessionFile) { + this.setSessionFile(sessionFile); + } else { + this.newSession(newSessionOptions); + } + } + + /** Switch to a different session file (used for resume and branching) */ + setSessionFile(sessionFile: string): void { + this.sessionFile = resolvePath(sessionFile); + if (existsSync(this.sessionFile)) { + this.fileEntries = loadEntriesFromFile(this.sessionFile); + + // If file was empty or corrupted (no valid header), truncate and start fresh + // to avoid appending messages without a session header (which breaks the session) + if (this.fileEntries.length === 0) { + const explicitPath = this.sessionFile; + this.newSession(); + this.sessionFile = explicitPath; + this._rewriteFile(); + this.flushed = true; + return; + } + + const header = this.fileEntries.find((e) => e.type === "session") as SessionHeader | undefined; + this.sessionId = header?.id ?? createSessionId(); + + if (migrateToCurrentVersion(this.fileEntries)) { + this._rewriteFile(); + } + + this._buildIndex(); + this.flushed = true; + } else { + const explicitPath = this.sessionFile; + this.newSession(); + this.sessionFile = explicitPath; // preserve explicit path from --session flag + } + } + + newSession(options?: NewSessionOptions): string | undefined { + if (options?.id !== undefined) { + assertValidSessionId(options.id); + } + this.sessionId = options?.id ?? createSessionId(); + const timestamp = new Date().toISOString(); + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: this.sessionId, + timestamp, + cwd: this.cwd, + parentSession: options?.parentSession, + }; + this.fileEntries = [header]; + this.byId.clear(); + this.labelsById.clear(); + this.leafId = null; + this.flushed = false; + + if (this.persist) { + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`); + } + return this.sessionFile; + } + + private _buildIndex(): void { + this.byId.clear(); + this.labelsById.clear(); + this.labelTimestampsById.clear(); + this.leafId = null; + for (const entry of this.fileEntries) { + if (entry.type === "session") continue; + this.byId.set(entry.id, entry); + this.leafId = entry.id; + if (entry.type === "label") { + if (entry.label) { + this.labelsById.set(entry.targetId, entry.label); + this.labelTimestampsById.set(entry.targetId, entry.timestamp); + } else { + this.labelsById.delete(entry.targetId); + this.labelTimestampsById.delete(entry.targetId); + } + } + } + } + + private _rewriteFile(): void { + if (!this.persist || !this.sessionFile) return; + const fd = openSync(this.sessionFile, "w"); + try { + for (const entry of this.fileEntries) { + writeFileSync(fd, `${JSON.stringify(entry)}\n`); + } + } finally { + closeSync(fd); + } + } + + isPersisted(): boolean { + return this.persist; + } + + getCwd(): string { + return this.cwd; + } + + getSessionDir(): string { + return this.sessionDir; + } + + usesDefaultSessionDir(): boolean { + return this.sessionDir === getDefaultSessionDirPath(this.cwd); + } + + getSessionId(): string { + return this.sessionId; + } + + getSessionFile(): string | undefined { + return this.sessionFile; + } + + _persist(entry: SessionEntry): void { + if (!this.persist || !this.sessionFile) return; + + const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant"); + if (!hasAssistant) { + if (this.flushed) { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } else { + // Mark as not flushed so when assistant arrives, all entries get written + this.flushed = false; + } + return; + } + + if (!this.flushed) { + const fd = openSync(this.sessionFile, "wx"); + try { + for (const e of this.fileEntries) { + writeFileSync(fd, `${JSON.stringify(e)}\n`); + } + } finally { + closeSync(fd); + } + this.flushed = true; + } else { + appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`); + } + } + + private _appendEntry(entry: SessionEntry): void { + this.fileEntries.push(entry); + this.byId.set(entry.id, entry); + this.leafId = entry.id; + this._persist(entry); + } + + /** Append a message as child of current leaf, then advance leaf. Returns entry id. + * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly. + * Reason: we want these to be top-level entries in the session, not message session entries, + * so it is easier to find them. + * These need to be appended via appendCompaction() and appendBranchSummary() methods. + */ + appendMessage(message: Message | CustomMessage | BashExecutionMessage): string { + const entry: SessionMessageEntry = { + type: "message", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + message, + }; + this._appendEntry(entry); + return entry.id; + } + + /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */ + appendThinkingLevelChange(thinkingLevel: string): string { + const entry: ThinkingLevelChangeEntry = { + type: "thinking_level_change", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + thinkingLevel, + }; + this._appendEntry(entry); + return entry.id; + } + + /** Append a model change as child of current leaf, then advance leaf. Returns entry id. */ + appendModelChange(provider: string, modelId: string): string { + const entry: ModelChangeEntry = { + type: "model_change", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + provider, + modelId, + }; + this._appendEntry(entry); + return entry.id; + } + + /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */ + appendCompaction( + summary: string, + firstKeptEntryId: string, + tokensBefore: number, + details?: T, + fromHook?: boolean, + ): string { + const entry: CompactionEntry = { + type: "compaction", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + summary, + firstKeptEntryId, + tokensBefore, + details, + fromHook, + }; + this._appendEntry(entry); + return entry.id; + } + + /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */ + appendCustomEntry(customType: string, data?: unknown): string { + const entry: CustomEntry = { + type: "custom", + customType, + data, + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + }; + this._appendEntry(entry); + return entry.id; + } + + /** Append a session info entry (e.g., display name). Returns entry id. */ + appendSessionInfo(name: string): string { + const sanitizedName = name.replace(/[\r\n]+/g, " ").trim(); + const entry: SessionInfoEntry = { + type: "session_info", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + name: sanitizedName, + }; + this._appendEntry(entry); + return entry.id; + } + + /** Get the current session name from the latest session_info entry, if any. */ + getSessionName(): string | undefined { + // Walk entries in reverse to find the latest session_info entry. + // Empty names explicitly clear the session title. + const entries = this.getEntries(); + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry.type === "session_info") { + return entry.name?.trim() || undefined; + } + } + return undefined; + } + + /** + * Append a custom message entry (for extensions) that participates in LLM context. + * @param customType Extension identifier for filtering on reload + * @param content Message content (string or TextContent/ImageContent array) + * @param display Whether to show in TUI (true = styled display, false = hidden) + * @param details Optional extension-specific metadata (not sent to LLM) + * @returns Entry id + */ + appendCustomMessageEntry( + customType: string, + content: string | (TextContent | ImageContent)[], + display: boolean, + details?: T, + ): string { + const entry: CustomMessageEntry = { + type: "custom_message", + customType, + content, + display, + details, + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + }; + this._appendEntry(entry); + return entry.id; + } + + // ========================================================================= + // Tree Traversal + // ========================================================================= + + getLeafId(): string | null { + return this.leafId; + } + + getLeafEntry(): SessionEntry | undefined { + return this.leafId ? this.byId.get(this.leafId) : undefined; + } + + getEntry(id: string): SessionEntry | undefined { + return this.byId.get(id); + } + + /** + * Get all direct children of an entry. + */ + getChildren(parentId: string): SessionEntry[] { + const children: SessionEntry[] = []; + for (const entry of this.byId.values()) { + if (entry.parentId === parentId) { + children.push(entry); + } + } + return children; + } + + /** + * Get the label for an entry, if any. + */ + getLabel(id: string): string | undefined { + return this.labelsById.get(id); + } + + /** + * Set or clear a label on an entry. + * Labels are user-defined markers for bookmarking/navigation. + * Pass undefined or empty string to clear the label. + */ + appendLabelChange(targetId: string, label: string | undefined): string { + if (!this.byId.has(targetId)) { + throw new Error(`Entry ${targetId} not found`); + } + const entry: LabelEntry = { + type: "label", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + targetId, + label, + }; + this._appendEntry(entry); + if (label) { + this.labelsById.set(targetId, label); + this.labelTimestampsById.set(targetId, entry.timestamp); + } else { + this.labelsById.delete(targetId); + this.labelTimestampsById.delete(targetId); + } + return entry.id; + } + + /** + * Walk from entry to root, returning all entries in path order. + * Includes all entry types (messages, compaction, model changes, etc.). + * Use buildSessionContext() to get the resolved messages for the LLM. + */ + getBranch(fromId?: string): SessionEntry[] { + const path: SessionEntry[] = []; + const startId = fromId ?? this.leafId; + let current = startId ? this.byId.get(startId) : undefined; + while (current) { + path.push(current); + current = current.parentId ? this.byId.get(current.parentId) : undefined; + } + path.reverse(); + return path; + } + + /** + * Build the session context (what gets sent to the LLM). + * Uses tree traversal from current leaf. + */ + buildSessionContext(): SessionContext { + return buildSessionContext(this.getEntries(), this.leafId, this.byId); + } + + /** + * Get session header. + */ + getHeader(): SessionHeader | null { + const h = this.fileEntries.find((e) => e.type === "session"); + return h ? (h as SessionHeader) : null; + } + + /** + * Get all session entries (excludes header). Returns a shallow copy. + * The session is append-only: use appendXXX() to add entries, branch() to + * change the leaf pointer. Entries cannot be modified or deleted. + */ + getEntries(): SessionEntry[] { + return this.fileEntries.filter((e): e is SessionEntry => e.type !== "session"); + } + + /** + * Get the session as a tree structure. Returns a shallow defensive copy of all entries. + * A well-formed session has exactly one root (first entry with parentId === null). + * Orphaned entries (broken parent chain) are also returned as roots. + */ + getTree(): SessionTreeNode[] { + const entries = this.getEntries(); + const nodeMap = new Map(); + const roots: SessionTreeNode[] = []; + + // Create nodes with resolved labels + for (const entry of entries) { + const label = this.labelsById.get(entry.id); + const labelTimestamp = this.labelTimestampsById.get(entry.id); + nodeMap.set(entry.id, { entry, children: [], label, labelTimestamp }); + } + + // Build tree + for (const entry of entries) { + const node = nodeMap.get(entry.id)!; + if (entry.parentId === null || entry.parentId === entry.id) { + roots.push(node); + } else { + const parent = nodeMap.get(entry.parentId); + if (parent) { + parent.children.push(node); + } else { + // Orphan - treat as root + roots.push(node); + } + } + } + + // Sort children by timestamp (oldest first, newest at bottom) + // Use iterative approach to avoid stack overflow on deep trees + const stack: SessionTreeNode[] = [...roots]; + while (stack.length > 0) { + const node = stack.pop()!; + node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime()); + stack.push(...node.children); + } + + return roots; + } + + // ========================================================================= + // Branching + // ========================================================================= + + /** + * Start a new branch from an earlier entry. + * Moves the leaf pointer to the specified entry. The next appendXXX() call + * will create a child of that entry, forming a new branch. Existing entries + * are not modified or deleted. + */ + branch(branchFromId: string): void { + if (!this.byId.has(branchFromId)) { + throw new Error(`Entry ${branchFromId} not found`); + } + this.leafId = branchFromId; + } + + /** + * Reset the leaf pointer to null (before any entries). + * The next appendXXX() call will create a new root entry (parentId = null). + * Use this when navigating to re-edit the first user message. + */ + resetLeaf(): void { + this.leafId = null; + } + + /** + * Start a new branch with a summary of the abandoned path. + * Same as branch(), but also appends a branch_summary entry that captures + * context from the abandoned conversation path. + */ + branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string { + if (branchFromId !== null && !this.byId.has(branchFromId)) { + throw new Error(`Entry ${branchFromId} not found`); + } + this.leafId = branchFromId; + const entry: BranchSummaryEntry = { + type: "branch_summary", + id: generateId(this.byId), + parentId: branchFromId, + timestamp: new Date().toISOString(), + fromId: branchFromId ?? "root", + summary, + details, + fromHook, + }; + this._appendEntry(entry); + return entry.id; + } + + /** + * Create a new session file containing only the path from root to the specified leaf. + * Useful for extracting a single conversation path from a branched session. + * Returns the new session file path, or undefined if not persisting. + */ + createBranchedSession(leafId: string): string | undefined { + const previousSessionFile = this.sessionFile; + const path = this.getBranch(leafId); + if (path.length === 0) { + throw new Error(`Entry ${leafId} not found`); + } + + // Filter out LabelEntry from path - we'll recreate them from the resolved map. + // Because labels are real tree entries, later entries can be children of labels; + // removing labels requires re-chaining the retained path to avoid orphaned subtrees. + const pathWithoutLabels: SessionEntry[] = []; + let pathParentId: string | null = null; + for (const entry of path) { + if (entry.type === "label") continue; + pathWithoutLabels.push({ ...entry, parentId: pathParentId }); + pathParentId = entry.id; + } + + const newSessionId = createSessionId(); + const timestamp = new Date().toISOString(); + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + const newSessionFile = join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`); + + const header: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: newSessionId, + timestamp, + cwd: this.cwd, + parentSession: this.persist ? previousSessionFile : undefined, + }; + + // Collect labels for entries in the path + const pathEntryIds = new Set(pathWithoutLabels.map((e) => e.id)); + const labelsToWrite: Array<{ targetId: string; label: string; timestamp: string }> = []; + for (const [targetId, label] of this.labelsById) { + if (pathEntryIds.has(targetId)) { + labelsToWrite.push({ targetId, label, timestamp: this.labelTimestampsById.get(targetId)! }); + } + } + + if (this.persist) { + // Build label entries + const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; + let parentId = lastEntryId; + const labelEntries: LabelEntry[] = []; + for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) { + const labelEntry: LabelEntry = { + type: "label", + id: generateId(new Set(pathEntryIds)), + parentId, + timestamp: labelTimestamp, + targetId, + label, + }; + pathEntryIds.add(labelEntry.id); + labelEntries.push(labelEntry); + parentId = labelEntry.id; + } + + this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; + this.sessionId = newSessionId; + this.sessionFile = newSessionFile; + this._buildIndex(); + + // Only write the file now if it contains an assistant message. + // Otherwise defer to _persist(), which creates the file on the + // first assistant response, matching the newSession() contract + // and avoiding the duplicate-header bug when _persist()'s + // no-assistant guard later resets flushed to false. + const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant"); + if (hasAssistant) { + this._rewriteFile(); + this.flushed = true; + } else { + this.flushed = false; + } + + return newSessionFile; + } + + // In-memory mode: replace current session with the path + labels + const labelEntries: LabelEntry[] = []; + let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null; + for (const { targetId, label, timestamp: labelTimestamp } of labelsToWrite) { + const labelEntry: LabelEntry = { + type: "label", + id: generateId(new Set([...pathEntryIds, ...labelEntries.map((e) => e.id)])), + parentId, + timestamp: labelTimestamp, + targetId, + label, + }; + labelEntries.push(labelEntry); + parentId = labelEntry.id; + } + this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries]; + this.sessionId = newSessionId; + this._buildIndex(); + return undefined; + } + + /** + * Create a new session. + * @param cwd Working directory (stored in session header) + * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). + */ + static create(cwd: string, sessionDir?: string, options?: NewSessionOptions): SessionManager { + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); + return new SessionManager(cwd, dir, undefined, true, options); + } + + /** + * Open a specific session file. + * @param path Path to session file + * @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent. + * @param cwdOverride Optional cwd override instead of the session header cwd. + */ + static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { + const resolvedPath = resolvePath(path); + // Extract cwd from session header if possible, otherwise use process.cwd() + const entries = loadEntriesFromFile(resolvedPath); + const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; + const cwd = cwdOverride ?? header?.cwd ?? process.cwd(); + // If no sessionDir provided, derive from file's parent directory + const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, ".."); + return new SessionManager(cwd, dir, resolvedPath, true); + } + + /** + * Continue the most recent session, or create new if none. + * @param cwd Working directory + * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). + */ + static continueRecent(cwd: string, sessionDir?: string): SessionManager { + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); + const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined); + if (mostRecent) { + return new SessionManager(cwd, dir, mostRecent, true); + } + return new SessionManager(cwd, dir, undefined, true); + } + + /** Create an in-memory session (no file persistence) */ + static inMemory(cwd: string = process.cwd()): SessionManager { + return new SessionManager(cwd, "", undefined, false); + } + + /** + * Fork a session from another project directory into the current project. + * Creates a new session in the target cwd with the full history from the source session. + * @param sourcePath Path to the source session file + * @param targetCwd Target working directory (where the new session will be stored) + * @param sessionDir Optional session directory. If omitted, uses default for targetCwd. + */ + static forkFrom( + sourcePath: string, + targetCwd: string, + sessionDir?: string, + options?: NewSessionOptions, + ): SessionManager { + const resolvedSourcePath = resolvePath(sourcePath); + const resolvedTargetCwd = resolvePath(targetCwd); + const sourceEntries = loadEntriesFromFile(resolvedSourcePath); + if (sourceEntries.length === 0) { + throw new Error(`Cannot fork: source session file is empty or invalid: ${resolvedSourcePath}`); + } + + const sourceHeader = sourceEntries.find((e) => e.type === "session") as SessionHeader | undefined; + if (!sourceHeader) { + throw new Error(`Cannot fork: source session has no header: ${resolvedSourcePath}`); + } + + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(resolvedTargetCwd); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Create new session file with new ID but forked content + if (options?.id !== undefined) { + assertValidSessionId(options.id); + } + const newSessionId = options?.id ?? createSessionId(); + const timestamp = new Date().toISOString(); + const fileTimestamp = timestamp.replace(/[:.]/g, "-"); + const newSessionFile = join(dir, `${fileTimestamp}_${newSessionId}.jsonl`); + + // Write new header pointing to source as parent, with updated cwd + const newHeader: SessionHeader = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: newSessionId, + timestamp, + cwd: resolvedTargetCwd, + parentSession: resolvedSourcePath, + }; + writeFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`, { flag: "wx" }); + + // Copy all non-header entries from source + for (const entry of sourceEntries) { + if (entry.type !== "session") { + appendFileSync(newSessionFile, `${JSON.stringify(entry)}\n`); + } + } + + return new SessionManager(resolvedTargetCwd, dir, newSessionFile, true); + } + + /** + * List all sessions for a directory. + * @param cwd Working directory (used to compute default session directory) + * @param sessionDir Optional session directory. If omitted, uses default (~/.pi/agent/sessions//). + * @param onProgress Optional callback for progress updates (loaded, total) + */ + static async list(cwd: string, sessionDir?: string, onProgress?: SessionListProgress): Promise { + const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd); + const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd); + const resolvedCwd = resolvePath(cwd); + const sessions = (await listSessionsFromDir(dir, onProgress)).filter( + (session) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd), + ); + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; + } + + /** + * List all sessions across all project directories. + * @param onProgress Optional callback for progress updates (loaded, total) + */ + static async listAll(onProgress?: SessionListProgress): Promise; + static async listAll(sessionDir?: string, onProgress?: SessionListProgress): Promise; + static async listAll( + sessionDirOrOnProgress?: string | SessionListProgress, + onProgress?: SessionListProgress, + ): Promise { + const customSessionDir = + typeof sessionDirOrOnProgress === "string" ? normalizePath(sessionDirOrOnProgress) : undefined; + const progress = typeof sessionDirOrOnProgress === "function" ? sessionDirOrOnProgress : onProgress; + if (customSessionDir) { + const sessions = await listSessionsFromDir(customSessionDir, progress); + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; + } + + const sessionsDir = getSessionsDir(); + + try { + if (!existsSync(sessionsDir)) { + return []; + } + const entries = await readdir(sessionsDir, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => join(sessionsDir, e.name)); + + // Count total files first for accurate progress + let totalFiles = 0; + const dirFiles: string[][] = []; + for (const dir of dirs) { + try { + const files = (await readdir(dir)).filter((f) => f.endsWith(".jsonl")); + dirFiles.push(files.map((f) => join(dir, f))); + totalFiles += files.length; + } catch { + dirFiles.push([]); + } + } + + // Process all files with progress tracking + let loaded = 0; + const sessions: SessionInfo[] = []; + const allFiles = dirFiles.flat(); + + const results = await buildSessionInfosWithConcurrency(allFiles, () => { + loaded++; + progress?.(loaded, totalFiles); + }); + + for (const info of results) { + if (info) { + sessions.push(info); + } + } + + sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime()); + return sessions; + } catch { + return []; + } + } +} diff --git a/cactus-code/packages/coding-agent/src/core/settings-manager.ts b/cactus-code/packages/coding-agent/src/core/settings-manager.ts new file mode 100644 index 000000000..f49fb7c19 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/settings-manager.ts @@ -0,0 +1,1175 @@ +import type { Transport } from "@earendil-works/pi-ai"; +import { randomUUID } from "crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname, join } from "path"; +import lockfile from "proper-lockfile"; +import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; +import { normalizePath, resolvePath } from "../utils/paths.ts"; +import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; + +export interface CompactionSettings { + enabled?: boolean; // default: true + reserveTokens?: number; // default: 16384 + keepRecentTokens?: number; // default: 20000 +} + +export interface BranchSummarySettings { + reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response) + skipPrompt?: boolean; // default: false - when true, skips "Summarize branch?" prompt and defaults to no summary +} + +export interface ProviderRetrySettings { + timeoutMs?: number; // SDK/provider request timeout in milliseconds + maxRetries?: number; // SDK/provider retry attempts + maxRetryDelayMs?: number; // default: 60000 (max server-requested delay before failing) +} + +export interface RetrySettings { + enabled?: boolean; // default: true + maxRetries?: number; // default: 3 + baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s) + provider?: ProviderRetrySettings; +} + +export interface TerminalSettings { + showImages?: boolean; // default: true (only relevant if terminal supports images) + imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells) + clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks) + showTerminalProgress?: boolean; // default: false (OSC 9;4 terminal progress indicators) +} + +export interface ImageSettings { + autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility) + blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers +} + +export interface ThinkingBudgetsSettings { + minimal?: number; + low?: number; + medium?: number; + high?: number; +} + +export interface MarkdownSettings { + codeBlockIndent?: string; // default: " " +} + +export interface WarningSettings { + anthropicExtraUsage?: boolean; // default: true +} + +export type DefaultProjectTrust = "ask" | "always" | "never"; + +export type TransportSetting = Transport; + +/** + * Package source for npm/git packages. + * - String form: load all resources from the package + * - Object form: filter which resources to load + */ +export type PackageSource = + | string + | { + source: string; + extensions?: string[]; + skills?: string[]; + prompts?: string[]; + themes?: string[]; + }; + +export interface Settings { + lastChangelogVersion?: string; + defaultProvider?: string; + defaultModel?: string; + defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + transport?: TransportSetting; // default: "auto" + steeringMode?: "all" | "one-at-a-time"; + followUpMode?: "all" | "one-at-a-time"; + theme?: string; + compaction?: CompactionSettings; + branchSummary?: BranchSummarySettings; + retry?: RetrySettings; + hideThinkingBlock?: boolean; + shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) + quietStartup?: boolean; + defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only + shellCommandPrefix?: string; // Prefix prepended to every bash command (e.g., "shopt -s expand_aliases" for alias support) + npmCommand?: string[]; // Command used for npm package lookup/install operations, argv-style (e.g., ["mise", "exec", "node@20", "--", "npm"]) + collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) + enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates + enableAnalytics?: boolean; // default: false - opt-in analytics data sharing + trackingId?: string; // analytics tracking identifier, generated when analytics is enabled + packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering) + extensions?: string[]; // Array of local extension file paths or directories + skills?: string[]; // Array of local skill file paths or directories + prompts?: string[]; // Array of local prompt template paths or directories + themes?: string[]; // Array of local theme file paths or directories + enableSkillCommands?: boolean; // default: true - register skills as /skill:name commands + terminal?: TerminalSettings; + images?: ImageSettings; + enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) + doubleEscapeAction?: "fork" | "tree" | "none"; // Action for double-escape with empty editor (default: "tree") + treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; // Default filter when opening /tree + thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels + editorPaddingX?: number; // Horizontal padding for input editor (default: 0) + autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5) + showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME + markdown?: MarkdownSettings; + warnings?: WarningSettings; + sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) + httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients + httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it + websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it +} + +/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ +function deepMergeSettings(base: Settings, overrides: Settings): Settings { + const result: Settings = { ...base }; + + for (const key of Object.keys(overrides) as (keyof Settings)[]) { + const overrideValue = overrides[key]; + const baseValue = base[key]; + + if (overrideValue === undefined) { + continue; + } + + // For nested objects, merge recursively + if ( + typeof overrideValue === "object" && + overrideValue !== null && + !Array.isArray(overrideValue) && + typeof baseValue === "object" && + baseValue !== null && + !Array.isArray(baseValue) + ) { + (result as Record)[key] = { ...baseValue, ...overrideValue }; + } else { + // For primitives and arrays, override value wins + (result as Record)[key] = overrideValue; + } + } + + return result; +} + +function parseTimeoutSetting(value: unknown, settingName: string): number | undefined { + const timeoutMs = parseHttpIdleTimeoutMs(value); + if (timeoutMs !== undefined) { + return timeoutMs; + } + if (value !== undefined) { + throw new Error(`Invalid ${settingName} setting: ${String(value)}`); + } + return undefined; +} + +export type SettingsScope = "global" | "project"; + +export interface SettingsManagerCreateOptions { + projectTrusted?: boolean; +} + +export interface SettingsStorage { + withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void; +} + +export interface SettingsError { + scope: SettingsScope; + error: Error; +} + +export class FileSettingsStorage implements SettingsStorage { + private globalSettingsPath: string; + private projectSettingsPath: string; + + constructor(cwd: string, agentDir: string) { + const resolvedCwd = resolvePath(cwd); + const resolvedAgentDir = resolvePath(agentDir); + this.globalSettingsPath = join(resolvedAgentDir, "settings.json"); + this.projectSettingsPath = join(resolvedCwd, CONFIG_DIR_NAME, "settings.json"); + } + + private acquireLockSyncWithRetry(path: string): () => void { + const maxAttempts = 10; + const delayMs = 20; + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return lockfile.lockSync(path, { realpath: false }); + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code !== "ELOCKED" || attempt === maxAttempts) { + throw error; + } + lastError = error; + const start = Date.now(); + while (Date.now() - start < delayMs) { + // Sleep synchronously to avoid changing callers to async. + } + } + } + + throw (lastError as Error) ?? new Error("Failed to acquire settings lock"); + } + + withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void { + const path = scope === "global" ? this.globalSettingsPath : this.projectSettingsPath; + const dir = dirname(path); + + let release: (() => void) | undefined; + try { + // Only create directory and lock if file exists or we need to write + const fileExists = existsSync(path); + if (fileExists) { + release = this.acquireLockSyncWithRetry(path); + } + const current = fileExists ? readFileSync(path, "utf-8") : undefined; + const next = fn(current); + if (next !== undefined) { + // Only create directory when we actually need to write + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + if (!release) { + release = this.acquireLockSyncWithRetry(path); + } + writeFileSync(path, next, "utf-8"); + } + } finally { + if (release) { + release(); + } + } + } +} + +export class InMemorySettingsStorage implements SettingsStorage { + private global: string | undefined; + private project: string | undefined; + + withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void { + const current = scope === "global" ? this.global : this.project; + const next = fn(current); + if (next !== undefined) { + if (scope === "global") { + this.global = next; + } else { + this.project = next; + } + } + } +} + +export class SettingsManager { + private storage: SettingsStorage; + private globalSettings: Settings; + private projectSettings: Settings; + private settings: Settings; + private projectTrusted: boolean; + private modifiedFields = new Set(); // Track global fields modified during session + private modifiedNestedFields = new Map>(); // Track global nested field modifications + private modifiedProjectFields = new Set(); // Track project fields modified during session + private modifiedProjectNestedFields = new Map>(); // Track project nested field modifications + private globalSettingsLoadError: Error | null = null; // Track if global settings file had parse errors + private projectSettingsLoadError: Error | null = null; // Track if project settings file had parse errors + private writeQueue: Promise = Promise.resolve(); + private errors: SettingsError[]; + + private constructor( + storage: SettingsStorage, + initialGlobal: Settings, + initialProject: Settings, + globalLoadError: Error | null = null, + projectLoadError: Error | null = null, + initialErrors: SettingsError[] = [], + projectTrusted = true, + ) { + this.storage = storage; + this.globalSettings = initialGlobal; + this.projectSettings = initialProject; + this.projectTrusted = projectTrusted; + this.globalSettingsLoadError = globalLoadError; + this.projectSettingsLoadError = projectLoadError; + this.errors = [...initialErrors]; + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + } + + /** Create a SettingsManager that loads from files */ + static create( + cwd: string, + agentDir: string = getAgentDir(), + options: SettingsManagerCreateOptions = {}, + ): SettingsManager { + const storage = new FileSettingsStorage(cwd, agentDir); + return SettingsManager.fromStorage(storage, options); + } + + /** Create a SettingsManager from an arbitrary storage backend */ + static fromStorage(storage: SettingsStorage, options: SettingsManagerCreateOptions = {}): SettingsManager { + const projectTrusted = options.projectTrusted ?? true; + const globalLoad = SettingsManager.tryLoadFromStorage(storage, "global"); + const projectLoad = SettingsManager.tryLoadFromStorage(storage, "project", projectTrusted); + const initialErrors: SettingsError[] = []; + if (globalLoad.error) { + initialErrors.push({ scope: "global", error: globalLoad.error }); + } + if (projectLoad.error) { + initialErrors.push({ scope: "project", error: projectLoad.error }); + } + + return new SettingsManager( + storage, + globalLoad.settings, + projectLoad.settings, + globalLoad.error, + projectLoad.error, + initialErrors, + projectTrusted, + ); + } + + /** Create an in-memory SettingsManager (no file I/O) */ + static inMemory(settings: Partial = {}, options: SettingsManagerCreateOptions = {}): SettingsManager { + const storage = new InMemorySettingsStorage(); + const initialSettings = SettingsManager.migrateSettings(structuredClone(settings) as Record); + storage.withLock("global", () => JSON.stringify(initialSettings, null, 2)); + return SettingsManager.fromStorage(storage, options); + } + + private static loadFromStorage(storage: SettingsStorage, scope: SettingsScope, projectTrusted = true): Settings { + if (scope === "project" && !projectTrusted) { + return {}; + } + + let content: string | undefined; + storage.withLock(scope, (current) => { + content = current; + return undefined; + }); + + if (!content) { + return {}; + } + const settings = JSON.parse(content); + return SettingsManager.migrateSettings(settings); + } + + private static tryLoadFromStorage( + storage: SettingsStorage, + scope: SettingsScope, + projectTrusted = true, + ): { settings: Settings; error: Error | null } { + try { + return { settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), error: null }; + } catch (error) { + return { settings: {}, error: error as Error }; + } + } + + /** Migrate old settings format to new format */ + private static migrateSettings(settings: Record): Settings { + // Migrate queueMode -> steeringMode + if ("queueMode" in settings && !("steeringMode" in settings)) { + settings.steeringMode = settings.queueMode; + delete settings.queueMode; + } + + // Migrate legacy websockets boolean -> transport enum + if (!("transport" in settings) && typeof settings.websockets === "boolean") { + settings.transport = settings.websockets ? "websocket" : "sse"; + delete settings.websockets; + } + + // Migrate old skills object format to new array format + if ( + "skills" in settings && + typeof settings.skills === "object" && + settings.skills !== null && + !Array.isArray(settings.skills) + ) { + const skillsSettings = settings.skills as { + enableSkillCommands?: boolean; + customDirectories?: unknown; + }; + if (skillsSettings.enableSkillCommands !== undefined && settings.enableSkillCommands === undefined) { + settings.enableSkillCommands = skillsSettings.enableSkillCommands; + } + if (Array.isArray(skillsSettings.customDirectories) && skillsSettings.customDirectories.length > 0) { + settings.skills = skillsSettings.customDirectories; + } else { + delete settings.skills; + } + } + + // Migrate retry.maxDelayMs -> retry.provider.maxRetryDelayMs + if ( + "retry" in settings && + typeof settings.retry === "object" && + settings.retry !== null && + !Array.isArray(settings.retry) + ) { + const retrySettings = settings.retry as Record; + const providerSettings = + typeof retrySettings.provider === "object" && retrySettings.provider !== null + ? (retrySettings.provider as Record) + : undefined; + if ( + typeof retrySettings.maxDelayMs === "number" && + (providerSettings?.maxRetryDelayMs === undefined || providerSettings?.maxRetryDelayMs === null) + ) { + retrySettings.provider = { + ...(providerSettings ?? {}), + maxRetryDelayMs: retrySettings.maxDelayMs, + }; + } + delete retrySettings.maxDelayMs; + } + + return settings as Settings; + } + + getGlobalSettings(): Settings { + return structuredClone(this.globalSettings); + } + + getProjectSettings(): Settings { + return structuredClone(this.projectSettings); + } + + isProjectTrusted(): boolean { + return this.projectTrusted; + } + + setProjectTrusted(trusted: boolean): void { + if (this.projectTrusted === trusted) { + return; + } + + this.projectTrusted = trusted; + this.modifiedProjectFields.clear(); + this.modifiedProjectNestedFields.clear(); + + if (!trusted) { + this.projectSettings = {}; + this.projectSettingsLoadError = null; + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + return; + } + + const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", trusted); + this.projectSettings = projectLoad.settings; + this.projectSettingsLoadError = projectLoad.error; + if (projectLoad.error) { + this.recordError("project", projectLoad.error); + } + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + } + + async reload(): Promise { + await this.writeQueue; + const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global"); + if (!globalLoad.error) { + this.globalSettings = globalLoad.settings; + this.globalSettingsLoadError = null; + } else { + this.globalSettingsLoadError = globalLoad.error; + this.recordError("global", globalLoad.error); + } + + this.modifiedFields.clear(); + this.modifiedNestedFields.clear(); + this.modifiedProjectFields.clear(); + this.modifiedProjectNestedFields.clear(); + + const projectLoad = SettingsManager.tryLoadFromStorage(this.storage, "project", this.projectTrusted); + if (!projectLoad.error) { + this.projectSettings = projectLoad.settings; + this.projectSettingsLoadError = null; + } else { + this.projectSettingsLoadError = projectLoad.error; + this.recordError("project", projectLoad.error); + } + + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + } + + /** Apply additional overrides on top of current settings */ + applyOverrides(overrides: Partial): void { + this.settings = deepMergeSettings(this.settings, overrides); + } + + /** Mark a global field as modified during this session */ + private markModified(field: keyof Settings, nestedKey?: string): void { + this.modifiedFields.add(field); + if (nestedKey) { + if (!this.modifiedNestedFields.has(field)) { + this.modifiedNestedFields.set(field, new Set()); + } + this.modifiedNestedFields.get(field)!.add(nestedKey); + } + } + + /** Mark a project field as modified during this session */ + private markProjectModified(field: keyof Settings, nestedKey?: string): void { + this.modifiedProjectFields.add(field); + if (nestedKey) { + if (!this.modifiedProjectNestedFields.has(field)) { + this.modifiedProjectNestedFields.set(field, new Set()); + } + this.modifiedProjectNestedFields.get(field)!.add(nestedKey); + } + } + + private assertProjectTrustedForWrite(): void { + if (!this.projectTrusted) { + throw new Error("Project is not trusted; refusing to write project settings"); + } + } + + private recordError(scope: SettingsScope, error: unknown): void { + const normalizedError = error instanceof Error ? error : new Error(String(error)); + this.errors.push({ scope, error: normalizedError }); + } + + private clearModifiedScope(scope: SettingsScope): void { + if (scope === "global") { + this.modifiedFields.clear(); + this.modifiedNestedFields.clear(); + return; + } + + this.modifiedProjectFields.clear(); + this.modifiedProjectNestedFields.clear(); + } + + private enqueueWrite(scope: SettingsScope, task: () => void): void { + this.writeQueue = this.writeQueue + .then(() => { + if (scope === "project") { + this.assertProjectTrustedForWrite(); + } + task(); + this.clearModifiedScope(scope); + }) + .catch((error) => { + this.recordError(scope, error); + }); + } + + private cloneModifiedNestedFields(source: Map>): Map> { + const snapshot = new Map>(); + for (const [key, value] of source.entries()) { + snapshot.set(key, new Set(value)); + } + return snapshot; + } + + private persistScopedSettings( + scope: SettingsScope, + snapshotSettings: Settings, + modifiedFields: Set, + modifiedNestedFields: Map>, + ): void { + this.storage.withLock(scope, (current) => { + const currentFileSettings = current + ? SettingsManager.migrateSettings(JSON.parse(current) as Record) + : {}; + const mergedSettings: Settings = { ...currentFileSettings }; + for (const field of modifiedFields) { + const value = snapshotSettings[field]; + if (modifiedNestedFields.has(field) && typeof value === "object" && value !== null) { + const nestedModified = modifiedNestedFields.get(field)!; + const baseNested = (currentFileSettings[field] as Record) ?? {}; + const inMemoryNested = value as Record; + const mergedNested = { ...baseNested }; + for (const nestedKey of nestedModified) { + mergedNested[nestedKey] = inMemoryNested[nestedKey]; + } + (mergedSettings as Record)[field] = mergedNested; + } else { + (mergedSettings as Record)[field] = value; + } + } + + return JSON.stringify(mergedSettings, null, 2); + }); + } + + private save(): void { + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + + if (this.globalSettingsLoadError) { + return; + } + + const snapshotGlobalSettings = structuredClone(this.globalSettings); + const modifiedFields = new Set(this.modifiedFields); + const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedNestedFields); + + this.enqueueWrite("global", () => { + this.persistScopedSettings("global", snapshotGlobalSettings, modifiedFields, modifiedNestedFields); + }); + } + + private saveProjectSettings(settings: Settings): void { + this.assertProjectTrustedForWrite(); + this.projectSettings = structuredClone(settings); + this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); + + if (this.projectSettingsLoadError) { + return; + } + + const snapshotProjectSettings = structuredClone(this.projectSettings); + const modifiedFields = new Set(this.modifiedProjectFields); + const modifiedNestedFields = this.cloneModifiedNestedFields(this.modifiedProjectNestedFields); + this.enqueueWrite("project", () => { + this.persistScopedSettings("project", snapshotProjectSettings, modifiedFields, modifiedNestedFields); + }); + } + + private updateProjectSettings(field: keyof Settings, update: (settings: Settings) => void): void { + this.assertProjectTrustedForWrite(); + const projectSettings = structuredClone(this.projectSettings); + update(projectSettings); + this.markProjectModified(field); + this.saveProjectSettings(projectSettings); + } + + async flush(): Promise { + await this.writeQueue; + } + + drainErrors(): SettingsError[] { + const drained = [...this.errors]; + this.errors = []; + return drained; + } + + getLastChangelogVersion(): string | undefined { + return this.settings.lastChangelogVersion; + } + + setLastChangelogVersion(version: string): void { + this.globalSettings.lastChangelogVersion = version; + this.markModified("lastChangelogVersion"); + this.save(); + } + + getSessionDir(): string | undefined { + const sessionDir = this.settings.sessionDir; + return sessionDir ? normalizePath(sessionDir) : sessionDir; + } + + getDefaultProvider(): string | undefined { + return this.settings.defaultProvider; + } + + getDefaultModel(): string | undefined { + return this.settings.defaultModel; + } + + setDefaultProvider(provider: string): void { + this.globalSettings.defaultProvider = provider; + this.markModified("defaultProvider"); + this.save(); + } + + setDefaultModel(modelId: string): void { + this.globalSettings.defaultModel = modelId; + this.markModified("defaultModel"); + this.save(); + } + + setDefaultModelAndProvider(provider: string, modelId: string): void { + this.globalSettings.defaultProvider = provider; + this.globalSettings.defaultModel = modelId; + this.markModified("defaultProvider"); + this.markModified("defaultModel"); + this.save(); + } + + getSteeringMode(): "all" | "one-at-a-time" { + return this.settings.steeringMode || "one-at-a-time"; + } + + setSteeringMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.steeringMode = mode; + this.markModified("steeringMode"); + this.save(); + } + + getFollowUpMode(): "all" | "one-at-a-time" { + return this.settings.followUpMode || "one-at-a-time"; + } + + setFollowUpMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.followUpMode = mode; + this.markModified("followUpMode"); + this.save(); + } + + getThemeSetting(): string | undefined { + const value = this.settings.theme; + if (typeof value === "string") return value; + return undefined; + } + + getTheme(): string | undefined { + const theme = this.getThemeSetting(); + return theme?.includes("/") ? undefined : theme; + } + + setTheme(theme: string): void { + this.globalSettings.theme = theme; + this.markModified("theme"); + this.save(); + } + + getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined { + return this.settings.defaultThinkingLevel; + } + + setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void { + this.globalSettings.defaultThinkingLevel = level; + this.markModified("defaultThinkingLevel"); + this.save(); + } + + getTransport(): TransportSetting { + return this.settings.transport ?? "auto"; + } + + setTransport(transport: TransportSetting): void { + this.globalSettings.transport = transport; + this.markModified("transport"); + this.save(); + } + + getCompactionEnabled(): boolean { + return this.settings.compaction?.enabled ?? true; + } + + setCompactionEnabled(enabled: boolean): void { + if (!this.globalSettings.compaction) { + this.globalSettings.compaction = {}; + } + this.globalSettings.compaction.enabled = enabled; + this.markModified("compaction", "enabled"); + this.save(); + } + + getCompactionReserveTokens(): number { + return this.settings.compaction?.reserveTokens ?? 16384; + } + + getCompactionKeepRecentTokens(): number { + return this.settings.compaction?.keepRecentTokens ?? 20000; + } + + getCompactionSettings(): { enabled: boolean; reserveTokens: number; keepRecentTokens: number } { + return { + enabled: this.getCompactionEnabled(), + reserveTokens: this.getCompactionReserveTokens(), + keepRecentTokens: this.getCompactionKeepRecentTokens(), + }; + } + + getBranchSummarySettings(): { reserveTokens: number; skipPrompt: boolean } { + return { + reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384, + skipPrompt: this.settings.branchSummary?.skipPrompt ?? false, + }; + } + + getBranchSummarySkipPrompt(): boolean { + return this.settings.branchSummary?.skipPrompt ?? false; + } + + getRetryEnabled(): boolean { + return this.settings.retry?.enabled ?? true; + } + + setRetryEnabled(enabled: boolean): void { + if (!this.globalSettings.retry) { + this.globalSettings.retry = {}; + } + this.globalSettings.retry.enabled = enabled; + this.markModified("retry", "enabled"); + this.save(); + } + + getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number } { + return { + enabled: this.getRetryEnabled(), + maxRetries: this.settings.retry?.maxRetries ?? 3, + baseDelayMs: this.settings.retry?.baseDelayMs ?? 2000, + }; + } + + getHttpIdleTimeoutMs(): number { + return parseTimeoutSetting(this.settings.httpIdleTimeoutMs, "httpIdleTimeoutMs") ?? DEFAULT_HTTP_IDLE_TIMEOUT_MS; + } + + setHttpIdleTimeoutMs(timeoutMs: number): void { + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new Error(`Invalid httpIdleTimeoutMs setting: ${String(timeoutMs)}`); + } + this.globalSettings.httpIdleTimeoutMs = Math.floor(timeoutMs); + this.markModified("httpIdleTimeoutMs"); + this.save(); + } + + getProviderRetrySettings(): { timeoutMs?: number; maxRetries?: number; maxRetryDelayMs: number } { + return { + timeoutMs: this.settings.retry?.provider?.timeoutMs, + maxRetries: this.settings.retry?.provider?.maxRetries, + maxRetryDelayMs: this.settings.retry?.provider?.maxRetryDelayMs ?? 60000, + }; + } + + getWebSocketConnectTimeoutMs(): number | undefined { + return parseTimeoutSetting(this.settings.websocketConnectTimeoutMs, "websocketConnectTimeoutMs"); + } + + getHideThinkingBlock(): boolean { + return this.settings.hideThinkingBlock ?? false; + } + + setHideThinkingBlock(hide: boolean): void { + this.globalSettings.hideThinkingBlock = hide; + this.markModified("hideThinkingBlock"); + this.save(); + } + + getShellPath(): string | undefined { + return this.settings.shellPath; + } + + setShellPath(path: string | undefined): void { + this.globalSettings.shellPath = path; + this.markModified("shellPath"); + this.save(); + } + + getQuietStartup(): boolean { + return this.settings.quietStartup ?? false; + } + + setQuietStartup(quiet: boolean): void { + this.globalSettings.quietStartup = quiet; + this.markModified("quietStartup"); + this.save(); + } + + getDefaultProjectTrust(): DefaultProjectTrust { + const value = this.globalSettings.defaultProjectTrust; + return value === "always" || value === "never" ? value : "ask"; + } + + setDefaultProjectTrust(defaultProjectTrust: DefaultProjectTrust): void { + this.globalSettings.defaultProjectTrust = defaultProjectTrust; + this.markModified("defaultProjectTrust"); + this.save(); + } + + getShellCommandPrefix(): string | undefined { + return this.settings.shellCommandPrefix; + } + + setShellCommandPrefix(prefix: string | undefined): void { + this.globalSettings.shellCommandPrefix = prefix; + this.markModified("shellCommandPrefix"); + this.save(); + } + + getNpmCommand(): string[] | undefined { + return this.settings.npmCommand ? [...this.settings.npmCommand] : undefined; + } + + setNpmCommand(command: string[] | undefined): void { + this.globalSettings.npmCommand = command ? [...command] : undefined; + this.markModified("npmCommand"); + this.save(); + } + + getEnableAnalytics(): boolean { + return this.settings.enableAnalytics ?? false; + } + + getTrackingId(): string | undefined { + return this.settings.trackingId; + } + + /** Set the analytics opt-in preference; generates a tracking identifier on first opt-in */ + setEnableAnalytics(enabled: boolean): void { + this.globalSettings.enableAnalytics = enabled; + this.markModified("enableAnalytics"); + if (enabled && !this.globalSettings.trackingId) { + this.globalSettings.trackingId = randomUUID(); + this.markModified("trackingId"); + } + this.save(); + } + + getPackages(): PackageSource[] { + return [...(this.settings.packages ?? [])]; + } + + setPackages(packages: PackageSource[]): void { + this.globalSettings.packages = packages; + this.markModified("packages"); + this.save(); + } + + setProjectPackages(packages: PackageSource[]): void { + this.updateProjectSettings("packages", (settings) => { + settings.packages = packages; + }); + } + + getExtensionPaths(): string[] { + return [...(this.settings.extensions ?? [])]; + } + + setExtensionPaths(paths: string[]): void { + this.globalSettings.extensions = paths; + this.markModified("extensions"); + this.save(); + } + + setProjectExtensionPaths(paths: string[]): void { + this.updateProjectSettings("extensions", (settings) => { + settings.extensions = paths; + }); + } + + getSkillPaths(): string[] { + return [...(this.settings.skills ?? [])]; + } + + setSkillPaths(paths: string[]): void { + this.globalSettings.skills = paths; + this.markModified("skills"); + this.save(); + } + + setProjectSkillPaths(paths: string[]): void { + this.updateProjectSettings("skills", (settings) => { + settings.skills = paths; + }); + } + + getPromptTemplatePaths(): string[] { + return [...(this.settings.prompts ?? [])]; + } + + setPromptTemplatePaths(paths: string[]): void { + this.globalSettings.prompts = paths; + this.markModified("prompts"); + this.save(); + } + + setProjectPromptTemplatePaths(paths: string[]): void { + this.updateProjectSettings("prompts", (settings) => { + settings.prompts = paths; + }); + } + + getThemePaths(): string[] { + return [...(this.settings.themes ?? [])]; + } + + setThemePaths(paths: string[]): void { + this.globalSettings.themes = paths; + this.markModified("themes"); + this.save(); + } + + setProjectThemePaths(paths: string[]): void { + this.updateProjectSettings("themes", (settings) => { + settings.themes = paths; + }); + } + + getEnableSkillCommands(): boolean { + return this.settings.enableSkillCommands ?? true; + } + + setEnableSkillCommands(enabled: boolean): void { + this.globalSettings.enableSkillCommands = enabled; + this.markModified("enableSkillCommands"); + this.save(); + } + + getThinkingBudgets(): ThinkingBudgetsSettings | undefined { + return this.settings.thinkingBudgets; + } + + getShowImages(): boolean { + return this.settings.terminal?.showImages ?? true; + } + + setShowImages(show: boolean): void { + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; + } + this.globalSettings.terminal.showImages = show; + this.markModified("terminal", "showImages"); + this.save(); + } + + getImageWidthCells(): number { + const width = this.settings.terminal?.imageWidthCells; + if (typeof width !== "number" || !Number.isFinite(width)) { + return 60; + } + return Math.max(1, Math.floor(width)); + } + + setImageWidthCells(width: number): void { + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; + } + this.globalSettings.terminal.imageWidthCells = Math.max(1, Math.floor(width)); + this.markModified("terminal", "imageWidthCells"); + this.save(); + } + + getClearOnShrink(): boolean { + // Settings takes precedence, then env var, then default false + if (this.settings.terminal?.clearOnShrink !== undefined) { + return this.settings.terminal.clearOnShrink; + } + return process.env.PI_CLEAR_ON_SHRINK === "1"; + } + + setClearOnShrink(enabled: boolean): void { + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; + } + this.globalSettings.terminal.clearOnShrink = enabled; + this.markModified("terminal", "clearOnShrink"); + this.save(); + } + + getShowTerminalProgress(): boolean { + return this.settings.terminal?.showTerminalProgress ?? false; + } + + setShowTerminalProgress(enabled: boolean): void { + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; + } + this.globalSettings.terminal.showTerminalProgress = enabled; + this.markModified("terminal", "showTerminalProgress"); + this.save(); + } + + getImageAutoResize(): boolean { + return this.settings.images?.autoResize ?? true; + } + + setImageAutoResize(enabled: boolean): void { + if (!this.globalSettings.images) { + this.globalSettings.images = {}; + } + this.globalSettings.images.autoResize = enabled; + this.markModified("images", "autoResize"); + this.save(); + } + + getBlockImages(): boolean { + return this.settings.images?.blockImages ?? false; + } + + setBlockImages(blocked: boolean): void { + if (!this.globalSettings.images) { + this.globalSettings.images = {}; + } + this.globalSettings.images.blockImages = blocked; + this.markModified("images", "blockImages"); + this.save(); + } + + getEnabledModels(): string[] | undefined { + return this.settings.enabledModels; + } + + setEnabledModels(patterns: string[] | undefined): void { + this.globalSettings.enabledModels = patterns; + this.markModified("enabledModels"); + this.save(); + } + + getDoubleEscapeAction(): "fork" | "tree" | "none" { + return this.settings.doubleEscapeAction ?? "tree"; + } + + setDoubleEscapeAction(action: "fork" | "tree" | "none"): void { + this.globalSettings.doubleEscapeAction = action; + this.markModified("doubleEscapeAction"); + this.save(); + } + + getTreeFilterMode(): "default" | "no-tools" | "user-only" | "labeled-only" | "all" { + const mode = this.settings.treeFilterMode; + const valid = ["default", "no-tools", "user-only", "labeled-only", "all"]; + return mode && valid.includes(mode) ? mode : "default"; + } + + setTreeFilterMode(mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all"): void { + this.globalSettings.treeFilterMode = mode; + this.markModified("treeFilterMode"); + this.save(); + } + + getShowHardwareCursor(): boolean { + return this.settings.showHardwareCursor ?? process.env.PI_HARDWARE_CURSOR === "1"; + } + + setShowHardwareCursor(enabled: boolean): void { + this.globalSettings.showHardwareCursor = enabled; + this.markModified("showHardwareCursor"); + this.save(); + } + + getEditorPaddingX(): number { + return this.settings.editorPaddingX ?? 0; + } + + setEditorPaddingX(padding: number): void { + this.globalSettings.editorPaddingX = Math.max(0, Math.min(3, Math.floor(padding))); + this.markModified("editorPaddingX"); + this.save(); + } + + getAutocompleteMaxVisible(): number { + return this.settings.autocompleteMaxVisible ?? 5; + } + + setAutocompleteMaxVisible(maxVisible: number): void { + this.globalSettings.autocompleteMaxVisible = Math.max(3, Math.min(20, Math.floor(maxVisible))); + this.markModified("autocompleteMaxVisible"); + this.save(); + } + + getCodeBlockIndent(): string { + return this.settings.markdown?.codeBlockIndent ?? " "; + } + + getWarnings(): WarningSettings { + return { ...(this.settings.warnings ?? {}) }; + } + + setWarnings(warnings: WarningSettings): void { + this.globalSettings.warnings = { ...warnings }; + this.markModified("warnings"); + this.save(); + } +} diff --git a/cactus-code/packages/coding-agent/src/core/skills.ts b/cactus-code/packages/coding-agent/src/core/skills.ts new file mode 100644 index 000000000..c104c8564 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/skills.ts @@ -0,0 +1,487 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "fs"; +import ignore from "ignore"; +import { basename, dirname, join, relative, resolve, sep } from "path"; +import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts"; +import { parseFrontmatter } from "../utils/frontmatter.ts"; +import { canonicalizePath, resolvePath } from "../utils/paths.ts"; +import type { ResourceDiagnostic } from "./diagnostics.ts"; +import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; + +/** Max name length per spec */ +const MAX_NAME_LENGTH = 64; + +/** Max description length per spec */ +const MAX_DESCRIPTION_LENGTH = 1024; + +const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"]; + +type IgnoreMatcher = ReturnType; + +function toPosixPath(p: string): string { + return p.split(sep).join("/"); +} + +function prefixIgnorePattern(line: string, prefix: string): string | null { + const trimmed = line.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("#") && !trimmed.startsWith("\\#")) return null; + + let pattern = line; + let negated = false; + + if (pattern.startsWith("!")) { + negated = true; + pattern = pattern.slice(1); + } else if (pattern.startsWith("\\!")) { + pattern = pattern.slice(1); + } + + if (pattern.startsWith("/")) { + pattern = pattern.slice(1); + } + + const prefixed = prefix ? `${prefix}${pattern}` : pattern; + return negated ? `!${prefixed}` : prefixed; +} + +function addIgnoreRules(ig: IgnoreMatcher, dir: string, rootDir: string): void { + const relativeDir = relative(rootDir, dir); + const prefix = relativeDir ? `${toPosixPath(relativeDir)}/` : ""; + + for (const filename of IGNORE_FILE_NAMES) { + const ignorePath = join(dir, filename); + if (!existsSync(ignorePath)) continue; + try { + const content = readFileSync(ignorePath, "utf-8"); + const patterns = content + .split(/\r?\n/) + .map((line) => prefixIgnorePattern(line, prefix)) + .filter((line): line is string => Boolean(line)); + if (patterns.length > 0) { + ig.add(patterns); + } + } catch {} + } +} + +export interface SkillFrontmatter { + name?: string; + description?: string; + "disable-model-invocation"?: boolean; + [key: string]: unknown; +} + +export interface Skill { + name: string; + description: string; + filePath: string; + baseDir: string; + sourceInfo: SourceInfo; + disableModelInvocation: boolean; +} + +export interface LoadSkillsResult { + skills: Skill[]; + diagnostics: ResourceDiagnostic[]; +} + +/** + * Validate skill name per Agent Skills spec. + * Returns array of validation error messages (empty if valid). + */ +function validateName(name: string): string[] { + const errors: string[] = []; + + if (name.length > MAX_NAME_LENGTH) { + errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`); + } + + if (!/^[a-z0-9-]+$/.test(name)) { + errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`); + } + + if (name.startsWith("-") || name.endsWith("-")) { + errors.push(`name must not start or end with a hyphen`); + } + + if (name.includes("--")) { + errors.push(`name must not contain consecutive hyphens`); + } + + return errors; +} + +/** + * Validate description per Agent Skills spec. + */ +function validateDescription(description: string | undefined): string[] { + const errors: string[] = []; + + if (!description || description.trim() === "") { + errors.push("description is required"); + } else if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); + } + + return errors; +} + +export interface LoadSkillsFromDirOptions { + /** Directory to scan for skills */ + dir: string; + /** Source identifier for these skills */ + source: string; +} + +function createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo { + switch (source) { + case "user": + return createSyntheticSourceInfo(filePath, { + source: "local", + scope: "user", + baseDir, + }); + case "project": + return createSyntheticSourceInfo(filePath, { + source: "local", + scope: "project", + baseDir, + }); + case "path": + return createSyntheticSourceInfo(filePath, { + source: "local", + baseDir, + }); + default: + return createSyntheticSourceInfo(filePath, { source, baseDir }); + } +} + +/** + * Load skills from a directory. + * + * Discovery rules: + * - if a directory contains SKILL.md, treat it as a skill root and do not recurse further + * - otherwise, load direct .md children in the root + * - recurse into subdirectories to find SKILL.md + */ +export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult { + const { dir, source } = options; + return loadSkillsFromDirInternal(dir, source, true); +} + +function loadSkillsFromDirInternal( + dir: string, + source: string, + includeRootFiles: boolean, + ignoreMatcher?: IgnoreMatcher, + rootDir?: string, +): LoadSkillsResult { + const skills: Skill[] = []; + const diagnostics: ResourceDiagnostic[] = []; + + if (!existsSync(dir)) { + return { skills, diagnostics }; + } + + const root = rootDir ?? dir; + const ig = ignoreMatcher ?? ignore(); + addIgnoreRules(ig, dir, root); + + try { + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name !== "SKILL.md") { + continue; + } + + const fullPath = join(dir, entry.name); + + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + if (!isFile || ig.ignores(relPath)) { + continue; + } + + const result = loadSkillFromFile(fullPath, source); + if (result.skill) { + skills.push(result.skill); + } + diagnostics.push(...result.diagnostics); + return { skills, diagnostics }; + } + + for (const entry of entries) { + if (entry.name.startsWith(".")) { + continue; + } + + // Skip node_modules to avoid scanning dependencies + if (entry.name === "node_modules") { + continue; + } + + const fullPath = join(dir, entry.name); + + // For symlinks, check if they point to a directory and follow them + let isDirectory = entry.isDirectory(); + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + const stats = statSync(fullPath); + isDirectory = stats.isDirectory(); + isFile = stats.isFile(); + } catch { + // Broken symlink, skip it + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + const ignorePath = isDirectory ? `${relPath}/` : relPath; + if (ig.ignores(ignorePath)) { + continue; + } + + if (isDirectory) { + const subResult = loadSkillsFromDirInternal(fullPath, source, false, ig, root); + skills.push(...subResult.skills); + diagnostics.push(...subResult.diagnostics); + continue; + } + + if (!isFile || !includeRootFiles || !entry.name.endsWith(".md")) { + continue; + } + + const result = loadSkillFromFile(fullPath, source); + if (result.skill) { + skills.push(result.skill); + } + diagnostics.push(...result.diagnostics); + } + } catch {} + + return { skills, diagnostics }; +} + +function loadSkillFromFile( + filePath: string, + source: string, +): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } { + const diagnostics: ResourceDiagnostic[] = []; + + try { + const rawContent = readFileSync(filePath, "utf-8"); + const { frontmatter } = parseFrontmatter(rawContent); + const skillDir = dirname(filePath); + const parentDirName = basename(skillDir); + + // Validate description + const descErrors = validateDescription(frontmatter.description); + for (const error of descErrors) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } + + // Use name from frontmatter, or fall back to parent directory name + const name = frontmatter.name || parentDirName; + + // Validate name + const nameErrors = validateName(name); + for (const error of nameErrors) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } + + // Still load the skill even with warnings (unless description is completely missing) + if (!frontmatter.description || frontmatter.description.trim() === "") { + return { skill: null, diagnostics }; + } + + return { + skill: { + name, + description: frontmatter.description, + filePath, + baseDir: skillDir, + sourceInfo: createSkillSourceInfo(filePath, skillDir, source), + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "failed to parse skill file"; + diagnostics.push({ type: "warning", message, path: filePath }); + return { skill: null, diagnostics }; + } +} + +/** + * Format skills for inclusion in a system prompt. + * Uses XML format per Agent Skills standard. + * See: https://agentskills.io/integrate-skills + * + * Skills with disableModelInvocation=true are excluded from the prompt + * (they can only be invoked explicitly via /skill:name commands). + */ +export function formatSkillsForPrompt(skills: Skill[]): string { + const visibleSkills = skills.filter((s) => !s.disableModelInvocation); + + if (visibleSkills.length === 0) { + return ""; + } + + const lines = [ + "\n\nThe following skills provide specialized instructions for specific tasks.", + "Use the read tool to load a skill's file when the task matches its description.", + "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", + "", + "", + ]; + + for (const skill of visibleSkills) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + lines.push(` ${escapeXml(skill.description)}`); + lines.push(` ${escapeXml(skill.filePath)}`); + lines.push(" "); + } + + lines.push(""); + + return lines.join("\n"); +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export interface LoadSkillsOptions { + /** Working directory for project-local skills. */ + cwd: string; + /** Agent config directory for global skills. */ + agentDir: string; + /** Explicit skill paths (files or directories) */ + skillPaths: string[]; + /** Include default skills directories. */ + includeDefaults: boolean; +} + +/** + * Load skills from all configured locations. + * Returns skills and any validation diagnostics. + */ +export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult { + const { agentDir, skillPaths, includeDefaults } = options; + + // Resolve agentDir - if not provided, use default from config + const resolvedCwd = resolvePath(options.cwd); + const resolvedAgentDir = resolvePath(agentDir ?? getAgentDir()); + + const skillMap = new Map(); + const realPathSet = new Set(); + const allDiagnostics: ResourceDiagnostic[] = []; + const collisionDiagnostics: ResourceDiagnostic[] = []; + + function addSkills(result: LoadSkillsResult) { + allDiagnostics.push(...result.diagnostics); + for (const skill of result.skills) { + // Resolve symlinks to detect duplicate files + const realPath = canonicalizePath(skill.filePath); + + // Skip silently if we've already loaded this exact file (via symlink) + if (realPathSet.has(realPath)) { + continue; + } + + const existing = skillMap.get(skill.name); + if (existing) { + collisionDiagnostics.push({ + type: "collision", + message: `name "${skill.name}" collision`, + path: skill.filePath, + collision: { + resourceType: "skill", + name: skill.name, + winnerPath: existing.filePath, + loserPath: skill.filePath, + }, + }); + } else { + skillMap.set(skill.name, skill); + realPathSet.add(realPath); + } + } + } + + if (includeDefaults) { + addSkills(loadSkillsFromDirInternal(join(resolvedAgentDir, "skills"), "user", true)); + addSkills(loadSkillsFromDirInternal(resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"), "project", true)); + } + + const userSkillsDir = join(resolvedAgentDir, "skills"); + const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills"); + + const isUnderPath = (target: string, root: string): boolean => { + const normalizedRoot = resolve(root); + if (target === normalizedRoot) { + return true; + } + const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return target.startsWith(prefix); + }; + + const getSource = (resolvedPath: string): "user" | "project" | "path" => { + if (!includeDefaults) { + if (isUnderPath(resolvedPath, userSkillsDir)) return "user"; + if (isUnderPath(resolvedPath, projectSkillsDir)) return "project"; + } + return "path"; + }; + + for (const rawPath of skillPaths) { + const resolvedPath = resolvePath(rawPath, resolvedCwd, { trim: true }); + if (!existsSync(resolvedPath)) { + allDiagnostics.push({ type: "warning", message: "skill path does not exist", path: resolvedPath }); + continue; + } + + try { + const stats = statSync(resolvedPath); + const source = getSource(resolvedPath); + if (stats.isDirectory()) { + addSkills(loadSkillsFromDirInternal(resolvedPath, source, true)); + } else if (stats.isFile() && resolvedPath.endsWith(".md")) { + const result = loadSkillFromFile(resolvedPath, source); + if (result.skill) { + addSkills({ skills: [result.skill], diagnostics: result.diagnostics }); + } else { + allDiagnostics.push(...result.diagnostics); + } + } else { + allDiagnostics.push({ type: "warning", message: "skill path is not a markdown file", path: resolvedPath }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "failed to read skill path"; + allDiagnostics.push({ type: "warning", message, path: resolvedPath }); + } + } + + return { + skills: Array.from(skillMap.values()), + diagnostics: [...allDiagnostics, ...collisionDiagnostics], + }; +} diff --git a/cactus-code/packages/coding-agent/src/core/slash-commands.ts b/cactus-code/packages/coding-agent/src/core/slash-commands.ts new file mode 100644 index 000000000..80b5dff9d --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/slash-commands.ts @@ -0,0 +1,37 @@ +import { APP_NAME } from "../config.ts"; +import type { SourceInfo } from "./source-info.ts"; + +export type SlashCommandSource = "extension" | "prompt" | "skill"; + +export interface SlashCommandInfo { + name: string; + description?: string; + source: SlashCommandSource; + sourceInfo: SourceInfo; +} + +export interface BuiltinSlashCommand { + name: string; + description: string; +} + +export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ + { name: "settings", description: "Open settings menu" }, + { name: "model", description: "Select model (opens selector UI)" }, + { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, + { name: "export", description: "Export the current session to a .jsonl file" }, + { name: "import", description: "Import and resume a session from a JSONL file" }, + { name: "copy", description: "Copy last agent message to clipboard" }, + { name: "name", description: "Set session display name" }, + { name: "session", description: "Show session info and stats" }, + { name: "hotkeys", description: "Show all keyboard shortcuts" }, + { name: "fork", description: "Create a new fork from a previous user message" }, + { name: "clone", description: "Duplicate the current session at the current position" }, + { name: "tree", description: "Navigate session tree (switch branches)" }, + { name: "trust", description: "Save project trust decision for future sessions" }, + { name: "new", description: "Start a new session" }, + { name: "compact", description: "Manually compact the session context" }, + { name: "resume", description: "Resume a different session" }, + { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" }, + { name: "quit", description: `Quit ${APP_NAME}` }, +]; diff --git a/cactus-code/packages/coding-agent/src/core/source-info.ts b/cactus-code/packages/coding-agent/src/core/source-info.ts new file mode 100644 index 000000000..c8c9837d1 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/source-info.ts @@ -0,0 +1,40 @@ +import type { PathMetadata } from "./package-manager.ts"; + +export type SourceScope = "user" | "project" | "temporary"; +export type SourceOrigin = "package" | "top-level"; + +export interface SourceInfo { + path: string; + source: string; + scope: SourceScope; + origin: SourceOrigin; + baseDir?: string; +} + +export function createSourceInfo(path: string, metadata: PathMetadata): SourceInfo { + return { + path, + source: metadata.source, + scope: metadata.scope, + origin: metadata.origin, + baseDir: metadata.baseDir, + }; +} + +export function createSyntheticSourceInfo( + path: string, + options: { + source: string; + scope?: SourceScope; + origin?: SourceOrigin; + baseDir?: string; + }, +): SourceInfo { + return { + path, + source: options.source, + scope: options.scope ?? "temporary", + origin: options.origin ?? "top-level", + baseDir: options.baseDir, + }; +} diff --git a/cactus-code/packages/coding-agent/src/core/system-prompt.ts b/cactus-code/packages/coding-agent/src/core/system-prompt.ts new file mode 100644 index 000000000..bff8d3c0b --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/system-prompt.ts @@ -0,0 +1,158 @@ +/** + * System prompt construction and project context loading + */ + +import { formatSkillsForPrompt, type Skill } from "./skills.ts"; + +export interface BuildSystemPromptOptions { + /** Custom system prompt (replaces default). */ + customPrompt?: string; + /** Tools to include in prompt. Default: [read, bash, edit, write] */ + selectedTools?: string[]; + /** Optional one-line tool snippets keyed by tool name. */ + toolSnippets?: Record; + /** Additional guideline bullets appended to the default system prompt guidelines. */ + promptGuidelines?: string[]; + /** Text to append to system prompt. */ + appendSystemPrompt?: string; + /** Working directory. */ + cwd: string; + /** Pre-loaded context files. */ + contextFiles?: Array<{ path: string; content: string }>; + /** Pre-loaded skills. */ + skills?: Skill[]; +} + +/** Build the system prompt with tools, guidelines, and context */ +export function buildSystemPrompt(options: BuildSystemPromptOptions): string { + const { + customPrompt, + selectedTools, + toolSnippets, + promptGuidelines, + appendSystemPrompt, + cwd, + contextFiles: providedContextFiles, + skills: providedSkills, + } = options; + const resolvedCwd = cwd; + const promptCwd = resolvedCwd.replace(/\\/g, "/"); + + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const date = `${year}-${month}-${day}`; + + const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : ""; + + const contextFiles = providedContextFiles ?? []; + const skills = providedSkills ?? []; + + if (customPrompt) { + let prompt = customPrompt; + + if (appendSection) { + prompt += appendSection; + } + + // Append project context files + if (contextFiles.length > 0) { + prompt += "\n\n\n\n"; + prompt += "Project-specific instructions and guidelines:\n\n"; + for (const { path: filePath, content } of contextFiles) { + prompt += `\n${content}\n\n\n`; + } + prompt += "\n"; + } + + // Append skills section (only if read tool is available) + const customPromptHasRead = !selectedTools || selectedTools.includes("read"); + if (customPromptHasRead && skills.length > 0) { + prompt += formatSkillsForPrompt(skills); + } + + // Add date and working directory last + prompt += `\nCurrent date: ${date}`; + prompt += `\nCurrent working directory: ${promptCwd}`; + + return prompt; + } + + // Build tools list based on selected tools. + // A tool appears in Available tools only when the caller provides a one-line snippet. + const tools = selectedTools || ["read", "bash", "edit", "write"]; + const visibleTools = tools.filter((name) => !!toolSnippets?.[name]); + const toolsList = + visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets![name]}`).join("\n") : "(none)"; + + // Build guidelines based on which tools are actually available + const guidelinesList: string[] = []; + const guidelinesSet = new Set(); + const addGuideline = (guideline: string): void => { + if (guidelinesSet.has(guideline)) { + return; + } + guidelinesSet.add(guideline); + guidelinesList.push(guideline); + }; + + const hasBash = tools.includes("bash"); + const hasGrep = tools.includes("grep"); + const hasFind = tools.includes("find"); + const hasLs = tools.includes("ls"); + const hasRead = tools.includes("read"); + + // File exploration guidelines + if (hasBash && !hasGrep && !hasFind && !hasLs) { + addGuideline("Use bash for file operations like ls, rg, find"); + } + + for (const guideline of promptGuidelines ?? []) { + const normalized = guideline.trim(); + if (normalized.length > 0) { + addGuideline(normalized); + } + } + + // Always include these + addGuideline("Be concise in your responses"); + addGuideline("Show file paths clearly when working with files"); + + const guidelines = guidelinesList.map((g) => `- ${g}`).join("\n"); + + let prompt = `You are an expert coding assistant operating inside cactus, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files. + +Available tools: +${toolsList} + +In addition to the tools above, you may have access to other custom tools depending on the project. + +Guidelines: +${guidelines}`; + + if (appendSection) { + prompt += appendSection; + } + + // Append project context files + if (contextFiles.length > 0) { + prompt += "\n\n\n\n"; + prompt += "Project-specific instructions and guidelines:\n\n"; + for (const { path: filePath, content } of contextFiles) { + prompt += `\n${content}\n\n\n`; + } + prompt += "\n"; + } + + // Append skills section (only if read tool is available) + if (hasRead && skills.length > 0) { + prompt += formatSkillsForPrompt(skills); + } + + // Add date and working directory last + prompt += `\nCurrent date: ${date}`; + prompt += `\nCurrent working directory: ${promptCwd}`; + + return prompt; +} diff --git a/cactus-code/packages/coding-agent/src/core/timings.ts b/cactus-code/packages/coding-agent/src/core/timings.ts new file mode 100644 index 000000000..0b3e5cf48 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/timings.ts @@ -0,0 +1,31 @@ +/** + * Central timing instrumentation for startup profiling. + * Enable with PI_TIMING=1 environment variable. + */ + +const ENABLED = process.env.PI_TIMING === "1"; +const timings: Array<{ label: string; ms: number }> = []; +let lastTime = Date.now(); + +export function resetTimings(): void { + if (!ENABLED) return; + timings.length = 0; + lastTime = Date.now(); +} + +export function time(label: string): void { + if (!ENABLED) return; + const now = Date.now(); + timings.push({ label, ms: now - lastTime }); + lastTime = now; +} + +export function printTimings(): void { + if (!ENABLED || timings.length === 0) return; + console.error("\n--- Startup Timings ---"); + for (const t of timings) { + console.error(` ${t.label}: ${t.ms}ms`); + } + console.error(` TOTAL: ${timings.reduce((a, b) => a + b.ms, 0)}ms`); + console.error("------------------------\n"); +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/bash.ts b/cactus-code/packages/coding-agent/src/core/tools/bash.ts new file mode 100644 index 000000000..5e29cccbb --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/bash.ts @@ -0,0 +1,453 @@ +import { constants } from "node:fs"; +import { access as fsAccess } from "node:fs/promises"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"; +import { spawn } from "child_process"; +import { type Static, Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.ts"; +import { theme } from "../../modes/interactive/theme/theme.ts"; +import { waitForChildProcess } from "../../utils/child-process.ts"; +import { + getShellConfig, + getShellEnv, + killProcessTree, + trackDetachedChildPid, + untrackDetachedChildPid, +} from "../../utils/shell.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { OutputAccumulator } from "./output-accumulator.ts"; +import { getTextOutput, invalidArgText, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult } from "./truncate.ts"; + +const bashSchema = Type.Object({ + command: Type.String({ description: "Bash command to execute" }), + timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })), +}); + +export type BashToolInput = Static; + +export interface BashToolDetails { + truncation?: TruncationResult; + fullOutputPath?: string; +} + +/** + * Pluggable operations for the bash tool. + * Override these to delegate command execution to remote systems (for example SSH). + */ +export interface BashOperations { + /** + * Execute a command and stream output. + * @param command The command to execute + * @param cwd Working directory + * @param options Execution options + * @returns Promise resolving to exit code (null if killed) + */ + exec: ( + command: string, + cwd: string, + options: { + onData: (data: Buffer) => void; + signal?: AbortSignal; + timeout?: number; + env?: NodeJS.ProcessEnv; + }, + ) => Promise<{ exitCode: number | null }>; +} + +/** + * Create bash operations using pi's built-in local shell execution backend. + * + * This is useful for extensions that intercept user_bash and still want pi's + * standard local shell behavior while wrapping or rewriting commands. + */ +export function createLocalBashOperations(options?: { shellPath?: string }): BashOperations { + return { + exec: async (command, cwd, { onData, signal, timeout, env }) => { + const shellConfig = getShellConfig(options?.shellPath); + try { + await fsAccess(cwd, constants.F_OK); + } catch { + throw new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`); + } + if (signal?.aborted) { + throw new Error("aborted"); + } + + const commandFromStdin = shellConfig.commandTransport === "stdin"; + const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], { + cwd, + detached: process.platform !== "win32", + env: env ?? getShellEnv(), + stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"], + windowsHide: true, + }); + if (commandFromStdin) { + child.stdin?.on("error", () => {}); + child.stdin?.end(command); + } + if (child.pid) trackDetachedChildPid(child.pid); + let timedOut = false; + let timeoutHandle: NodeJS.Timeout | undefined; + const onAbort = () => { + if (child.pid) killProcessTree(child.pid); + }; + + try { + // Set timeout if provided. + if (timeout !== undefined && timeout > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + if (child.pid) killProcessTree(child.pid); + }, timeout * 1000); + } + // Stream stdout and stderr. + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + // Handle abort signal by killing the entire process tree. + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + // Handle shell spawn errors and wait for the process to terminate without hanging + // on inherited stdio handles held by detached descendants. + const exitCode = await waitForChildProcess(child); + if (signal?.aborted) { + throw new Error("aborted"); + } + if (timedOut) { + throw new Error(`timeout:${timeout}`); + } + return { exitCode }; + } finally { + if (child.pid) untrackDetachedChildPid(child.pid); + if (timeoutHandle) clearTimeout(timeoutHandle); + if (signal) signal.removeEventListener("abort", onAbort); + } + }, + }; +} + +export interface BashSpawnContext { + command: string; + cwd: string; + env: NodeJS.ProcessEnv; +} + +export type BashSpawnHook = (context: BashSpawnContext) => BashSpawnContext; + +function resolveSpawnContext(command: string, cwd: string, spawnHook?: BashSpawnHook): BashSpawnContext { + const baseContext: BashSpawnContext = { command, cwd, env: { ...getShellEnv() } }; + return spawnHook ? spawnHook(baseContext) : baseContext; +} + +export interface BashToolOptions { + /** Custom operations for command execution. Default: local shell */ + operations?: BashOperations; + /** Command prefix prepended to every command (for example shell setup commands) */ + commandPrefix?: string; + /** Optional explicit shell path from settings */ + shellPath?: string; + /** Hook to adjust command, cwd, or env before execution */ + spawnHook?: BashSpawnHook; +} + +const BASH_PREVIEW_LINES = 5; +const BASH_UPDATE_THROTTLE_MS = 100; + +type BashRenderState = { + startedAt: number | undefined; + endedAt: number | undefined; + interval: NodeJS.Timeout | undefined; +}; + +type BashResultRenderState = { + cachedWidth: number | undefined; + cachedLines: string[] | undefined; + cachedSkipped: number | undefined; +}; + +class BashResultRenderComponent extends Container { + state: BashResultRenderState = { + cachedWidth: undefined, + cachedLines: undefined, + cachedSkipped: undefined, + }; +} + +function formatDuration(ms: number): string { + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatBashCall(args: { command?: string; timeout?: number } | undefined): string { + const command = str(args?.command); + const timeout = args?.timeout as number | undefined; + const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : ""; + const commandDisplay = command === null ? invalidArgText(theme) : command ? command : theme.fg("toolOutput", "..."); + return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix; +} + +function rebuildBashResultRenderComponent( + component: BashResultRenderComponent, + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: BashToolDetails; + }, + options: ToolRenderResultOptions, + showImages: boolean, + startedAt: number | undefined, + endedAt: number | undefined, +): void { + const state = component.state; + component.clear(); + + let output = getTextOutput(result as any, showImages).trim(); + const truncation = result.details?.truncation; + const fullOutputPath = result.details?.fullOutputPath; + if (!options.isPartial && truncation?.truncated && fullOutputPath && output.endsWith("]")) { + const footerStart = output.lastIndexOf("\n\n["); + if (footerStart !== -1 && output.slice(footerStart).includes(fullOutputPath)) { + output = output.slice(0, footerStart).trimEnd(); + } + } + + if (output) { + const styledOutput = output + .split("\n") + .map((line) => theme.fg("toolOutput", line)) + .join("\n"); + + if (options.expanded) { + component.addChild(new Text(`\n${styledOutput}`, 0, 0)); + } else { + component.addChild({ + render: (width: number) => { + if (state.cachedLines === undefined || state.cachedWidth !== width) { + const preview = truncateToVisualLines(styledOutput, BASH_PREVIEW_LINES, width); + state.cachedLines = preview.visualLines; + state.cachedSkipped = preview.skippedCount; + state.cachedWidth = width; + } + if (state.cachedSkipped && state.cachedSkipped > 0) { + const hint = + theme.fg("muted", `... (${state.cachedSkipped} earlier lines,`) + + ` ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + return ["", truncateToWidth(hint, width, "..."), ...(state.cachedLines ?? [])]; + } + return ["", ...(state.cachedLines ?? [])]; + }, + invalidate: () => { + state.cachedWidth = undefined; + state.cachedLines = undefined; + state.cachedSkipped = undefined; + }, + }); + } + } + + if (truncation?.truncated || fullOutputPath) { + const warnings: string[] = []; + if (fullOutputPath) { + warnings.push(`Full output: ${fullOutputPath}`); + } + if (truncation?.truncated) { + if (truncation.truncatedBy === "lines") { + warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`); + } else { + warnings.push( + `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`, + ); + } + } + component.addChild(new Text(`\n${theme.fg("warning", `[${warnings.join(". ")}]`)}`, 0, 0)); + } + + if (startedAt !== undefined) { + const label = options.isPartial ? "Elapsed" : "Took"; + const endTime = endedAt ?? Date.now(); + component.addChild(new Text(`\n${theme.fg("muted", `${label} ${formatDuration(endTime - startedAt)}`)}`, 0, 0)); + } +} + +export function createBashToolDefinition( + cwd: string, + options?: BashToolOptions, +): ToolDefinition { + const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath }); + const commandPrefix = options?.commandPrefix; + const spawnHook = options?.spawnHook; + return { + name: "bash", + label: "bash", + description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`, + promptSnippet: "Execute bash commands (ls, grep, find, etc.)", + parameters: bashSchema, + async execute( + _toolCallId, + { command, timeout }: { command: string; timeout?: number }, + signal?: AbortSignal, + onUpdate?, + _ctx?, + ) { + const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command; + const spawnContext = resolveSpawnContext(resolvedCommand, cwd, spawnHook); + const output = new OutputAccumulator({ tempFilePrefix: "cactus-bash" }); + let acceptingOutput = true; + let updateTimer: NodeJS.Timeout | undefined; + let updateDirty = false; + let lastUpdateAt = 0; + + const emitOutputUpdate = () => { + if (!onUpdate || !updateDirty) return; + updateDirty = false; + lastUpdateAt = Date.now(); + const snapshot = output.snapshot({ persistIfTruncated: true }); + onUpdate({ + content: [{ type: "text", text: snapshot.content || "" }], + details: { + truncation: snapshot.truncation.truncated ? snapshot.truncation : undefined, + fullOutputPath: snapshot.fullOutputPath, + }, + }); + }; + + const clearUpdateTimer = () => { + if (updateTimer) { + clearTimeout(updateTimer); + updateTimer = undefined; + } + }; + + const scheduleOutputUpdate = () => { + if (!onUpdate) return; + updateDirty = true; + const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt); + if (delay <= 0) { + clearUpdateTimer(); + emitOutputUpdate(); + return; + } + updateTimer ??= setTimeout(() => { + updateTimer = undefined; + emitOutputUpdate(); + }, delay); + }; + + if (onUpdate) { + onUpdate({ content: [], details: undefined }); + } + + const handleData = (data: Buffer) => { + if (!acceptingOutput) return; + output.append(data); + scheduleOutputUpdate(); + }; + + const finishOutput = async () => { + acceptingOutput = false; + output.finish(); + clearUpdateTimer(); + emitOutputUpdate(); + const snapshot = output.snapshot({ persistIfTruncated: true }); + await output.closeTempFile(); + return snapshot; + }; + + const formatOutput = (snapshot: Awaited>, emptyText = "(no output)") => { + const truncation = snapshot.truncation; + let text = snapshot.content || emptyText; + let details: BashToolDetails | undefined; + if (truncation.truncated) { + details = { truncation, fullOutputPath: snapshot.fullOutputPath }; + const startLine = truncation.totalLines - truncation.outputLines + 1; + const endLine = truncation.totalLines; + if (truncation.lastLinePartial) { + const lastLineSize = formatSize(output.getLastLineBytes()); + text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`; + } else if (truncation.truncatedBy === "lines") { + text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`; + } else { + text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${snapshot.fullOutputPath}]`; + } + } + return { text, details }; + }; + + const appendStatus = (text: string, status: string) => `${text ? `${text}\n\n` : ""}${status}`; + + try { + let exitCode: number | null; + try { + const result = await ops.exec(spawnContext.command, spawnContext.cwd, { + onData: handleData, + signal, + timeout, + env: spawnContext.env, + }); + exitCode = result.exitCode; + } catch (err) { + const snapshot = await finishOutput(); + const { text } = formatOutput(snapshot, ""); + if (err instanceof Error && err.message === "aborted") { + throw new Error(appendStatus(text, "Command aborted")); + } + if (err instanceof Error && err.message.startsWith("timeout:")) { + const timeoutSecs = err.message.split(":")[1]; + throw new Error(appendStatus(text, `Command timed out after ${timeoutSecs} seconds`)); + } + throw err; + } + + const snapshot = await finishOutput(); + const { text: outputText, details } = formatOutput(snapshot); + if (exitCode !== 0 && exitCode !== null) { + throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`)); + } + return { content: [{ type: "text", text: outputText }], details }; + } finally { + clearUpdateTimer(); + } + }, + renderCall(args, _theme, context) { + const state = context.state; + if (context.executionStarted && state.startedAt === undefined) { + state.startedAt = Date.now(); + state.endedAt = undefined; + } + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatBashCall(args)); + return text; + }, + renderResult(result, options, _theme, context) { + const state = context.state; + if (state.startedAt !== undefined && options.isPartial && !state.interval) { + state.interval = setInterval(() => context.invalidate(), 1000); + } + if (!options.isPartial || context.isError) { + state.endedAt ??= Date.now(); + if (state.interval) { + clearInterval(state.interval); + state.interval = undefined; + } + } + const component = + (context.lastComponent as BashResultRenderComponent | undefined) ?? new BashResultRenderComponent(); + rebuildBashResultRenderComponent( + component, + result as any, + options, + context.showImages, + state.startedAt, + state.endedAt, + ); + component.invalidate(); + return component; + }, + }; +} + +export function createBashTool(cwd: string, options?: BashToolOptions): AgentTool { + return wrapToolDefinition(createBashToolDefinition(cwd, options)); +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/edit-diff.ts b/cactus-code/packages/coding-agent/src/core/tools/edit-diff.ts new file mode 100644 index 000000000..5a4d966b0 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/edit-diff.ts @@ -0,0 +1,560 @@ +/** + * Shared diff computation utilities for the edit and similar tools. + */ + +import * as Diff from "diff"; +import { constants } from "fs"; +import { access, readFile } from "fs/promises"; +import { resolveToCwd } from "./path-utils.ts"; + +export function detectLineEnding(content: string): "\r\n" | "\n" { + const crlfIdx = content.indexOf("\r\n"); + const lfIdx = content.indexOf("\n"); + if (lfIdx === -1) return "\n"; + if (crlfIdx === -1) return "\n"; + return crlfIdx < lfIdx ? "\r\n" : "\n"; +} + +export function normalizeToLF(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string { + return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; +} + +/** + * Normalize text for fuzzy matching. Applies progressive transformations: + * - Strip trailing whitespace from each line + * - Normalize smart quotes to ASCII equivalents + * - Normalize Unicode dashes/hyphens to ASCII hyphen + * - Normalize special Unicode spaces to regular space + */ +export function normalizeForFuzzyMatch(text: string): string { + return ( + text + .normalize("NFKC") + // Strip trailing whitespace per line + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + // Smart single quotes → ' + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") + // Smart double quotes → " + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') + // Various dashes/hyphens → - + // U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash, + // U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-") + // Special spaces → regular space + // U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP, + // U+205F medium math space, U+3000 ideographic space + .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ") + ); +} + +function splitLinesWithEndings(content: string): string[] { + return content.match(/[^\n]*\n|[^\n]+/g) ?? []; +} + +interface LineSpan { + start: number; + end: number; +} + +interface MatchedEdit { + editIndex: number; + matchIndex: number; + matchLength: number; + newText: string; +} + +type TextReplacement = Pick; + +function getLineSpans(content: string): LineSpan[] { + let offset = 0; + return splitLinesWithEndings(content).map((line) => { + const span = { start: offset, end: offset + line.length }; + offset = span.end; + return span; + }); +} + +function getReplacementLineRange(lines: LineSpan[], replacement: TextReplacement) { + const replacementStart = replacement.matchIndex; + const replacementEnd = replacement.matchIndex + replacement.matchLength; + + let startLine = -1; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (replacementStart >= line.start && replacementStart < line.end) { + startLine = i; + break; + } + } + if (startLine === -1) { + throw new Error("Replacement range is outside the base content."); + } + + let endLine = startLine; + while (endLine < lines.length && lines[endLine].end < replacementEnd) { + endLine++; + } + if (endLine >= lines.length) { + throw new Error("Replacement range is outside the base content."); + } + + return { startLine, endLine: endLine + 1 }; +} + +function applyReplacements(content: string, replacements: TextReplacement[], offset = 0): string { + let result = content; + for (let i = replacements.length - 1; i >= 0; i--) { + const replacement = replacements[i]; + const matchIndex = replacement.matchIndex - offset; + result = + result.substring(0, matchIndex) + replacement.newText + result.substring(matchIndex + replacement.matchLength); + } + return result; +} + +/** + * Apply replacements matched against `baseContent` to `originalContent` while + * preserving unchanged line blocks from the original. + * + * This is useful when `baseContent` is a normalized view of the original. Each + * replacement is widened to the lines it actually touches, those touched lines + * are rewritten from the normalized base, and all other lines are copied back + * from `originalContent`. The actual replacement ranges drive preservation so + * duplicate normalized lines cannot be aligned to the wrong occurrence. + */ +export function applyReplacementsPreservingUnchangedLines( + originalContent: string, + baseContent: string, + replacements: TextReplacement[], +): string { + const originalLines = splitLinesWithEndings(originalContent); + const baseLines = getLineSpans(baseContent); + if (originalLines.length !== baseLines.length) { + throw new Error("Cannot preserve unchanged lines because the base content has a different line count."); + } + + const groups: Array<{ startLine: number; endLine: number; replacements: TextReplacement[] }> = []; + const sortedReplacements = [...replacements].sort((a, b) => a.matchIndex - b.matchIndex); + for (const replacement of sortedReplacements) { + const range = getReplacementLineRange(baseLines, replacement); + const current = groups[groups.length - 1]; + if (current && range.startLine < current.endLine) { + current.endLine = Math.max(current.endLine, range.endLine); + current.replacements.push(replacement); + continue; + } + groups.push({ ...range, replacements: [replacement] }); + } + + let originalLineIndex = 0; + let result = ""; + for (const group of groups) { + result += originalLines.slice(originalLineIndex, group.startLine).join(""); + + const groupStartOffset = baseLines[group.startLine].start; + const groupEndOffset = baseLines[group.endLine - 1].end; + result += applyReplacements( + baseContent.slice(groupStartOffset, groupEndOffset), + group.replacements, + groupStartOffset, + ); + originalLineIndex = group.endLine; + } + result += originalLines.slice(originalLineIndex).join(""); + + return result; +} + +export interface FuzzyMatchResult { + /** Whether a match was found */ + found: boolean; + /** The index where the match starts (in the content that should be used for replacement) */ + index: number; + /** Length of the matched text */ + matchLength: number; + /** Whether fuzzy matching was used (false = exact match) */ + usedFuzzyMatch: boolean; + /** + * The content to use for replacement operations. + * When exact match: original content. When fuzzy match: normalized content. + */ + contentForReplacement: string; +} + +export interface Edit { + oldText: string; + newText: string; +} + +export interface AppliedEditsResult { + baseContent: string; + newContent: string; +} + +/** + * Find oldText in content, trying exact match first, then fuzzy match. + * When fuzzy matching is used, the returned contentForReplacement is the + * fuzzy-normalized version of the content (trailing whitespace stripped, + * Unicode quotes/dashes normalized to ASCII). + */ +export function fuzzyFindText(content: string, oldText: string): FuzzyMatchResult { + // Try exact match first + const exactIndex = content.indexOf(oldText); + if (exactIndex !== -1) { + return { + found: true, + index: exactIndex, + matchLength: oldText.length, + usedFuzzyMatch: false, + contentForReplacement: content, + }; + } + + // Try fuzzy match - work entirely in normalized space + const fuzzyContent = normalizeForFuzzyMatch(content); + const fuzzyOldText = normalizeForFuzzyMatch(oldText); + const fuzzyIndex = fuzzyContent.indexOf(fuzzyOldText); + + if (fuzzyIndex === -1) { + return { + found: false, + index: -1, + matchLength: 0, + usedFuzzyMatch: false, + contentForReplacement: content, + }; + } + + // When fuzzy matching, return offsets in normalized space. Callers can use + // the normalized content to compute replacements, then decide how much of + // that normalized output should be written back. + return { + found: true, + index: fuzzyIndex, + matchLength: fuzzyOldText.length, + usedFuzzyMatch: true, + contentForReplacement: fuzzyContent, + }; +} + +/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ +export function stripBom(content: string): { bom: string; text: string } { + return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; +} + +function countOccurrences(content: string, oldText: string): number { + const fuzzyContent = normalizeForFuzzyMatch(content); + const fuzzyOldText = normalizeForFuzzyMatch(oldText); + return fuzzyContent.split(fuzzyOldText).length - 1; +} + +function getNotFoundError(path: string, editIndex: number, totalEdits: number): Error { + if (totalEdits === 1) { + return new Error( + `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`, + ); + } + return new Error( + `Could not find edits[${editIndex}] in ${path}. The oldText must match exactly including all whitespace and newlines.`, + ); +} + +function getDuplicateError(path: string, editIndex: number, totalEdits: number, occurrences: number): Error { + if (totalEdits === 1) { + return new Error( + `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, + ); + } + return new Error( + `Found ${occurrences} occurrences of edits[${editIndex}] in ${path}. Each oldText must be unique. Please provide more context to make it unique.`, + ); +} + +function getEmptyOldTextError(path: string, editIndex: number, totalEdits: number): Error { + if (totalEdits === 1) { + return new Error(`oldText must not be empty in ${path}.`); + } + return new Error(`edits[${editIndex}].oldText must not be empty in ${path}.`); +} + +function getNoChangeError(path: string, totalEdits: number): Error { + if (totalEdits === 1) { + return new Error( + `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, + ); + } + return new Error(`No changes made to ${path}. The replacements produced identical content.`); +} + +/** + * Apply one or more exact-text replacements to LF-normalized content. + * + * All edits are matched against the same original content. Replacements are + * then applied in reverse order so offsets remain stable. If any edit needs + * fuzzy matching, the operation runs in fuzzy-normalized content space and then + * overlays those line-level changes onto the original content so unchanged line + * blocks keep their original bytes. + */ +export function applyEditsToNormalizedContent( + normalizedContent: string, + edits: Edit[], + path: string, +): AppliedEditsResult { + const normalizedEdits = edits.map((edit) => ({ + oldText: normalizeToLF(edit.oldText), + newText: normalizeToLF(edit.newText), + })); + + for (let i = 0; i < normalizedEdits.length; i++) { + if (normalizedEdits[i].oldText.length === 0) { + throw getEmptyOldTextError(path, i, normalizedEdits.length); + } + } + + const initialMatches = normalizedEdits.map((edit) => fuzzyFindText(normalizedContent, edit.oldText)); + const usedFuzzyMatch = initialMatches.some((match) => match.usedFuzzyMatch); + const replacementBaseContent = usedFuzzyMatch ? normalizeForFuzzyMatch(normalizedContent) : normalizedContent; + + const matchedEdits: MatchedEdit[] = []; + for (let i = 0; i < normalizedEdits.length; i++) { + const edit = normalizedEdits[i]; + const matchResult = fuzzyFindText(replacementBaseContent, edit.oldText); + if (!matchResult.found) { + throw getNotFoundError(path, i, normalizedEdits.length); + } + + const occurrences = countOccurrences(replacementBaseContent, edit.oldText); + if (occurrences > 1) { + throw getDuplicateError(path, i, normalizedEdits.length, occurrences); + } + + matchedEdits.push({ + editIndex: i, + matchIndex: matchResult.index, + matchLength: matchResult.matchLength, + newText: edit.newText, + }); + } + + matchedEdits.sort((a, b) => a.matchIndex - b.matchIndex); + for (let i = 1; i < matchedEdits.length; i++) { + const previous = matchedEdits[i - 1]; + const current = matchedEdits[i]; + if (previous.matchIndex + previous.matchLength > current.matchIndex) { + throw new Error( + `edits[${previous.editIndex}] and edits[${current.editIndex}] overlap in ${path}. Merge them into one edit or target disjoint regions.`, + ); + } + } + + const baseContent = normalizedContent; + const newContent = usedFuzzyMatch + ? applyReplacementsPreservingUnchangedLines(normalizedContent, replacementBaseContent, matchedEdits) + : applyReplacements(replacementBaseContent, matchedEdits); + + if (baseContent === newContent) { + throw getNoChangeError(path, normalizedEdits.length); + } + + return { baseContent, newContent }; +} + +/** Generate a standard unified patch. */ +export function generateUnifiedPatch(path: string, oldContent: string, newContent: string, contextLines = 4): string { + return Diff.createTwoFilesPatch(path, path, oldContent, newContent, undefined, undefined, { + context: contextLines, + headerOptions: Diff.FILE_HEADERS_ONLY, + }); +} + +/** + * Generate a display-oriented diff string with line numbers and context. + * Returns both the diff string and the first changed line number (in the new file). + */ +export function generateDiffString( + oldContent: string, + newContent: string, + contextLines = 4, +): { diff: string; firstChangedLine: number | undefined } { + const parts = Diff.diffLines(oldContent, newContent); + const output: string[] = []; + + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + const maxLineNum = Math.max(oldLines.length, newLines.length); + const lineNumWidth = String(maxLineNum).length; + + let oldLineNum = 1; + let newLineNum = 1; + let lastWasChange = false; + let firstChangedLine: number | undefined; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const raw = part.value.split("\n"); + if (raw[raw.length - 1] === "") { + raw.pop(); + } + + if (part.added || part.removed) { + // Capture the first changed line (in the new file) + if (firstChangedLine === undefined) { + firstChangedLine = newLineNum; + } + + // Show the change + for (const line of raw) { + if (part.added) { + const lineNum = String(newLineNum).padStart(lineNumWidth, " "); + output.push(`+${lineNum} ${line}`); + newLineNum++; + } else { + // removed + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(`-${lineNum} ${line}`); + oldLineNum++; + } + } + lastWasChange = true; + } else { + // Context lines - only show a few before/after changes + const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); + const hasLeadingChange = lastWasChange; + const hasTrailingChange = nextPartIsChange; + + if (hasLeadingChange && hasTrailingChange) { + if (raw.length <= contextLines * 2) { + for (const line of raw) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + } else { + const leadingLines = raw.slice(0, contextLines); + const trailingLines = raw.slice(raw.length - contextLines); + const skippedLines = raw.length - leadingLines.length - trailingLines.length; + + for (const line of leadingLines) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skippedLines; + newLineNum += skippedLines; + + for (const line of trailingLines) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + } + } else if (hasLeadingChange) { + const shownLines = raw.slice(0, contextLines); + const skippedLines = raw.length - shownLines.length; + + for (const line of shownLines) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + + if (skippedLines > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skippedLines; + newLineNum += skippedLines; + } + } else if (hasTrailingChange) { + const skippedLines = Math.max(0, raw.length - contextLines); + if (skippedLines > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + oldLineNum += skippedLines; + newLineNum += skippedLines; + } + + for (const line of raw.slice(skippedLines)) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + } else { + // Skip these context lines entirely + oldLineNum += raw.length; + newLineNum += raw.length; + } + + lastWasChange = false; + } + } + + return { diff: output.join("\n"), firstChangedLine }; +} + +export interface EditDiffResult { + diff: string; + firstChangedLine: number | undefined; +} + +export interface EditDiffError { + error: string; +} + +/** + * Compute the diff for one or more edit operations without applying them. + * Used for preview rendering in the TUI before the tool executes. + */ +export async function computeEditsDiff( + path: string, + edits: Edit[], + cwd: string, +): Promise { + const absolutePath = resolveToCwd(path, cwd); + + try { + // Check if file exists and is readable + try { + await access(absolutePath, constants.R_OK); + } catch (error: unknown) { + const errorMessage = error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error); + return { error: `Could not edit file: ${path}. ${errorMessage}.` }; + } + + // Read the file + const rawContent = await readFile(absolutePath, "utf-8"); + + // Strip BOM before matching (LLM won't include invisible BOM in oldText) + const { text: content } = stripBom(rawContent); + const normalizedContent = normalizeToLF(content); + const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); + + // Generate the diff + return generateDiffString(baseContent, newContent); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * Compute the diff for a single edit operation without applying it. + * Kept as a convenience wrapper for single-edit callers. + */ +export async function computeEditDiff( + path: string, + oldText: string, + newText: string, + cwd: string, +): Promise { + return computeEditsDiff(path, [{ oldText, newText }], cwd); +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/edit.ts b/cactus-code/packages/coding-agent/src/core/tools/edit.ts new file mode 100644 index 000000000..25aa1e17b --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/edit.ts @@ -0,0 +1,437 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui"; +import { constants } from "fs"; +import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises"; +import { type Static, Type } from "typebox"; +import { renderDiff } from "../../modes/interactive/components/diff.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import type { ToolDefinition } from "../extensions/types.ts"; +import { + applyEditsToNormalizedContent, + computeEditsDiff, + detectLineEnding, + type Edit, + type EditDiffError, + type EditDiffResult, + generateDiffString, + generateUnifiedPatch, + normalizeToLF, + restoreLineEndings, + stripBom, +} from "./edit-diff.ts"; +import { withFileMutationQueue } from "./file-mutation-queue.ts"; +import { resolveToCwd } from "./path-utils.ts"; +import { renderToolPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; + +type EditPreview = EditDiffResult | EditDiffError; + +type EditRenderState = { + callComponent?: EditCallRenderComponent; +}; + +const replaceEditSchema = Type.Object( + { + oldText: Type.String({ + description: + "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call.", + }), + newText: Type.String({ description: "Replacement text for this targeted edit." }), + }, + { additionalProperties: false }, +); + +const editSchema = Type.Object( + { + path: Type.String({ description: "Path to the file to edit (relative or absolute)" }), + edits: Type.Array(replaceEditSchema, { + description: + "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.", + }), + }, + { additionalProperties: false }, +); + +export type EditToolInput = Static; +type LegacyEditToolInput = EditToolInput & { + oldText?: unknown; + newText?: unknown; +}; + +export interface EditToolDetails { + /** Display-oriented diff of the changes made */ + diff: string; + /** Standard unified patch of the changes made */ + patch: string; + /** Line number of the first change in the new file (for editor navigation) */ + firstChangedLine?: number; +} + +/** + * Pluggable operations for the edit tool. + * Override these to delegate file editing to remote systems (for example SSH). + */ +export interface EditOperations { + /** Read file contents as a Buffer */ + readFile: (absolutePath: string) => Promise; + /** Write content to a file */ + writeFile: (absolutePath: string, content: string) => Promise; + /** Check if file is readable and writable (throw if not) */ + access: (absolutePath: string) => Promise; +} + +const defaultEditOperations: EditOperations = { + readFile: (path) => fsReadFile(path), + writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), + access: (path) => fsAccess(path, constants.R_OK | constants.W_OK), +}; + +export interface EditToolOptions { + /** Custom operations for file editing. Default: local filesystem */ + operations?: EditOperations; +} + +function prepareEditArguments(input: unknown): EditToolInput { + if (!input || typeof input !== "object") { + return input as EditToolInput; + } + + const args = input as Record; + + // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array + if (typeof args.edits === "string") { + try { + const parsed = JSON.parse(args.edits); + if (Array.isArray(parsed)) args.edits = parsed; + } catch {} + } + + const legacy = args as LegacyEditToolInput; + if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") { + return args as EditToolInput; + } + + const edits = Array.isArray(legacy.edits) ? [...legacy.edits] : []; + edits.push({ oldText: legacy.oldText, newText: legacy.newText }); + const { oldText: _oldText, newText: _newText, ...rest } = legacy; + return { ...rest, edits } as EditToolInput; +} + +function validateEditInput(input: EditToolInput): { path: string; edits: Edit[] } { + if (!Array.isArray(input.edits) || input.edits.length === 0) { + throw new Error("Edit tool input is invalid. edits must contain at least one replacement."); + } + return { path: input.path, edits: input.edits }; +} + +type RenderableEditArgs = { + path?: string; + file_path?: string; + edits?: Edit[]; + oldText?: string; + newText?: string; +}; + +type EditToolResultLike = { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: EditToolDetails; +}; + +type EditCallRenderComponent = Box & { + preview?: EditPreview; + previewArgsKey?: string; + previewPending?: boolean; + settledError?: boolean; +}; + +function createEditCallRenderComponent(): EditCallRenderComponent { + return Object.assign(new Box(1, 1, (text: string) => text), { + preview: undefined as EditPreview | undefined, + previewArgsKey: undefined as string | undefined, + previewPending: false, + settledError: false, + }); +} + +function getEditCallRenderComponent(state: EditRenderState, lastComponent: unknown): EditCallRenderComponent { + if (lastComponent instanceof Box) { + const component = lastComponent as EditCallRenderComponent; + state.callComponent = component; + return component; + } + if (state.callComponent) { + return state.callComponent; + } + const component = createEditCallRenderComponent(); + state.callComponent = component; + return component; +} + +function getRenderablePreviewInput(args: RenderableEditArgs | undefined): { path: string; edits: Edit[] } | null { + if (!args) { + return null; + } + + const path = typeof args.path === "string" ? args.path : typeof args.file_path === "string" ? args.file_path : null; + if (!path) { + return null; + } + + if ( + Array.isArray(args.edits) && + args.edits.length > 0 && + args.edits.every((edit) => typeof edit?.oldText === "string" && typeof edit?.newText === "string") + ) { + return { path, edits: args.edits }; + } + + if (typeof args.oldText === "string" && typeof args.newText === "string") { + return { path, edits: [{ oldText: args.oldText, newText: args.newText }] }; + } + + return null; +} + +function formatEditCall(args: RenderableEditArgs | undefined, theme: Theme, cwd: string): string { + const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd); + return `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`; +} + +function formatEditResult( + args: RenderableEditArgs | undefined, + preview: EditPreview | undefined, + result: EditToolResultLike, + theme: Theme, + isError: boolean, +): string | undefined { + const rawPath = str(args?.file_path ?? args?.path); + const previewDiff = preview && !("error" in preview) ? preview.diff : undefined; + const previewError = preview && "error" in preview ? preview.error : undefined; + if (isError) { + const errorText = result.content + .filter((c) => c.type === "text") + .map((c) => c.text || "") + .join("\n"); + if (!errorText || errorText === previewError) { + return undefined; + } + return theme.fg("error", errorText); + } + + const resultDiff = result.details?.diff; + if (resultDiff && resultDiff !== previewDiff) { + return renderDiff(resultDiff, { filePath: rawPath ?? undefined }); + } + + return undefined; +} + +function getEditHeaderBg( + preview: EditPreview | undefined, + settledError: boolean | undefined, + theme: Theme, +): (text: string) => string { + if (preview) { + if ("error" in preview) { + return (text: string) => theme.bg("toolErrorBg", text); + } + return (text: string) => theme.bg("toolSuccessBg", text); + } + if (settledError) { + return (text: string) => theme.bg("toolErrorBg", text); + } + return (text: string) => theme.bg("toolPendingBg", text); +} + +function buildEditCallComponent( + component: EditCallRenderComponent, + args: RenderableEditArgs | undefined, + theme: Theme, + cwd: string, +): EditCallRenderComponent { + component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme)); + component.clear(); + component.addChild(new Text(formatEditCall(args, theme, cwd), 0, 0)); + + if (!component.preview) { + return component; + } + + const body = + "error" in component.preview ? theme.fg("error", component.preview.error) : renderDiff(component.preview.diff); + component.addChild(new Spacer(1)); + component.addChild(new Text(body, 0, 0)); + return component; +} + +function setEditPreview( + component: EditCallRenderComponent, + preview: EditPreview, + argsKey: string | undefined, +): boolean { + const current = component.preview; + const changed = + current === undefined || + ("error" in current && "error" in preview + ? current.error !== preview.error + : "error" in current !== "error" in preview) || + (!("error" in current) && + !("error" in preview) && + (current.diff !== preview.diff || current.firstChangedLine !== preview.firstChangedLine)); + component.preview = preview; + component.previewArgsKey = argsKey; + component.previewPending = false; + return changed; +} + +export function createEditToolDefinition( + cwd: string, + options?: EditToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultEditOperations; + return { + name: "edit", + label: "edit", + description: + "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.", + promptSnippet: + "Make precise file edits with exact text replacement, including multiple disjoint edits in one call", + promptGuidelines: [ + "Use edit for precise changes (edits[].oldText must match exactly)", + "When changing multiple separate locations in one file, use one edit call with multiple entries in edits[] instead of multiple edit calls", + "Each edits[].oldText is matched against the original file, not after earlier edits are applied. Do not emit overlapping or nested edits. Merge nearby changes into one edit.", + "Keep edits[].oldText as small as possible while still being unique in the file. Do not pad with large unchanged regions.", + ], + parameters: editSchema, + renderShell: "self", + prepareArguments: prepareEditArguments, + async execute(_toolCallId, input: EditToolInput, signal?: AbortSignal, _onUpdate?, _ctx?) { + const { path, edits } = validateEditInput(input); + const absolutePath = resolveToCwd(path, cwd); + + return withFileMutationQueue(absolutePath, async () => { + // Do not reject from an abort event listener here: that would release the + // mutation queue while an in-flight filesystem operation may still finish. + // Checking signal.aborted after each await observes the same aborts while + // keeping the queue locked until the current operation has settled. + const throwIfAborted = (): void => { + if (signal?.aborted) throw new Error("Operation aborted"); + }; + + throwIfAborted(); + + // Check if file exists. + try { + await ops.access(absolutePath); + } catch (error: unknown) { + throwIfAborted(); + const errorMessage = + error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error); + throw new Error(`Could not edit file: ${path}. ${errorMessage}.`); + } + throwIfAborted(); + + // Read the file. + const buffer = await ops.readFile(absolutePath); + const rawContent = buffer.toString("utf-8"); + throwIfAborted(); + + // Strip BOM before matching. The model will not include an invisible BOM in oldText. + const { bom, text: content } = stripBom(rawContent); + const originalEnding = detectLineEnding(content); + const normalizedContent = normalizeToLF(content); + const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); + throwIfAborted(); + + const finalContent = bom + restoreLineEndings(newContent, originalEnding); + await ops.writeFile(absolutePath, finalContent); + throwIfAborted(); + + const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); + return { + content: [ + { + type: "text", + text: `Successfully replaced ${edits.length} block(s) in ${path}.`, + }, + ], + details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, + }; + }); + }, + renderCall(args, theme, context) { + const component = getEditCallRenderComponent(context.state, context.lastComponent); + const previewInput = getRenderablePreviewInput(args as RenderableEditArgs | undefined); + const argsKey = previewInput + ? JSON.stringify({ path: previewInput.path, edits: previewInput.edits }) + : undefined; + + if (component.previewArgsKey !== argsKey) { + component.preview = undefined; + component.previewArgsKey = argsKey; + component.previewPending = false; + component.settledError = false; + } + + if (context.argsComplete && previewInput && !component.preview && !component.previewPending) { + component.previewPending = true; + const requestKey = argsKey; + void computeEditsDiff(previewInput.path, previewInput.edits, context.cwd).then((preview) => { + if (component.previewArgsKey === requestKey) { + setEditPreview(component, preview, requestKey); + context.invalidate(); + } + }); + } + + return buildEditCallComponent(component, args, theme, context.cwd); + }, + renderResult(result, _options, theme, context) { + const callComponent = context.state.callComponent; + const previewInput = getRenderablePreviewInput(context.args as RenderableEditArgs | undefined); + const argsKey = previewInput + ? JSON.stringify({ path: previewInput.path, edits: previewInput.edits }) + : undefined; + const typedResult = result as EditToolResultLike; + const resultDiff = !context.isError ? typedResult.details?.diff : undefined; + let changed = false; + if (callComponent) { + if (typeof resultDiff === "string") { + changed = + setEditPreview( + callComponent, + { diff: resultDiff, firstChangedLine: typedResult.details?.firstChangedLine }, + argsKey, + ) || changed; + } + if (callComponent.settledError !== context.isError) { + callComponent.settledError = context.isError; + changed = true; + } + if (changed) { + buildEditCallComponent( + callComponent, + context.args as RenderableEditArgs | undefined, + theme, + context.cwd, + ); + } + } + + const output = formatEditResult(context.args, callComponent?.preview, typedResult, theme, context.isError); + const component = (context.lastComponent as Container | undefined) ?? new Container(); + component.clear(); + if (!output) { + return component; + } + component.addChild(new Spacer(1)); + component.addChild(new Text(output, 1, 0)); + return component; + }, + }; +} + +export function createEditTool(cwd: string, options?: EditToolOptions): AgentTool { + return wrapToolDefinition(createEditToolDefinition(cwd, options)); +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/file-mutation-queue.ts b/cactus-code/packages/coding-agent/src/core/tools/file-mutation-queue.ts new file mode 100644 index 000000000..5505a7a27 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/file-mutation-queue.ts @@ -0,0 +1,61 @@ +import { realpath } from "node:fs/promises"; +import { resolve } from "node:path"; + +const fileMutationQueues = new Map>(); +let registrationQueue = Promise.resolve(); + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} + +async function getMutationQueueKey(filePath: string): Promise { + const resolvedPath = resolve(filePath); + try { + return await realpath(resolvedPath); + } catch (error) { + if (isMissingPathError(error)) { + return resolvedPath; + } + throw error; + } +} + +/** + * Serialize file mutation operations targeting the same file. + * Operations for different files still run in parallel. + */ +export async function withFileMutationQueue(filePath: string, fn: () => Promise): Promise { + const registration = registrationQueue.then(async () => { + const key = await getMutationQueueKey(filePath); + const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve(); + + let releaseNext!: () => void; + const nextQueue = new Promise((resolveQueue) => { + releaseNext = resolveQueue; + }); + const chainedQueue = currentQueue.then(() => nextQueue); + fileMutationQueues.set(key, chainedQueue); + + return { key, currentQueue, chainedQueue, releaseNext }; + }); + registrationQueue = registration.then( + () => undefined, + () => undefined, + ); + + const { key, currentQueue, chainedQueue, releaseNext } = await registration; + await currentQueue; + try { + return await fn(); + } finally { + releaseNext(); + if (fileMutationQueues.get(key) === chainedQueue) { + fileMutationQueues.delete(key); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/find.ts b/cactus-code/packages/coding-agent/src/core/tools/find.ts new file mode 100644 index 000000000..e03b728a9 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/find.ts @@ -0,0 +1,374 @@ +import { createInterface } from "node:readline"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { Text } from "@earendil-works/pi-tui"; +import { spawn } from "child_process"; +import path from "path"; +import { type Static, Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import { ensureTool } from "../../utils/tools-manager.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { pathExists, resolveToCwd } from "./path-utils.ts"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; + +function toPosixPath(value: string): string { + return value.split(path.sep).join("/"); +} + +const findSchema = Type.Object({ + pattern: Type.String({ + description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'", + }), + path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })), +}); + +export type FindToolInput = Static; + +const DEFAULT_LIMIT = 1000; + +export interface FindToolDetails { + truncation?: TruncationResult; + resultLimitReached?: number; +} + +/** + * Pluggable operations for the find tool. + * Override these to delegate file search to remote systems (for example SSH). + */ +export interface FindOperations { + /** Check if path exists */ + exists: (absolutePath: string) => Promise | boolean; + /** Find files matching glob pattern. Returns relative or absolute paths. */ + glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise | string[]; +} + +const defaultFindOperations: FindOperations = { + exists: pathExists, + // This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided. + glob: () => [], +}; + +export interface FindToolOptions { + /** Custom operations for find. Default: local filesystem plus fd */ + operations?: FindOperations; +} + +function formatFindCall(args: { pattern: string; path?: string; limit?: number } | undefined, theme: Theme): string { + const pattern = str(args?.pattern); + const rawPath = str(args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; + const limit = args?.limit; + const invalidArg = invalidArgText(theme); + let text = + theme.fg("toolTitle", theme.bold("find")) + + " " + + (pattern === null ? invalidArg : theme.fg("accent", pattern || "")) + + theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`); + if (limit !== undefined) { + text += theme.fg("toolOutput", ` (limit ${limit})`); + } + return text; +} + +function formatFindResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: FindToolDetails; + }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 20; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + } + + const resultLimit = result.details?.resultLimitReached; + const truncation = result.details?.truncation; + if (resultLimit || truncation?.truncated) { + const warnings: string[] = []; + if (resultLimit) warnings.push(`${resultLimit} results limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createFindToolDefinition( + cwd: string, + options?: FindToolOptions, +): ToolDefinition { + const customOps = options?.operations; + return { + name: "find", + label: "find", + description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, + promptSnippet: "Find files by glob pattern (respects .gitignore)", + parameters: findSchema, + async execute( + _toolCallId, + { pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number }, + signal?: AbortSignal, + _onUpdate?, + _ctx?, + ) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + + let settled = false; + let stopChild: (() => void) | undefined; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + signal?.removeEventListener("abort", onAbort); + stopChild = undefined; + fn(); + }; + const onAbort = () => { + stopChild?.(); + settle(() => reject(new Error("Operation aborted"))); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + (async () => { + try { + const searchPath = resolveToCwd(searchDir || ".", cwd); + const effectiveLimit = limit ?? DEFAULT_LIMIT; + const ops = customOps ?? defaultFindOperations; + + // If custom operations provide glob(), use that instead of fd. + if (customOps?.glob) { + if (!(await ops.exists(searchPath))) { + settle(() => reject(new Error(`Path not found: ${searchPath}`))); + return; + } + if (signal?.aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + const results = await ops.glob(pattern, searchPath, { + ignore: ["**/node_modules/**", "**/.git/**"], + limit: effectiveLimit, + }); + if (signal?.aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + if (results.length === 0) { + settle(() => + resolve({ + content: [{ type: "text", text: "No files found matching pattern" }], + details: undefined, + }), + ); + return; + } + + // Relativize paths against the search root for stable output. + const relativized = results.map((p) => { + if (p.startsWith(searchPath)) return toPosixPath(p.slice(searchPath.length + 1)); + return toPosixPath(path.relative(searchPath, p)); + }); + const resultLimitReached = relativized.length >= effectiveLimit; + const rawOutput = relativized.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let resultOutput = truncation.content; + const details: FindToolDetails = {}; + const notices: string[] = []; + if (resultLimitReached) { + notices.push(`${effectiveLimit} results limit reached`); + details.resultLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) { + resultOutput += `\n\n[${notices.join(". ")}]`; + } + settle(() => + resolve({ + content: [{ type: "text", text: resultOutput }], + details: Object.keys(details).length > 0 ? details : undefined, + }), + ); + return; + } + + // Default implementation uses fd. + const fdPath = await ensureTool("fd", true); + if (signal?.aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + if (!fdPath) { + settle(() => reject(new Error("fd is not available and could not be downloaded"))); + return; + } + + const args: string[] = ["--glob", "--color=never", "--hidden"]; + + // fd normally ignores .gitignore outside git repos, so keep --no-require-git + // there. Inside repos, use fd's default git-aware behavior so parent + // .gitignore rules stop at nested repo boundaries: + // https://github.com/earendil-works/pi/issues/5960 + let insideGitRepo = false; + for (let current = searchPath; ; ) { + if (await pathExists(path.join(current, ".git"))) { + insideGitRepo = true; + break; + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + if (!insideGitRepo) args.push("--no-require-git"); + args.push("--max-results", String(effectiveLimit)); + + // fd --glob matches against the basename unless --full-path is set; in --full-path + // mode it matches against the absolute candidate path, so a path-containing + // pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything. + let effectivePattern = pattern; + if (pattern.includes("/")) { + args.push("--full-path"); + if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") { + effectivePattern = `**/${pattern}`; + } + } + args.push("--", effectivePattern, searchPath); + + const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] }); + const rl = createInterface({ input: child.stdout }); + let stderr = ""; + const lines: string[] = []; + + stopChild = () => { + if (!child.killed) { + child.kill(); + } + }; + + const cleanup = () => { + rl.close(); + }; + + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + rl.on("line", (line) => { + lines.push(line); + }); + + child.on("error", (error) => { + cleanup(); + settle(() => reject(new Error(`Failed to run fd: ${error.message}`))); + }); + + child.on("close", (code) => { + cleanup(); + if (signal?.aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + const output = lines.join("\n"); + if (code !== 0) { + const errorMsg = stderr.trim() || `fd exited with code ${code}`; + if (!output) { + settle(() => reject(new Error(errorMsg))); + return; + } + } + if (!output) { + settle(() => + resolve({ + content: [{ type: "text", text: "No files found matching pattern" }], + details: undefined, + }), + ); + return; + } + + const relativized: string[] = []; + for (const rawLine of lines) { + const line = rawLine.replace(/\r$/, "").trim(); + if (!line) continue; + const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\"); + let relativePath = line; + if (line.startsWith(searchPath)) { + relativePath = line.slice(searchPath.length + 1); + } else { + relativePath = path.relative(searchPath, line); + } + if (hadTrailingSlash && !relativePath.endsWith("/")) relativePath += "/"; + relativized.push(toPosixPath(relativePath)); + } + + const resultLimitReached = relativized.length >= effectiveLimit; + const rawOutput = relativized.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let resultOutput = truncation.content; + const details: FindToolDetails = {}; + const notices: string[] = []; + if (resultLimitReached) { + notices.push( + `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, + ); + details.resultLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) { + resultOutput += `\n\n[${notices.join(". ")}]`; + } + settle(() => + resolve({ + content: [{ type: "text", text: resultOutput }], + details: Object.keys(details).length > 0 ? details : undefined, + }), + ); + }); + } catch (e) { + if (signal?.aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + const error = e instanceof Error ? e : new Error(String(e)); + settle(() => reject(error)); + } + })(); + }); + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatFindCall(args, theme)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatFindResult(result as any, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool { + return wrapToolDefinition(createFindToolDefinition(cwd, options)); +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/grep.ts b/cactus-code/packages/coding-agent/src/core/tools/grep.ts new file mode 100644 index 000000000..e4ed36d1b --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/grep.ts @@ -0,0 +1,385 @@ +import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises"; +import { createInterface } from "node:readline"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { Text } from "@earendil-works/pi-tui"; +import { spawn } from "child_process"; +import path from "path"; +import { type Static, Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import { ensureTool } from "../../utils/tools-manager.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { resolveToCwd } from "./path-utils.ts"; +import { getTextOutput, invalidArgText, shortenPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { + DEFAULT_MAX_BYTES, + formatSize, + GREP_MAX_LINE_LENGTH, + type TruncationResult, + truncateHead, + truncateLine, +} from "./truncate.ts"; + +const grepSchema = Type.Object({ + pattern: Type.String({ description: "Search pattern (regex or literal string)" }), + path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })), + glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })), + ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })), + literal: Type.Optional( + Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }), + ), + context: Type.Optional( + Type.Number({ description: "Number of lines to show before and after each match (default: 0)" }), + ), + limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })), +}); + +export type GrepToolInput = Static; +const DEFAULT_LIMIT = 100; + +export interface GrepToolDetails { + truncation?: TruncationResult; + matchLimitReached?: number; + linesTruncated?: boolean; +} + +/** + * Pluggable operations for the grep tool. + * Override these to delegate search to remote systems (for example SSH). + */ +export interface GrepOperations { + /** Check if path is a directory. Throws if path does not exist. */ + isDirectory: (absolutePath: string) => Promise | boolean; + /** Read file contents for context lines */ + readFile: (absolutePath: string) => Promise | string; +} + +const defaultGrepOperations: GrepOperations = { + isDirectory: async (p) => (await fsStat(p)).isDirectory(), + readFile: (p) => fsReadFile(p, "utf-8"), +}; + +export interface GrepToolOptions { + /** Custom operations for grep. Default: local filesystem plus ripgrep */ + operations?: GrepOperations; +} + +function formatGrepCall( + args: { pattern: string; path?: string; glob?: string; limit?: number } | undefined, + theme: Theme, +): string { + const pattern = str(args?.pattern); + const rawPath = str(args?.path); + const path = rawPath !== null ? shortenPath(rawPath || ".") : null; + const glob = str(args?.glob); + const limit = args?.limit; + const invalidArg = invalidArgText(theme); + let text = + theme.fg("toolTitle", theme.bold("grep")) + + " " + + (pattern === null ? invalidArg : theme.fg("accent", `/${pattern || ""}/`)) + + theme.fg("toolOutput", ` in ${path === null ? invalidArg : path}`); + if (glob) text += theme.fg("toolOutput", ` (${glob})`); + if (limit !== undefined) text += theme.fg("toolOutput", ` limit ${limit}`); + return text; +} + +function formatGrepResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: GrepToolDetails; + }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 15; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + } + + const matchLimit = result.details?.matchLimitReached; + const truncation = result.details?.truncation; + const linesTruncated = result.details?.linesTruncated; + if (matchLimit || truncation?.truncated || linesTruncated) { + const warnings: string[] = []; + if (matchLimit) warnings.push(`${matchLimit} matches limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + if (linesTruncated) warnings.push("some lines truncated"); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createGrepToolDefinition( + cwd: string, + options?: GrepToolOptions, +): ToolDefinition { + const customOps = options?.operations; + return { + name: "grep", + label: "grep", + description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`, + promptSnippet: "Search file contents for patterns (respects .gitignore)", + parameters: grepSchema, + async execute( + _toolCallId, + { + pattern, + path: searchDir, + glob, + ignoreCase, + literal, + context, + limit, + }: { + pattern: string; + path?: string; + glob?: string; + ignoreCase?: boolean; + literal?: boolean; + context?: number; + limit?: number; + }, + signal?: AbortSignal, + _onUpdate?, + _ctx?, + ) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + let settled = false; + const settle = (fn: () => void) => { + if (!settled) { + settled = true; + fn(); + } + }; + + (async () => { + try { + const rgPath = await ensureTool("rg", true); + if (!rgPath) { + settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded"))); + return; + } + + const searchPath = resolveToCwd(searchDir || ".", cwd); + const ops = customOps ?? defaultGrepOperations; + let isDirectory: boolean; + try { + isDirectory = await ops.isDirectory(searchPath); + } catch { + settle(() => reject(new Error(`Path not found: ${searchPath}`))); + return; + } + + const contextValue = context && context > 0 ? context : 0; + const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); + const formatPath = (filePath: string): string => { + if (isDirectory) { + const relative = path.relative(searchPath, filePath); + if (relative && !relative.startsWith("..")) { + return relative.replace(/\\/g, "/"); + } + } + return path.basename(filePath); + }; + + const fileCache = new Map(); + const getFileLines = async (filePath: string): Promise => { + let lines = fileCache.get(filePath); + if (!lines) { + try { + const content = await ops.readFile(filePath); + lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + } catch { + lines = []; + } + fileCache.set(filePath, lines); + } + return lines; + }; + + const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"]; + if (ignoreCase) args.push("--ignore-case"); + if (literal) args.push("--fixed-strings"); + if (glob) args.push("--glob", glob); + args.push("--", pattern, searchPath); + + const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] }); + const rl = createInterface({ input: child.stdout }); + let stderr = ""; + let matchCount = 0; + let matchLimitReached = false; + let linesTruncated = false; + let aborted = false; + let killedDueToLimit = false; + const outputLines: string[] = []; + + const cleanup = () => { + rl.close(); + signal?.removeEventListener("abort", onAbort); + }; + const stopChild = (dueToLimit = false) => { + if (!child.killed) { + killedDueToLimit = dueToLimit; + child.kill(); + } + }; + const onAbort = () => { + aborted = true; + stopChild(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const formatBlock = async (filePath: string, lineNumber: number): Promise => { + const relativePath = formatPath(filePath); + const lines = await getFileLines(filePath); + if (!lines.length) return [`${relativePath}:${lineNumber}: (unable to read file)`]; + const block: string[] = []; + const start = contextValue > 0 ? Math.max(1, lineNumber - contextValue) : lineNumber; + const end = contextValue > 0 ? Math.min(lines.length, lineNumber + contextValue) : lineNumber; + for (let current = start; current <= end; current++) { + const lineText = lines[current - 1] ?? ""; + const sanitized = lineText.replace(/\r/g, ""); + const isMatchLine = current === lineNumber; + // Truncate long lines so grep output stays compact. + const { text: truncatedText, wasTruncated } = truncateLine(sanitized); + if (wasTruncated) linesTruncated = true; + if (isMatchLine) block.push(`${relativePath}:${current}: ${truncatedText}`); + else block.push(`${relativePath}-${current}- ${truncatedText}`); + } + return block; + }; + + // Collect matches during streaming, then format them after rg exits. + const matches: Array<{ filePath: string; lineNumber: number; lineText?: string }> = []; + rl.on("line", (line) => { + if (!line.trim() || matchCount >= effectiveLimit) return; + let event: any; + try { + event = JSON.parse(line); + } catch { + return; + } + if (event.type === "match") { + matchCount++; + const filePath = event.data?.path?.text; + const lineNumber = event.data?.line_number; + const lineText = event.data?.lines?.text; + if (filePath && typeof lineNumber === "number") + matches.push({ filePath, lineNumber, lineText }); + if (matchCount >= effectiveLimit) { + matchLimitReached = true; + stopChild(true); + } + } + }); + + child.on("error", (error) => { + cleanup(); + settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`))); + }); + child.on("close", async (code) => { + cleanup(); + if (aborted) { + settle(() => reject(new Error("Operation aborted"))); + return; + } + if (!killedDueToLimit && code !== 0 && code !== 1) { + const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`; + settle(() => reject(new Error(errorMsg))); + return; + } + if (matchCount === 0) { + settle(() => + resolve({ content: [{ type: "text", text: "No matches found" }], details: undefined }), + ); + return; + } + + // Format matches after streaming finishes so custom readFile() backends can be async. + for (const match of matches) { + if (contextValue === 0 && match.lineText !== undefined) { + const relativePath = formatPath(match.filePath); + const sanitized = match.lineText + .replace(/\r\n/g, "\n") + .replace(/\r/g, "") + .replace(/\n$/, ""); + const { text: truncatedText, wasTruncated } = truncateLine(sanitized); + if (wasTruncated) linesTruncated = true; + outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`); + } else { + const block = await formatBlock(match.filePath, match.lineNumber); + outputLines.push(...block); + } + } + + const rawOutput = outputLines.join("\n"); + // Apply byte truncation. There is no line limit here because the match limit already capped rows. + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let output = truncation.content; + const details: GrepToolDetails = {}; + // Build actionable notices for truncation and match limits. + const notices: string[] = []; + if (matchLimitReached) { + notices.push( + `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, + ); + details.matchLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (linesTruncated) { + notices.push( + `Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`, + ); + details.linesTruncated = true; + } + if (notices.length > 0) output += `\n\n[${notices.join(". ")}]`; + settle(() => + resolve({ + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }), + ); + }); + } catch (err) { + settle(() => reject(err as Error)); + } + })(); + }); + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatGrepCall(args, theme)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatGrepResult(result as any, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createGrepTool(cwd: string, options?: GrepToolOptions): AgentTool { + return wrapToolDefinition(createGrepToolDefinition(cwd, options)); +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/index.ts b/cactus-code/packages/coding-agent/src/core/tools/index.ts new file mode 100644 index 000000000..e55f91406 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/index.ts @@ -0,0 +1,196 @@ +export { + type BashOperations, + type BashSpawnContext, + type BashSpawnHook, + type BashToolDetails, + type BashToolInput, + type BashToolOptions, + createBashTool, + createBashToolDefinition, + createLocalBashOperations, +} from "./bash.ts"; +export { + createEditTool, + createEditToolDefinition, + type EditOperations, + type EditToolDetails, + type EditToolInput, + type EditToolOptions, +} from "./edit.ts"; +export { withFileMutationQueue } from "./file-mutation-queue.ts"; +export { + createFindTool, + createFindToolDefinition, + type FindOperations, + type FindToolDetails, + type FindToolInput, + type FindToolOptions, +} from "./find.ts"; +export { + createGrepTool, + createGrepToolDefinition, + type GrepOperations, + type GrepToolDetails, + type GrepToolInput, + type GrepToolOptions, +} from "./grep.ts"; +export { + createLsTool, + createLsToolDefinition, + type LsOperations, + type LsToolDetails, + type LsToolInput, + type LsToolOptions, +} from "./ls.ts"; +export { + createReadTool, + createReadToolDefinition, + type ReadOperations, + type ReadToolDetails, + type ReadToolInput, + type ReadToolOptions, +} from "./read.ts"; +export { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + type TruncationOptions, + type TruncationResult, + truncateHead, + truncateLine, + truncateTail, +} from "./truncate.ts"; +export { + createWriteTool, + createWriteToolDefinition, + type WriteOperations, + type WriteToolInput, + type WriteToolOptions, +} from "./write.ts"; + +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { ToolDefinition } from "../extensions/types.ts"; +import { type BashToolOptions, createBashTool, createBashToolDefinition } from "./bash.ts"; +import { createEditTool, createEditToolDefinition, type EditToolOptions } from "./edit.ts"; +import { createFindTool, createFindToolDefinition, type FindToolOptions } from "./find.ts"; +import { createGrepTool, createGrepToolDefinition, type GrepToolOptions } from "./grep.ts"; +import { createLsTool, createLsToolDefinition, type LsToolOptions } from "./ls.ts"; +import { createReadTool, createReadToolDefinition, type ReadToolOptions } from "./read.ts"; +import { createWriteTool, createWriteToolDefinition, type WriteToolOptions } from "./write.ts"; + +export type Tool = AgentTool; +export type ToolDef = ToolDefinition; +export type ToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls"; +export const allToolNames: Set = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]); + +export interface ToolsOptions { + read?: ReadToolOptions; + bash?: BashToolOptions; + write?: WriteToolOptions; + edit?: EditToolOptions; + grep?: GrepToolOptions; + find?: FindToolOptions; + ls?: LsToolOptions; +} + +export function createToolDefinition(toolName: ToolName, cwd: string, options?: ToolsOptions): ToolDef { + switch (toolName) { + case "read": + return createReadToolDefinition(cwd, options?.read); + case "bash": + return createBashToolDefinition(cwd, options?.bash); + case "edit": + return createEditToolDefinition(cwd, options?.edit); + case "write": + return createWriteToolDefinition(cwd, options?.write); + case "grep": + return createGrepToolDefinition(cwd, options?.grep); + case "find": + return createFindToolDefinition(cwd, options?.find); + case "ls": + return createLsToolDefinition(cwd, options?.ls); + default: + throw new Error(`Unknown tool name: ${toolName}`); + } +} + +export function createTool(toolName: ToolName, cwd: string, options?: ToolsOptions): Tool { + switch (toolName) { + case "read": + return createReadTool(cwd, options?.read); + case "bash": + return createBashTool(cwd, options?.bash); + case "edit": + return createEditTool(cwd, options?.edit); + case "write": + return createWriteTool(cwd, options?.write); + case "grep": + return createGrepTool(cwd, options?.grep); + case "find": + return createFindTool(cwd, options?.find); + case "ls": + return createLsTool(cwd, options?.ls); + default: + throw new Error(`Unknown tool name: ${toolName}`); + } +} + +export function createCodingToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] { + return [ + createReadToolDefinition(cwd, options?.read), + createBashToolDefinition(cwd, options?.bash), + createEditToolDefinition(cwd, options?.edit), + createWriteToolDefinition(cwd, options?.write), + ]; +} + +export function createReadOnlyToolDefinitions(cwd: string, options?: ToolsOptions): ToolDef[] { + return [ + createReadToolDefinition(cwd, options?.read), + createGrepToolDefinition(cwd, options?.grep), + createFindToolDefinition(cwd, options?.find), + createLsToolDefinition(cwd, options?.ls), + ]; +} + +export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): Record { + return { + read: createReadToolDefinition(cwd, options?.read), + bash: createBashToolDefinition(cwd, options?.bash), + edit: createEditToolDefinition(cwd, options?.edit), + write: createWriteToolDefinition(cwd, options?.write), + grep: createGrepToolDefinition(cwd, options?.grep), + find: createFindToolDefinition(cwd, options?.find), + ls: createLsToolDefinition(cwd, options?.ls), + }; +} + +export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] { + return [ + createReadTool(cwd, options?.read), + createBashTool(cwd, options?.bash), + createEditTool(cwd, options?.edit), + createWriteTool(cwd, options?.write), + ]; +} + +export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] { + return [ + createReadTool(cwd, options?.read), + createGrepTool(cwd, options?.grep), + createFindTool(cwd, options?.find), + createLsTool(cwd, options?.ls), + ]; +} + +export function createAllTools(cwd: string, options?: ToolsOptions): Record { + return { + read: createReadTool(cwd, options?.read), + bash: createBashTool(cwd, options?.bash), + edit: createEditTool(cwd, options?.edit), + write: createWriteTool(cwd, options?.write), + grep: createGrepTool(cwd, options?.grep), + find: createFindTool(cwd, options?.find), + ls: createLsTool(cwd, options?.ls), + }; +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/ls.ts b/cactus-code/packages/coding-agent/src/core/tools/ls.ts new file mode 100644 index 000000000..8a689e8da --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/ls.ts @@ -0,0 +1,225 @@ +import { readdir as fsReaddir, stat as fsStat } from "node:fs/promises"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { Text } from "@earendil-works/pi-tui"; +import nodePath from "path"; +import { type Static, Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { pathExists, resolveToCwd } from "./path-utils.ts"; +import { getTextOutput, renderToolPath, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; + +const lsSchema = Type.Object({ + path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })), +}); + +export type LsToolInput = Static; + +const DEFAULT_LIMIT = 500; + +export interface LsToolDetails { + truncation?: TruncationResult; + entryLimitReached?: number; +} + +/** + * Pluggable operations for the ls tool. + * Override these to delegate directory listing to remote systems (for example SSH). + */ +export interface LsOperations { + /** Check if path exists */ + exists: (absolutePath: string) => Promise | boolean; + /** Get file or directory stats. Throws if not found. */ + stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean }> | { isDirectory: () => boolean }; + /** Read directory entries */ + readdir: (absolutePath: string) => Promise | string[]; +} + +const defaultLsOperations: LsOperations = { + exists: pathExists, + stat: fsStat, + readdir: fsReaddir, +}; + +export interface LsToolOptions { + /** Custom operations for directory listing. Default: local filesystem */ + operations?: LsOperations; +} + +function formatLsCall(args: { path?: string; limit?: number } | undefined, theme: Theme, cwd: string): string { + const limit = args?.limit; + const pathDisplay = renderToolPath(str(args?.path), theme, cwd, { emptyFallback: "." }); + let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${pathDisplay}`; + if (limit !== undefined) { + text += theme.fg("toolOutput", ` (limit ${limit})`); + } + return text; +} + +function formatLsResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: LsToolDetails; + }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, +): string { + const output = getTextOutput(result, showImages).trim(); + let text = ""; + if (output) { + const lines = output.split("\n"); + const maxLines = options.expanded ? lines.length : 20; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + } + + const entryLimit = result.details?.entryLimitReached; + const truncation = result.details?.truncation; + if (entryLimit || truncation?.truncated) { + const warnings: string[] = []; + if (entryLimit) warnings.push(`${entryLimit} entries limit`); + if (truncation?.truncated) warnings.push(`${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`); + text += `\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`; + } + return text; +} + +export function createLsToolDefinition( + cwd: string, + options?: LsToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultLsOperations; + return { + name: "ls", + label: "ls", + description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, + promptSnippet: "List directory contents", + parameters: lsSchema, + async execute( + _toolCallId, + { path, limit }: { path?: string; limit?: number }, + signal?: AbortSignal, + _onUpdate?, + _ctx?, + ) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + + const onAbort = () => reject(new Error("Operation aborted")); + signal?.addEventListener("abort", onAbort, { once: true }); + + (async () => { + try { + const dirPath = resolveToCwd(path || ".", cwd); + const effectiveLimit = limit ?? DEFAULT_LIMIT; + + // Check if path exists. + if (!(await ops.exists(dirPath))) { + reject(new Error(`Path not found: ${dirPath}`)); + return; + } + + // Check if path is a directory. + const stat = await ops.stat(dirPath); + if (!stat.isDirectory()) { + reject(new Error(`Not a directory: ${dirPath}`)); + return; + } + + // Read directory entries. + let entries: string[]; + try { + entries = await ops.readdir(dirPath); + } catch (e: any) { + reject(new Error(`Cannot read directory: ${e.message}`)); + return; + } + + // Sort alphabetically, case-insensitive. + entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + // Format entries with directory indicators. + const results: string[] = []; + let entryLimitReached = false; + for (const entry of entries) { + if (results.length >= effectiveLimit) { + entryLimitReached = true; + break; + } + + const fullPath = nodePath.join(dirPath, entry); + let suffix = ""; + try { + const entryStat = await ops.stat(fullPath); + if (entryStat.isDirectory()) suffix = "/"; + } catch { + // Skip entries we cannot stat. + continue; + } + results.push(entry + suffix); + } + + signal?.removeEventListener("abort", onAbort); + + if (results.length === 0) { + resolve({ content: [{ type: "text", text: "(empty directory)" }], details: undefined }); + return; + } + + const rawOutput = results.join("\n"); + // Apply byte truncation. There is no separate line limit because entry count is already capped. + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + let output = truncation.content; + const details: LsToolDetails = {}; + // Build actionable notices for truncation and entry limits. + const notices: string[] = []; + if (entryLimitReached) { + notices.push(`${effectiveLimit} entries limit reached. Use limit=${effectiveLimit * 2} for more`); + details.entryLimitReached = effectiveLimit; + } + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + if (notices.length > 0) { + output += `\n\n[${notices.join(". ")}]`; + } + + resolve({ + content: [{ type: "text", text: output }], + details: Object.keys(details).length > 0 ? details : undefined, + }); + } catch (e: any) { + signal?.removeEventListener("abort", onAbort); + reject(e); + } + })(); + }); + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatLsCall(args, theme, context.cwd)); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(formatLsResult(result as any, options, theme, context.showImages)); + return text; + }, + }; +} + +export function createLsTool(cwd: string, options?: LsToolOptions): AgentTool { + return wrapToolDefinition(createLsToolDefinition(cwd, options)); +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/output-accumulator.ts b/cactus-code/packages/coding-agent/src/core/tools/output-accumulator.ts new file mode 100644 index 000000000..5c25b73fa --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/output-accumulator.ts @@ -0,0 +1,222 @@ +import { randomBytes } from "node:crypto"; +import { createWriteStream, type WriteStream } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, type TruncationResult, truncateTail } from "./truncate.ts"; + +export interface OutputAccumulatorOptions { + maxLines?: number; + maxBytes?: number; + tempFilePrefix?: string; +} + +export interface OutputSnapshot { + content: string; + truncation: TruncationResult; + fullOutputPath?: string; +} + +function defaultTempFilePath(prefix: string): string { + const id = randomBytes(8).toString("hex"); + return join(tmpdir(), `${prefix}-${id}.log`); +} + +function byteLength(text: string): number { + return Buffer.byteLength(text, "utf-8"); +} + +/** + * Incrementally tracks streaming output with bounded memory. + * + * Appends decode chunks with a streaming UTF-8 decoder, keeps only a decoded + * tail for display snapshots, and opens a temp file when the full output needs + * to be preserved. + */ +export class OutputAccumulator { + private readonly maxLines: number; + private readonly maxBytes: number; + private readonly maxRollingBytes: number; + private readonly tempFilePrefix: string; + private readonly decoder = new TextDecoder(); + + private rawChunks: Buffer[] = []; + private tailText = ""; + private tailBytes = 0; + private tailStartsAtLineBoundary = true; + private totalRawBytes = 0; + private totalDecodedBytes = 0; + private completedLines = 0; + private totalLines = 0; + private currentLineBytes = 0; + private hasOpenLine = false; + private finished = false; + + private tempFilePath: string | undefined; + private tempFileStream: WriteStream | undefined; + + constructor(options: OutputAccumulatorOptions = {}) { + this.maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + this.maxRollingBytes = Math.max(this.maxBytes * 2, 1); + this.tempFilePrefix = options.tempFilePrefix ?? "cactus-output"; + } + + append(data: Buffer): void { + if (this.finished) { + throw new Error("Cannot append to a finished output accumulator"); + } + + this.totalRawBytes += data.length; + this.appendDecodedText(this.decoder.decode(data, { stream: true })); + + if (this.tempFileStream || this.shouldUseTempFile()) { + this.ensureTempFile(); + this.tempFileStream?.write(data); + } else if (data.length > 0) { + this.rawChunks.push(data); + } + } + + finish(): void { + if (this.finished) { + return; + } + this.finished = true; + this.appendDecodedText(this.decoder.decode()); + if (this.shouldUseTempFile()) { + this.ensureTempFile(); + } + } + + snapshot(options: { persistIfTruncated?: boolean } = {}): OutputSnapshot { + const tailTruncation = truncateTail(this.getSnapshotText(), { + maxLines: this.maxLines, + maxBytes: this.maxBytes, + }); + const truncated = this.totalLines > this.maxLines || this.totalDecodedBytes > this.maxBytes; + const truncatedBy = truncated + ? (tailTruncation.truncatedBy ?? (this.totalDecodedBytes > this.maxBytes ? "bytes" : "lines")) + : null; + const truncation: TruncationResult = { + ...tailTruncation, + truncated, + truncatedBy, + totalLines: this.totalLines, + totalBytes: this.totalDecodedBytes, + maxLines: this.maxLines, + maxBytes: this.maxBytes, + }; + + if (options.persistIfTruncated && truncation.truncated) { + this.ensureTempFile(); + } + + return { + content: truncation.content, + truncation, + fullOutputPath: this.tempFilePath, + }; + } + + async closeTempFile(): Promise { + if (!this.tempFileStream) { + return; + } + + const stream = this.tempFileStream; + this.tempFileStream = undefined; + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + stream.off("finish", onFinish); + reject(error); + }; + const onFinish = () => { + stream.off("error", onError); + resolve(); + }; + stream.once("error", onError); + stream.once("finish", onFinish); + stream.end(); + }); + } + + getLastLineBytes(): number { + return this.currentLineBytes; + } + + private appendDecodedText(text: string): void { + if (text.length === 0) { + return; + } + + const bytes = byteLength(text); + this.totalDecodedBytes += bytes; + this.tailText += text; + this.tailBytes += bytes; + if (this.tailBytes > this.maxRollingBytes * 2) { + this.trimTail(); + } + + let newlines = 0; + let lastNewline = -1; + for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) { + newlines++; + lastNewline = i; + } + if (newlines === 0) { + this.currentLineBytes += bytes; + this.hasOpenLine = true; + } else { + this.completedLines += newlines; + const tail = text.slice(lastNewline + 1); + this.currentLineBytes = byteLength(tail); + this.hasOpenLine = tail.length > 0; + } + this.totalLines = this.completedLines + (this.hasOpenLine ? 1 : 0); + } + + private trimTail(): void { + const buffer = Buffer.from(this.tailText, "utf-8"); + if (buffer.length <= this.maxRollingBytes) { + this.tailBytes = buffer.length; + return; + } + + let start = buffer.length - this.maxRollingBytes; + while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) { + start++; + } + + this.tailStartsAtLineBoundary = start === 0 ? this.tailStartsAtLineBoundary : buffer[start - 1] === 0x0a; + this.tailText = buffer.subarray(start).toString("utf-8"); + this.tailBytes = byteLength(this.tailText); + } + + private getSnapshotText(): string { + if (this.tailStartsAtLineBoundary) { + return this.tailText; + } + + const firstNewline = this.tailText.indexOf("\n"); + return firstNewline === -1 ? this.tailText : this.tailText.slice(firstNewline + 1); + } + + private shouldUseTempFile(): boolean { + return ( + this.totalRawBytes > this.maxBytes || this.totalDecodedBytes > this.maxBytes || this.totalLines > this.maxLines + ); + } + + private ensureTempFile(): void { + if (this.tempFilePath) { + return; + } + this.tempFilePath = defaultTempFilePath(this.tempFilePrefix); + this.tempFileStream = createWriteStream(this.tempFilePath); + for (const chunk of this.rawChunks) { + this.tempFileStream.write(chunk); + } + this.rawChunks = []; + } +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/path-utils.ts b/cactus-code/packages/coding-agent/src/core/tools/path-utils.ts new file mode 100644 index 000000000..1f9ab4cc9 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/path-utils.ts @@ -0,0 +1,118 @@ +import { accessSync, constants } from "node:fs"; +import { access } from "node:fs/promises"; +import { normalizePath, resolvePath } from "../../utils/paths.ts"; + +const NARROW_NO_BREAK_SPACE = "\u202F"; + +function tryMacOSScreenshotPath(filePath: string): string { + return filePath.replace(/ (AM|PM)\./gi, `${NARROW_NO_BREAK_SPACE}$1.`); +} + +function tryNFDVariant(filePath: string): string { + // macOS stores filenames in NFD (decomposed) form, try converting user input to NFD + return filePath.normalize("NFD"); +} + +function tryCurlyQuoteVariant(filePath: string): string { + // macOS uses U+2019 (right single quotation mark) in screenshot names like "Capture d'écran" + // Users typically type U+0027 (straight apostrophe) + return filePath.replace(/'/g, "\u2019"); +} + +function fileExists(filePath: string): boolean { + try { + accessSync(filePath, constants.F_OK); + return true; + } catch { + return false; + } +} + +export async function pathExists(filePath: string): Promise { + try { + await access(filePath, constants.F_OK); + return true; + } catch { + return false; + } +} + +export function expandPath(filePath: string): string { + return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); +} + +/** + * Resolve a path relative to the given cwd. + * Handles ~ expansion and absolute paths. + */ +export function resolveToCwd(filePath: string, cwd: string): string { + return resolvePath(filePath, cwd, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); +} + +export function resolveReadPath(filePath: string, cwd: string): string { + const resolved = resolveToCwd(filePath, cwd); + + if (fileExists(resolved)) { + return resolved; + } + + // Try macOS AM/PM variant (narrow no-break space before AM/PM) + const amPmVariant = tryMacOSScreenshotPath(resolved); + if (amPmVariant !== resolved && fileExists(amPmVariant)) { + return amPmVariant; + } + + // Try NFD variant (macOS stores filenames in NFD form) + const nfdVariant = tryNFDVariant(resolved); + if (nfdVariant !== resolved && fileExists(nfdVariant)) { + return nfdVariant; + } + + // Try curly quote variant (macOS uses U+2019 in screenshot names) + const curlyVariant = tryCurlyQuoteVariant(resolved); + if (curlyVariant !== resolved && fileExists(curlyVariant)) { + return curlyVariant; + } + + // Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran") + const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant); + if (nfdCurlyVariant !== resolved && fileExists(nfdCurlyVariant)) { + return nfdCurlyVariant; + } + + return resolved; +} + +export async function resolveReadPathAsync(filePath: string, cwd: string): Promise { + const resolved = resolveToCwd(filePath, cwd); + + if (await pathExists(resolved)) { + return resolved; + } + + // Try macOS AM/PM variant (narrow no-break space before AM/PM) + const amPmVariant = tryMacOSScreenshotPath(resolved); + if (amPmVariant !== resolved && (await pathExists(amPmVariant))) { + return amPmVariant; + } + + // Try NFD variant (macOS stores filenames in NFD form) + const nfdVariant = tryNFDVariant(resolved); + if (nfdVariant !== resolved && (await pathExists(nfdVariant))) { + return nfdVariant; + } + + // Try curly quote variant (macOS uses U+2019 in screenshot names) + const curlyVariant = tryCurlyQuoteVariant(resolved); + if (curlyVariant !== resolved && (await pathExists(curlyVariant))) { + return curlyVariant; + } + + // Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran") + const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant); + if (nfdCurlyVariant !== resolved && (await pathExists(nfdCurlyVariant))) { + return nfdCurlyVariant; + } + + return resolved; +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/read.ts b/cactus-code/packages/coding-agent/src/core/tools/read.ts new file mode 100644 index 000000000..52e87b1df --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/read.ts @@ -0,0 +1,362 @@ +import { basename, dirname, isAbsolute, relative, resolve as resolvePath, sep } from "node:path"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; +import { Text } from "@earendil-works/pi-tui"; +import { constants } from "fs"; +import { access as fsAccess, readFile as fsReadFile } from "fs/promises"; +import { type Static, Type } from "typebox"; +import { getReadmePath } from "../../config.ts"; +import { keyHint, keyText } from "../../modes/interactive/components/keybinding-hints.ts"; +import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts"; +import { formatDimensionNote, resizeImage } from "../../utils/image-resize.ts"; +import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts"; +import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts"; +import { getTextOutput, renderToolPath, replaceTabs, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; + +const readSchema = Type.Object({ + path: Type.String({ description: "Path to the file to read (relative or absolute)" }), + offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" })), +}); + +export type ReadToolInput = Static; + +export interface ReadToolDetails { + truncation?: TruncationResult; +} + +interface CompactReadClassification { + kind: "docs" | "resource" | "skill"; + label: string; +} + +const COMPACT_RESOURCE_FILE_NAMES = new Set(["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]); + +/** + * Pluggable operations for the read tool. + * Override these to delegate file reading to remote systems (for example SSH). + */ +export interface ReadOperations { + /** Read file contents as a Buffer */ + readFile: (absolutePath: string) => Promise; + /** Check if file is readable (throw if not) */ + access: (absolutePath: string) => Promise; + /** Detect image MIME type, return null or undefined for non-images */ + detectImageMimeType?: (absolutePath: string) => Promise; +} + +const defaultReadOperations: ReadOperations = { + readFile: (path) => fsReadFile(path), + access: (path) => fsAccess(path, constants.R_OK), + detectImageMimeType: detectSupportedImageMimeTypeFromFile, +}; + +export interface ReadToolOptions { + /** Whether to auto-resize images to 2000x2000 max. Default: true */ + autoResizeImages?: boolean; + /** Custom operations for file reading. Default: local filesystem */ + operations?: ReadOperations; +} + +type ReadRenderArgs = { path?: string; file_path?: string; offset?: number; limit?: number }; + +function formatReadLineRange(args: ReadRenderArgs | undefined, theme: Theme): string { + if (args?.offset === undefined && args?.limit === undefined) return ""; + const startLine = args.offset ?? 1; + const endLine = args.limit !== undefined ? startLine + args.limit - 1 : ""; + return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); +} + +function formatReadCall(args: ReadRenderArgs | undefined, theme: Theme, cwd: string): string { + const pathDisplay = renderToolPath(str(args?.file_path ?? args?.path), theme, cwd); + return `${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}${formatReadLineRange(args, theme)}`; +} + +function trimTrailingEmptyLines(lines: string[]): string[] { + let end = lines.length; + while (end > 0 && lines[end - 1] === "") { + end--; + } + return lines.slice(0, end); +} + +function getNonVisionImageNote(model: Model | undefined): string | undefined { + if (!model || model.input.includes("image")) { + return undefined; + } + return "[Current model does not support images. The image will be omitted from this request.]"; +} + +function toPosixPath(filePath: string): string { + return filePath.split(sep).join("/"); +} + +function getPiDocsClassification(absolutePath: string): CompactReadClassification | undefined { + const packageRoot = dirname(getReadmePath()); + const relativePath = relative(resolvePath(packageRoot), resolvePath(absolutePath)); + if ( + relativePath === "" || + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + return undefined; + } + + const label = toPosixPath(relativePath); + if (label === "README.md" || label.startsWith("docs/") || label.startsWith("examples/")) { + return { kind: "docs", label }; + } + return undefined; +} + +function getCompactReadClassification( + args: ReadRenderArgs | undefined, + cwd: string, +): CompactReadClassification | undefined { + const rawPath = str(args?.file_path ?? args?.path); + if (!rawPath) return undefined; + + const absolutePath = resolveToCwd(rawPath, cwd); + const fileName = basename(absolutePath); + if (fileName === "SKILL.md") { + return { kind: "skill", label: basename(dirname(absolutePath)) || fileName }; + } + + const docsClassification = getPiDocsClassification(absolutePath); + if (docsClassification) return docsClassification; + + if (COMPACT_RESOURCE_FILE_NAMES.has(fileName)) { + return { kind: "resource", label: formatPathRelativeToCwdOrAbsolute(absolutePath, cwd) }; + } + + return undefined; +} + +function formatCompactReadCall( + classification: CompactReadClassification, + args: ReadRenderArgs | undefined, + theme: Theme, +): string { + const expandHint = theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`); + if (classification.kind === "skill") { + return ( + theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + + theme.fg("customMessageText", classification.label) + + formatReadLineRange(args, theme) + + expandHint + ); + } + + return ( + theme.fg("toolTitle", theme.bold(`read ${classification.kind}`)) + + " " + + theme.fg("accent", classification.label) + + formatReadLineRange(args, theme) + + expandHint + ); +} + +function formatReadResult( + args: ReadRenderArgs | undefined, + result: { content: (TextContent | ImageContent)[]; details?: ReadToolDetails }, + options: ToolRenderResultOptions, + theme: Theme, + showImages: boolean, + _cwd: string, + isError: boolean, +): string { + if (!options.expanded && !isError) { + return ""; + } + + const rawPath = str(args?.file_path ?? args?.path); + const output = getTextOutput(result, showImages); + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + const renderedLines = lang ? highlightCode(replaceTabs(output), lang) : output.split("\n"); + const lines = trimTrailingEmptyLines(renderedLines); + const maxLines = options.expanded ? lines.length : 10; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + let text = `\n${displayLines.map((line) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + + const truncation = result.details?.truncation; + if (truncation?.truncated) { + if (truncation.firstLineExceedsLimit) { + text += `\n${theme.fg("warning", `[First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`)}`; + } else if (truncation.truncatedBy === "lines") { + text += `\n${theme.fg("warning", `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`)}`; + } else { + text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`; + } + } + return text; +} + +export function createReadToolDefinition( + cwd: string, + options?: ReadToolOptions, +): ToolDefinition { + const autoResizeImages = options?.autoResizeImages ?? true; + const ops = options?.operations ?? defaultReadOperations; + return { + name: "read", + label: "read", + description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.`, + promptSnippet: "Read file contents", + promptGuidelines: ["Use read to examine files instead of cat or sed."], + parameters: readSchema, + async execute( + _toolCallId, + { path, offset, limit }: { path: string; offset?: number; limit?: number }, + signal?: AbortSignal, + _onUpdate?, + ctx?, + ) { + return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>( + (resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + let aborted = false; + const onAbort = () => { + aborted = true; + reject(new Error("Operation aborted")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + + (async () => { + try { + const absolutePath = await resolveReadPathAsync(path, cwd); + if (aborted) return; + // Check if file exists and is readable. + await ops.access(absolutePath); + if (aborted) return; + const mimeType = ops.detectImageMimeType ? await ops.detectImageMimeType(absolutePath) : undefined; + let content: (TextContent | ImageContent)[]; + let details: ReadToolDetails | undefined; + const nonVisionImageNote = getNonVisionImageNote(ctx?.model); + if (mimeType) { + // Read image as binary. + const buffer = await ops.readFile(absolutePath); + if (autoResizeImages) { + // Resize image if needed before sending it back to the model. + const resized = await resizeImage(buffer, mimeType); + if (!resized) { + let textNote = `Read image file [${mimeType}]\n[Image omitted: could not be resized below the inline image size limit.]`; + if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; + content = [{ type: "text", text: textNote }]; + } else { + const dimensionNote = formatDimensionNote(resized); + let textNote = `Read image file [${resized.mimeType}]`; + if (dimensionNote) textNote += `\n${dimensionNote}`; + if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; + content = [ + { type: "text", text: textNote }, + { type: "image", data: resized.data, mimeType: resized.mimeType }, + ]; + } + } else { + let textNote = `Read image file [${mimeType}]`; + if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; + content = [ + { type: "text", text: textNote }, + { type: "image", data: buffer.toString("base64"), mimeType }, + ]; + } + } else { + // Read text content. + const buffer = await ops.readFile(absolutePath); + const textContent = buffer.toString("utf-8"); + const allLines = textContent.split("\n"); + const totalFileLines = allLines.length; + // Apply offset if specified. Convert from 1-indexed input to 0-indexed array access. + const startLine = offset ? Math.max(0, offset - 1) : 0; + const startLineDisplay = startLine + 1; + // Check if offset is out of bounds. + if (startLine >= allLines.length) { + throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`); + } + let selectedContent: string; + let userLimitedLines: number | undefined; + // If limit is specified by the user, honor it first. Otherwise truncateHead decides. + if (limit !== undefined) { + const endLine = Math.min(startLine + limit, allLines.length); + selectedContent = allLines.slice(startLine, endLine).join("\n"); + userLimitedLines = endLine - startLine; + } else { + selectedContent = allLines.slice(startLine).join("\n"); + } + // Apply truncation, respecting both line and byte limits. + const truncation = truncateHead(selectedContent); + let outputText: string; + if (truncation.firstLineExceedsLimit) { + // First line alone exceeds the byte limit. Point the model at a bash fallback. + const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8")); + outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`; + details = { truncation }; + } else if (truncation.truncated) { + // Truncation occurred. Build an actionable continuation notice. + const endLineDisplay = startLineDisplay + truncation.outputLines - 1; + const nextOffset = endLineDisplay + 1; + outputText = truncation.content; + if (truncation.truncatedBy === "lines") { + outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; + } else { + outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`; + } + details = { truncation }; + } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) { + // User-specified limit stopped early, but the file still has more content. + const remaining = allLines.length - (startLine + userLimitedLines); + const nextOffset = startLine + userLimitedLines + 1; + outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; + } else { + // No truncation and no remaining user-limited content. + outputText = truncation.content; + } + content = [{ type: "text", text: outputText }]; + } + + if (aborted) return; + signal?.removeEventListener("abort", onAbort); + resolve({ content, details }); + } catch (error: any) { + signal?.removeEventListener("abort", onAbort); + if (!aborted) reject(error); + } + })(); + }, + ); + }, + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + const classification = !context.expanded ? getCompactReadClassification(args, context.cwd) : undefined; + text.setText( + classification + ? formatCompactReadCall(classification, args, theme) + : formatReadCall(args, theme, context.cwd), + ); + return text; + }, + renderResult(result, options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText( + formatReadResult(context.args, result, options, theme, context.showImages, context.cwd, context.isError), + ); + return text; + }, + }; +} + +export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool { + return wrapToolDefinition(createReadToolDefinition(cwd, options)); +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/render-utils.ts b/cactus-code/packages/coding-agent/src/core/tools/render-utils.ts new file mode 100644 index 000000000..48a5b0af0 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/render-utils.ts @@ -0,0 +1,85 @@ +import * as os from "node:os"; +import { pathToFileURL } from "node:url"; +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import { getCapabilities, getImageDimensions, hyperlink, imageFallback } from "@earendil-works/pi-tui"; +import type { Theme } from "../../modes/interactive/theme/theme.ts"; +import { stripAnsi } from "../../utils/ansi.ts"; +import { resolvePath } from "../../utils/paths.ts"; +import { sanitizeBinaryOutput } from "../../utils/shell.ts"; + +export function shortenPath(path: unknown): string { + if (typeof path !== "string") return ""; + const home = os.homedir(); + if (path.startsWith(home)) { + return `~${path.slice(home.length)}`; + } + return path; +} + +export function linkPath(styledText: string, rawPath: string, cwd: string): string { + if (!getCapabilities().hyperlinks) return styledText; + const absolutePath = resolvePath(rawPath, cwd); + return hyperlink(styledText, pathToFileURL(absolutePath).href); +} + +export function str(value: unknown): string | null { + if (typeof value === "string") return value; + if (value == null) return ""; + return null; +} + +export function replaceTabs(text: string): string { + return text.replace(/\t/g, " "); +} + +export function normalizeDisplayText(text: string): string { + return text.replace(/\r/g, ""); +} + +export function getTextOutput( + result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> } | undefined, + showImages: boolean, +): string { + if (!result) return ""; + + const textBlocks = result.content.filter((c) => c.type === "text"); + const imageBlocks = result.content.filter((c) => c.type === "image"); + + let output = textBlocks.map((c) => sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "")).join("\n"); + + const caps = getCapabilities(); + if (imageBlocks.length > 0 && (!caps.images || !showImages)) { + const imageIndicators = imageBlocks + .map((img) => { + const mimeType = img.mimeType ?? "image/unknown"; + const dims = + img.data && img.mimeType ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined; + return imageFallback(mimeType, dims); + }) + .join("\n"); + output = output ? `${output}\n${imageIndicators}` : imageIndicators; + } + + return output; +} + +export type ToolRenderResultLike = { + content: (TextContent | ImageContent)[]; + details: TDetails; +}; + +export function invalidArgText(theme: Theme): string { + return theme.fg("error", "[invalid arg]"); +} + +export function renderToolPath( + rawPath: string | null, + theme: Theme, + cwd: string, + options?: { emptyFallback?: string }, +): string { + if (rawPath === null) return invalidArgText(theme); + const value = rawPath || options?.emptyFallback; + if (!value) return theme.fg("toolOutput", "..."); + return linkPath(theme.fg("accent", shortenPath(value)), value, cwd); +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts b/cactus-code/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts new file mode 100644 index 000000000..c9da525ad --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/tool-definition-wrapper.ts @@ -0,0 +1,45 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts"; + +/** Wrap a ToolDefinition into an AgentTool for the core runtime. */ +export function wrapToolDefinition( + definition: ToolDefinition, + ctxFactory?: () => ExtensionContext, +): AgentTool { + return { + name: definition.name, + label: definition.label, + description: definition.description, + parameters: definition.parameters, + prepareArguments: definition.prepareArguments, + executionMode: definition.executionMode, + execute: (toolCallId, params, signal, onUpdate) => + definition.execute(toolCallId, params, signal, onUpdate, ctxFactory?.() as ExtensionContext), + }; +} + +/** Wrap multiple ToolDefinitions into AgentTools for the core runtime. */ +export function wrapToolDefinitions( + definitions: ToolDefinition[], + ctxFactory?: () => ExtensionContext, +): AgentTool[] { + return definitions.map((definition) => wrapToolDefinition(definition, ctxFactory)); +} + +/** + * Synthesize a minimal ToolDefinition from an AgentTool. + * + * This keeps AgentSession's internal registry definition-first even when a caller + * provides plain AgentTool overrides that do not include prompt metadata or renderers. + */ +export function createToolDefinitionFromAgentTool(tool: AgentTool): ToolDefinition { + return { + name: tool.name, + label: tool.label, + description: tool.description, + parameters: tool.parameters as any, + prepareArguments: tool.prepareArguments, + executionMode: tool.executionMode, + execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate), + }; +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/truncate.ts b/cactus-code/packages/coding-agent/src/core/tools/truncate.ts new file mode 100644 index 000000000..c638ee480 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/truncate.ts @@ -0,0 +1,276 @@ +/** + * Shared truncation utilities for tool outputs. + * + * Truncation is based on two independent limits - whichever is hit first wins: + * - Line limit (default: 2000 lines) + * - Byte limit (default: 50KB) + * + * Never returns partial lines (except bash tail truncation edge case). + */ + +export const DEFAULT_MAX_LINES = 2000; +export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB +export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line + +export interface TruncationResult { + /** The truncated content */ + content: string; + /** Whether truncation occurred */ + truncated: boolean; + /** Which limit was hit: "lines", "bytes", or null if not truncated */ + truncatedBy: "lines" | "bytes" | null; + /** Total number of lines in the original content */ + totalLines: number; + /** Total number of bytes in the original content */ + totalBytes: number; + /** Number of complete lines in the truncated output */ + outputLines: number; + /** Number of bytes in the truncated output */ + outputBytes: number; + /** Whether the last line was partially truncated (only for tail truncation edge case) */ + lastLinePartial: boolean; + /** Whether the first line exceeded the byte limit (for head truncation) */ + firstLineExceedsLimit: boolean; + /** The max lines limit that was applied */ + maxLines: number; + /** The max bytes limit that was applied */ + maxBytes: number; +} + +export interface TruncationOptions { + /** Maximum number of lines (default: 2000) */ + maxLines?: number; + /** Maximum number of bytes (default: 50KB) */ + maxBytes?: number; +} + +function splitLinesForCounting(content: string): string[] { + if (content.length === 0) { + return []; + } + const lines = content.split("\n"); + if (content.endsWith("\n")) { + lines.pop(); + } + return lines; +} + +/** + * Format bytes as human-readable size. + */ +export function formatSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes}B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)}KB`; + } else { + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + } +} + +/** + * Truncate content from the head (keep first N lines/bytes). + * Suitable for file reads where you want to see the beginning. + * + * Never returns partial lines. If first line exceeds byte limit, + * returns empty content with firstLineExceedsLimit=true. + */ +export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = Buffer.byteLength(content, "utf-8"); + const lines = splitLinesForCounting(content); + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Check if first line alone exceeds byte limit + const firstLineBytes = Buffer.byteLength(lines[0], "utf-8"); + if (firstLineBytes > maxBytes) { + return { + content: "", + truncated: true, + truncatedBy: "bytes", + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + maxLines, + maxBytes, + }; + } + + // Collect complete lines that fit + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + + for (let i = 0; i < lines.length && i < maxLines; i++) { + const line = lines[i]; + const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + break; + } + + outputLinesArr.push(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8"); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate content from the tail (keep last N lines/bytes). + * Suitable for bash output where you want to see the end (errors, final results). + * + * May return partial first line if the last line of original content exceeds byte limit. + */ +export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + + const totalBytes = Buffer.byteLength(content, "utf-8"); + const lines = splitLinesForCounting(content); + const totalLines = lines.length; + + // Check if no truncation needed + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + // Work backwards from the end + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + let lastLinePartial = false; + + for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { + const line = lines[i]; + const lineBytes = Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline + + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + // Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes, + // take the end of the line (partial) + if (outputLinesArr.length === 0) { + const truncatedLine = truncateStringToBytesFromEnd(line, maxBytes); + outputLinesArr.unshift(truncatedLine); + outputBytesCount = Buffer.byteLength(truncatedLine, "utf-8"); + lastLinePartial = true; + } + break; + } + + outputLinesArr.unshift(line); + outputBytesCount += lineBytes; + } + + // If we exited due to line limit + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8"); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +/** + * Truncate a string to fit within a byte limit (from the end). + * Handles multi-byte UTF-8 characters correctly. + */ +function truncateStringToBytesFromEnd(str: string, maxBytes: number): string { + const buf = Buffer.from(str, "utf-8"); + if (buf.length <= maxBytes) { + return str; + } + + // Start from the end, skip maxBytes back + let start = buf.length - maxBytes; + + // Find a valid UTF-8 boundary (start of a character) + while (start < buf.length && (buf[start] & 0xc0) === 0x80) { + start++; + } + + return buf.slice(start).toString("utf-8"); +} + +/** + * Truncate a single line to max characters, adding [truncated] suffix. + * Used for grep match lines. + */ +export function truncateLine( + line: string, + maxChars: number = GREP_MAX_LINE_LENGTH, +): { text: string; wasTruncated: boolean } { + if (line.length <= maxChars) { + return { text: line, wasTruncated: false }; + } + return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true }; +} diff --git a/cactus-code/packages/coding-agent/src/core/tools/write.ts b/cactus-code/packages/coding-agent/src/core/tools/write.ts new file mode 100644 index 000000000..12668e61a --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/tools/write.ts @@ -0,0 +1,267 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { Container, Text } from "@earendil-works/pi-tui"; +import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises"; +import { dirname } from "path"; +import { type Static, Type } from "typebox"; +import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; +import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme.ts"; +import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; +import { withFileMutationQueue } from "./file-mutation-queue.ts"; +import { resolveToCwd } from "./path-utils.ts"; +import { normalizeDisplayText, renderToolPath, replaceTabs, str } from "./render-utils.ts"; +import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; + +const writeSchema = Type.Object({ + path: Type.String({ description: "Path to the file to write (relative or absolute)" }), + content: Type.String({ description: "Content to write to the file" }), +}); + +export type WriteToolInput = Static; + +/** + * Pluggable operations for the write tool. + * Override these to delegate file writing to remote systems (for example SSH). + */ +export interface WriteOperations { + /** Write content to a file */ + writeFile: (absolutePath: string, content: string) => Promise; + /** Create directory recursively */ + mkdir: (dir: string) => Promise; +} + +const defaultWriteOperations: WriteOperations = { + writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), + mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => {}), +}; + +export interface WriteToolOptions { + /** Custom operations for file writing. Default: local filesystem */ + operations?: WriteOperations; +} + +type WriteHighlightCache = { + rawPath: string | null; + lang: string; + rawContent: string; + normalizedLines: string[]; + highlightedLines: string[]; +}; + +class WriteCallRenderComponent extends Text { + cache?: WriteHighlightCache; + + constructor() { + super("", 0, 0); + } +} + +const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50; + +function highlightSingleLine(line: string, lang: string): string { + const highlighted = highlightCode(line, lang); + return highlighted[0] ?? ""; +} + +function refreshWriteHighlightPrefix(cache: WriteHighlightCache): void { + const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length); + if (prefixCount === 0) return; + const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n"); + const prefixHighlighted = highlightCode(prefixSource, cache.lang); + for (let i = 0; i < prefixCount; i++) { + cache.highlightedLines[i] = + prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang); + } +} + +function rebuildWriteHighlightCacheFull(rawPath: string | null, fileContent: string): WriteHighlightCache | undefined { + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + if (!lang) return undefined; + const displayContent = normalizeDisplayText(fileContent); + const normalized = replaceTabs(displayContent); + return { + rawPath, + lang, + rawContent: fileContent, + normalizedLines: normalized.split("\n"), + highlightedLines: highlightCode(normalized, lang), + }; +} + +function updateWriteHighlightCacheIncremental( + cache: WriteHighlightCache | undefined, + rawPath: string | null, + fileContent: string, +): WriteHighlightCache | undefined { + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + if (!lang) return undefined; + if (!cache) return rebuildWriteHighlightCacheFull(rawPath, fileContent); + if (cache.lang !== lang || cache.rawPath !== rawPath) return rebuildWriteHighlightCacheFull(rawPath, fileContent); + if (!fileContent.startsWith(cache.rawContent)) return rebuildWriteHighlightCacheFull(rawPath, fileContent); + if (fileContent.length === cache.rawContent.length) return cache; + + const deltaRaw = fileContent.slice(cache.rawContent.length); + const deltaDisplay = normalizeDisplayText(deltaRaw); + const deltaNormalized = replaceTabs(deltaDisplay); + cache.rawContent = fileContent; + if (cache.normalizedLines.length === 0) { + cache.normalizedLines.push(""); + cache.highlightedLines.push(""); + } + + const segments = deltaNormalized.split("\n"); + const lastIndex = cache.normalizedLines.length - 1; + cache.normalizedLines[lastIndex] += segments[0]; + cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang); + for (let i = 1; i < segments.length; i++) { + cache.normalizedLines.push(segments[i]); + cache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang)); + } + refreshWriteHighlightPrefix(cache); + return cache; +} + +function trimTrailingEmptyLines(lines: string[]): string[] { + let end = lines.length; + while (end > 0 && lines[end - 1] === "") { + end--; + } + return lines.slice(0, end); +} + +function formatWriteCall( + args: { path?: string; file_path?: string; content?: string } | undefined, + options: ToolRenderResultOptions, + theme: Theme, + cache: WriteHighlightCache | undefined, + cwd: string, +): string { + const rawPath = str(args?.file_path ?? args?.path); + const fileContent = str(args?.content); + const pathDisplay = renderToolPath(rawPath, theme, cwd); + let text = `${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}`; + + if (fileContent === null) { + text += `\n\n${theme.fg("error", "[invalid content arg - expected string]")}`; + } else if (fileContent) { + const lang = rawPath ? getLanguageFromPath(rawPath) : undefined; + const renderedLines = lang + ? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang)) + : normalizeDisplayText(fileContent).split("\n"); + const lines = trimTrailingEmptyLines(renderedLines); + const totalLines = lines.length; + const maxLines = options.expanded ? lines.length : 10; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + text += `\n\n${displayLines.map((line) => (lang ? line : theme.fg("toolOutput", replaceTabs(line)))).join("\n")}`; + if (remaining > 0) { + text += `${theme.fg("muted", `\n... (${remaining} more lines, ${totalLines} total,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`; + } + } + + return text; +} + +function formatWriteResult( + result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean }, + theme: Theme, +): string | undefined { + if (!result.isError) { + return undefined; + } + const output = result.content + .filter((c) => c.type === "text") + .map((c) => c.text || "") + .join("\n"); + if (!output) { + return undefined; + } + return `\n${theme.fg("error", output)}`; +} + +export function createWriteToolDefinition( + cwd: string, + options?: WriteToolOptions, +): ToolDefinition { + const ops = options?.operations ?? defaultWriteOperations; + return { + name: "write", + label: "write", + description: + "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.", + promptSnippet: "Create or overwrite files", + promptGuidelines: ["Use write only for new files or complete rewrites."], + parameters: writeSchema, + async execute( + _toolCallId, + { path, content }: { path: string; content: string }, + signal?: AbortSignal, + _onUpdate?, + _ctx?, + ) { + const absolutePath = resolveToCwd(path, cwd); + const dir = dirname(absolutePath); + return withFileMutationQueue(absolutePath, async () => { + // Do not reject from an abort event listener here: that would release the + // mutation queue while an in-flight filesystem operation may still finish. + // Checking signal.aborted after each await observes the same aborts while + // keeping the queue locked until the current operation has settled. + const throwIfAborted = (): void => { + if (signal?.aborted) throw new Error("Operation aborted"); + }; + + throwIfAborted(); + // Create parent directories if needed. + await ops.mkdir(dir); + throwIfAborted(); + + // Write the file contents. + await ops.writeFile(absolutePath, content); + throwIfAborted(); + + return { + content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }], + details: undefined, + }; + }); + }, + renderCall(args, theme, context) { + const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined; + const rawPath = str(renderArgs?.file_path ?? renderArgs?.path); + const fileContent = str(renderArgs?.content); + const component = + (context.lastComponent as WriteCallRenderComponent | undefined) ?? new WriteCallRenderComponent(); + if (fileContent !== null) { + component.cache = context.argsComplete + ? rebuildWriteHighlightCacheFull(rawPath, fileContent) + : updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent); + } else { + component.cache = undefined; + } + component.setText( + formatWriteCall( + renderArgs, + { expanded: context.expanded, isPartial: context.isPartial }, + theme, + component.cache, + context.cwd, + ), + ); + return component; + }, + renderResult(result, _options, theme, context) { + const output = formatWriteResult({ ...result, isError: context.isError }, theme); + if (!output) { + const component = (context.lastComponent as Container | undefined) ?? new Container(); + component.clear(); + return component; + } + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); + text.setText(output); + return text; + }, + }; +} + +export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentTool { + return wrapToolDefinition(createWriteToolDefinition(cwd, options)); +} diff --git a/cactus-code/packages/coding-agent/src/core/trust-manager.ts b/cactus-code/packages/coding-agent/src/core/trust-manager.ts new file mode 100644 index 000000000..9c494b47a --- /dev/null +++ b/cactus-code/packages/coding-agent/src/core/trust-manager.ts @@ -0,0 +1,244 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import lockfile from "proper-lockfile"; +import { CONFIG_DIR_NAME } from "../config.ts"; +import { canonicalizePath, resolvePath } from "../utils/paths.ts"; + +export type ProjectTrustDecision = boolean | null; + +export interface ProjectTrustStoreEntry { + path: string; + decision: boolean; +} + +export interface ProjectTrustUpdate { + path: string; + decision: ProjectTrustDecision; +} + +export interface ProjectTrustOption { + label: string; + trusted: boolean; + updates: ProjectTrustUpdate[]; + savedPath?: string; +} + +type TrustFile = Record; + +const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [ + "settings.json", + "extensions", + "skills", + "prompts", + "themes", + "SYSTEM.md", + "APPEND_SYSTEM.md", +] as const; + +function normalizeCwd(cwd: string): string { + return canonicalizePath(resolvePath(cwd)); +} + +function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null { + let currentDir = normalizeCwd(cwd); + while (true) { + const value = data[currentDir]; + if (value === true || value === false) { + return { path: currentDir, decision: value }; + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +export function getProjectTrustParentPath(cwd: string): string | undefined { + const trustPath = normalizeCwd(cwd); + const parentDir = dirname(trustPath); + return parentDir === trustPath ? undefined : parentDir; +} + +export function getProjectTrustOptions(cwd: string, options?: { includeSessionOnly?: boolean }): ProjectTrustOption[] { + const trustPath = normalizeCwd(cwd); + const trustOptions: ProjectTrustOption[] = [ + { label: "Trust", trusted: true, updates: [{ path: trustPath, decision: true }], savedPath: trustPath }, + ]; + const parentPath = getProjectTrustParentPath(cwd); + if (parentPath !== undefined) { + trustOptions.push({ + label: `Trust parent folder (${parentPath})`, + trusted: true, + updates: [ + { path: parentPath, decision: true }, + { path: trustPath, decision: null }, + ], + savedPath: parentPath, + }); + } + if (options?.includeSessionOnly) { + trustOptions.push({ label: "Trust (this session only)", trusted: true, updates: [] }); + } + trustOptions.push({ + label: "Do not trust", + trusted: false, + updates: [{ path: trustPath, decision: false }], + savedPath: trustPath, + }); + if (options?.includeSessionOnly) { + trustOptions.push({ label: "Do not trust (this session only)", trusted: false, updates: [] }); + } + return trustOptions; +} + +function readTrustFile(path: string): TrustFile { + if (!existsSync(path)) { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf-8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to read trust store ${path}: ${message}`); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid trust store ${path}: expected an object`); + } + + const data: TrustFile = {}; + for (const [key, value] of Object.entries(parsed)) { + if (value !== true && value !== false && value !== null) { + throw new Error(`Invalid trust store ${path}: value for ${JSON.stringify(key)} must be true, false, or null`); + } + data[key] = value; + } + return data; +} + +function writeTrustFile(path: string, data: TrustFile): void { + const sorted: TrustFile = {}; + for (const key of Object.keys(data).sort()) { + const value = data[key]; + if (value === true || value === false || value === null) { + sorted[key] = value; + } + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(sorted, null, 2)}\n`, "utf-8"); +} + +function acquireTrustLockSync(path: string): () => void { + const trustDir = dirname(path); + mkdirSync(trustDir, { recursive: true }); + const maxAttempts = 10; + const delayMs = 20; + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return lockfile.lockSync(trustDir, { realpath: false, lockfilePath: `${path}.lock` }); + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code !== "ELOCKED" || attempt === maxAttempts) { + throw error; + } + lastError = error; + const start = Date.now(); + while (Date.now() - start < delayMs) { + // Sleep synchronously to avoid changing trust store callers to async. + } + } + } + + if (lastError instanceof Error) { + throw lastError; + } + throw new Error("Failed to acquire trust store lock"); +} + +function withTrustFileLock(path: string, fn: () => T): T { + const release = acquireTrustLockSync(path); + try { + return fn(); + } finally { + release(); + } +} + +/** + * Returns true when cwd has project-local resources that must be gated by + * project trust: trust-requiring entries under cwd/.pi, or .agents/skills in + * cwd or one of its ancestors. Returns false when no such project resources + * exist. The user/global ~/.agents/skills directory is always treated as a + * trusted user resource and is ignored here, even when cwd is $HOME. + */ +export function hasTrustRequiringProjectResources(cwd: string): boolean { + const homeDir = canonicalizePath(resolvePath(process.env.HOME || homedir())); + const userAgentsSkillsDir = join(homeDir, ".agents", "skills"); + let currentDir = canonicalizePath(resolvePath(cwd)); + + const configDir = join(currentDir, CONFIG_DIR_NAME); + if (TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES.some((entry) => existsSync(join(configDir, entry)))) { + return true; + } + + while (true) { + const agentsSkillsDir = join(currentDir, ".agents", "skills"); + if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) { + return true; + } + + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + return false; + } + currentDir = parentDir; + } +} + +export class ProjectTrustStore { + private trustPath: string; + + constructor(agentDir: string) { + this.trustPath = join(resolvePath(agentDir), "trust.json"); + } + + get(cwd: string): ProjectTrustDecision { + return this.getEntry(cwd)?.decision ?? null; + } + + getEntry(cwd: string): ProjectTrustStoreEntry | null { + return withTrustFileLock(this.trustPath, () => { + const data = readTrustFile(this.trustPath); + return findNearestTrustEntry(data, cwd); + }); + } + + set(cwd: string, decision: ProjectTrustDecision): void { + this.setMany([{ path: cwd, decision }]); + } + + setMany(decisions: ProjectTrustUpdate[]): void { + withTrustFileLock(this.trustPath, () => { + const data = readTrustFile(this.trustPath); + for (const { path, decision } of decisions) { + const key = normalizeCwd(path); + if (decision === null) { + delete data[key]; + } else { + data[key] = decision; + } + } + writeTrustFile(this.trustPath, data); + }); + } +} diff --git a/cactus-code/packages/coding-agent/src/index.ts b/cactus-code/packages/coding-agent/src/index.ts new file mode 100644 index 000000000..2540dbcc3 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/index.ts @@ -0,0 +1,368 @@ +// Core session management + +export { type Args, parseArgs } from "./cli/args.ts"; + +// Config paths +export { + CONFIG_DIR_NAME, + getAgentDir, + getDocsPath, + getExamplesPath, + getPackageDir, + getReadmePath, + VERSION, +} from "./config.ts"; +export { + AgentSession, + type AgentSessionConfig, + type AgentSessionEvent, + type AgentSessionEventListener, + type ModelCycleResult, + type ParsedSkillBlock, + type PromptOptions, + parseSkillBlock, + type SessionStats, +} from "./core/agent-session.ts"; +// Auth and model registry +export { + type ApiKeyCredential, + type AuthCredential, + type AuthStatus, + AuthStorage, + type AuthStorageBackend, + FileAuthStorageBackend, + InMemoryAuthStorageBackend, + type OAuthCredential, +} from "./core/auth-storage.ts"; +// Compaction +export { + type BranchPreparation, + type BranchSummaryResult, + type CollectEntriesResult, + type CompactionResult, + type CutPointResult, + calculateContextTokens, + collectEntriesForBranchSummary, + compact, + DEFAULT_COMPACTION_SETTINGS, + estimateTokens, + type FileOperations, + findCutPoint, + findTurnStartIndex, + type GenerateBranchSummaryOptions, + generateBranchSummary, + generateSummary, + getLastAssistantUsage, + prepareBranchEntries, + serializeConversation, + shouldCompact, +} from "./core/compaction/index.ts"; +export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.ts"; +// Extension system +export type { + AgentEndEvent, + AgentStartEvent, + AgentToolResult, + AgentToolUpdateCallback, + AppKeybinding, + AutocompleteProviderFactory, + BashToolCallEvent, + BeforeAgentStartEvent, + BeforeAgentStartEventResult, + BeforeProviderRequestEvent, + BeforeProviderRequestEventResult, + BuildSystemPromptOptions, + CompactOptions, + ContextEvent, + ContextUsage, + CustomToolCallEvent, + EditToolCallEvent, + ExecOptions, + ExecResult, + Extension, + ExtensionActions, + ExtensionAPI, + ExtensionCommandContext, + ExtensionCommandContextActions, + ExtensionContext, + ExtensionContextActions, + ExtensionError, + ExtensionEvent, + ExtensionFactory, + ExtensionFlag, + ExtensionHandler, + ExtensionRuntime, + ExtensionShortcut, + ExtensionUIContext, + ExtensionUIDialogOptions, + ExtensionWidgetOptions, + FindToolCallEvent, + GrepToolCallEvent, + InputEvent, + InputEventResult, + InputSource, + KeybindingsManager, + LoadExtensionsResult, + LsToolCallEvent, + MessageRenderer, + MessageRenderOptions, + ProjectTrustContext, + ProjectTrustEvent, + ProjectTrustEventDecision, + ProjectTrustEventResult, + ProjectTrustHandler, + ProviderConfig, + ProviderModelConfig, + ReadToolCallEvent, + RegisteredCommand, + RegisteredTool, + ResolvedCommand, + SessionBeforeCompactEvent, + SessionBeforeForkEvent, + SessionBeforeSwitchEvent, + SessionBeforeTreeEvent, + SessionCompactEvent, + SessionShutdownEvent, + SessionStartEvent, + SessionTreeEvent, + SlashCommandInfo, + SlashCommandSource, + SourceInfo, + TerminalInputHandler, + ToolCallEvent, + ToolCallEventResult, + ToolDefinition, + ToolExecutionMode, + ToolInfo, + ToolRenderResultOptions, + ToolResultEvent, + TurnEndEvent, + TurnStartEvent, + UserBashEvent, + UserBashEventResult, + WidgetPlacement, + WorkingIndicatorOptions, + WriteToolCallEvent, +} from "./core/extensions/index.ts"; +export { + createExtensionRuntime, + defineTool, + discoverAndLoadExtensions, + ExtensionRunner, + isBashToolResult, + isEditToolResult, + isFindToolResult, + isGrepToolResult, + isLsToolResult, + isReadToolResult, + isToolCallEventType, + isWriteToolResult, + wrapRegisteredTool, + wrapRegisteredTools, +} from "./core/extensions/index.ts"; +// Footer data provider (git branch + extension statuses - data not otherwise available to extensions) +export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.ts"; +export { convertToLlm } from "./core/messages.ts"; +export { ModelRegistry } from "./core/model-registry.ts"; +export type { + PackageManager, + PathMetadata, + ProgressCallback, + ProgressEvent, + ResolvedPaths, + ResolvedResource, +} from "./core/package-manager.ts"; +export { DefaultPackageManager } from "./core/package-manager.ts"; +export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.ts"; +export { DefaultResourceLoader, loadProjectContextFiles } from "./core/resource-loader.ts"; +// SDK for programmatic usage +export { + AgentSessionRuntime, + type AgentSessionRuntimeDiagnostic, + type AgentSessionServices, + type CreateAgentSessionFromServicesOptions, + type CreateAgentSessionOptions, + type CreateAgentSessionResult, + type CreateAgentSessionRuntimeFactory, + type CreateAgentSessionRuntimeResult, + type CreateAgentSessionServicesOptions, + // Factory + createAgentSession, + createAgentSessionFromServices, + createAgentSessionRuntime, + createAgentSessionServices, + createBashTool, + // Tool factories (for custom cwd) + createCodingTools, + createEditTool, + createFindTool, + createGrepTool, + createLsTool, + createReadOnlyTools, + createReadTool, + createWriteTool, + type PromptTemplate, +} from "./core/sdk.ts"; +export { + type BranchSummaryEntry, + buildSessionContext, + type CompactionEntry, + CURRENT_SESSION_VERSION, + type CustomEntry, + type CustomMessageEntry, + type FileEntry, + getLatestCompactionEntry, + type ModelChangeEntry, + migrateSessionEntries, + type NewSessionOptions, + parseSessionEntries, + type SessionContext, + type SessionEntry, + type SessionEntryBase, + type SessionHeader, + type SessionInfo, + type SessionInfoEntry, + SessionManager, + type SessionMessageEntry, + type ThinkingLevelChangeEntry, +} from "./core/session-manager.ts"; +export { + type CompactionSettings, + type DefaultProjectTrust, + type ImageSettings, + type PackageSource, + type RetrySettings, + SettingsManager, + type SettingsManagerCreateOptions, +} from "./core/settings-manager.ts"; +// Skills +export { + formatSkillsForPrompt, + type LoadSkillsFromDirOptions, + type LoadSkillsResult, + loadSkills, + loadSkillsFromDir, + type Skill, + type SkillFrontmatter, +} from "./core/skills.ts"; +export { createSyntheticSourceInfo } from "./core/source-info.ts"; +export { type EditDiffResult, generateDiffString, generateUnifiedPatch } from "./core/tools/edit-diff.ts"; +// Tools +export { + type BashOperations, + type BashSpawnContext, + type BashSpawnHook, + type BashToolDetails, + type BashToolInput, + type BashToolOptions, + createBashToolDefinition, + createEditToolDefinition, + createFindToolDefinition, + createGrepToolDefinition, + createLocalBashOperations, + createLsToolDefinition, + createReadToolDefinition, + createWriteToolDefinition, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + type EditOperations, + type EditToolDetails, + type EditToolInput, + type EditToolOptions, + type FindOperations, + type FindToolDetails, + type FindToolInput, + type FindToolOptions, + formatSize, + type GrepOperations, + type GrepToolDetails, + type GrepToolInput, + type GrepToolOptions, + type LsOperations, + type LsToolDetails, + type LsToolInput, + type LsToolOptions, + type ReadOperations, + type ReadToolDetails, + type ReadToolInput, + type ReadToolOptions, + type ToolsOptions, + type TruncationOptions, + type TruncationResult, + truncateHead, + truncateLine, + truncateTail, + type WriteOperations, + type WriteToolInput, + type WriteToolOptions, + withFileMutationQueue, +} from "./core/tools/index.ts"; +export { + hasTrustRequiringProjectResources, + type ProjectTrustDecision, + ProjectTrustStore, + type ProjectTrustStoreEntry, + type ProjectTrustUpdate, +} from "./core/trust-manager.ts"; +// Main entry point +export { type MainOptions, main } from "./main.ts"; +// Run modes for programmatic SDK usage +export { + InteractiveMode, + type InteractiveModeOptions, + type PrintModeOptions, + runPrintMode, +} from "./modes/index.ts"; +// UI components for extensions +export { + AssistantMessageComponent, + BashExecutionComponent, + BorderedLoader, + BranchSummaryMessageComponent, + CompactionSummaryMessageComponent, + CustomEditor, + CustomMessageComponent, + DynamicBorder, + ExtensionEditorComponent, + ExtensionInputComponent, + ExtensionSelectorComponent, + FooterComponent, + keyHint, + keyText, + ModelSelectorComponent, + type RenderDiffOptions, + rawKeyHint, + renderDiff, + SessionSelectorComponent, + type SettingsCallbacks, + type SettingsConfig, + SettingsSelectorComponent, + SkillInvocationMessageComponent, + ThemeSelectorComponent, + ThinkingSelectorComponent, + ToolExecutionComponent, + type ToolExecutionOptions, + TreeSelectorComponent, + truncateToVisualLines, + UserMessageComponent, + UserMessageSelectorComponent, + type VisualTruncateResult, +} from "./modes/interactive/components/index.ts"; +// Theme utilities for custom tools and extensions +export { + getLanguageFromPath, + getMarkdownTheme, + getSelectListTheme, + getSettingsListTheme, + highlightCode, + initTheme, + Theme, + type ThemeColor, +} from "./modes/interactive/theme/theme.ts"; +// Clipboard utilities +export { copyToClipboard } from "./utils/clipboard.ts"; +export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts"; +export { convertToPng } from "./utils/image-convert.ts"; +export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts"; +// Shell utilities +export { getShellConfig } from "./utils/shell.ts"; diff --git a/cactus-code/packages/coding-agent/src/main.ts b/cactus-code/packages/coding-agent/src/main.ts new file mode 100644 index 000000000..acfb0abaa --- /dev/null +++ b/cactus-code/packages/coding-agent/src/main.ts @@ -0,0 +1,779 @@ +/** + * Main entry point for the coding agent CLI. + * + * This file handles CLI argument parsing and translates them into + * createAgentSession() options. The SDK does the heavy lifting. + */ + +import { createInterface } from "node:readline"; +import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; +import chalk from "chalk"; +import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; +import { processFileArguments } from "./cli/file-processor.ts"; +import { buildInitialMessage } from "./cli/initial-message.ts"; +import { listModels } from "./cli/list-models.ts"; +import { createProjectTrustContext } from "./cli/project-trust.ts"; +import { selectSession } from "./cli/session-picker.ts"; +import { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from "./cli/startup-ui.ts"; +import { ENV_SESSION_DIR, expandTildePath, getAgentDir, VERSION } from "./config.ts"; +import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; +import { + type AgentSessionRuntimeDiagnostic, + createAgentSessionFromServices, + createAgentSessionServices, +} from "./core/agent-session-services.ts"; +import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts"; +import { AuthStorage } from "./core/auth-storage.ts"; +import type { ExtensionFactory } from "./core/extensions/types.ts"; +import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; +import type { ModelRegistry } from "./core/model-registry.ts"; +import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; +import { restoreStdout, takeOverStdout } from "./core/output-guard.ts"; +import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; +import type { CreateAgentSessionOptions } from "./core/sdk.ts"; +import { + formatMissingSessionCwdPrompt, + getMissingSessionCwdIssue, + MissingSessionCwdError, + type SessionCwdIssue, +} from "./core/session-cwd.ts"; +import { assertValidSessionId, SessionManager } from "./core/session-manager.ts"; +import { SettingsManager } from "./core/settings-manager.ts"; +import { printTimings, resetTimings, time } from "./core/timings.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; +import { InteractiveMode, runPrintMode } from "./modes/index.ts"; +import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; +import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; + + +/** + * Read all content from piped stdin. + * Returns undefined if stdin is a TTY (interactive terminal). + */ +async function readPipedStdin(): Promise { + // If stdin is a TTY, we're running interactively - don't read stdin + if (process.stdin.isTTY) { + return undefined; + } + + return new Promise((resolve) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => { + resolve(data.trim() || undefined); + }); + process.stdin.resume(); + }); +} + +function collectSettingsDiagnostics( + settingsManager: SettingsManager, + context: string, +): AgentSessionRuntimeDiagnostic[] { + return settingsManager.drainErrors().map(({ scope, error }) => ({ + type: "warning", + message: `(${context}, ${scope} settings) ${error.message}`, + })); +} + +function reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void { + for (const diagnostic of diagnostics) { + const color = diagnostic.type === "error" ? chalk.red : diagnostic.type === "warning" ? chalk.yellow : chalk.dim; + const prefix = diagnostic.type === "error" ? "Error: " : diagnostic.type === "warning" ? "Warning: " : ""; + console.error(color(`${prefix}${diagnostic.message}`)); + } +} + +function isTruthyEnvFlag(value: string | undefined): boolean { + if (!value) return false; + return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; +} + +function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode { + if (parsed.mode === "json") { + return "json"; + } + if (parsed.print || !stdinIsTTY || !stdoutIsTTY) { + return "print"; + } + return "interactive"; +} + +function toPrintOutputMode(appMode: AppMode): Mode { + return appMode === "json" ? "json" : "text"; +} + +function isPlainRuntimeMetadataCommand(parsed: Args): boolean { + return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined); +} + +async function prepareInitialMessage( + parsed: Args, + autoResizeImages: boolean, + stdinContent?: string, +): Promise<{ + initialMessage?: string; + initialImages?: ImageContent[]; +}> { + if (parsed.fileArgs.length === 0) { + return buildInitialMessage({ parsed, stdinContent }); + } + + const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages }); + return buildInitialMessage({ + parsed, + fileText: text, + fileImages: images, + stdinContent, + }); +} + +/** Result from resolving a session argument */ +type ResolvedSession = + | { type: "path"; path: string } // Direct file path + | { type: "local"; path: string } // Found in current project + | { type: "global"; path: string; cwd: string } // Found in different project + | { type: "not_found"; arg: string }; // Not found anywhere + +/** + * Resolve a session argument to a file path. + * If it looks like a path, use as-is. Otherwise try to match as session ID prefix. + */ +async function findLocalSessionByExactId( + sessionId: string, + cwd: string, + sessionDir?: string, +): Promise<{ type: "local"; path: string } | undefined> { + const localSessions = await SessionManager.list(cwd, sessionDir); + const localMatch = localSessions.find((s) => s.id === sessionId); + return localMatch ? { type: "local", path: localMatch.path } : undefined; +} + +async function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise { + // If it looks like a file path, resolve it before handing it to the session manager. + if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { + return { type: "path", path: resolvePath(sessionArg, cwd) }; + } + + // Try to match as session ID in current project first + const localSessions = await SessionManager.list(cwd, sessionDir); + const localMatch = + localSessions.find((s) => s.id === sessionArg) ?? localSessions.find((s) => s.id.startsWith(sessionArg)); + + if (localMatch) { + return { type: "local", path: localMatch.path }; + } + + // Try global search across all projects + const allSessions = await SessionManager.listAll(sessionDir); + const globalMatch = + allSessions.find((s) => s.id === sessionArg) ?? allSessions.find((s) => s.id.startsWith(sessionArg)); + + if (globalMatch) { + return { type: "global", path: globalMatch.path, cwd: globalMatch.cwd }; + } + + // Not found anywhere + return { type: "not_found", arg: sessionArg }; +} + +/** Prompt user for yes/no confirmation */ +async function promptConfirm(message: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(`${message} [y/N] `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + }); + }); +} + +function validateForkFlags(parsed: Args): void { + if (!parsed.fork) return; + + const conflictingFlags = [ + parsed.session ? "--session" : undefined, + parsed.continue ? "--continue" : undefined, + parsed.resume ? "--resume" : undefined, + parsed.noSession ? "--no-session" : undefined, + ].filter((flag): flag is string => flag !== undefined); + + if (conflictingFlags.length > 0) { + console.error(chalk.red(`Error: --fork cannot be combined with ${conflictingFlags.join(", ")}`)); + process.exit(1); + } +} + +function validateSessionIdFlags(parsed: Args): void { + if (parsed.sessionId === undefined) return; + + const conflictingFlags = [ + parsed.session ? "--session" : undefined, + parsed.continue ? "--continue" : undefined, + parsed.resume ? "--resume" : undefined, + parsed.noSession ? "--no-session" : undefined, + ].filter((flag): flag is string => flag !== undefined); + + if (conflictingFlags.length > 0) { + console.error(chalk.red(`Error: --session-id cannot be combined with ${conflictingFlags.join(", ")}`)); + process.exit(1); + } + + try { + assertValidSessionId(parsed.sessionId); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.red(`Error: ${message}`)); + process.exit(1); + } +} + +function forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string, sessionId?: string): SessionManager { + try { + return SessionManager.forkFrom(sourcePath, cwd, sessionDir, { id: sessionId }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(chalk.red(`Error: ${message}`)); + process.exit(1); + } +} + +async function createSessionManager( + parsed: Args, + cwd: string, + sessionDir: string | undefined, + settingsManager: SettingsManager, +): Promise { + if (parsed.noSession || parsed.help || parsed.listModels !== undefined) { + return SessionManager.inMemory(cwd); + } + + if (parsed.fork) { + if (parsed.sessionId) { + const existingTarget = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir); + if (existingTarget) { + console.error(chalk.red(`Session already exists with id '${parsed.sessionId}'`)); + process.exit(1); + } + } + + const resolved = await resolveSessionPath(parsed.fork, cwd, sessionDir); + + switch (resolved.type) { + case "path": + case "local": + case "global": + return forkSessionOrExit(resolved.path, cwd, sessionDir, parsed.sessionId); + + case "not_found": + console.error(chalk.red(`No session found matching '${resolved.arg}'`)); + process.exit(1); + } + } + + if (parsed.session) { + const resolved = await resolveSessionPath(parsed.session, cwd, sessionDir); + + switch (resolved.type) { + case "path": + case "local": + return SessionManager.open(resolved.path, sessionDir); + + case "global": { + console.log(chalk.yellow(`Session found in different project: ${resolved.cwd}`)); + const shouldFork = await promptConfirm("Fork this session into current directory?"); + if (!shouldFork) { + console.log(chalk.dim("Aborted.")); + process.exit(0); + } + return forkSessionOrExit(resolved.path, cwd, sessionDir); + } + + case "not_found": + console.error(chalk.red(`No session found matching '${resolved.arg}'`)); + process.exit(1); + } + } + + if (parsed.resume) { + try { + const selectedPath = await selectSession( + (onProgress) => SessionManager.list(cwd, sessionDir, onProgress), + (onProgress) => SessionManager.listAll(sessionDir, onProgress), + settingsManager, + ); + if (!selectedPath) { + console.log(chalk.dim("No session selected")); + process.exit(0); + } + return SessionManager.open(selectedPath, sessionDir); + } finally { + stopThemeWatcher(); + } + } + + if (parsed.continue) { + return SessionManager.continueRecent(cwd, sessionDir); + } + + if (parsed.sessionId) { + const existingSession = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir); + if (existingSession) { + return SessionManager.open(existingSession.path, sessionDir); + } + } + + return SessionManager.create(cwd, sessionDir, { id: parsed.sessionId }); +} + +function buildSessionOptions( + parsed: Args, + scopedModels: ScopedModel[], + hasExistingSession: boolean, + modelRegistry: ModelRegistry, + settingsManager: SettingsManager, +): { + options: CreateAgentSessionOptions; + cliThinkingFromModel: boolean; + diagnostics: AgentSessionRuntimeDiagnostic[]; +} { + const options: CreateAgentSessionOptions = {}; + const diagnostics: AgentSessionRuntimeDiagnostic[] = []; + let cliThinkingFromModel = false; + + // Model from CLI + // - supports --provider --model + // - supports --model / + if (parsed.model) { + const resolved = resolveCliModel({ + cliProvider: parsed.provider, + cliModel: parsed.model, + cliThinking: parsed.thinking, + modelRegistry, + }); + if (resolved.warning) { + diagnostics.push({ type: "warning", message: resolved.warning }); + } + if (resolved.error) { + diagnostics.push({ type: "error", message: resolved.error }); + } + if (resolved.model) { + options.model = resolved.model; + // Allow "--model :" as a shorthand. + // Explicit --thinking still takes precedence (applied later). + if (!parsed.thinking && resolved.thinkingLevel) { + options.thinkingLevel = resolved.thinkingLevel; + cliThinkingFromModel = true; + } + } + } + + if (!options.model && scopedModels.length > 0 && !hasExistingSession) { + // Check if saved default is in scoped models - use it if so, otherwise first scoped model + const savedProvider = settingsManager.getDefaultProvider(); + const savedModelId = settingsManager.getDefaultModel(); + const savedModel = savedProvider && savedModelId ? modelRegistry.find(savedProvider, savedModelId) : undefined; + const savedInScope = savedModel ? scopedModels.find((sm) => modelsAreEqual(sm.model, savedModel)) : undefined; + + if (savedInScope) { + options.model = savedInScope.model; + // Use thinking level from scoped model config if explicitly set + if (!parsed.thinking && savedInScope.thinkingLevel) { + options.thinkingLevel = savedInScope.thinkingLevel; + } + } else { + options.model = scopedModels[0].model; + // Use thinking level from first scoped model if explicitly set + if (!parsed.thinking && scopedModels[0].thinkingLevel) { + options.thinkingLevel = scopedModels[0].thinkingLevel; + } + } + } + + // Thinking level from CLI (takes precedence over scoped model thinking levels set above) + if (parsed.thinking) { + options.thinkingLevel = parsed.thinking; + } + + // Scoped models for Ctrl+P cycling + // Keep thinking level undefined when not explicitly set in the model pattern. + // Undefined means "inherit current session thinking level" during cycling. + if (scopedModels.length > 0) { + options.scopedModels = scopedModels.map((sm) => ({ + model: sm.model, + thinkingLevel: sm.thinkingLevel, + })); + } + + // API key from CLI - set in authStorage + // (handled by caller before createAgentSession) + + // Tools + if (parsed.noTools) { + options.noTools = "all"; + } else if (parsed.noBuiltinTools) { + options.noTools = "builtin"; + } + if (parsed.tools) { + options.tools = [...parsed.tools]; + } + if (parsed.excludeTools) { + options.excludeTools = [...parsed.excludeTools]; + } + + return { options, cliThinkingFromModel, diagnostics }; +} + +function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined { + return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value)); +} + +async function promptForMissingSessionCwd( + issue: SessionCwdIssue, + settingsManager: SettingsManager, +): Promise { + return showStartupSelector(settingsManager, formatMissingSessionCwdPrompt(issue), [ + { label: "Continue", value: issue.fallbackCwd }, + { label: "Cancel", value: undefined }, + ]); +} + +export interface MainOptions { + extensionFactories?: ExtensionFactory[]; +} + +export async function main(args: string[], options?: MainOptions) { + resetTimings(); + if (args[0] === "code") { + args = args.slice(1); + } + const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE); + if (offlineMode) { + process.env.PI_OFFLINE = "1"; + process.env.PI_SKIP_VERSION_CHECK = "1"; + } + + const cwd = process.cwd(); + const agentDir = getAgentDir(); + const bootstrapSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); + applyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy); + configureHttpDispatcher(); + + const parsed = parseArgs(args); + if (parsed.diagnostics.length > 0) { + for (const d of parsed.diagnostics) { + const color = d.type === "error" ? chalk.red : chalk.yellow; + console.error(color(`${d.type === "error" ? "Error" : "Warning"}: ${d.message}`)); + } + if (parsed.diagnostics.some((d) => d.type === "error")) { + process.exit(1); + } + } + time("parseArgs"); + + if (parsed.version) { + console.log(VERSION); + process.exit(0); + } + + let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY); + const shouldTakeOverStdout = appMode !== "interactive" && !isPlainRuntimeMetadataCommand(parsed); + if (shouldTakeOverStdout) { + takeOverStdout(); + } + + validateForkFlags(parsed); + validateSessionIdFlags(parsed); + + const migratedProviders: string[] = []; + + const startupSettingsManager = SettingsManager.create(cwd, agentDir); + reportDiagnostics(collectSettingsDiagnostics(startupSettingsManager, "startup session lookup")); + + // Experimental first-time setup: theme choice and analytics opt-in. + // Runs before any runtime services are created so the chosen settings apply everywhere. + if (appMode === "interactive" && !parsed.help && parsed.listModels === undefined && shouldRunFirstTimeSetup()) { + await showFirstTimeSetup(startupSettingsManager); + time("firstTimeSetup"); + } + + // Decide the final runtime cwd before creating cwd-bound runtime services. + // --session and --resume may select a session from another project, so project-local + // settings, resources, provider registrations, and models must be resolved only after + // the target session cwd is known. The startup-cwd settings manager is used only for + // sessionDir lookup during session selection. + const envSessionDir = process.env[ENV_SESSION_DIR]; + const sessionDir = + (parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ?? + (envSessionDir ? expandTildePath(envSessionDir) : undefined) ?? + startupSettingsManager.getSessionDir(); + let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager); + const missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd); + if (missingSessionCwdIssue) { + if (appMode === "interactive") { + const selectedCwd = await promptForMissingSessionCwd(missingSessionCwdIssue, startupSettingsManager); + if (!selectedCwd) { + process.exit(0); + } + sessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd); + } else { + console.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message)); + process.exit(1); + } + } + if (parsed.name !== undefined) { + const name = parsed.name.trim(); + if (!name) { + console.error(chalk.red("Error: --name requires a non-empty value")); + process.exit(1); + } + sessionManager.appendSessionInfo(name); + } + time("createSessionManager"); + + const trustStore = new ProjectTrustStore(agentDir); + const sessionCwd = sessionManager.getCwd(); + const autoTrustOnReloadCwd = + parsed.projectTrustOverride === undefined && !hasTrustRequiringProjectResources(sessionCwd) + ? sessionCwd + : undefined; + const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; + const projectTrustByCwd = new Map(); + + const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); + const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates); + const resolvedThemePaths = resolveCliPaths(cwd, parsed.themes); + const authStorage = AuthStorage.create(); + const createRuntime: CreateAgentSessionRuntimeFactory = async ({ + cwd, + agentDir, + sessionManager, + sessionStartEvent, + projectTrustContext, + }) => { + const isInitialRuntime = sessionStartEvent === undefined; + const projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = []; + const cachedProjectTrust = projectTrustByCwd.get(cwd); + const hasTrustRequiringResources = hasTrustRequiringProjectResources(cwd); + const shouldResolveProjectTrust = + parsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustRequiringResources; + const projectTrusted = shouldResolveProjectTrust + ? false + : (cachedProjectTrust ?? + parsed.projectTrustOverride ?? + (!hasTrustRequiringResources || trustStore.get(cwd) === true)); + const runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted }); + const services = await createAgentSessionServices({ + cwd, + agentDir, + authStorage, + settingsManager: runtimeSettingsManager, + extensionFlagValues: parsed.unknownFlags, + resourceLoaderReloadOptions: shouldResolveProjectTrust + ? { + resolveProjectTrust: async ({ extensionsResult }) => { + const trusted = await resolveProjectTrusted({ + cwd, + trustStore, + trustOverride: parsed.projectTrustOverride, + defaultProjectTrust: startupSettingsManager.getDefaultProjectTrust(), + extensionsResult, + projectTrustContext: + projectTrustContext ?? + createProjectTrustContext({ + cwd, + mode: isInitialRuntime ? trustPromptMode : appMode, + settingsManager: startupSettingsManager, + hasUI: isInitialRuntime && trustPromptMode === "interactive", + }), + onExtensionError: (message) => projectTrustDiagnostics.push({ type: "warning", message }), + }); + projectTrustByCwd.set(cwd, trusted); + return trusted; + }, + } + : undefined, + resourceLoaderOptions: { + additionalSkillPaths: resolvedSkillPaths, + additionalPromptTemplatePaths: resolvedPromptTemplatePaths, + additionalThemePaths: resolvedThemePaths, + noExtensions: true, + noSkills: parsed.noSkills, + noPromptTemplates: parsed.noPromptTemplates, + noThemes: parsed.noThemes, + noContextFiles: parsed.noContextFiles, + systemPrompt: parsed.systemPrompt, + appendSystemPrompt: parsed.appendSystemPrompt, + extensionFactories: options?.extensionFactories, + }, + }); + const { settingsManager, modelRegistry, resourceLoader } = services; + const diagnostics: AgentSessionRuntimeDiagnostic[] = [ + ...projectTrustDiagnostics, + ...services.diagnostics, + ...collectSettingsDiagnostics(settingsManager, "runtime creation"), + ...resourceLoader.getExtensions().errors.map(({ path, error }) => ({ + type: "error" as const, + message: `Failed to load extension "${path}": ${error}`, + })), + ]; + + const modelPatterns = parsed.models ?? settingsManager.getEnabledModels(); + const scopedModels = + modelPatterns && modelPatterns.length > 0 ? await resolveModelScope(modelPatterns, modelRegistry) : []; + const { + options: sessionOptions, + cliThinkingFromModel, + diagnostics: sessionOptionDiagnostics, + } = buildSessionOptions( + parsed, + scopedModels, + sessionManager.buildSessionContext().messages.length > 0, + modelRegistry, + settingsManager, + ); + diagnostics.push(...sessionOptionDiagnostics); + + if (parsed.apiKey) { + if (!sessionOptions.model) { + diagnostics.push({ + type: "error", + message: "--api-key requires a model to be specified via --model, --provider/--model, or --models", + }); + } else { + authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey); + } + } + + const created = await createAgentSessionFromServices({ + services, + sessionManager, + sessionStartEvent, + model: sessionOptions.model, + thinkingLevel: sessionOptions.thinkingLevel, + scopedModels: sessionOptions.scopedModels, + tools: sessionOptions.tools, + excludeTools: sessionOptions.excludeTools, + noTools: sessionOptions.noTools, + customTools: sessionOptions.customTools, + }); + const cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel; + if (created.session.model && cliThinkingOverride) { + created.session.setThinkingLevel(created.session.thinkingLevel); + } + + return { + ...created, + services, + diagnostics, + }; + }; + time("createRuntime"); + const runtime = await createAgentSessionRuntime(createRuntime, { + cwd: sessionManager.getCwd(), + agentDir, + sessionManager, + }); + time("createAgentSessionRuntime"); + const { services, session, modelFallbackMessage } = runtime; + const { settingsManager, modelRegistry, resourceLoader } = services; + applyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy); + configureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs()); + + if (parsed.help) { + const extensionFlags = resourceLoader + .getExtensions() + .extensions.flatMap((extension) => Array.from(extension.flags.values())); + printHelp(extensionFlags); + process.exit(0); + } + + if (parsed.listModels !== undefined) { + const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined; + await listModels(modelRegistry, searchPattern); + process.exit(0); + } + + // Read piped stdin content (if any) + let stdinContent: string | undefined = await readPipedStdin(); + if (stdinContent !== undefined && appMode === "interactive") { + appMode = "print"; + } + time("readPipedStdin"); + + const { initialMessage, initialImages } = await prepareInitialMessage( + parsed, + settingsManager.getImageAutoResize(), + stdinContent, + ); + time("prepareInitialMessage"); + initTheme(settingsManager.getTheme(), appMode === "interactive"); + time("initTheme"); + + time("resolveModelScope"); + reportDiagnostics(runtime.diagnostics); + if (runtime.diagnostics.some((diagnostic) => diagnostic.type === "error")) { + process.exit(1); + } + time("createAgentSession"); + + if (appMode !== "interactive" && !session.model) { + console.error(chalk.red(formatNoModelsAvailableMessage())); + process.exit(1); + } + + const startupBenchmark = isTruthyEnvFlag(process.env.PI_STARTUP_BENCHMARK); + if (startupBenchmark && appMode !== "interactive") { + console.error(chalk.red("Error: PI_STARTUP_BENCHMARK only supports interactive mode")); + process.exit(1); + } + + if (appMode === "interactive") { + const interactiveMode = new InteractiveMode(runtime, { + migratedProviders, + modelFallbackMessage, + autoTrustOnReloadCwd, + initialMessage, + initialImages, + initialMessages: parsed.messages, + verbose: parsed.verbose, + }); + if (startupBenchmark) { + await interactiveMode.init(); + time("interactiveMode.init"); + interactiveMode.stop(); + stopThemeWatcher(); + printTimings(); + if (process.stdout.writableLength > 0) { + await new Promise((resolve) => process.stdout.once("drain", resolve)); + } + if (process.stderr.writableLength > 0) { + await new Promise((resolve) => process.stderr.once("drain", resolve)); + } + return; + } + + printTimings(); + await interactiveMode.run(); + } else { + printTimings(); + const exitCode = await runPrintMode(runtime, { + mode: toPrintOutputMode(appMode), + messages: parsed.messages, + initialMessage, + initialImages, + }); + stopThemeWatcher(); + restoreStdout(); + if (exitCode !== 0) { + process.exitCode = exitCode; + } + return; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/index.ts b/cactus-code/packages/coding-agent/src/modes/index.ts new file mode 100644 index 000000000..ff9fb633e --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/index.ts @@ -0,0 +1,6 @@ +/** + * Run modes for the coding agent. + */ + +export { InteractiveMode, type InteractiveModeOptions } from "./interactive/interactive-mode.ts"; +export { type PrintModeOptions, runPrintMode } from "./print-mode.ts"; diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/assets/clankolas.png b/cactus-code/packages/coding-agent/src/modes/interactive/assets/clankolas.png new file mode 100644 index 0000000000000000000000000000000000000000..d3dc65da9c17bf964a940e29032514c56271b3fb GIT binary patch literal 539053 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

P|~99~;#fs>D1R7k-Qs zTMAR*Ou&FD;31FW#Zc8fb5+iabWfZgsRCVZ8tkgURH?DqJdkkSU;|ZTdoox06#6+{ zHQ(OcI}7Dj?bWa3eR+oNlVe_7k+VOe3zB>#4EZ#Qsx;@?Rjd+?lT4z_6jWH2Cxxso zjd@30f3*MiG!^06wwU!FP_=FD`urcq1Wj$BMiN+zQ)T?&jF`DzION`Nh_FC zS@@0PLf4rLKg?L!UN_#OuP{8?tiDag9AN8{QMvG z|3?07B{m)bg4QyvHEb2tU9~r&m%h%FRWjh^2)3E@2w@s_QyqjOCs-`cct$4q?|Z6x zW5T{pqR$-ho|naq?Qg8|1xS=cFto z{D@$0ygFdAUfsXe_y5pv-BYw&8b!V6+L{VCHJO}6^FI2bWTa12WRHU)-NG$`0H4Xh zDzx}}kt0Nq$&IBpK`U%bQCRDN5;sDat^YGgfyCdS$^KgI|Ez! z4LZAJ^S<~39p^5JrPBxx^1aKR8fwHLK%25i>DkEakf7jSB&y)`HF-P_h-Q#jsde@Y zm&%Z-!@6_@B=WhJ>uHh=3xd(PW+v5?LH%?S0mB)k6z=xlq0y`W?EUel+mlVi?)*Fk z(;D68XDmeeZTITlQ?mIq_pP$xZ~fn7FI6r$_k+4IUn%Pho7^cVEhzV64nMjAxT()i zwk#+CNbm#m(GG~?r#eG*x*vE|)$K|Njr&V{>>mjK02_g)5-h(G_|PIWYtpQFWjqK} zrXCF4DDau=zqBLhTWeY9WJ_tuD|7w#8Pk9S*FiRuGu~&LIK~Ra(=bVdM{KY;r8@GA zhT&P2J$z~T3vuYMW{KBp6D5Csf4VB0Gx`J9(wjJEC6yB0XyuLXnTw1yC{4e+#|6VA zteZLrV_w7uM~}RDo(TrO>t#% zu;2A_dvNHZoXXEc1psF}eP~0p>z~tP5W4)psFP&Y4wBU$;lPyzOA)Y(moqzBC0!EV zHI-}X{m{iD!rl*Rx~6(?n=z#Cx3Pl2U^WGN@0x1S%bvZxtCYTA1LN@Vc)0kjP`Sqa zF`~1rHccTU`0n=Bl$WC6DpGF6tVz;lnVoQ0kj=D&*NH|Yy`b7miW%wbh2L1K4v5$_ zqg%1=+;!@@yX>Ac_ud$(;sbVG`<)C5Ub{xlTs&K3eq8e_r}l?H{?XF{v%YBK2xl9I z*=LZ7aWTjRH%gYT+k!U9)qn_cTT>1+{lV!pXY0Y+t7wlJH0VF1rbCP37CmMFuWENKpo z34if7f^dW9p@SKy#T;RhCzdZi7ueauN2SOs3uOJ}{9Hy++)bT4czXNGC47#$dy^H- zuS=8v%wu4lD-M|FK>g3j<>@M|1d4e>rMK~TlEF36sh6vNswyXULMLB+4{-mS#k;4A zuXsUqYnr0ooHJp(j!O;kV%3%FWhG)lOz{rRvPm8K*2eOcCvGw#zCC`9c1rTh`(P+H zS2dc$f-3`fBFR#fjyal-iST;;RGw}6goLmnJeh6O;e&|fOG+$4;kGjb)0_Qb(z&2? z<<6(mlm7Vb4z=s9kY)Z#fVH>;2kq^cwodTyWJd}iZnI?roZqGo=r9y4h%ZYnnRTwRGSO&QToilO%ALt}v^VsLC zKz8vUlb1=^(8JX{vdV>(#A+I8ChhkcGBk?^hj_8@zgrIH?2RTtwUx%AzRK|*D;FNV z-M2K^GxEjea>ghB+FCY3oEdakhm>8kIT@xCy3ZfHjy6MVCM%~U%Hl(Ls`4624{wb znKT|YXu+VfmJ5}E+jiWJXu8R>NPLa;?19!kkv5WO8q4{VysR_Lt&O%{8bfNgL^rXZ zDqHSqCuez)p?$mU7JE;8Tp@Z$LRLRtP&qRggd zpH?$7zOYp?q!YW50mIbRo*vxXLfaiuzm_Pi4i9A`dHr7RWz@}`a+Fr-rE#E|StdHyb4Z&it z(DB;S<2&>nD^M(_kWfZd-p%de{$BnuEc#~oMx3RKl6W%~=80pDZKZXB*fpjvNSlI< zlVcZrnrXym>6mx}&rTI(q)l}v+G}_yZkDW(1hZ?*(G)?_+M&`$TGp1|&}=D3f>EoD za&=4&)`BAbxgX}1i4J-2j3V-OXA%kNQ1530bKJ5zE9h$v^zrA1en#DYu3{s z=AwU?^c=NgUSor7(6sl+D$#s*Z1*))*X>+PJPA?>&Zn!lMXRi=*_zNb(h8IXs1jaf zck{%2tBjQ`eQs-^!D|7$*ri^e{!GYAF3X2bD&Pri5 zE^E1HR>+C3wJd>>t;S1@2Pn|e)n|mf&9QUUIPMr&T2jgoTa{A%V3Nk(&adBJPa{5c zvL5;{+t`&`O*jtV$ADWm+n%<Qc%5o|goA)=fs6wi{2D;1%E>!I1mikQ;@&<^Q!$ z*uQA3e4{4n$cF3w1j(u}zc24U=kVzut2G)n-#0Eb`tK04j5;`|R2e$j+`*+#FeDYy z(}7FNaNO+fqukK8n<@xzyz{dLNe+;8D}?C%{Ft*c_I39Y>NOf%U{{Gm3+otEm>eeI zOq8wAHR}1Pmqa@jaAz@}vatRNdWjcz&cj-)IZ5J6!!GU%W2#s3cj(FEG+vEt=8@+` zSg?2KB#eF=qiD6Xk7!2y~w7JUQ~wfZ~cy zi}krKp`MF@cFiJBMf}!O@@}lSo~wwr(Qbx`pkT8X6)~yr%l1;u8yfHzCjADU>_>19 zz1ImFK~bRUzE*l{jIvQ_0m(u$S;)DK{o4n}^an!E>KQ}+PGZ2N)jpJ4erSi418@H@ z*Bg=8XoyxUP-&JsbzMs$`mH#rGD8WYV*D*UVF zR>0&W7&>YZSE_vGS<&ta!GYw;X*JCvnpSD})yqPy%O1BU#}}V+d+%-obHCe^|HUP; zObUMP@1^h(G4hy9{)jx1+nWF7mf4=>nSGii!~H1O+_u~3=A$L!y!eMDhyT7NpBvq_ zHxM8>-g*J3s-eO1L(Nzfx{q>3F{|KIE3K7)tQQyfpDKQSfzf~}i_YDc<%%bd(ozh; z_?X1z#A~Pij;izw&H&&4s)|K{A}cJN@xMv^ljh2(f@Vslz=xjV}h+5Y~0PCG_6#3;H0f!fUk9|DU6SWjG+i*&h70M?w`^0~#g z#BT>V-Gw*y5t|3MI8=5MF687A^J|%myERUtzDem}|rLXYtAVuI{-lx}C+}v}H59 z)Y@W>b2-b*U&W5Ao~_uc2@kY1+eSyTAOK088N_my-RcBlo8~Cq5;Nnq2-#wwWylC z8ZCQ+ZpB;8DNuFz7Z#2*x9ba^)BNIszb|-@wSvB#PbI~XqqJAAc5Nn+=vr1*cg#(7 zfjg$pRGSFQYuz#LdWi_S-9X&rhumD9fAl(eckX4=!|tq-A|-j7n~;<|EN0N)Ty>;V zzE{NXHelpqnOOC39KD?+(up`n7^N3=p+W!3t9Q_0YuO?6>3h>813LsVi4P86uetgJ@^#GnCLvc5qNLG&k!rx-2Gyh*=v3AdiLW5 z&rQ{9eqrOmQOyq=?ETl$n9$pgMm0!iMO*MXltk*6M@670S^sJ;Aa0k&-UU#W`^zg$ zU>7C~G^l)xS@x`ImQvw8k)hhNE)yK?e$(5qckr@1_Z>#5(|*rcrZrx;!S}bAN}&_c zplO-n`4e4HmEqTdJx&wq; z``xtmpll6uPY`VhFWQYA9l+AhjiINWovW*m4YWHN_i9~6-ED4WDB#Z5yrnw=b-Mc< zbho;G7k9%5#$FyDpPye*VvktwPu#I6kFjLBiXE0M&S}-L5d;K#$%?)5%#%=~ zUxE1`%(P~1r4pA5;X7rK#?#uf{J@@G+>`XvUGs->%ir8gfey0T6P%#~<*Pl~&>evb7sr2KG@t!K)N6MC97 zr8wV9E$fS!y{^x$ZtWZ~q0B4uPP5!qwt`deEhZ}<1nb;@#h*Uu~>M_@$ACdL} z*-R9_)%BQ?^twU57{)ZFM*W$GD&;0f+U<*o$K$iiD+`}Rn6lJ7o~nXJ80SfH9$J5~ z6k>Lk=b6^VEDU@k6O4VE+zd74=6d?4XQ`&|K#5}5xQR!-#Jnc^Z6~K$jkax>Q}?Oz zllNL&A4Cl*i$$5OO(QK(fK`FYa{et`@+G%^ay3%y`P@@cNK$%-7UKI9+Z)S7y&`>U zHGObV=28)y4~y!WPW1Gw3YYXs&E2TG9&Lx%(2~!`EUlYM4R-CQajy_uGs&K zta|rWh0A}NUqQ`1VTF#-C+T%&pLVHn5ZQjw%Geu0sv?dhMHNrsvDssV<0F)7!+sFK z7afu$py=k$Td~uVG1mg)(8>ZIsngA`(txp5UXe}BJhr)-0@phOn)`r@LcNLjy%?I; z_SA_q**x-cU;$}ft<}U3Fr9PX_{77rz+5kBdh?^`)OL-SOK_YR+R8~&tkk(Aoo$Yc zRBooyiRI1KXlA~5*cbX1J=%JuB=U=gSxilA5{}>CSEw&PSy?K|xipGiU z%~x^r#b(=Q)sayoGN1E$Gs6;b)chwvz6W^~6bX|AS1dH-S=7`?O4_ zDqN#`j^kL}?;Cp);K+QOyv))PmSQoDKuI9i#}RkImIvb>Cyj4jHB~P8<#mthflJob z*Ri*|3OSW&!qpu&cRp{-pfpt@J)ureHE|_M$>IU_Abvu7w`AMT6MFDyxg8I%qnVDz z^!kLxb_!ox9nIg9PazHp{mNtTxZtPt1fdmELaYlGEl)o=SN|{Jknck}OmmqM(ysmT_P9)<2!n72keXUPmESX^EbouK{^{fHekZi+KwVMo z%J#W^6`)cNY#2;63XIoF{q4Xiz4XSxy(n&&E4tayO~MRGVJ=(M?ex?sy$@f(RL-+$ zL1c#3X>`MyJKj^*pJ0#i&98wEr&3Nnz3TzDiiFN~n%t=z$&@L7QO}Y~X$2aEkgjgC zJSg_mby15mI~N(w#U6pDXS{J=OlXIAzxOPOwLQ-;QlMLl>}LM1@_xW3ufa1!aeSh^ zQ>^GtS_Hn3B8~1i3x;WylN};Zh#RwL`JH&n-|BMhw#uqWJSkJm59h`aQyNovx86L=cfn4FTCA{zsJDhkAF*^_kU2{V^AlaT(kSVd?e zcVZ}bqkmcMF^_b5Y*FG*U-_ux{#N(3{cstbx}1-jb0d2?HVsfN(p+xm`fH@Qp1UK! zrXaLjjHy%KUMNJo+3;1Gd*6q57Us8-4$h-tf?5%N?s|T;1lDgFM$lEb#Ffgg6v@iZ zpX;|9<(VuI5w*UqXRn|G^J+4FPR8MVaH0xg2bd(ZS97ZbR@lWiG#nl6jj)i5N-Zi* z@7Fi5v=|)nEY4E2^XzO_)NUW+?+g=QHsmdz5rZ;YC{{7rqz;>kE`O~Km(Y)Hz0r#p zFHrThtvg8OS$2y(BrdPmxeNMMIWJrLJG~p#yjzwj+U7@Y?X>)dSa+%ydlPcLjEB}yCH6B<57iP=_LBIaY zU}~v!D09?yla%zUPt35$3QyGqKmBA2iU75n99;d_8C7RW%;eRw65yn6-`R(UPNeCJ z&VkpV64R4VL(+v7L$%V>_>BPTexJ`uzceFFY8T1^J`axymD#+P7O;Q8<|Uy2G0E?; z&yL7@{!PH8g9-(-aidc6Eg)iv@r}v&!v`?2#qY`OY<0x<26VaMj$dGDNpS*SZQ_?v z61!hAo3gGqs7%th2(}8NA1{B^C%bV@M5hj% zh#T!ft}|lv`oq_Xf*GSeFAS4thiVxj1EEp01m#czLBXi(IFO*`l3BM;%-!IBegzjB zmBK4!F|;(ZG=ZBE0ta@#`Q51XriHuM5T3Ui^On6x4-7(f*L0@`h>&t?y|KJM$v|x0 z_>h0GBA0}cC;8{%R3rhH^T=9H=FK~lPy7RT7EzM;y9$=SQKP6$Tz9cqKew3+JB9E% z!up>=ff1`2bh9nMf;iRlbhmWK|4y@Yut;5HmmdH zaHn?}WEnp!T0%dH*Ww99()XWWE zMw+qy_YuATYf+!3Jn0(g+GqGy`17~^JM~&2>O!Ujw-<`pJ@vnfU%dIrrv1XMLpC1GF68Qrl|6zH7A>8@+Pd*zrzrc!=Z)Ck zUEIg6xu+ZUntPU|KeJ^V$;5lt4|Q>F0Lp6%p5J)8E{AZONwz<=a+4`BvK0aZ-F8{yRO2T~u0v8*P@bn4tG_MFZv*3D13lu0MT4NleNq@oOUU9<+~%eD1J&kOtP*3w2R`SrjsDc{05 z<(kE+5R^RHamliqv8;adVsm*AcA@DNU7qtY3X2=?q8wp*ip@(r`g>y~a#Lcve=OBX z;O%3%jpub@T`S$^Y9-8`{O^-%s(ZjAWwfpPPbtH99=~yl#7QN}v)*OVvIBIM8k+kL z-HCYq6*khU#XYHUiPuKQJ^$+UJp**n92QGB^Q66EYv*O_b87I^yUL5|=Ttc+5Lc(U z)svlpEvs&g?Z9&-6W52GWMW@*)yr1wYr*mH zEcb5hx-bdS*Q!j1@62Em2<%&6lPgUNZ7-{n=;;Pk;g!~4Hgxry=+pV9jxX3Np4bR^ zA9b=s;Ti?tob-HE4)EK@5MD0%_KD^kME6(ZM zuKh>I%#^`IqN*IGFS$f=#n>+JGHNN18qQtx%(`a|1obFqW8vY|J^86>yj@y(yZYO} zwH|MR){Z%e8W6HwE@DYu+WPqs9`{V1?e5r8TI^A7((0Y??%^^ZkEp-2rbGRJ%%X?d z>>V|RV z*zr+lx>a5yd|anc=umn3k3^!jA}-E({Sf0B-`aY!yu5t0>6sUD`dy*ByI0vZ-m~** z=(QAxE;i&Afz`dDb;KB@105T*e3nG0n82brP+Uv_E5bG2r<{HSgP|-(q_3``eQlC! zz7L1JuYvDCvaT*Jg5}@Jb=A3h3w;LI)0Fpw1j_dGg3s^naQ9lJ67biZ)KF5EWq!W4 zmGe%nD18S0{4xr4Bh}ci4cjz^z2m<0fI*-W(z8~Fs6BnvAXpX!E6&m|afrE@pZ~D# z{e+Lvt#x^Rei!n6MrKOhwm-VudIApwN)#r1Jw4+;(E=tOr*A9k=nsb5?(HHHpq_cYKcywgY>| zZ&Vk@jZ?GQ)Yg>-{(*zdXEPybsNp<;ZsVwbs1jF;Cx8sX3_x0+-15{^2@j8-7``o0 z(^WOETMPQOQgCoBl3h%+Dk51par3L6JdcxTG#T$*!P}^oUymx7P6Z@al?}WbW!x~W zIk~%=o45zUeCu z`K!mM>!WScriAY8y1U*)9S6zd$SGL`*7Yp!HhlfC=mgsNtB;U1o@WIG(GCPqv!A5| zoqU54hV^@G5FgVL0}h_Do+z^?R5hq#Z}YGGXj8$EdGWb1L*;VQ!H%Xlqj%RXu7bxp z=T0wAWBB zUd*u1JHPjs2Mjqlvh*Hf@%wAmUiolw^a4-auF-+UK`F5TJWlI)!J_Ln zHl@6_rIENj24l+Xd zpw&{34P0q7T1dElzhxe`R^~B%QKzd8 z_*}TA`3p}`*r@*W_^ZS|YTqGRZq+0b$M3}!R&3twf0*rM#+-QLvxU_s4ItM?W5J>M z;Xf0zNVg6SZZ>^nB4gRwqPUbx3M@>VC&?D9u#ziRDyfT%Wk2D$q8CvOI|E$z&Hju*Ze^~ns8s05Z>Y$7OM zb_7OD)qII0+siePVe-|2d)qHMFk7%Fb1Ap^%%2`oy%slkMdstZ_NABndRMxz0-lCM z^_xYVuN04LUFVmhxTD~C-Cdh>7}wilD9RocxW3n-~%H&`fW)m>5&d#r~8 z=|9|F;0((4&-zeSqZMeTtGnfQ5iIfrx(@a@66XH8CO_X2SAtq^ZcsiCwmc-+cE-nr zBXc(GTQ}vl?+}n(>E0gJBpuP8k!exdc?QaTs+1XekC)QK_y7Bqb*Wl$yz;PnRyeRm zABx$U0*B8EHJ7TvdR`2m5}U%@lj1N$lM z2I}PoN>gG{v-$sy)jnWktY5TJ3{h2m>F3&ZZYsd;X~`f{(wR#);&{#8zUoVN8M?~~ z5`0gVk%D=_Mm6iQu(HB4*X6THvg@||sc>and!7fcb#pR-?VnQ3W)R@h%>%818Me}2 zlW$6i$ppOhvz2ELfAFS_Ay=c|uKZ2#QP0y~(jSBj*U;#Yn<#~0Q|wJI76X$Gx!Rr& zIj_Z@;-D}D8eJx$$xCn?KWrH_*^ddii^8Hpt}O3c*L$$ZNg>zM>$h>eHyQeL zyX}U7A;FWUw+g}cRd9tnR0w?%RZz%{5G!yy7%yA+famu`*NXVW-?^DtpR9kTw~PZg zlH_1>*fc=eI89M6X_mJjobyeYPwy(-s*`TX-@^}l-9vM$Gl6lF4wSg4C{DP4=Q<9o zTZgR|m=^tyP+P|Ax)1yq7fJ49<<+km?|Q~7n&_G78Qi(T4>YO*80wb=ne8Vv0bw?S zbNKBt5jsnL^$rF)#b>d6pPz`3aVT@2yCm4EdbLOL(#w5nG=2fG9#CSK^KQcP7mstP z^bt*nkM*Z}D+)U-NP^Z3(=vWIjB%?y)h-$K7L8AT*g-ig!SKiUn<5?5>$z^utmF}> zKUUuQwO%*pZ+&&W>#uwqCT1S*wl5%GI3p?F;^CKbu?m#GCDMbcYONP?(1DZjKQF(1 zB_>3>if}(CBM61a@_v0X{F)6yyA?gwg=^MHvRziuD}}I*I9HflXPH& zM;U3<8f+keUg@8Gtc74`z(n58;TL=&m+Df8NtT?%r-WL`dQ;U( z1tM!Ct2e(@qa#@=bSX8W0fd^j+E%9N`mpNNeu0=c$~-f-!LRu zK9m}{SETX$cBV{O(nI)rJ|;;hMGGTBBk+YbzwY?2Zu0uArCehpjT0H^R+1mGzZ%x> z76WuB2!$37VkKQVzF~juyrGV05UVj0lKeI_bnr))$G|JVgTUUOJY>n~{yt5x{Obb+ zW<`QppXPxtBY0`cKRzpn+U7!IHDNDkR=2ZY#?bD&PZ-ZzG zR;&7B{ATNV@I3=|XZ`j7dy6YKQ3^pJLD+L-NS1S==Zd>8rf5Q&D?9anrl=FQt|2C^3rGbM-0Z@YNqw#Q z&>exh3)?;ATr44O;N`dooTzkSbE6r~O{FF}O-+;@=~m@Uu_6PjjvQCk*fi z##T{_yu~%(=bzdZe*`!hj&Hl8aft~=gPxsNjLpx0B7s7~kncn{_};!|g-)%@Is(Ov z6T)gLg1PiDN_J@%->QeVYnlot;8mH<;4&Uy?US4jV3pWI4%vX6XYUmJ`oz9rf409@ETLK$vC>>K?VM+%==VUf!fV*g&-n7}KEJtVJR%N2)!%SZmGLpj$>KXVo>s)~F!`v-^meXC47a-e8QlLVdbrbusw>=j_TdN8h)#e1%(A zSfRZZ%jVJ;wv(EppqfR=u=KwvQ7IXeqs@PBY6-)ZrKFx(%!>^tPS$p#pB;iRHZqgD zYGf&8TJN66SF}$l{c3Uu+p~%HG?$fO{yHngLS$3~ElvrC2&K!oZ z_TuCc)cCEePd7gWgkYpuVWNKoamQnRLm7;~T{ie^TEW4%6S8ZW^atm(8$XkBBf!bM zzDM_j+)|y7CzBTE(2-l~X6~CJ#VK1P@m=8C(!|wpUiuL}$&g%VH3yY|0legUc)!ZI zvn0DWdm(z{3901vE z@FGp>Plty`@ee*L%NMTHKIGzY2>4q1RmrQbE{nmtX1yYs>-W-DTdarZ2aSl>EG3aa ze?Kn0hl`{NzBCK%e>`id$^xT3X<*Bxe$4)L&BO6)GrxF~G)q>_AS|cv)r2@ZqF3DX zz64->gk2U=a>Y6&BDmGwC1g+7@EoCw}?QoGGN< zq{*MN$sve9qOnbSsZBPA@t<||8ULO zBqAWEx!{wlD~jPepYPwAvxsb~5m&7D$H6j(t;?+UmvNLC4iipQBclT@fh+Fr2;-!6 z=b#{kt<nFHQ7pTEvBze3Q5UI&Sj8UMb*o+}E3| zSYt2_RSEm%;BoBJ8MLzAgR9uVZ%?1=0=iexaEl_3S;7*xR%U34lO)(CUT(I7H3R^N zY1Z6``t{$&AA`M62cTOX9bC;I8a|r#+0mbWmaD^GyqjRWyur&5LZQx+pUYNU zvZ+LAq@kVPO{Mt`NoQ3Trn7rxk*CL>$Qy+Nzw_ps*bm5gI_><+mCJ41Hy-^SDfRLr ze~#giG;&m*hFM~WghvNM3x`L77-;kl(~}?{8eHF>;lg?3y$D(8Au;3+E*=hONJqo zs}ZFF6gI_d`ZfzkNxbAziNd?4qER`WLHUgrd8^}0Sw%{smTG zQ`2R}8fLjI#yV4(=$xxVCFKkKy`{u9%~!5JW_#j=Tk2Y#Dz@&_VT!^Y@J~%^Hi+WS zZ?Lx*w#c~PGb|Z?HhL+A+n^O^3B^d^g5CZNx@gCItU0XrMfv5;P%lW)swWA%)R^+_ zKoBTv9A#JzLSjaxnCcG zY7^#S^2!I$b-qQ$x)tRszmKOw`%%&XOTL7maK9VJO0P#TR9Q&!&si^so;rtrG{9#(2jO_Gq-zxyU`yD zu8IwJ8s#SiyFwL+ZZ*AtqIFPfZG1&mgXotEI&)Wwpc5katH^cSknKrjSG+3<3c16T znak}ATw8`qQLumCx-8eQle@{7b+zo?+kS&f0bM&pC+#1GT%J>6|0vva#tY42V3uwe zYVc`c8MFraLs+P{myXe`Mq-8aI`zcPzLUkpE?{t!ygd1zs3@v-I^@;UY!gkpXcMiQ z=%l^KYb})xoARX5TEvwDHdKrMrT)tP8P@K=GM!y3`JdufOQ6y0xExTFY*yL$On3Sg z>pt#8->8B)Bgf~$3-`w@i*3uPumA*)|A~e)6TUJ8JUeu=_unQJWtFFV`<@2C5fKp= zTj$|VHRibjQq|#VZ*`mF37o7LM|kp9xyqTkkIU|kYdWWcl}h`&_cI%ikyLX&z-lIf zR2A>|3Q}TXkZViG%3u4RSsWx3Kpf`j zCepy8laTpB0~C$V_Ga+b|9BbsrBRXC!+;lslsr3|3RMb7O9n$dF_G z9F*`>8bBww9+0ZoFQTpz12WElL}-i4n!0z#(&ZmLHn%cWM~`iWHD1Dn0ZDY6RdFB3 zsJJSeEc(XbaDo(;gQrfP*gW9Yq+hcmw*Lri?sT2M0E)=2;7^J-uXVs)kL6oUd5dg1 zM49A3t;=yKy}kF>mG8M9s^$F_68Ui4!6BmaFTNh*la_U>e(g0n%o!mT}I0vWBL76wVPeh9~ljjocRISN0(65x9%P+|y4YYqg zj;6=mQ2C;&D_^3kOcMO#-|Qff?y&zCo}d#Oy6w4NC*C&fWHe<4cz8|xrbkAww8hROK3P9MK=9%6^C-1u|^LG6B;m8a*B zVh)J?w~vV^Oz-o)rjNuYfI;dPO_HG#Re|E7VaMt|r5`GB28#{$9CM1+WkPJqbPpZy z!U+pz=ugXkwqSUj$}qv1OdSL@x0jcD)z0o{9F%Z*d9}H@i^AYsG0b|C(g@iYO|Ypm z#vNUK-eRk&l!jSbJ5zsj->k5+GRDan`o)U~SDGS1Hw=SkP8y7gno?v2T2p zEpLaiT%}ntE+U~cuu0QC!G}IKv`rB0`wx9Jq+v`hd0LZWA&DdHBBB**{3t9=E}sj88e!R_W!GgkgDiZq$?_K`yAXk+{-!+ zkn>gX3Fk)Wgg1o4E6M^ort?&R${H%|>cEC%k=N-vYn^pV)KDsu13pqonM?QDymD*H z5a!Y4$PTTAR@lGU(T%5L@c6B7ioP4YG$X*YKoS&X&+Gk7p{+6K%PRM&Z%yQ){O7z*(<&To}lJiosW* z(NvV*vAVar`?<~GmFx?GKBAxV?=11*ho4PQyEqR9OUV5JF81{X2j(qL;V0RrdN2O7 z>sa+i-!bBT|4{#F=1(wMS_hoPH=V`Kn<+?6x}9YNAr*Y{&O)S)-5##D#7xg=>(URWga))yq1n?Y|13w2k_Hb}hc>d;AblWwl-l8h`)*tiUk;r1@z$IpB4k zcyzG#;7HQ{N|oOhZZwMXu^}m>n%Upc#q?E+Rv)+T_h(fR^Om8N)UY4aOB$hf~uiYn5n7JFO6-Bf~_Uf#~ zgd;DY_H|sKeRU5&GW^6NF!V6VMCYR%1B2ewN^qF5iyG+{?=D0BU1@7T_Zcf+y3+Kp+w@jB4FZp}#OYx=rU zjj#aC`ra>b;bP;t7V3y{KtMS(_e|9&HiYVK0@NTl2jRRoN@sEPm!rR54h7O2zne$@ zd(#+oIG|T8zPFB7y78;4p=!$0>yfbDF8r0h3?iKEA^f>>YS#4X?rmV=oT*_Z-o8B~;AVT?^8PsAkhS^WVGq?jNi*k6{z|h%zp+iECwCdz5v><&s>oC2q1K z*9gD&_xG^zF)8BIp_H}-$MoYI+%vN_is3P-1LVtYY!VqEPXun@>VX9twm>| ze$nSNpsDVjtW*Hs19L?9zZ&oqyaZm$)w(8Cp`G}-{iD}uokV}1f;(g~$0SaqJ*i}#3F?o_ig^!WC1#Q7R6yg#NfVCB_BiED~Zhn)v2v#|A) z(6MSZv?TtOc8sCm2_D3}9?g^e&yQd&Q{uI_qC9AV@-KKaz49?>@7d(0ih)!J{o5nI z<-doFqZx?&T_fmJ=?l}Xno_i?Y9Zu#?{bZ2N1B3KA^sfe?zeLhd472jreuJc>XtUM zoN;~By0@49S01|tyqAdV_m7GH*+QVMqjj}#)ws(}X)rJnwG>p_|%W_23x$=G! z8882mgOE=4T3c@_&8h57Ew&i5L7YMHOQyO#bU4h({c8%b4}-b>)iZgJ+xXaIi0#zy z8a@AhjamJpCEq_2mM1V{I7+OD>N!oMj{$cu+)1lv#J8+Rln#xgIr1~s;`wR=)G;A0 zAIPLt;;@I`ac7fP|I-riQ2-|A^xp~Ko9)c_E%(3il7dA`J#eAH(>^W5k+ z(V1Q74s880o$Mh3*?`KmI@9#G_TEiOZE5-92uFREj&o5|olUZpdo;fiA+loOY!W|3 z_>T~vDQs%9paP-Teu7b%3D><78t4h2T3zFnpGaFN2_5A~a?Yx9D-|hD86;5 z!kNWh)iajQ-wYd=3!(P`lt6rEzpn|~##cA~{aZizQVJ$=U6e#do++76#a>5IeSWM& zIFGC8Itb^@=ygJ9jW%Bi2M4pXM=YEFhyT4>W7wGsFzVH!E_uPs7ZJTwgvQERS9!yrf_|9K-8r-wu6$)I!PV!M1T6XI0b<@ z6URUA9r_|bpZzlMKKw`JOkjVG;#JV~E<>Odb}4=?EXG~{l*XyE_?tb^Dq|)iHmT=p zu_sZ@e&Y}8Wr|uyV^^DN_G*lkMlF*XY>}42)3BJd&i4ac*0)-{62&|@xa`btD$86i z-BkLb&%yGG3H>4T74Uv7~>U{GU=x+1H6{T3}FJA?lPbo*0^i4DsS4}^gYdCvOhWpTT{v4 z=IeIFoW~WEZK3^#`8+)RL~1{$l)&aL&)5)~5~&}zWL{NbO_0J8O)M{u?#q4RMO9no zyea`3-ZFphRjWhqXE5OB=ck6tkp>sy4`*8UKy4vat^8=jE#r{R{r0`!L3L8 zXkavr5Sz=DEwOwF9uFxm&tgh;ql8T)#ij9nDz?PevR)I>M7!Dt7o*0cG;aNHc5|}Z z-I@vPo~NQm2K@(t#1wsUj6ab@%QwZJ%NbSPiFA2YbM|Yru>!*{Iz|>?1CoOM6x42G zj~!h41cOsf`u%OZHOCQZE79>JI*y$umU8qpgLwvSfXoJxCu_QXafWXi(SG${+1YcFH>xvitneiVlPmah+^5bm@sq{phY&N3T)!K_j}#+c@;KIja}x zugyY#3jwxrP3nuaB+FU-cw>;$=tBv(B0cmy~pq2-a}iCO-qH z|041^I8@bXd>!OVsPXhZ;)uMQcev{7?e1OdjSAZu3-vZEmkrP19$1W>OK>;j%(Oz4D=(LSu4o zsm|zz+Z^7L)^Ei1wI_vko6y#mo}OjDR`b(24s=BJY`531=1?z7?eKwjJcO1E&c$4p zmqf4KI*#TpNm-H5y3^d?@jR>EGw|x^U9OB#&H6e@mpEx-2tb@X%VI8_-A~;H!lXQO1MAy7d z&$8ghkfb+!OhqhUTwyU$P&&S|#?tHgPks436m;u(H0vw!^~!&z2M1V(9@Ovi^Z8y` zCH#jfb2q60W_m7{b(p`Kwq(6C79+ao83=>*JVB1c@}^dTzv6JS zv11PiLBQsAO~aGmL@(D7np+X+`C0uTaCbS3zow}kD7udx)cvqB0ru{5pK}4CfViIn z$z+yUtibO0O5#{R+}%Uijvh8mvDo6j+?D%XdUmbql>?U_A~yVV(OXq!Zd z-H(%FlX=*b@{IH-ZS30BU57{30q+KRKpA-A;lb(2x)QMx8PeCH3Ud(++8?z+N0jBgY zVl7Pej4N7Qyg78*SlT|<=ABb_&&e!I((1!K(#ASz-e$+j0=SNFFmvNa-UM++vtwJ~#+Pdb^UDywhp8Y$&m^5yHJDGVF8!_T!al1ce z{XA})lQUiS8rC;W6={`5*C1~DJR+pLAODkHc{`aKJ*9M=0K}|-i8)fJmHmrM_rwe#QlG%Z==+`)p zbpg<|@m-L;`lF|jcFDcjQku?S`_8_Y1S*-I*4G@&FetGQmZqxDB(Fh>!+;ul(yNb^ zCKM-TaLO~`f0+_HTd?7Mt-VSA_uDwLDT3Hn)K-w1CEHdr1G5mw=>xI@EX|L$FxODN z3|tIzdKtXal#!&)`CZ~`@HoBArr5` z8o$+W6a2V+q@=89<;$!zDfmgqxD*j#aP)VIm`}_(RQ~XBAv6s=WrtK%Q`^P*1I6&A zK$=+p`CFlsZ}${(n;m)qH7z1iRZUfmm|~s2PaTn337aNwB!6rNcdE)BeQ2hY0Y&~A z9vwBXv;s-$dzbO!e}X~pb)XN*Ef+QeHFE5hhG!TpWUNLRN_KcD{-I4W4$+PG30MEU zJ)m#|ze`LGQeSXB6P-`GAg||vT(Mqlk2^-Fm4h+5mJTlj8*jMQMSXA!XzfT~)-2Gq z5fM!e8UH9;Dk_B(Crjz8m*`{v5+W2NJ@vtFd_lALVC=!xv-0}IxjJztPrJ857L~_; zYF;fPA9%yf+j|sOXyBXOV!IuZ4JQiFBf#Yh4@V9YvD$u;raO$Ep1I1%O=IN?@c^?# zZFy-a^5XBzn!I!O->vhJq;=PupWh7={EA*x*cRj_KFz~?WsGO!IGdpx(@bakwQ?4$ zT8MY|es5;eG_zU6+_4m9A^RT}!i=xsXbCE)T-Wefg^(I~I?JD&dq=AfSXUYPWb}I; z;4@tw{6{3>!39%9`8>~)0ra29-W%A6uqZzQl7CiT@x&U>2s4-S#tD36Il9@U;tb;W z;3XQ6YI%JNGQ;^U*1DC5xMtqorO(r{*~egPCdMP*TPXq2N3IxyMQf;%d?^`|?BGbN z*_01N&F#1}!tLvyRzdq4Q%iZ0Fva*kbILX_H)E0@o}d`>lZU4iEDauGPd*CDQ}7o9 znYQ|Z)9*+nTQn=ba}oY}n>Nn1Vwj1UG4QcWoD=ieEx5TU`i~SIF}|sUjLDU?bgvy5 zT(C|)a?7NtF3h&zyzcW!f&AU0_LEw&Fr(gWIQ7r{vF}4{r%A>09O723pbv?ao_n=g zNXh)kUB8Fbq9e{#&e!N`vl^~dx6IM2T~0+v_qcO|psuNaF&K*CGX#ji{=%(f%0=Ed9fCIf<^lZ-9D-`jl2Yw&rC8HS2^nMn`W_urP7dophku?e%@8gTnYz6#uLPY3W<$8UuMpEI>pNp>@Sd)a?Dbs)&a*gBZ~zv%jaobsxvS zmS?dPGe|KKQs|HRT#JfI53kjI^gx)wbOR6cW39Y=YWAy5FlR=RHw<{CC9S{{Z*wrz zO$Efqyx9CTg`wCds?hK3$+rsA{Cfah7#LwvJ_vHU#7|uu8yc!bswVassHsK*aF}b? zM@tj+g!7(U9jjvhom{liTwQK)JwJtgnG13kYDyB<7GRF;S(U8eGIOG9QtpWPU2>R< zuH>DPrX`=Ml}%=IF;Ay=PcM`7|NJ_-Tvi6g2wm~*BL(_2d!;W!7l<2;(jbL&LD2*~ zD8wfPDQc_$c!k;^TK6F$b`Nm(5^{{auj9K>VISFVwJMql6{0y6c-&ndb}sMYu?bWt zPZneTA3gUgytMQBt1~hTZ@WuXH|9W+k_F>*1R+cyHBjcD2v>}tiuhuayH?%nYYndH zAKyINm7+7ZwWDK(w3(RxrTVX|X)|9VbFqz@`tW{g%^gp}%tl?Q(ak4`EGD00t;`}i zXg+-BAg*gbG_8Id{=`-2nej8g?(b>Gu`rCiMd4=q4d3NdRx~0L!^SsxV-k#n3D7dI zd?}|(qYj0T$iq};3GvBcWrMuN1G)ts2HEX``{FQTeknVq%~5{|Zf2L{&CGq_$_iUp z2ibM1k<=f;RXNc^b4@WxOux5d3@bfHYlbJ5G*pfhzLt{uYqT?Jt#f1%5FIt&;pe4e zD6)y0w}fS2IXOkf?N7;joSXXkMtdYBIQBZ;^rlE>%QJI~?yP7?m>8J*&=LZN8JbUG z`eHT0JW-<$QFnZ0%U8e$-~ky08Czqp=1(@@>A24iJ4{Ie*A1hrI(S!ar66H1fQxlG zW~o}3`(PjK9AxHJ!mx_;Xx-opD;@{4X$V#W3;Rk94Wt_=U)0~VwL=bfILc!w;4FVC zDm=0PzVFyHj6FP^S^gUY)KbpZ0>)o0i6Sil|Lh^C6S!7fECQ}h@72HX0v_*5Sxo$Y z|Jl2szA*8N)N%(HrlxbxQ?tCe$HM#aVZco~>1oKsXptr6}&!?Sa`VnyZA}{S} zzb-Cr;-h{V9tRH{{77%*%Ao9y4w~a*`5V!a z3H~ayw1ul{$v+9zIX#2xbX1!0KYm(3U^md7aFobJN1 zzhWi%aje3k%1KDU9;p*){uM~J0pN{GX0J*GfdZuh7kEeE)CDjr9dAi=w*oHLT5E4l zZ@0G}tE5&YC8w9vQULR=5B0o`%J`VgLgx&idB3wmgq-wp9$kOXO9hJdK;$I2(K)bn?f;P+HFp`SBj_^ixX5-~PvB_@!e zUsk8e31JIAcMDwdVIbI{6jJB)m~b*D(X3*A63`a7T1!!ZveF|+XK`Vf)61Qm$leI$ z0z6Tt#6bI+iY~RO*?19r#4CSTeoc%kBf`6O@t1||2Cwr{(uQ>$FPge_Ia=yssbE?x~4rbXM~H&8F>Q0Zq$# zt);ChPc;5s-}(93CgG7)&l97p&HaDD+!k#EhC5`_FOrM;_9<+_FT0Ji3^cj=yglmB z3~frQB6|e62>s^P4~u`O7x$Z}Ud<*^-HWMra+7IVN_1}UgnX<)9sa{c{R2k4hu$=M z$@_pJ{f%HVGn%tx))NtSetMXYdwGx+)wX!XMhsDnx;iLDK{{l4eqh&(VZhu3%$TI* zt3L^9mkpSBP)|;nUydostx+{9w2Ir==_jx5Xh1fnKxup6_%C%BErl& z*+w{gfo>^aXd#_hzGiEcjQG;RnY<)&rJVNAP_ z4FRiyO=ffc%~YgehAf8tX=%h6xk2~wcm4F6p0ZMmtOt7jkBaV?%xegX!cnT}uJ3huYQuBZcLgckNrFb;)!`oHKs83tw!bsdBC%G zACH4xI9ALAFqV8;|I?O?)b67k#qoBjx5o&}5^nZb-Q4ee@{`NC#B|bE%R8zWCo>uM zDs}+3>pO(=B5XW=ar8b~er%^A#p1PemekwrQDHe4h3shdzqyY&N~t`Dt?LJ6@H@J= z?mTVjyr(wDz3E%sZ+^z8vlkP88CKJ8KtGmiLgimN;mrHwyte#+8UCmJ6dZd^0CNTk=|Jm zF97G;m%AgS+Zk;cYu{nO`^TD+K8Ew|PrY^xb=9u?t)4jwhcu?FRD`e16dQxAwW#y*)^bW`c#EN{2Ko#`%X=m0A zi7AH-Qb5s`PAy#)r{+yEzPtV8b@6g z?x$FloeSTmk)l1zfA`LPI@s9cJJJXEYyJCDs}; z2W(*hF(ma6i%(O#xad6xoJdyrbc)EVQ?^S=q9Q zLG~V%JgNphv{mk6w8QuB4N>9rhU1Fnfc^|ZPzt$(W-}pe?eZkNkFIq&wUy-8dvU46 z>^7)SKraj7xA;pyOZS~mszr_Ree)F?H-;g`iED`*R*XJl3#QMEO6;AEU?5a_j;%^Y zl9?z<`5uj(xB0*wfwG?p6@7nGfUiz5kQeG&B0P}%PQ3)Dn;y+#UR%Z9q+0OU*?h~3 z^-h+d6$(zVj}0^wlTHXGqmh1ar^W*OK?=coZ&y{o!YB>wRm;tA^VOdm#!%jg|N5Is zwDO-xHI=%&Gs6g5*1S!;=1T?&?nsjwVuK|Qn`~P0TYJ~K<22utv{WQfQ2gj~5Z}$W zXykCGgjET#x@T%`GN$i3k2Xknzj_L>-`_N!Z-j1LG;^Aox z+Tm)k_pZzMCcmQGVF`wi#*BLf#wNJ z!iSHyzk~C^PM67&1@}&|dBBG#m&XZxLVc<{>7>@04+;T!#RTvzC$c9HUnbDHVywQ2 z^(cApSc8P@{%;-!%cG31K5`H8b?O|JQ>8E+AuQvzL59KJK+yaX^%uG8E~f#K{amlC z>g?SNTT#y`ek4`?2{2N(vRfwmy!GMm(xlNO%aTo;~hq6s@IEoYWKZP

&ahTq1n!DJ!khnS~N{s``=F*dICd>9)URvLBBoF=+g5h)5Z6SAr zL`MY~3NxNe(tT%wVQg7gYON%|J|^#!77T@w+dm-&Zr{*hWEH;}Ab`)axYfcGiufQ zFq_m6H$Jhj`T7%X?tE7psCYcnPGOGvtJj#%pZWs)h6!B~_2nxx`C5C1v>AO90bd?e zCFvS4-+l@~c`>=iFZV&6xBvlt2N>$)*yb>3PG$Wt<;->xt^9@Y17AxLSs5IclNieIcPkmMl zogEUTak5-voZgxDz*!|V%7(? z`eb(}KsD~>m3L;cRAzpguZX4m5qrwEsr)vb+B|o7*ipN+Q|oyGo}fHH;@L26s~Wp!!zv-FsS)^&apl&6TpeC?GCBSlM{Ufk7l zi-~O+Ngik4JBs5NCYOB16V0s*gTx%}wbPV7`n{8d!uy*zO+2lv^T_-r3TEYzsFsea z{Ojuj){*0>`r5zOdVVDPA@P~%#voELrg7qHGCe|9qZ9HD&Fane%8tRmjdp2-%K3M< zo#8wot*G5VQp{Q=iM%gdNLudoTBev69P1F+RKy zG$FFX{Gre+eKRN;-if{9+7{T<<9jQHe_dnfp4nYoZQEu3vnb2!)!CcMZ&{ZonColr zjfRj|Cl(y4F8E#^2AKF%eX)l0He+oUG+{sHFI2H1or^5&kGo*v3-8MF zP!UnR%X_T|++p}vZLu@x&;Jj)=t9P}5v!X!jO`5pSt;^ZCn?)t%1>(`HBUZ!JX zSS}lV`(=Bo+>^In)zKO7UE^J^@@nLH@yx&fu53&C67-AK?FygV$nF<2AC1ms%#1Jl zf);|Ut7twOd8n~E`2&qnZ}(3w697|bitFkyikn4ze#f~~XD z^Fg9!C76({J3ZgN&w76P#^S98FAIYi2b+PfKvDPAt zsyxEg_f&>TWnD@X5-ihhy=ZNH$a>q~G%#@>N02P|QEWqOsB(d@SM93tt7~?dwh&El zEn1nMg`ZF<=TA+H(#^xMNS2zUPWnchoUkoEvT+wW=FKromNxd+hu1Xd?CT$5m?jEkS{zJ4KA?^-khm!+&8^BivCfy2^6v!OjE8*3h|zQO~x#&Hm#=}cE|D#P?2;DL(nKNBs~ z*G&yyv3b%#{(rTHVft*;Q=aM@0@J`veydM8bj168B8X=e*jCfAgyuvt-0n z2;Go1?2Co!Af2BF<;We_DWr^Vd(i%1TfSmVc`JzO>sn#~-hJ<$@?RiD+_FY5D;a=g zgO!!=!S0k$L95W3e-vb78`LG(`QmKRu3$zt8SoduR}aV!CYh~lQKH*Yc`cjcqR@p@ zo(-l<wkW7!sY_`d6Wo=UXJ>QTi%jS^;=%@>vlgtoUKVj zT^y_t0Z9L{A9m8OH4Lsh#P%q5dKPkau|hh>u7#&gX(Q>6{+n={3thIS6OQ4*9LGf~ zmVn8Kfyy2o!VAvvjw;0#JFaF}x9np#l>=wJ(nvo}uCLKaiN!8te2(+C|3nVZEr-KX zB}Q}jq};6tBj;J4Z+9yQRX-L5$N8)-S*%!0~vBJqyfX>m&Y1&46uun)5A5Q z_TJ685!^GN;SR`R7Zw7nf~zBJ@0yy_B~AGK{d+Yf^E;71-~&6>UtJOvhMmwI;Dp-p z^d)G=(u6R588WuVQmTG|K-zh|G)3?Y!e3N2@)4;)9P?}q&PohnCr2K&d&=n&)t6vX zsD(0-10f1Kk@hHN);F_g<6|^76Y76)twLYld}{7G_yH{!prY>Vs$%73%9DJt*QZ~7 zzmQMGDoodq))2}~4C4;Lf<;BiXx`>VV?+yK!DX)x1`FvB{qY1=A?=C1Fa zYKd1j^h6ZCI$N~^&;co!x_-R;3_SjhYAxn*Q``#CT}p*}JppcR#vL04gZIc8=C<~fG=xr)aV z4H8ncqy8MwWH_1Gf4@yXnwW1G!eNFlgd@+7RTpPoYmnz7GL<<7M&A|+OXCqaOIggx z7|M&rUmd*Sk?l!+KEAj_w=GW4lj$rcahRJqeYpMeK->A<``qP@eVmm&DfegKb@nTF zCocn=1JgnEykNKqi?1_gj5Ri^D7>9_B0#~u^h@aNeehM;6PX#CBB7I`GZfM!^kJrYG;HbP?NH#U~=g=uSRuq8MWxw8K2@W&lBL1uxc zp4X2AHfu?d*E}Unvyz@tPquCzD$D%-b@Q#(6Bxi|IN1q;eC5#!)mpzezC>IGtB%ie z?ad0o81$v|r}zh?E>4vT@uueuuPpT~;qr9~1Ucp82UcJXrHfm&^(7k7v&$1niIt>AL}B&-V@^Du;}S)G}Up=An01czzPf1F=kSaq$lY- zPwyJN`u$^l?WXnoW=SN+MNmI;P*kUl-zVn#1M;aZ%7rhVE$sr_hpUK`roWGBt6dqd z3s8{5EWa=NwIWiG!`lxQ4e*Bp!HlYj*~?rk;3<`0f9pi~Mrn@7g3PiCRb}|TuVD5( z1GNQ%5v_6hn>R>a3x>~vcO99V5`}AL#~8ALp3fezlvh5}6dl##03qn`j+00C-Awr= zXJ3|~-V-ZxE6CELg7c4X&qjtVVnyADc8x!CfLH3eWyuXVx|PN@>(G=4ftW4&`vOUJ z=4R}i^Of`zA2p_$P@F@0<)ZS;?{qa&pdx2Z_ANU)|7>cVSpHB!$$6{iERq^mZfOU@ za;;3R8#;UA^mw0wg{8>WDy3U9Ym`+E0io$mzYKsU*uZB7YI)IAa72O>NwF_suq$Vo?SoA8uNU3BCLTWJ-)1R|Ju({`McTKDS`;C(nO`ZVfsDppH8SC;9!gsIYwE$;m&# zABdAfjz2=$dxW{2$U~{ZLu~KG{9daP`2c)ii-wE{ZlYf9?G5{;;H<7?je@Q+QW=?< zd_+pa2ANg@*fe8aBu&TpI{IUR)2L|7)(zK*Xo!i#5Sy9D)Aw--%12MUO(^ctxBb*+ zz{S#P#B8Ec$i}F0x+;F#$z~tR$sAmX)91~!oHzby3s--Z%sgqb?nCY&Q6Q5=pQIm4 zJSs8JlN%V5Wcg=hY#?k+ji}5di20uew|l0l)xyyL|$bx_f)w`wJ4B9$H?2f@ic`32!V(qGVD1MGF?1>RD1A}U#NS}o?Yr&a@gpYgFcTJ)1hsB6 zs1BX%57+n|>ZS*#EUV6Zl!!$2UBQi@g>O#_3M=G>HdP1Nk9(CO*8e`qrmT}AYbdyR zWSiuFdU&wES&KQpFbDQeFLzpjwcfnke3y>LpzgrYXBf@_y&7RVw-Z+t`c6Yzz3^ta z0+(~R%X@9f&K63=4We-=ZUlf0z6tQmG)vdvPo$Zn<8^aX78A}K8(gTrV^qPA=_SqloNncsJJozGhKneZ zCpKLzDaDGA{`@-jp#^DawV?ZlF_vw4dHF3^dd$EuE%Jf)fGfs$oxrZ1_Sewo)*TCh zCRYRX`CpqoV13q>uCGseyIe3%UX~$Fs~zg4y|Q0Stmy3j{`kJTiLx4?f7|1ptK(^4Ct}L*_bmBI3+!vhH@u0{Q3Zxjn#pif>|<u?de}PZ=O*4d$NqW7iZ|ZYL^SZ=ft&H-Oxch!8mZ`oXRP&nFaeL4(#Q zIsT+*2B}l4Mehs8^d@xL*qpv%EqL0p5iK%@LV_7I(6P;PuZy0d$5?OD=Qmhu)h89tRKnAItP#h&z-VgJbjxNF%Qktp?v%zm0yD3f+N<;cITMb8A1=W&3k? zxY)bYIeVx>TGvyEZdGKMWwTM{XLg&+SJv+G#igaxC0JR8SE8=Rslbuet%b%~v6>0s zKA4XOoCm|+h|}4VwtHyyJ60hJel?0%$C2a*6bz=esJqt~vYynegqTYiU&h5!i=9WYv`IZe=_P3uz`9L@5_GFZlh*_~>V%5NSmD%$RBQx*fH$f%>I7p%_V1k9?2LN1PN9ZoRS%u4LExye6;grS;P6CQ}o= zics$0;d6<4r^L-&~5iiSm0p@p1KkmnJD{zR6fd5CVTeedsKOOBVWk zLUu-bV()*iZ6-YYk=rV5l43Vv16z)7@szMAc#3tiV3;po zV7^MfMIzSix&n$k#P$d*w=3$UDsoo`ET)09b493{HSRiHe~QjwWLQ619v}*%3=_B8 zRPd&T$r(3x=k@CuLBI+lDN!r{fh@w`Sn z-0@l1O^_l8ba|AUWkx;#WR3sbPL_uDH!qb1+|ZNdn{yoZgXIy?F(fdm4n3qPQl0rS zMl|j~&dai5B#-`0n3ag+$L{dKdiuBs#kMTYX0WDs>TLF`XYt^3yA<_`Wb0e?+lw@% zE;_>h?*CGi%5}gAJ?nZq*v6)s4H>1p^Ki7WP4ws8o*)j?7b_&cluX6@&rdfHEB}dS z1=`u>_{1ivAG{v`i`>YmQ}hA1$?CmBVKDRl3(HFo(JAOO4swKE58HeT)`IFz<911{ zfFrdAz!*igyHJqKI49GIP=N}#-d=cGhjd!>mJCQdFHpioymxHt0ZG+cExJT;qk z%*c(ecE#0IV#(U$dvS5OqWp)hcu`P zgg|t{4E(DKB`>B4_t9s^mw;)ZREVd--ulpb7fJA5mg1Mm!}zPBN0`32gRG|PPU_%R z->qFj&ZrYsDU4g-R@(3aqU|yW1TqUQVkHk35ccfQrqH96S5%(?n%g|kE5b!!h`Iuq zKpt;NiiejT?`_^Lx4jr^{Q;{Cku+CQeHTH-a>NyW7V5u|UvXib{PAOs-A6{_dlyu% zaSyy#!qV7i*BPEV#Xv1Ra80D~xcu^(%$Sc%m7?Js-353A9SB3n(Q-|+UYpP1i58vW zya|=oZ+qE=(uG&{^+$>MYZxB~ShMQ{Pw@8@2k+$nvQA;}hwv)qm6bkxGYL+v&i%f2 zfK)Jcqm{X)9+ow~<6HY4j|Vd_Gg+Cs#Jk&z3a|Jt;Mgab$BJ=J(ISC)4wNk@V+fjo z(;`yxV}S@r6B1s_@$%p|Cp#Pd9r}#DiI|pn|CyA|psk5mX%azoGYgc{i|+iVjHk1)4M^BSr~zjTe5o0#p-m9<@^%X1g}fIoWJ z8Vux?B?LtDF8>~PIm6?z3R=vc!(_mgY%v@uJgk9U_18$8PW9W;)q(;^>ztTp%^p^& z)73=Em&muKWfhKoF#excVa-mkzhbW!Z3H6i_`>eet~gtSE*()-xk-X)l1Aq0H6mIf zi3Yu)V}>-#8Pu?UR8I=_(JU7H{FF!8PcjqNN46GL(q%=`7EkJK1ATd@?2GM=ytqV< zv+0NQAm~P;yfc56o8njr&Fv3%I@1;TqG}RHy!4>^I_ z2jg5~YU#i+*;Z+eY1L{b}FIVt}nx_=RT=cnC*ZNhXEXK z-_#9*hh{Gpw}Tt}(2V`1fV#A(CaRl@C@)*ws6V24tJXb< zsov3ZJ$+x2ZP&JnAe2bP;mU0MBa^a*AGUed9u6l}Ef~B``uZMikQt{Elf`QR)xYmY zgJm?HUYchhhc++Wmq!2eFMySY-ZV=gs~;=rd(YDs>b}8TY9HuJ>-y$#P0$&h#f7}f zLVD_5`ST0j#+Hk|y#4mggo+M3g~ZC0FYyobV~g69n$(?hP&xxnaE}kqN->0wC@V)E z`FaCZ>1S1M{gE?n7MPrycZIpZq*=MBfW@YI+81|k0>^^bxB(tXZ|V&vDBrkyzsjpN zdM}%AjpO|t&{MO(@sQ^6c_RwzcG>d+9vKnvbe!T#Lvs-m&nzqVUuu{0d+tv_7V$X* zLQm~>t%0#&8ctG*g9Jb35Rd`1Z{`Cm)*be@G$CO!141HoYLAxqi+|^uHHbQyu5gDK zso@W)^KghzmB+^2%e%Z)eNu=sAaPv?ep<1-w46Gh9?25=z}fV4eg0hG={S(j&X49B zVF=gp8Tjw3mLz+Qf{bwXO95${DMRS`NSkKWq?ZdzUtIf$oWFgA8Du5SZVCss=#qW` z3|Ga~Z=6@rdJSFUz z0(rdpjT?4aN807hq&4&sYejy}s14w1$O`K-HQPjurPBODIAJi22+Prgzh{**TP=_D z*j{e}G~p$^XI>M4x)v+&X1`pa{Nim!SS9r~_2+{p(?J|i4p42N%vjBuyoY{m5v1=? zbQ}emmgd=Bt(Qt^%}=uT1r+?H?V17m9%XDwqRe9k(GFuXaqK%a8?2=8D}D1@*|&L} z6<(+!LUS;Y3*q6Mt);+aOBKtFC%WLi;E3`cI$QXK@x&WneVXj!$Xj?#UU0eOJeR4Z zPTm}7T%pZx!rk<~`B`W?G7&1sV;G05kZfw^7Y-=E_RG{ib72%p2R^@w=exC_ zt|^y-fb6a{6C0QHZ(Cfnoav;p<=($>&55nsv!6vm(B|J;?GfO-U(c#Xi#9@jI4|W6aXlsZ5N})2ZgpM>-V@FN^ zkzw~#osz8s3XlJ*K=+Ng`{Si~es2l5hVbLJVm&48nvJf1Un10EVoFE{E8K&}fcGHZrNVOXv05=;d3Pxg+)d&)`@ zimr_!h*KWj=r8NKt*6x5x3-U?j2SC(%s|Qcy?}9g+Syq^sLD zcxHKP$gv`2n5xZ^ae9(nJsFvUf&-1STnS%De#=+qs!w+#3yA(l!^oVAAa6q;rEgsy zNSi1;Q{#JEZ8E=!&HSgxC-FYF9Z?gQbYKby^=7eOsaKd16dk^k+2Pf)tk$nqn$U zQ+yWm$DuQ>*4B+BeW|lk;9zp^Whjld`U*Cfpur7i2Mj^;z6@aex_0q(FV!zr zUC1_yC4x}U_dOoZ!TiF|(}TBAAAX$a9>ctNBte$&;zLnOSRCiGlgT5XEYPn}0?r!+Yob+uME2 z@pwMbj<`D&d3eqhTtOl%e2)1zM{joT$L;OjTid&{6fR0vm2dC&%wx_t$9#@~3ba-a zB8AdL*Tq`Q%lQP8@yd%;W2QMLE#b9O!b_QGyXRc|KHdJ_Yqtx-zDmH$MX6#1`4gA$ z7qDb==>_n36%~cU@s$>eUQ41tw7{{;hXf^7MT;mRF^yks#A{R)iF9naHr2KkZo8c0 zGGZ?^d3kzV_tYgWq-|4!g%vb<`*YaL$Io3X$Ed(Js|Iiz0xg)iIWMFNei zh@c(p!7Gzj>6HifWR7|Mtu6c7Hw}(pv8w-Gk4Zb2fJ(j_~7lZ>_sOsX)<~ zbB<;W-kVC?b%v*<6x zN0cA%s?5x^y9ZJAru+EH7!GUKs+vBDNOci0dh3;g1>c@$I_>mIRTb-=E-cNA7A7yr zwJ4dHt+kaDawVU75GN5SF)>?fk%_GzQO!9Dbhd5AEW++HGCPaAH)+^Avs7keIBZ^s zGSh*U!!NHNNAEqtiAYSds;z**;P5M8ZY_Zd>wdGCQ!cN^+A`afF^9(=%Vl%}#j?E` zuBy5Bc|hR^{&T_%-3mx?*Lna~KtzxryoE0t!VBqI`wY1xOw0TpWg`KI0AhgNLsZqU zs}@yvUtN+}F^QzLh8PCwJxKa-oag!TV^!o78$;ST!};Vf1@j@z8`9Ouyw7P;Tw z!pC_&+j7W)t$dB=$s)JgovocSRp_7o^)LVX|M{QNj-Sz;WOOvfAaSJ+$#7^*JW-+aTfaFXM!O4LIsaUP3>}&K~U?V17 zlnMdIyRHZ+F*8}h2SH2$3Q;i&idJjXFe-|uEGI}!9Z znQGc-L1pFP;Xq0f;khD$B*b;7h{FRnFSF3%&A`DFmZIeu<9tF)2=n;W>|mz8=$}+?fLn8>&LP;QZ=*b{{H?=o{!JZM>}q28j;MTW?a^kAJ6Aj zVQ@rLw?&gxLu4}8TfOzCPqR)Wj|Z4gq_RNHaA)d8)ceidmx~isGo38yp_~UzHem)NCK!n z;n7<&H6j;u#_33%!ZN$J#++Q75xPBsDnpy8O7H5y)60~@^L88$EW!)=D2?=pkhO=d zF{hbS7A?O7CsOxuwIFy)o9S`fdhef~Q`JEE?5*|Q`|X%ntHM*&1@x(k`y^$eWRfiB zLP3K&ZbKviGlNB*RjN`I*xPQ!FjodukZ=`moBzsU5l)ZV=9-rqAMP_TqssD03xv$C zdt8{J5q9oia*TEIr{Va`@5=& zwWdNDYkxs`@9i)>dpqX@#JwI|7LzPfbL=FxV*o9y4J`je$d=C*6J9jhPSL8{@?#0m zRn>}+Ww(4kY9V82&sk}0H1>4sjPt;YKP5PI%p{9|T{{@QR>Y(-lSc}0yy9XO>$I$A z7wMXqy|S1{SX5{w_?lWrcs5*}wvpXhf^Pm3!RMBjYpXCTbDDBdMNv~Yf?#RKER9(! z$4b#v1-x2CQBe%Bcuh}hhc=txYMn?k$=ARxl>x6FNOK5*bVcv)-y$M?GIO`yTYta5 z-ETJl1sMU*B8elDnAoIZEfAyfF(%DytqbdM+<5Chu*E-aZz_UNHzr88l9jxI6TX2S zF~@jP(&}1BR5{X#Qqi9gn~AV+Epx#Izlrws6)VHfae~apmFUa3wgC(5uc(Sx*cOfp zz=By#GyM7aiKpN1-+uo5A!4`N{pZgg#asv+BoVeb&&s^r@78RLDJt$e_pGX`R`K&W zkE45}N-||t5(dOdZLN!Ol~-ar3Vidp9WjSb|M~eTtYR%$lfct_F}ailhZv0gm;^N?FwOmBmS{Gp|VwIdBtw}rn?Z@N)`TzQV|M~aNU;p&?CH=?4#cqBM zQt%ml_>5}c?s2=hM`RKsF6kLj{pchVS%X4EtTh(R$VgG8PyfJ}5}9Jfsz!3oCuW7# zn&?iIZ?J94*4j0J0Zy;&T$)63TZ{I7^o(@(bBy$vKrSK-#So)pdW{J7sqOw|7L zr@#BlU;d2yEh@*DxL4*F)|!f*=TH^6V-!*EEt5!iddwNO-Y(WEo+&)lBAGL&66^Fa zC!7y3q|bAhF-6QV!X1B{Drvt6d8Nc+!&ejiBdp@3-US+v*;+OCYD?B0AW=(nNZZ0K)tUMJ} zQQ=IF@JblVuqHE-LL%mzt#y?O&PA>)sg+$Jx7%^M-5<}%8(QNl=3ucRr60GqN0b7iY7B>(X|iW$Pv!g+P&?s0%;etXr-nxnR46r?!!SZ{_2*g6;;u3+_EU5 z;5NppoJ^`?MiHyzz}G-hP8pPT0a1`B$c*=3MvQ!VPs^|0R?hXLTXGDzQhHZCL zJ;&$cSy}II_v3b`3gs+HB33OHHDgshpJ&FJeP2(L3SImQFeYSfMiRoT3^pCS8N4XK z7Za%vN@%he150rc{Ia>(|Ga3=30^2GHe#siFwmf{;P6Ei#_iKut5wATJ->`SUhN$` zqG_cPh1i^DW>zWFYf#IVmT6r;tfDI6tBc48>1*RHJ3BoyOR13ds#2{-5scGx zuM(^YAJ1nyj^2Ck4R^1Z3a|9D>rX`mMA+J!Y(YV0xyP7Ogj6IV#+=XRxt90H1z%lm zUL77KBiSkE6kfRfwRjT|^cfdz4L(E5(0C`fXLv3#0;v#*(IvGO2qP)?{sB0OEQgi` z9f7dK@`Oh>gf$xc7jtn>*lL-cB&?mnM^(WDLt#ls)Y@^}-cEn^`~5g>Gu(Yr*5mPb zyWfdujM=)InS1u$&L>@hG!SjzBs|7!s;xCy3VI69^`lTR5yH?i^Blt?ib<^<#B(Aa zo|>ru;Vj(m%#;;T%&nP-Mo~JbNm^CC^-5nZb(ChxY_uY8a@jLvAr5l!vA61GcWLX9 zApX2J%M4bOe1^mG@;GD`)>R8wGUGy?u5NhF3820z=k((^*r$hAQ`8g|j$%~{B2NO9 zDX3UmWmK$u3F5u~HoM=C$2oUmF{$hxZwvgwqc^dQ87wCy!rZh{GHH`Vo57M9=cQDo zQG@y}BG0i}G$QgjXr&aFGP{Fenj+Ggf#w~NDrRazJOk=6QZ-=}W{>dX73@P)rprK| zS;yGBx2@@}3UGNu1z~W!_iDwjT`%u-Zmv0EQ6$igXF{a3AVv%pyB5BJS}*5}WrX_@ zqWuj))XdOsET$f44x&WhwSt1A?ykyBZKAbYHYj^xObq$Is5%p~CKZc4hYwHfR*2k_ z9bv|=JmAVSlS&%r;~ZyYFeCRnS=vAS<+uOz-~K7}&fKgWpO4Sp$QU{SBMKHVYq$r) zXOQdKTMOoXVFh(Uq$7D?%#lUS)O|)cF^xG|J8(Do7`Xb0I3vEUTJeBZ0Ff$kenjQa zUdy=#TQY_sTu3St-XqWRDUq$;GIRQzbB233kl{c__4|*X`uMyZ?d^V;r4XA4iJYe& zUC-y@_I-J}OcYl{FaWE+VIDCjv1e?)aEZcF0U+=eey{O&lZO}MWLMT*Al2-e43+bO3pns{ZJXlNH?+R2(XY%Xl@*+@l>(x=Smeaa z2=_!Ir>)s`A<87q%tpT6=qY|MvdQs#GhJ9D6UMcktS76F8Vv z)q5`%@M>T`F0A3s4NXA0&&T8Q@pxE27+$26Rn>ZHW~%DLBfOavQ}`4U+)$vKfuCV& z8NN3T%dYSGuxt6u7gbUQ8ejN!(wZ7`rZ-z%er;ANe*D(j(GL>J2&5@waTb=8;#J^B zFcSw>Y@|Y5%ScOvd}?bg3+CLg598zZ_Wbzm{r#8Q+uQi@yQnhPILEKQ{L&ht52Lj{ z25b)`eco<2cmI5x$O(o*C^J>0nXyoe%q%FzJ?5BaM#4reA}V1-Oz_`eB1KY;azxdp z+KPyAdIW>?UPN3X4X}WyEFIJwce@i&CBo%aXSwXkzbN0X;L*g-4zWe?GG2w0NSTGR z94c#Qb93u?)GTvlw_u7hv%n}3aF5oD4UVh@kTbHlSeV&brzYnN5=mO=XDaOB5mT+v zPVn7v^$hz=D!ka3Kq4bdg)1eiu^Jkdy^Cf_5$VlTB*RlfwYA<*BgUMOuEOVBUQ(z; zYHGqhU4>0oN?S&$6bP-yaqKahLA+44EQ(0?bdOTeX4qf^o8=DXTu!`9C?wY$5aH85au%2V|CaUI9 zSHK7|`rx4k4k7XlL30vj;ij#H&&T6=;kvT+hz$QPRm0ZpjO9;4Tv>^Zn7@#omhF_T zqQX0m4=6^XUmaciyn2JkVAjxo;j#Kp>-*wbVFQ`6jI zv5E?lZAzfEhIs-QW+c3d%8aZj6{|>7z2AD2t1^rAW^kd|qT(%F`C`PlKr9wEjx5;W zEiE{#jTRldD)y~Jf5Y^2cP5Z73q1rgyH8RJhl)lcp^WB*yhAC6!w{id&6fEq6ZPKv z(ec!t&&T8Oyubg_k2jXC*1vuG_3?a^2d%sy`S$+)R(R;5f zU&O)MF=ec(1*}y24DvNZDiuLwlRHdJm)MD**AO8XB%pHjZa$ruB6EyErQHOKMO9HI z-ehn5oQTpvY$6xrVMc32S%H3Ay_RCltS$7bYR*wbQi8u{&Xo~K!#uNibAL#L?Sxbmnu}Xk}IrEB6Ai&kb(M*joX7cCHpMU!K%khWrS)u5yaL`$pD3W4q^{C^xCj!iwsz|#rD012`#Pp0YMn zD#9sKh?{6P^Kk}KX7=85`K%;b})#qF`afC^@>W(X)nX8zm7F=oLiDm6jgFfC2dQpaB(RBh7? z77^O!7^pYcDG65;bkY%#SqqI^m6wrcaP__mJ=zatwNqb)?|S*P<5OKP|3w17Rxrq% zZisvP!iNZtF@~8P{ZK>5(h3K%X0fSNz|%lASSAt)8&QPHvuVdSaJ${q?D2eJ&inrU zj?gVU_DcThLsEMSy-?x|=8kV0(Wt66-7`RFL%qypv9d|j`=8hIO5#ZBO(NVqSOtld zNfobsG`^0|vMzSyg`&~99XCW@L{6g0V(Yhldq3y%*FXH>=a1jU`M{O=`20MMF0z~q zgk`!nGfy9pTmMF8x@n(vnE)<`ZzJd7&`A>!s@@e`p{h1>Oa~B3Bn*zjj)c-GM^!bZ zBvRo_DlYfQW|eu|7L6~j2K42AoX|t%4wJ?6*&$)uiZZdf&g%t*Fbo-b3>z<_F$R#D zrKwp#07T4Y8Xh9b#AqAgu)(TD;*t4+zN3N+>&`4twOQnLJ0j*7vjTEB%F4{}=@p3v z-bAS4JfB?FnsPE=f2bU@XlXSg3{1%^F`aPJP1*&51O}ExOob)}xg%q`sx~t)Dl)V6 zZmlDhEu;1oYN&=7N zLMre4neEGU7@Ok$ac)%2>uzbuu; zb^})x*-QS+)NbhMsMu81I4d$slv}e(I9)IkpJN2sIp*&_KmPdZcM**WD4HV^=anj( zY6xd$I((0}KZyvl_)ON`)I_C-jOA$enDcx-%z{`wJVTz(XUsQZIgW#=0*ZyA%TZC) zXgM@dL=uw~}~g{~r3MAd$dMMTXs zGBX_3vQW1Q^Ko>d<}H{lD`JkEXS4pNKm76W@q1-RJER?_d($>KnH4W5Z=dwtz=9b7 zhx4K(?8a)W1o3B?Gn|+80y^tuw1=!rAhd82xiHDs{r*BJ*Y=@yX}NyGPS|@=x}u)4woNMs2vmxKl{*RIy^*Lc-(JP5^raYypYWg&@`~8kvGFR506cf}(RLevR9XU$c`e13b zorp?==B2{95-^&n_TEHfjlV2GsiKNDe{``z^N`|8Al#{gBFYS?S^ac{}N%azJbwNsZXJb z)@=I}E$*PS))wed$V)7rPE|M;Ao;6^sV&FtwR*`az)*bkvmlVlBFXDD!eXSRm4rh( z25coB0zH#2s1!a0e2YLZ;HM7Wsfd^grYgD?d!Tr1X4dpM#*Fa2DA_atv7#K~0dqc7 zUo%w|=s?!rTvR0g@L&Ji?>~MPaX3~%XiFy&7#BWV<>RDEAfR9_h-xHzN;~e{x|nKH zv!K+_LWu2zVs(u{nVBVOmzVPEZ=elZ z*2RK;&6HJ1l*>_y$P5Qh2uWQ0x|nt)Ua3m>(!5ORmTU>f%3hQ1my(EiXYE{<(G3$f zgZd&bq^ouor} zK4FAx)=Do}=|Bk`L?xbnvj(uSzWy*@93J*bC-`UK2F%JaC%p29yTgh%uipv$gBBT_ z=rWlHKg*ik?{AgAh=Q*dRyN`h%$*0fr#G*lUd2;t{{yOjUsc_1=5$$8mJ) zDw-Ks0a$COK1^i11P(r?;L+Lw2+b6}VYZB{=_=KtLZVqU-HW9khp0WC=k$10I$HM- zDyA>)&PsfmKABlnOqIbz79m6p!{l4kSXJAt)&$1W9R}Zk&LPB(sH~*xe!+cO5VNNW z+(ZHTNfDDOZ5HBc!qMF`W1YSoF_TvVuGia=8#h_EA+5dIy2@2NqBlcuYi`?@D_)9s zAVy|XWF{T$2A&7XGMaf0PbM!ur52(DQ0WWmGd&&K#fQq3M+FME!PCPN}N0NMP0M` zIi_C)hFQ$)IF6%t;2yM=kA}mpJB(Kty5NG}IEzG)M@K>%FQxru)pohA~-{ z4Pr&7%HhRQXZXZ&Cw)$szAfTwrBGF5Rp2?I#JAR|`1yRgN5MNtl~nC^ zoMXO!`^F+4pP%mjJf9JMzul|&^YMtwH9{q;dONxr$Zmj0x$Rm!xfwGn|23o=G# zosRE9Dr1bN-Hvv2=JMysku{?U{oNnFNj|^d`+FCP;l!TF>6sIEEOId~X3Zu))5ff2UGd1*pXwOB3g;cpU149y;d0lI@t(0ZrCA5V8h9fa-5iQFLzQ)MKy^xnK zf{Nic1&? zZC##UA_9xDYOy+DN*CL=7)?Y5=7H*|D%K6LmR`Rf>ptd)UH)WR0<~tYgMFn$O4MqlV4M5fyvO!n{IDX?=$iKQGP!%i8Ktacf6wE!@XBs*kSv>40QtT92v`W#GF4#Lg9Tw9JCYM@+Sum__l7rI0Hw!{U*un|2dZHr3_(P%D?4sLX_zl92^h zBog>Uh?9A`6Ornw{b(Ftf+G^JwfAJCwelXyeeS&^GjmZ!IE;D|7Ol#}<>3)dTm(rf zFQzvaMPyM`xX-D=!eq*3EL4dow8$YTZ~k!bPZHdBI2%LRNhgt+j5gwSKVJb2TUbZ#FYA}#qE-RU-DwWIG z3VIlTGb>7cJK)G#H`a!cskRN_MJd|UKaypbo|wj$8TG<@f!Xe{d(X|wqr)ttxQh5C zle-Sp61Xq9@HGcB$AV&rNO*^5OK~YjUJ}%8FGaD!2g_-8FB%&m8|9RCn5^-vDS9{t(#=l2iy+?uV) zG-(0cw@y4FJ?3JT5vXrHJeWkdIGL-rzEK3$vXoNCW@lnv2?<6U zN~$(_CHwwq>}z6RcT!U|O__7Dn-hb4K6Yleuw>p})>8ZX^K>)xR3|xHp{l7pMou^3 za59xrz@2wb*gmz-8apD~nJ^jj$tb2t*r(~(0swc*c*w|UJ%qcxefRFw`}-cOHfrtG z+IqPlL@Cnxmg0+~r67_;mAwgc4vR<~?W}vZI9=9nzyHC3 ziI|W9P~G5t9I4#1rsClhfkj_>xBqeIK!29X+t)`!Ysm> zOR)oW6Ky>=VVI@pQ}NV32G8qf|D(>5e9*<`c6l7ZdAOzm5InC(%whFz#+^Eb$aT?_ zI}M3ee)Nw+PIz?Lg+SYrUQvk|1>KGx@=03D( z+15Qx(a!dfXPV|57R>(~AJMQ0WiE1WeVBLlOB4|S*2`rNoFKwI!3>8JbX>7{Li$)7 z4ldqf;xbUJi*SvAtE=mqo13LB`MV#luMdYq=7@_3LOiKjCUs@#!^iC~0^mGXeQ0j( zUhBeC@``c;2%OVnn)$@m(_J~z!G_x^mF3n|wZ)Lu&&1H-?O_p4AMj8O?0d0<6|1K1 zM78B<1AxT5EJt&YQkf+j2x(onPoM5n5p!6Ux-6}AKvY%LCPK?nO3GG->CrFi3IW&G z*F_j|g*k;0xeCm4a=^uVCsW`)HJG*3)P>yK-afm#TbAQtxvF(p>Vc5wrf)D=YAH*}{2NHUMNR^q z>_zXq&5lN+|@5v$4FOfnPBG~mDxk01H{00i2 zj8pgCw3g~-+IkwQ*2@Fs=zvA$P%CNVC4i7P-!&pht>%!NOlZ|$_|4>9OIPw##AL^2 z$tT7!BBx=@AIE|NjO5^vVLmoGbJ0dA`KzU*P6}YqHj)DKi^G^gTrQW(`E)s-h8!U< z90)lJc@N)uYrUx%gx_7&fBk1a`}}qh=tc1Q`l$VMb5*aF3XX*<0-0)SoA!^{<+pQ}MA*fA7#zOjWitKv_-)?B(f1TqsBB-*xq**bAa>1pas<|P{N1bej0h42YeWoO41DW%kW5BH%6 z?w1wK0GvshjcH-@p-%rJbjIHK&3n<{$zOSiMMSpy!-+nlbr6whSc!5c!pw~O-Vyg( zBriWAnldf`03S2h8OMoveNM1Ue#ite?5CcD_t70r?bZ0z;c?13ry)7-%FCFOXbk`a zC_F6l!j$N)k$p6+z!9l7au(iYIQ!lm=HF8C0OmcM29?}+jYwd?2v9ZmFpE?>HSJy1 zJk(Upx+)9TQgc_HbnJv9?sXCpMJhS6G$+dE%m$NnXi!FCJz|m&(;7b+R)B%~_5l&c zn+R^&wfBe|n5W^Da#Bl~50p{mFf(BhMu`#;0H$UG&k2O#9u8O!SAkNNLtPd?3c!bl z2UX2{^$ZbF?die2zrUZmj?mu2HQZ@b113e5y14cTi*fT=5g|e|^{{l@nkfkt$spS_ zTm;i-lNhNM5$0OTVOat|)d11MGsYm5hFQn&bx-;*%`%D6_Qoz{U(2$vNY-jZ$f=$& zBf?rMAen+{n2wh#MY4c{M(LCJn&5|_*qtIuX3GB*kq9?eADuw3U@4LyWf=aL6gfMB z=X!#fS-94rQArj%Tzrtpj+cv_sBf?BQGOv~*U}QF@ z1~4gXA~c6v`D5m5n=^(1F-*nOJ!LgFcE*;Zfq4;Y~va2xIxTC$-H#3hZa%ew@V~L`eCw?OuD%Pn72NVdw!O zu_5^MS1YAXcSo{s03l3UYn!R2o^5mw`Pq$D*o#Q2c@fFXwyv5tum~{!=G*W7-~XS# z`u6)*fgC_}sUl@Puj|$Tm`H}2tOz2t)-s(_UVAE7^)KIJ1iw-tMe zAMP^U9F4}bAtsG|ACv5VrTt*9OmpIw5{7ZhKH+KLAQC)|RqzQUZ#KIFCz-!2&RPKx z=Ux*=Rck>o$L55>0;8D7yEwfwFty%UIKwDl2_kgXg|WBqJAFMz$a5wUQ-k<4GYSwe z^9*#WK$6yMWjk`_ujjyg-5=f!%_(8rX4WHedMsuavwjFIcLf=z`SPiDNDps zw%V8Td0p3@UFX(XZxKPnGDTj=zf7ja{PhzmkYnVm2rc`lyc>uZ5D+K~R1*LXzU%4c z{~_d1hhm1hY44BA&LJTlUOqyw-oq^;M!-i3)-Jq=82kiGD2f?i8sY#+>T;BNEcLi7 zM+?Y0!_1fEkinNJpxGar!g8n!Bf8~u%`$-_+=c1p`btFH5;boC*$Z*DgNUeV?k2*- z9ByHbfI`GbxpgBzM#}G6h{HkEcW&5T7tRd%DW{n1hrRjZX(!5m5&@RwP`C(Sm?H)u zF!C_xg-?IfP*LREF0We~3_je8Yyjx3rELT;bBqiDGP^gcB19@9?R5TOC{vF-yMN&f zkpg0rBA9lwc^-_dH^%6^i5RKoE`c)oh<52x`tK82g-Eq5OIesWkGLET%?+35!;jN} zf;f+AjuLXX~AEYBUFKlj$=*O#qO*yYhFP zh+Q5J+2eb=Pvj68ktmCC0?zRVj>LgHJZyC4xSxP2E%31@juiC96x!rPlHMj5F3G{% z&JKW?ms&*_o*177GRe%G?#=KP6yeW0FKlBapswJn!UWG6wo3dfSR_pVMJTkvB}u) zP>AF^=6rAe>*@Fx=hkpPR=}LcrT`a5tZu@V!lJYRbrSb%wb z?g_YiabRW^{1(vzj`Dk8PiRQvzYFaU-?G`os9FnH1gPv_XK{~ZTGo&rQq zKfO$+--}MbOc@mfP#ts5QR4Xk5IzdN=chCe6x!oHP1Vc+!p&4?bS(xkN@eEsOJ^>E z+T9rnJR+njRuG>%D8(UM}pG=%KQj`tV^j@qk=5 zPl5LsgJ;j49a5UV$H`;+l!tgI#$C0y+}q{61c=IVxDKG>@p@=X10L=l%&bVs@W*AT z0a0spv#qT}cvu$B-)OgWm_q~$UmcJ6U1VirrukB)n=!xE+!16ilXcU+mB_PmjNDy; z*%7;02J7aBv%h!aYkgu{XW{)7!?}Q%(gz~W5i91&3-HI$(XMkZGjwk|Q&|cVrD$j7 z)DdOyc&Jg%gWSU$Aq^Uje%u{?p2wHFt9G{@80j)Rgj|i+ zQVLUHs#R#>2l8&qzQWA9c0`ImA~_rmc|F%sMWmFPk7aIGv+^52Uy-bDM)&f>>6f1m z6Q`|bpTGt9&F%Hr$%1E*PJ6|%4{{$juOr_#7j-FA#{GAl-Rk}uxIaCt^0o*_sb82= zgk;bq4w8XSkM?MhrgvoT&-O}u0-o}SFaS@%It|i1aGC2tQzxXWt7}uQby+X%bh^}1@@3+TXT|(flW+n99Yv592#;`Z7|sy) zc{k*ob5wRBshk7^5A$#qQPlu{`}WQIckf=lcz%0(<35Xh_R2Zd``|(f9JWhn1jpOjn(H4@ z{c%2>RJ#f|5!==XaV{Et_B=!VkYk2C zF(T6c#}1K=5IiZX_U3ACAL2>3H=r-0Dat|3V6g!q2=j7S#=Fm03Mc=Id0OLm+zX}3 zUK1CVj5FG8rQ_wD1Rg=k-W?3k$FhxqIJ++lA}Bd}Ni|Hu3VX`|mCcwkGxzYECOX70 zc0h2h6(AU~FcCOm6viT4h%lLyL*W)~<_YN*E)^1iSkKAL=mou5OW{fui1p8HZJG2EV-secb{%!T$W{@ z!{i^qrdc~79Qpm3ySi`P%wuaC1TgcnXU}e*Kikw^y?y`d-+$ZG5NYde+nPwZxp{WH zxqJ8F(|`GgfBKLA>F<91yWd4-jv`h0xFh|ceX|_P)OPpn2 zzM1(;>{y9J0K-^tjt18!RyfOrr}cOYt3YCG(Z`RK3PFP>2ljph;A1U64t;amFSh(Y zPOD#}=O{Ht0^yiQLCe zg;9P-UHiJOW}d9_UCcFF1%x@+7|P9js1U$RO*KO%N~z1?P?lxzi84gPEh5avRsFP9 z$e$)<4JjzGy!NDZUZ<3oY78p(?-`7jpY2#|eJ1O#BIhuhm{rPSNI+gz7+ z({0^|2q9W;SzQ5AT`D4)YPgrWAR=V=9nxi8x3(e1)%D@_<~oOylm{ka$`wZ&Llo)n z94>_#91A}6tYAE{)uIR~QSzc9CL+rk}* zSg0^{kjO!6&w#B)0pOCxpGF>j0ZQHUbH&dd>xpD?5Nn%8@1yM`=^r$J)Gs-;0v6L1O zwIImUlyM5sF76-et|8^Xa0txQBftq6hk*PYg~pO{^xA{+I8OGY|IPt*(4c>LOlwDg z<#CfbZ$~!_DO7~q!;#F~!#Fs=O; z<>%ho+fVQR`tSb!C7ers004jhNkl#x4}=~tguE)3K)kYqV7YxlE8bNbcqzx(M= ze^QuD?fS5Yz}fx%d2@F(T~!UKGMH{}KYWD4b5{=wCdTl<&>(M0hz_xB7Zwo~3=E50 zcMkIg4V!Iv49rBPVkNmEqMN!|XgBR7<#cKHpC10?XFvPN*FVY_R0l@{L5hIF>}r7A zLL)qx;~tKfym$mI3iS5~rhqtQLx@1+*Xsni$1xl>7nc5HuCkS>KW%Q>X?}{^Pi$pgpy2`j#UhB3q%mStbN^-2_QnlJ-iS{gt{4` z!VEe?N+b_}2Ut3mGD#Vz5SzM<19njLd$-)y^N3l=AC!{#1vHy=&(8rN5Tz6V*d-li zj$cPod$|PymedoUu}QX#?$ntvF>m8PYhCOv4e~ zW2Cuz*u&{`bvO`$tM67N7_OGc-~^nkq){#IRqhj43rzDuDa9dL@7gr4??6umxF?Vc zFlf}=j*wGNj6e^J?4u-is1daFo=%bSvMh|BTI(z zsvD6olK05Lpd(l~!RRlVcA76Zp>UFECCT=N0F->(AO}x9Rj7gc)u9q{H%-Kc_C_?> zY2%@OJe5ZDkm}4}1W0a9?tcdwh%mKb@udCDC5D-K*xp9(|3GdOaCFK0G<%qalB!4} zwRoP{?iOy~L5NIGS}543=zB68A*Do9%|=;Fklvyy1rg0{Ynyorm(q1-5Ybi7>*j!4 zYudG|UbZce4p-O9a(w;f{a^jh|J(1sdzCYiv2TV%(>f5pdH?adSFdyaTB-oTdD~vU zdw)J}2n>KtTP?yE_xJaw^VtIwpj#R!Ttt?7nCDX@KPhY92=}2DH+NI-?uHl$k?&RS z8WT*Ys+V;&4-(mW|1baaPbBj5pZz?E%EQl)dM*HB4nhmvm-j4{NN7e$vxvFhp8&%^ zJipH8%Q`fHOaWjXIZyKl;enatVV&%Wc%(US8SBm2A!T3a1MBbZnq61^?Z_FrUwwgKQ#Bs3Kg)_!C&UvW+ZV%H}PC?4c%r4|V~$pyF!J;aD{8iJ+6;V7k~ zhPih&A5;@l4S+p}B6}B@CEHZ;^R{gt@9)>GZLPaI67A6!iPL)0_$JC^kcCsBlmF{T zi;Ga}5M%t1g87`eLr_{plZZ6NDuK)rfnBYSd~d)Mdm*cZ)TJJ;x7HB{&!MgB<#Ni* z+SWEe5GlDjPa%3*NIbl?mH{HI_qMe#TWYDb3X5zh6wyJr(Mw@#1ypYdIcZy%=T!t^4^P<*GpA&y)>2EITTVAq?Jcj^VM?B}A7&QeV_jsdT{FJok^R6TFgZWM4B=^P zOH@d{0*Aw4e1@K$uHh{lB2&q*5+)`<$Z+e?7$hNUOtWyPZizC@WuKpnhY&G`;W(XN z+7*y$U1DP3vZx*Nwfvp(NgbWfh#MNplmS+C_wZaf332|;Oj+^+W&FiHpuk7n2jb%sk*P0ON<{)w<*hsm0CjPZu>{=9|0Q72`L5`0o5a{7?VS|NH;- z7eD>RE@xp{pnUcDi@WQq_xEQ5`cHrVPj|P^fBN^`eI9UK zFF(9`^V{G4CfqmG*YDna{BZxHuYPnm9$c*+%3%FP4$2W7fiNPe1a;&WLMoYSEuz{Z zp!L4BP9mxsdLS_dhN_1Najmt~g_*mx@!HQ=9*;m6Yn3D>?53C99-1Ov)0RuzoVI1B zp9?SIF;7Ys#N(i4&U<}|fg(9fk8SbeLc~mQX7uh4WL5xqTc-v+$%}-PyuKKRG>#RW z8xa=5QA5v#a{k03JgQOvVxEErL`2~Mu_ER^!oy=feX>T$VQ`Uhwlhu|XjaDiZGKid&%XHL`|p1X>uFt2jL6JwyBx2M8eqNGTFf*e>QZ}eUG@6`#j!%*QCz6*dqzW}2(e{JrvD zOq2SqIN7%pX?UR#L9Ik&=7&X&b!ll4&VP$A_lZSHir9qY_V$J%5WFy9O7;e1*Ygzy zL}v1EA`TmKX*0{r?6qHV?5x_8BAnGvPx=$Gd(M3@GtO#Va@~_Qtu$E?5>r7$HSN7e zz;akTf=H0Kb;C4#MjWqiQc;~KP8MbYGS^bY$IcrGC_I>HKL&<;5ylJfbi<$Z%jjzn zhv)`uFS4>A&7nMGoac^FnTAm!ia{bW7-0{fW`K}K2?+s$YE$j*ngfo!{l`iyAoB+Y znP+-h+>MEX2CXLZh0HX77)jOFtpOeY&^(s9#A#Erh3I&F7v`a&l> zBVa~)LGF7VXC49RdQJUH&Tn^_rJ6!G3z_Sz;w{;X!jL1d98kcxIrcF}M9gvJFlFs& zRPgkM#%X-aU!NvbBMZV(Wc+D8zyV~A%m4^D*@Qe1x)}qEV_}(18^dmafNWbS0|55& zF%f1&97#}Db@N<9`jo(U7(HfP+hCCOMEd2AGhJ9Q03D|4Zh$CdwATy}G>k>#;wKh> z2oLnxmt?Sa8f|UzJjT6)NDp zT@FMtz7T&rqhb7-QfqZnm|}j%9Fhh-ssYG7O?sovfe`{7S(12St^y(~L;i*)H88`e z^21=BO6Um9{va|3e(bzFBFq_)g_pyDOAUbYW$j&yBy`LX2#6fx`tJGd-HX$w4^$Qk z^{{ol+}>PML150&0tD3M(8{K!=kvLgnz;Y$wzh50l%&jfxU5@iO_=8KNoJaJ!L4lo zJd})k8R8z{lXR4mRRDfMG_l(HZn#8Fl4NI;BXSqo1- zO=P!6>bYDXB2Im|yA`Q450f-Z}WG3I3I+Oy$?>0BTt?Ut6;7}3d1#yg*{qdRGyNaCA zF)_HCT7VM`etbZvDbb_af^*uLGfa=xyLBDQN$ZT5etsf0^KISQ);waV3lsJ9oO6-o zh+O~h>h=Hkum0Vuw{Jb7x6VXgfB8jaa`&z4x8MJ;wnhj=n1!3!>$e}?e!PD;U3yml zI2?|q-P_7&OO*t~XR?!<5ADga5Mk~K#+nMmJx5#_Paff@nplyxu4~i2ZR`7Y?_R%p zy{_BmpMCys{>{Jn#m|3E042paVN;M3GkkYsUO@x_v1Bst@DHkLpLIa%?bYj7_xGRX zg%=3ORC2I3*A|Zxj067Km&YS^10Gx9u@>^2m(GJA%+hEzH0?}9hQT)6VR(BU z^)<5)#K%$g6W{Reh}fUf{Ec~{)AlLm)S?hmP8QO6KE<>?6Ley9bbH;oKk8$>l46`J zzo+Od`LMGDK}59PXAG2?SxmRXJ|+OnMkn=Ukh9Wk&C^20B6%1kFFqo(${CY%F+U=n zXYQEE=OQ#|m4FV|4a`Trgbmpg?mGE&L*|6s-OYU4T2nKRuDz+NLza=*Ddw=U)IyT{ z*ZV(MNaR~~ab8tP%G-U@P?SFt*_fQwgmZ&AX)_1!}HI7guDQWBdGPu`DCU{pxOXDMbQX& zI2;hznuc4_C(`?XL!CFwJW@t*MUE&gmvaCBQ2zWmdzdSTJzMj!xP<*B@(g{La@?8u z7*Fg*2!NU0N&7)MSo$K5DxA418J%(Kt()#{*Bo5U7YDM#D^dYdIx)72S|!V9|ABjO_^(ytBQO>vs*K_*4m87%Wl>3s*)lIA%wLE3yYM@M4IwrOJRY# z3rnuLRW-9~_q5N5(9A8y$Jqcg)he0YGCvSVyBbo;3lNDChd0Phlm|*6!w@fesF{ar zx1mLJquFszNADCjVp1M6%A;^{Omb|PukLJdegE!97~dSMJ#B4sp16OPquMna2iiO? zA018UtgXu~IpcX`5%*ZC zxtfp&5%}n7vQ9J)Goxwfd>nodX4-MSgn2p6bg+5f%!4%-_G!Y5cucO&FNb!h%G_Cu z3JhX5?8gYt;r${XCdu8MJTAgV!hjFrkBz$FQRDg;sXJ$_`yv*1D{TfCM<8?AD-WM% zlaG;Qj!PAvaK0vpBY%BJo=Hp{I1*xv)GB-oA&zN{#YA$Ae?1#N!o7E=5q6Qrc#Owc z!VaB?MUuGGx;g-o z*odhfD%9EH4?_$8S6|l14H_ec?0t6TpW9<`lRx12mXh%{JQWd>JEmr8HnwyyPER)r zw=o=>2h=Q{AcfaR$kWG)L)8dH(xno$mOZ^HSCs%jBo6%I<(Ie5KX2;IoQnX`x^9F) z5V>MbkQQ@UmZcPNcjiG=Btj%K>s#C0dKFn}k&!BfIpIhYR}SYhpLq9A?t{bqNgj|K z$~*57Pc#i{v@;ckNI!syGOuIoiiX!m=fZ0@hb8fta{fd}u_H^mL7=I5cqUvWCxal% zPIFfX8xoN$b_UlI(k8mQQunuVABD;w32?0yW;8&Kb(lG(V?soj2t0)8>aZ+D#)Us% zO~d&H(-Mi`y|>nyYA2$@;kYcvL^0)|AuLSNN9{MOOE>8FO%j`>H@ z829PKovFGWlpb&x2CRL~@Khj^x{s$GWnvx*>Hq@Z9Sbs}#HLM|7%1ySVXn32#5-nI zWj+m$SoRz)1rXv=E2cFwe7&3zLWqvXBb8E?1#pN*0_nTg@BY{S<8Oce-D?zi{_OUf zFTcn^<+`=gWmPrP)?0IrP5sr|_xI;>DT{ez!p6;^eD&h?x*}LNvq16)3=2~Xu&x0h zOzvvdBMd<)jL{KX`)b<2)st42z&h3U@7}%t@czY%=YRGmzxe9&&kltdpfJ^1+)auA z?i)T&^sXRr03!+_g<+Tw6=B2>p#X?Lbvqu9FJHbq9uMJ`Ep>9lAVO91*cdISNAG=~ zh>V9}|Db7vZ4na8lnDom@M%4S>H8h8(Om9kr{Z%!!aLGq7Y)Rd#(Ex?kL~I1keR#H zM=;iGheu%T+=4}t!;!@Ok=F%JE{~zz!9@HAK!|Ymqshp@X%otdUUkQ7Oe1mtF~_8j zjk6W@skbM{H@DZqG68##-S8|^#!WWn&m-Uu9%*&+{VKJTQX(LM4>+icG1MPSf#^es zHkdVtDaVTp4mWp=5hx|h7~l?Fy>;8vyTLS2j!?BSSb#^(WKR(woqCb*P*HW46c3_jp_k;UJ<;3pR&yDpw$&u~Y=y43s%ax?C( z7y|=|5azb7y_DmRe)QA2T!|DZ)g0T_34t*nLPT;PF?%>t?ctUao%BqP2@0YVJ}gz3 zbMlm(-#!>lr|*uU+l!L@$i+v+0B~}=m}mQ#puLzkZ`O#z`zuKy81hTZ3m8TiPlDWO z&Ni8li6l#HL};z~*wZtyq>5t4+`vc`%o(KSs(3P)a27)u1JCTBV3j zSLA$ge1N@1_|+U(A&GF2GV;yRzs{UtM)TAU9x^T>!atKpm>}v?MTx{0&b_F*G&^*X`aJ&W)|&zH-W(fvgBzve9(C- zdzhLg_By{>H_PfgkuLjKKi}Pbx1U(xU1pWVEAbq=&;B@u3qOe*FwfBa-6Y!4O(}qn z$agARpu|N%gp@Qx^BjXe&clazXO8xA^6<1mM?^Qz05UM&)~&ZD%q7vIwH}Vg9ESYl z$3Fq2^QPba@b3ThKmOHU|IOdF*1q}CS2u?W?(4e#@ai=S9}i19XWqSk|EpjBw)Jdm z*aNle^PB6Re*Hy3$57S2^#&sa0D5a}+pIV3&9%E~Zx_=|b=$Uc@0Z@!)-LAFY-jyC z0K9(lnhAgM;~#(d#TUmagwVH(5XvYphfNlSKElTE8Bw?H+A(tG-EdM`(sKH6{}2(M zfA+GLL^{Jr*#iM00UFkFzJ*cpuroLqHJ_oWddMgXOL z6!Te!*C4GjLZy2H^3-)?j$~9|8a&?`*Gku2Z4~^8Vv<_ zzCVD3>2OF`blyFAXbth#Jj@emlT(PPjvgXv76F>wyhpf2j0qMXHq)(j2OOIHId-B9 zxusz+n}>6*%cg2%3cs_&NwIiqE!%o~Lj2BQ3=FpOfO%O2a+)RxftGq;;xs-DwSS~2 zDSs5&@8G$V9((QBHQbr-0|5}K+FNtga7(VJ_O@-?<+5(gp1=6)_Sp+SP9W>nx@o2w zM2cg!Z5x|$L?Y~}DP5&O6~?@r!huHq?AcH`$-FP>o=h{ zqt+Q?l~IU|K6JMaJWhb-INoO;oc}HWX8la#oee@9gh$%j?x_M;^g|o(?P;fIZ^sc1KL_J^NRh{(@}7CbZ^}V37lX6rkiu84x?yI6tUl zoWb-IjDiE1xkikzVvnIR?!85r2$dovn49m%wazGVhHh!xJK=;-3-`X6bqE(3<4Cih z;7pIpwi}5Ui_WvnMkeD#J$8peJ z(B;^0=S9yU3K8evIZv5zGwm5`Xr3kkG5};0W=U!qV@g;1h>DIhG%;h&{<>*5C8ER* zb3#K1x64VjrD*9;4`02wy}POzx*5d5OvL})fBWD3>%aVSH&uvN@816pfAv5AyZ`NP z%Hiho&%bnSX6>JU_nS*^x7XKU4#;b7%{BlyuloAq>HYmF!ViUC-dttTSn7dE`nI)g z3s+_+jOg9gRUj(5_jBLw+j_6<46f*=?b6$2T_3DB>kR|my?giD-~A2|YbnR2ig18S zt;^wXcXxMvbA|M{$ehqPOZ8A;WDNAip`n|%jlvL|2nf-7ckS2L*9l6U?T7_>&us?} z0$08s!zN4Q=E*_ZZxH|>jQkEGro%ZsK7cz9aXu2mZ|ZL5N(1cUY8v1$PnD-(KD#mn zGuk#X5qH2!cRbbJht1;8R6C4j0!A<}%;zvahdtylRZ0Vgl~xeS#^}i!g~k9^IJ?F$ zN|K1z#V>hKV&w3G~zPdVrb1pnnYik4KnmZfwV&(;X?bOv7wrma$O7 zyfK(}0BRX>M}V~VoMQ+;X|~8hyjl1 zT;mo0BIh49E7bzvi zWdk;J(|glDnv1ZZ`O3dxBnotk90|E<+cpocRR{x2(cN7=RK0I)le)Zk@fi}c$gh*ARP0j}27dh96y1OSedq2PIQSb%r!YH9!;NuPE`0zk6>4@O7|ee1oM zf^g0TYON%r!`ZV;fMC2I5xIg->f00DdzdOBR1wOzX2h8=;b_@~g;^rJ^_ETN?DdB} z`Q7_>y=_D(jO*YkG~(}a<C)`91aJM7?}@9n`%>MDZp%D-8KK%fewUO{-8T^ z&Mj1XN62a8=rY{F+yKELx@ztS5W>xS*VIh`L<*b2ln_};1h{sv?!D=@5z@0~&jcbE zDHz?%R5KbR@ssYF9ghbPgj>&ahp~G>OjyT^IT>TLl*B|P?M!vdezL$kVh4KOZReys z-qU~d|0WWI5!tMRBZOz)78B?{5~(1Vsg@$bNcm2N2QUCmA$JbF2Lf?{&gwRE&l!={`uiyy+55x z5h8r|{=;`a{BUzPpoeflMkK7IzW?y?fBdU|e}4ER7-o$y!aY-1DZsQlhM8uziMHm} z5W>tMtg9hVIJ>9bfx`g_&06Jpdbs@O|NQsA``z!q{^m#5S653ZYhTsD&5y^cQiUjl zK-D0IzX!p?!MvN9t9p3v9Z{GnxM_EF2P7$1Oe9>uVJAlukq?h@6rRu=M1=@QYRs^$ zPVe39N%Q<26Bo$o#k37gH|rcoT82^pI2Z=*^`)h1Xx#T>0{nC$x6jxy!&4uFD{Mz; z=BY8)wU98{J9@UQ@P8*aDZ(+l!EG7xgolR_BN?#%HxKLh}9 zb769`QJ}z3P3Q0@2lXBf4ooFc^r^JkO&T7LERGBn!^c_(fdF%EKO-MXEyv?=tg}-3 z1j533-!t-bZUev!0Ljc8Pn{`hSD#KWM5;@1=&iMEK3Z!;=$0$X_QAjV?_1b_kQ*I=g6%M+6TpmtT&gicHc#{MW` z_#^rtC++5r5yDtXtz~i52#?6n3k+~%CSovU0tP0Q7cakf^TVxf_oY;8_VLsG?cJT5 zF%%+zh;`eRrHs7P#8Wfpc7r+1CWe^e8@#%@x?CRL|!#mr|qzB2ld8^VXwlXm0{A0!9MR z%n?#7V-sKIW{Sg@HC3JwT}>F4y7+v8_Y*K46Cob4(nFkO&de||U5J@#GG2Csr*p*d z3!2T19U%g1vVXxnyodmy`X-W>j;>m2 zp#2#I8Z629{g~8|MA1W`Imq8|M>6!d->o0!@Kuy-7T#B^DqAq z27iaww*BzkcQ3#EF?eSRDROlzfc){(y{S^f?d@&0cd9KywQocyfH*!=nt6|~ zfIwux;($eJroT84NJ&6NH*4c5yDJ7 zqB6kU5ppM#f$$`_gNtc;b0Nkcw{QvgBLRFoPOo4ez3ewP5$&^;#~~ZjK7W3ER>33E zQe+w4jsQal_e7=jsav)WHe?54y8`VJ$$f>0vM4=vMN9#&wphm8_w{Ep=5S*Hv@N~ikbL1mvBS`kH&>3 zsle{pmrPxoCSF4pswyueGb%3r~jFWV)7OC<Aj$ zTo1=E%hJ;=u~Fpag{AlHda3T>j^%KDefRSH>kCGMz_x9<>^PrK5P^j2dN~|Qtu@yv z-IM`pt=qPhQZ}>Rd#YBXEX%SS4hK~Y$WE7Qt(VKiEawbpsaHh?Lbtv@;S>717p7xU zz01e`ctm70;NAfdX@>AHL?U7D>JY+$>SJk0l+)U_ZV&hO&+qPtSmziYAcV>18c2H& zGfaaY1QR5r#Uqk#8;*exKmiaG0dRUauUi|^Vxn3rBDCJub%OvTthKIf0Jn*dSfnnB z?%B7ZZDuLDIr_p(#N2ggY(23(F@m>MEpzi=*OPjNYUO(yG|d@4Cv5rmsT2+b)y_-^ z=@pv8JTpZ+91gj=$*Fjb1GTNlawUrgw&vPeDfP0QAMQ`+F1u80M86{lmjQ`FB$n(q zM9AElS?}E(L@Lf4M4u+bxirF()aA(a9YsVDdBVHF7zz;|-Gj$$V)7duop4NE0YI1= z0TEEwjhO(ccSUBAvOi%t3@-ytHn0yxid!F;q{y|9=W0>46G>Jss)~$2xYR}4CM;il z{`t#i&#bSX-(J1HV0S|zuFJ!^u9wR%e)`idUcCIxx4->QfA^n0e)w=)>Nu7Hij;S6 z-~H@Q{`|UfUr&ca{n^ic{^7&>@4kD*rS|Ro`Db6(BHOkSx`#DAqXYn1Ge9UJsdfhN zZ1)XNq)M$steRo26ez7-h)_y>{r2s<4bM^s`|N7&?by86inVG%+<4z>GFX6e2 z43CuABLqGg*v1p z`P?iZgasgnx*@|Yg&8;z;R6;nbgGU4TibvRII$ZsjE3V!F3Hugp+FMh2;BFtG0Y1= zjB`wz;_7@PN|B=4%vIF@IRIPh8JVEHyBkq43m|fbuBN8QLd;3$m=$(TQ$1#ab-G7# z3qG~w0pYzjl%DR1fx;it0cYTdFb_uFC$!+;mmE*p^Cz4-jytM4GN6ajNpJsb|&n~y<@ zc|fTJ0Mra4cIf&zSG52khYE~e2{!@aAz*>b26cs^gU+8f_3LTg<_N+Cq5 zhbvhQ2o&zxdscqqN6NYwvDTU*cMqKPG^UEu_p)Uq8vIxFN+iPBtedOt53!rk zAKAdB9iNMuUoMwg>i~S2C1ipKQ^=-ft#9UCfMl2=GV+sI&6s65F1d@HH@O8-&ZR|! z#!g{SrFO63{BzIg@qDAQV24?zdHC+aq@jO`=|35tb_CAjU__~B6A)(M0nFiU4$Nd0 zm&=NXwN$*~tt+iih?*tmLo?;;YIjvh5NDm0Y{p8Wwckuw4psV`nW1F+j;{pRz} z;6pn#KecUB`*?qPIG?|`zJ76Yef^hz{^I%d-~9D|{NdFrVal4ESU!IISQ)?h^0Qxm z`vWfJ>gu|$7ba8>z`*cjDVOz93tQM_yQG1PFm)N;d&^ySn0f>W6JnLJwQd#xREhu~ zN$uKy`>S97hrj-t)8%}9ef9I7{p9xUCd08W>-y@|4>e_c=$o#dcaS%;AmRwXaTU6H zaAA>xMIvY_C$abb>EQu@>aq|ak)S|#Uz@#t^X~cWE#2M%V&;LsfI)`26{gYUMDksS zB`F89kCF;he0*^pN6@=fHzIHXLwsZwMPxw8c!^*qp*~$+WS^Na@;hc8cJQPJ;1grU zoV{eDpMUPuSVQW7hVU>oOF%@XmS?qv<18*cQpz#qR8M3_BGN67@+?>}1u}^QvOd`K zZa$P8j1ZooJ(NQ0nHfIbvPg(H6fS8hB?whT0b&{Z?tm~&Eh`O08zyyijw@*&9*l1! zG>f0X@kC0x7>*?(gr#Ijd(%|J)>`9G&*yV%>*0`rYoMB?mWUJxF!#!s;ByZb<_L^% zHH$DeM+heLz}!s&z_x8%M;Np);}~Niq9bMk=2&m}@xH-0nZE=lhFdNG+v2fBx*n%jX|n zeJlBDtlRN;JS@wn`}=&f&!_Xv@sLZwT$zpMq4zxF%nY?>BYr#{*LBU@r}T7WFhj#GT;Ax0(cLGxIT8g_9v%0~M zW#xGSvjnu+?9#46+@~OOLIzW(nF}V!Ju=e>cLE4$$Z1K}KHZia5WV-Dl8K0US|l+rU1A9UvIK&sjBcEOFo(UCr!eP( z@W-+}njO*OE*Ymk1O@;okVxI#O-d<6)KVUogFDWOj66!InHO%>1KivQrbXBdsSr`9 z6JYNx+=*q(dk_wX1;Vu(B5u8Z|LWCGo*!=I8Un%`JJrw}1Gu4kecmTSmeNtV)Pn%VX`_4koD+z3Ks zk<0(=^OaI0M5B1WrgVGbjIcIG5&P64Bg!mx3h}E>Gm)oEgjQ$lEt3`4MQ7;GL#? zRYL^ep+3zImYFkysV-HdAo46(hWvCK=RABMiGiM$-^Y(GXK>!nKPtz^WMHP7{ zxJN)&9XozVWA%{!rMrkSSci!a%@s3Ki)cQ9p88?!jfpTQEYgLTH54%kVqp%jtzEWu zhR~Fzw%$H{`uLB3`yW2Md#%7wmd`%_>cz7=(}s*{9TB!|ZQE9BL4>Xy09)&=wNh9L zck4dqG{Rg;*|v>{x~i06xva}#+uC|N)CGyaEy7Keh0T(f79tWDt!CRHP3u96Tw9(H2dwiVp!QZa_Apopj{vn;iE z7!kX}>3n8lQ}yVk=9i6`Ki%J7w(WeneE9IOt3qlQ1DKN?U5G#=Map5_4$D#xi%214 zGb83y_~c~5EbdRIzS`BHw5|8vnaEU~un5Przv4hSIil}#ltdsSbKx>#j_~od%rG}I zMW!&#{fl#2gm4^a%tN4RR%9UrksQaOndWr~)5=SGeBngFZ0ou;rC`AvUSagE?iz8E zG;S#cJWMUAwg7H!ELBAM+H%ptL_!j~pk`;~4;)8I9+KFAIZr?ur_9qrV{d)-Q+OZr zA)pOZA`+3$ObC=hW&u#A0VWWW|Ol%8@>ntfT*PeP<#=IHm+h5P(>Ugjo&%_Sn9CR+jP;pLx9rHOtLS0s-8Uus@fT zQ|_17_Eg)z;~g_-9}$CMzt6eB!}F029AnMYAixv?$7qN zZM`=jWZ{Cet?g-xoWsdNF5(hf$-!tnpNyTY!MfHq<00lBP1Il zlI=C6k^W?F{kTM*TfpCPQVT>dVggeW zWY~L$@umX}U<;(Y#nDYQ|K1|Tx=}ShY)8lW1tEe*xGEB5fKnc#f#~kqJBexUsmPrx zf)o-apaTWj5ep$;V2UCM!&>MD-d(rm_Wd7z+b{Q_tz910^`hE8y#7wxr5>-|zkm1c zhdthvJ6bgpWK~U*zM6~2Bv!d9Ko9|QK?1cf zaEd@6_Z*7B$B!SFun8XY@XgoXP}bc{+AS>B^Z9%}m3lazPUm$irQk%xnc4ApETuHn zyXSY$ohbXY{vkXp zymG;5jI{9y>z<|zfTh;FlHEf=M_qzET;n+zBed8B8=VYH`!qG6RZi%!LkU$tS@#pL%?G;&eO&p=j#$M_d?x~fS9wM z&F#ABh#AR$*W8duL~1PvHaAY8rVEcaVIoiLd$#5*Tq}HfxCCys*4x|L-+ccqBI8o) z@woQZwG+bSa^KoUD3#0o{r$Ul@4ors4uir&gqKoYJb(7D|J7eM)9+usgYZ(z>o>3e z?9c!5_U8KX@zmS)#fz&#_;Fo{g@~`NZa%zuZ2`>HJPJ9)+4_I_$6x;L+aErzzG+wUV>xPT-+cM`zy6Cqy}CXwbuj}6bhmf! z-d$Z?Ez5E`p9rs-_5dap04OX?d)9yu;JpU`L>8f9W?fr;dS)8oKycaC%#^KEUGaSB z&z`>!DdZ`m))+EX_3M`01a(USrG>kl+1j1hlaFjT#cAk6sA&Z2JWeA z8k8Z>W3Mg}E{PF!_Kh(hW0N?;sK?Ina_NKk{zO}Zk>v}#t^zFQ!KfZbO-R1sc z0RQ&k{&x|_LeH+3uue=1dab1tJVW1F?jJz`h2-`-4O9~I^A4f zr7$h}`sDE@8lgizZGj*nj7xsqIxM-Akg@SxBjz|E=PN|0svefwZ=AsiyZSZT=qIa6 z9Vs}N^KS>D6eQ*|NkOGlwKch{gYD1s>C*`zo?Ra)Lff{rzO{zL+uGcHJw0q|D~E-p z5Q=#)W2sB6CBRFmW_CCpGVCrQE|*KX_|&X#O^6SNT1zoktAZpdD<*)kF;eP%wHhb6Otqh)Q72n$B;^F%_yRA{3Gc?|lW+v04o(T$o8BOifd9 zn#jtWrI{&8ks@xst*b~OCJz{!JWORj`WO_CH$28nFOq2C`Ykeboafru0FxCC z^Y$GRY*^j^e)$iY8k;B#1&Gk)Fc-YiY%HdFJFUz8;UF{D)y!w}a{1cR##=pwoH#@Zrt#XV>4pdVBxr)74KtySZ8(K505N6KX_2!lI@u zB}})b1{#4MP7lBO-S2;R{Z`6ycXw9{8y1WB;m!N+et64ed4K<49z-1d{OoY}vp@Nh zLVUF>m$d7EKE1wUYmvvj2HqKw5b5h3qSup5NHZDa_H!g3Ilgw9&PP3jLvIv zh^zM=muA2bPR?GsjdRCCfI(DBks^r#FtZ{xS6?|8eA00a$<&w(=%^~Dd2g>bd>66- zKq{&LP^2)Uxe~C*l0XBPS=_KwJYsxw5w6-*wJr%6osu}5|B-*Hon{gN1R}iDx;Mv` zTL7W}ShtM^^*17%u;fr+$h&};NPwjji^(;EA*aeb)BFkZOO3E?U0YjoCB{j|j0hXq z^9gI~Wj#L-vW2Q@+qT}Csy;m2U(T0xUEAfMZ5Ipet*w`H z?;XRyyXlspk{-$kQp&UIXKvhE1E$tgw-wCLOxxPGpy##MTf1B?*EiR-Rywb0 zdbwQIb%(ObG(1Ie@DxV-c3kn&rA35s8+=p|_S>kHUno`0ef0u`Y`U z!>b>@H-{b|T#zW-uW#>`r7y=LOBulu$neNZA7CF$?7uq`QSxe-d8vcwjgH`@h#3-- zshgQdof*cd`VaLeOy-Wn%i*98Cn2r{g$N)%y#E+>^X&Pvl<20ALI^#gi0q*t5t-Cv z`>r!9p1ht?RWMq+93X_7X(AN!f66=(CPKhkDj~PFdH5nFU~p`RnT2zCoDQ4_Pd6Ze zOW)P7fB@*GM7R`bt!0pofw?;nQ1ITJM0hT%n1)4Ub zEs>D+4`NtF$En~5fi&1dKm^QNw~^pLg2WJE9u^@iH0m$cq$A8#y` zj8F~&D9nNI?5Z9XvQn?EuF!o`di7~rj$%-bSJzZzYZof~?&GHqA3uHh?7HOf>dwS> zH@7dIKl}MlzH#fX-krMBn>Rn)K6{C7@85r9rmsGK_U_a7OxXLD2$gs5-+cbr3t$2S zq}rRc^%8`a^}IGct#0cY^;lZ}=imGui2m^156K%yU9Pp&2!8f2{u~iXT_Wgw*>Y4^ zN;w=4$OsV2au}YNa1RVZ2Y?_z6fWy_aZ`ZGrFitMotgRSaCE@#@$A|2ySwLGds}O* z$KyZ${?)I3|2haQ>}ny`P6$W<7U&S>2oyj7#3CfcRoyJ04+mldgiRn<(AbesU}gel z!H5Bwup_R@EaqL!kRw~1^m!)_Yain6-SgvQC3DunJ?*=5pqot-GsDO~GbWC(rfavx zg$Gh6W(SQt2Glvx3q;CvsZ{R|6>dHTsTl@~FyFCX*%GN5NgC=n1tz<0aje2KWF@1} zCnzf66`6}i$jpL}uwjEt@hUtCz=Ti=8xm)MtfmkM$UexIG3>eEY6{>?=wac3l%g#F zaPXKg4&38|lK6p{9_<tO+z5H)|rDD8n8G2rGgn z?Gg~W^@xO_y4&WxqXLV*BN#A7xfo_n0A@{lcZbVmh0E!;zuMlt{)2n; z)_QBIUA6b#dvBu#auWs;*47Y05F?x!7zKb>2rw7|A`@Q#g=MLi%bA>nD`4-|PM3#z zC;x*flQOwyx{l-5o#(GZQ4l5QpLcRh1|@qLfO^7Jw`RR*JZp z&Z+*Otp{cnBSp{<kg02^2+$odugwnA7^ zX9)}#K7aZA?(RB#?M;z@q(X?4N<@_Jw^{(GYTH&s0CND!Rb-1+mnD}Y`I~Vmu9;Vj z%%N&Ppb>xqs3L%vcPCf`jSVV*rw5mNHxD49B83?>;CMJ9fVrvq!+E=Vo|6J+VrKA> z$}Fx*O!;IZN`{XO2G%3gbCkK^dll|@CNlcFJ69mcXR#l@acTH{Nhi4@tg17ub1`1r~6M2r?MPgzkPRo z{rxvze~nD*)yBU!}aym z>2!KHovyF1(;J>a$TnhhN9)vk55hw|nk9LK&7&+uL;@uwv^6*Fd8z^m(e3Ts+jk#; z7+5}hI{m{h|M~9Ov(I1L7U3LVq&_n>vPfuZDFQ&i44fw-qXhRt&c|}lLL+_F_VRN+ zT0w|5Tf;Q(?@cZXn>m0JmED4Z(+xS^pC`uL{Sl=(W|;!}C(47g?#-U>aT+tQk~_^b zr_;E{pk^KI7DEq&iDE{GfxjQ}&Io(l4bzhicQA84;y4$0M3nC8{3wQtXry9~KXU}w zlscMPzPH^>%`(FY0*N62lUISn2nGOBYCRlkonYayCr=9*48qO`+s%1D8q$(Gd(B9U|;pWOLR2^}^hx6O-y_X7RI)7PYW2H*amd|MbE73ef@V^x@6-Sr?6z_>sLp?ir>A zLCh%3&J`j%5FtWUQ}yh1$St!4Oq(pF)J&iP35am@AnW%ZKl;_t-OM^8bk%j;h`F$w z^wPTB-=D6pZvl{)h1o4uYX~e{TR*F+2!a_GC{hqmq~vrO5sOH!A0WwoLy3Sk04Ro;d8m=K-` zj+e_hDO5z1D+nBXZBG~PQ$G0qN8Epgu`Qc=<~)G3D={VhsMclXiRSg}Y8@idFE^Yd zgle&r!(geor!01!fH8f&lqUV$Tc{^@nuRNbGZRF#*1{c=>7(lIrl#80uBcY(a(8w0 zqt8G4_QUyjH~`7oE@r?2%%Zvx;m!5U>BDw+dv|+#$EXni7;45wrPk}~>#M7W=P#bU z|9E=!=5NoJc>DhS?d|QSPoI8x_4;4^#h+E?x93YQ_^aRi?pWlDm!F&CuYUd8&%byH z;P2nRS$nsL*Kgi^`0&vkdh5Uc_S@yKFmvx4A|IFKyk5*qqzEmoZ=dcz{5SvdPethc zhYyH&dwY92oszG6JRDWnJVB;t=5ODmKygO@rkQ%>QnZ=d18wUm=A*vVKW3~nFx*$oa?eNDjxjv zVSC7n2FFk$dv{X|b2sZcs~-t>q^M*`utEkH5wiKcz$6Wn=_o@a)UJfYLd`p5 zVv1VQFv5XJ5TdtcuE0o`)?rL(SPH+kb?w__IUErK33|87x?W%39u5ck^l5AQ{^R|N zmoKlcjvii0F%RspZEIc_<75NC098$RXDPxW+O||qWxO$`Kw&8IbD@j5Jsd;CVRfCy zuNxAobr1CcLWtwlvR@}%&CGh&@R9h$BG%hyFP<^ub~+LAVW|Yr+7?DgrPflGqk}lg zW$i_{_YH&HqBYIvyiBnKAl1$ki0EeMsNorKG&k7}IXy2$)#%CEt}9!eV*F zAmWS*+SWB9V@t;VMVOamVZc84xB-Eq5=Dq`AruGOH+eaoskO!=hXF!aM0?NbRVNZN zJNGdIOb>WsaG9S9Qudl+U!Eb2Mp=lMc!KmiBAB8Onx}>$o0ja7W%z|hKZI&JG%0Fh zSU(c?f)aO-At%h}KK;e0z31l@Ke^w28}9Gkf9h>}c6WCz zQWrUDe7wKcuK8<;ltK&$#3fvjFg>+yh5`TTFaF{;zy0p@`&Xy)h3JY1Ulg5(KbvhE zh7*+7f+`v-R<-ul+Ct6Jp!OauYSfCoN0i#NORGk0MeJ47s2F__H4+q2dlj|6eEAdd zOYY~nuj@FEldyt3n%f;hc-A2J&u=bghaTF59?r}v-Hgx@ej)m||3(`$IXel&-Su^U zRZG!}6ZTi&UEet)&wY=`@7NaHU^IQ4UG2q~%+ewjYt6o=gbfclJW8Y@6`kq}3H7_{ z9Lvf7jPQp2&-XR~Mp(Z7BhU=m0s;>dAt{?_Ed~+{!fivUbiyIQmLzHxON5!LCB?Ut zo{OuEr4VBly9XcOzzxUVm}jjJisRlFvZh4#Kv8)rxC7|2>&a=rlOK|!|4l>4dWXr_ z(zwVSRH0nWF~butyl?X**YwJ+A9$~5d2fZxEhpSz8e)}&q#=wX^`2~w^R>Kz(v@ku zA?v**Ph?G>QC4;|dUXrPSv?)VJzed{;PtOCW2eBgf}hw8qLJz_BZk_%(FZ05!UJey z3b8xPl#S%{L#81PoHS5LXI>SUpb>rIH$zb2nW6nR=iGb~*3xpI+GlI5o^Udxw|MteJ;-{kT zfO&snEIUrDbIb-$3}J6U_L8r}iz^i3%q=*XH&Sh2_VrQ~h?HNk#mS(uA4o9i+L8NJxdQBB#rVyki@EdFOPONvO!J5wA%=KXjO?;J;nYKXral89&$m1oP zK~<*FwY4;Bo6&vZUm!`N^K;(X9nNzp7c=HmCPTU)&qP&+vjir35BCi6 zT*a4Cxiu&zXY2xHi_q1X>PO?vwS*`8pB5RD7}7oWuAGMqHdZ!Vvq(B&Rz{jqM!UyG zfdRKPM%k<1#*CkEo#Kv?69D$n~5k?7_d*Zf&SR&dc?#Nf7K3KChELY>~mWv9{N zohKw=_u-YBg?9%%4;=ujYfNk>2RW5{qjmJ)BYv{as-xMX=25%Wpek>`JhpG9T5WBX zp)?a+>g$82|45p0+4vR74&hB^sZ*f3l3)z7c~hR_q~x9<0{~#htd3J9@B2>W=%>W{ zutZk3Nsu8e$2a0f2k5>*=R3UU`%;k&$IfdbP#S?)@C=*70Q|p!O+&EP#|ROn;KR9X z+Mb>LS2O0-pqbI~fpx>kj+8-9Hpn$#ge95X zV39giV#<4wWWK1U^YVA1Nc&cmNgaVXIyh<+8LQ4JUS4@d*J~8} z@P(~VTFrLQ?8EMjPADgjH&^3^^B>Qc@uc#Cn{RYDV!Fp`Y~(9jNO?fOIq84OAt^FH z_~*D2&6d7~oadDSox@o-#$`4f0I6EwWxKs+ni0wweILL_m2VAKAmH4aH0k?1MEA|t zhf-hDN~jgLiI>6g<{mUz88kV@ff|PIp32IPr+S49x>&k^1W4=9!u8Kh7$h@eiV zG<1im9_@Y@q7|wo4d7GNoesQ?v6&z}j_4$Jd2x07Jn&syISCmM8y4~d% zr%+IihgcGuu~BH5`Thqz+YS`L3^7LptSyLV%SG`q%UF%S zK6?J#*lx4ZDi}ZMnDd6ldC)pRKt3VT6B@EH0}ufON`+CV8h5v-uo6ZvDZo%~4jt0@V6i=zU2A)tAimEc9w~l9T2ivOBpfed zy?A}mAu~WpFSENb`n`t1M}T*-#qVq~gieJTST7~xhJr7O}liB3%WY!Ka-iAJxa9c3^|_@xmnW>yY0EE<50AU zq5*#IjS@#SIC@&Sjoqt8-0O2P%RJcHy6}=$lrO;b+}68)K$2E0K{Gr6TExO*<6E>< zM86xRN7w%nnp*gm?@cIibIDk=jAC9m`xB=@p?+qI3XAZH2c>^K2*#b@(17!uxHkjr zyP{-FkiU}=xIA$pA_g^l8! zh?QvDm2ib5ZuCYjv0DAxGk?bE!xxs47SYj>SkFr_@qsSjVveCZgWC5+mvg0apP)GN z2PRVB@+OBU2tb5*V)G0o?BUKgM*B^M?6DKnO0+~-;Ol#um9Dytc>O<2T?!#rA6=~o zKRZA78g=HFKym6Ww3k2o}7(DBlMJUqg9z+iRi z!O4+YAwkcV1%4g-3Zo8#p&ighz$qKoh?ZHIctZU(B^S_yvOW0p%J*?wrNt~RUqfKg zVtf7ve5Ts1uGEwSNL9iMcs22bJxY^IJEkgdyXGv5NnOHlcRR0}I{5O&(BAI9Ne}J; zJ4+HjGo&nC4ljfpttL@&c#1#WJC$Z(b$WY+kT32)NH{JU-)oy}a%qUAV$=lQt*@Vk z=L$$rOWX}}Z9Tfr2}3_R-)tUTtXhM;1$fCp7Mo5KEjtH*oPi_+KPm}>y#{?&bq&4Z zKD+bC@)#>qyX$&h_;2&76fwxV>6b7bbgyF${U3h9+sLO!z@q=s4}bdd)BABlx$tm$ zxlB1}@UjP9n*ROE<;+b$RhdRTMmuKD*+rMi_V*`^H|5~m2>CBdBoTuD%@9{)M)eUE z+4Ozw*2-B;uQl)TxOQ=a?|QgjUL(T)ou~9fd2L%~tD~Mqn9H1D#+teU!Y#?v zoqU;(Y(q8Aa_CuY{RVmxtS=ijH(Mg%rw%#SmT#{G=|-XP+o0Ch3;FuxOW~JT+PWo% zp~n0w5vAKs;n7RPe&zJ`ON6ey)i*2GcL|x|yBXyaD8Zqs4uX*4-0Sw>*t1)p55VNW z_MKZq@T(SVB%Iz3JXa$!ASk$ToR>$8)GQ0c)#*QK3+?!MDe8yRWXV-M`vm=u4xbgc0=bfO{uXNIvjX(&VG`!Pv+`g$@A<+Aa29uC?x zK7iDFyEp9kU!-7(y8y1xiPejVtq!*$sei&`S~sK$bH*uB$u4JNsgNf{15c^Q$qj|l z7x>EL_2gATxrcg#JRh~zWKRXjoum2vC+P0EZI{g(j~QVJ(^fRLtFHXScd2>C{d%`< zoW^mbj5X^`B84c?kf^*E8o10J<<w3TE3y9~b#!iGJsr201lD@VYp)&&MHEYX zN;8Pc5gkN7bk>ND=5|cxB0)03j9TQWo!~I|4xV`X53~VA4R`>bde`O29-Er>r9=C@ z`NOsQWWxj7?YZB)s|hV9Q3a7}_^*ZIiJ}s!qzAxey=I(So=rRA-e{RVZY53P(rLF*@;hd7X06D88aG z>{w@JkZH2j<*#UQx^!Z}(dWiG74dz9s}2MXLDlYF;F&bPKpZ2__7w$LEq6d6INyqs z^$ihR*uUwqEGIF&DyloFZUq*6Gr^T#8|KFu4?07iGAXiDOUUf51ki|~*>6aAd? zPmB2}i$r|N6QL9GCsrF3e|YW@&Zs~b^i#tv&w!Je*ItB?aY^eCCb;rFr|r%g1y$`& zzy+ZFkKD=>&gK~Nj~(1TR4{&ppKeKC{*rfa{<0tnfl&Pi8lmsydR?XrXkuaC;P$2B zRMx)J3*s&bRh^v2%!qr~y}lu`6WybhZ##8=P2v;92n0b3Y+Px`?3te*4MpLCl&x9s zAgwzC-URzxu8i?-Q}<&|yg#aKO=p!3u1x+tB0MUU3dWz>wRC%IuXx46mNTnis+KUt z+nr;Zn~Q}$v}(5VzEa55NYRy~@Y#vM&CZ6>dAZFk9veTk`S;-L_JG!6q5EpcamKEnl2^Dyyd+2ux!9Sl}=Pxl+a7*@b(R2)-a~ zEqYLqI`^(af$tYLwT*czS=;H|FFAqxZHnSEwOJY0VYi!Xt*&RD;b#jnb=DP?X8N(7 z6oEl*^HTAzb-~~<2oWf$FJ1bf-`u))j46`*2?=8bXpP3@%`t$so?S;sH=+E1CBN_q zaRhQ8Ttk+K`qv8>SbEI*WkCYXH(d|bWMCGT3_C?6P(_vfMJf=om~pGFsajyMMG~?5 z!xJkF8JcIe8*ck5iz#-V4-UHrjl%DRA%dxKGe%$N(sRqq;5k4oV~c$=M@Owj$d6A4 z=POMh!WjWc8EWxlx9$l$o*SKrRacS7>e4n_0gruZZ)>D$sd3T2FX{Y!5S^4snPw-i zX5fB4G@-m}Rn_bRygzh-RoCat$qF1|=1S6pfym3o00juA&hBczlRJS8?P+FE zhijbGIC?`@JOyBIjB#((WedXOq=C=@pS0`kLW+8E)@=1G`?&0z z-u24W4fgtcl%tyv#+FL=NX;`M*=EtQnAij^ zjkgP#3@OXj?JC`DgopfU*Bo0rfmh0X@mT5!y%oXi;`iog>u_Obi2t*VMK|sh_AiKY zOQy}QLIy24(qCga#ut>X`bEHprhi%)`(lcrV8(*?1{xSLS-1=WL^c%RMg0S2kpvp- zoQG#Pi4A|`2BZ6cmNn`(Cd-({?@5Af8pJv|0w9Js-93-CW+&YbE6RGYDM{RU8NY^f z3wPYPtwMD>-FLTs{P*Mu4T}G*BTe^K7$8;kOCJ@m zA`2->Lek$R{TP|r5` z=Y$51(43d(Q@@6eTxqF}+kat6AL%Fz1LjBUvlL0e1~I9_)SS$pgn#2f14#)t{u3)% z;t0Aa!1)Sb(1j>~B1r zPFYWHWA(k#0}FYe3F$Yo*e<#D@@su8%oNsMJu4$K;pvI%zWs+^?Jtj?B5VxOf>?WV zV>H`OLGW$ZccXm*u*>ci!42hZeZs)|T^d!6OzY6Glm0QnpYolJgiFd@E=2_<@t{;w zK-yX28~w*F)Vm5tdaxL(rj)A2eGIF=adOkNhvgnZEZa$$PK6$98Kryf>mleh-NkVF~mS8{fH3*AVXu58TW(;9`SyA^evZf`#W~H$RK2f4#p+DX^!Nyh>3a1 zm>)pFzo*cw-}=z<&<%E#f!zNnVP-MT+mh%y*dNUZCb0(KLCUN9$y2u(J@d&TyiDI7 zYqX0B|71IeX>p- zGPo*nx?!!A8H%hI_Op^P7KvSynreM$r0UgJk7o9QJ~Knn(%n_uV&W22DK*2iQfEId5Cy`&E2(BlGFl>Ane@akm}q_fnrnIf0hlcszkQG=YGi)Syc;MVcG zvUcd3A45;yKGXuwWz0pM{Tbvs*BG9tS6^xYkgWV++*zrtsaku`A!^fHgUZs(g+kb^ z?v|7D5GhbKD)5p4{z}5*+uo%b9!CHmBhiwF#h?AzE~NZ-fjlU_wP7H}j~J$U`VGeL z&VTQkDS@(W(_5A({X912?E&(?-}HFpXTz_4q6oyU!(CplM)S4Pi;KC&dxUgv!13O7 zV90e`^~qn<_sscKPp^&)5D&g5y_b`p=I}uZNB?4Lv0u*(a*QL(%fVVF(`|V&=YdPax6S^b~i6 z9kWot{4j;*=QYp+3CDzd&X7Ou4SP8P3Cyd z%kQ$&=qD~s8xI@>%=U3>E1H-&XDxO|*!Jg{~*8LxV;@lPIr_Y-M7;v;A|gyY_nPd7l%rwRg2#b&o@HUHpaOksJap^W*pZ#X*d@~Cn+G+CAR(y1n;%43k<3EL4`xiM>7h@ri)mD~4avvWbTL3`9 z-9l~fkv1%*%@48PgoSS*=N_z3?8Z8XSsU`6)?+8 zWAb8t*n{=U2}+!EF&qC5w7plt9c8wU-^0u_wNw`cxse!XyOhP$Pp1#jGUN(~;TA0= zs8NiQ4w_Egvs4LRAlryMi8T)vBQ2Up-A(*56!1qPft&ccfz7dk6xpkpSum;mCY}?5 zmOjJPhe*()5JTdi=8>zh|kt`d$dbEim%)Pd&xtr6+13{38}1;?Fk#j zo{qM3_~$x!K*f5et1zoWn3$E2{9r!ms-gpwmH8b%{&7mAN^D?0$+{8;3~lM=mx7a; zWrI-x*~g*ycCW5yF-W(HDCGOMp1TGQ-^?}3)NFgpY7Dg1BpD3X)n$#p2V`ke`G-~m zo-=Rn`bFgXySGnkgP2*L0l&C6;%G*prLY*5_@Uh~sSQRMN?R!(9}Z_AS)C=cx351J ziN;DHgf=mE$U6&!H>UdGSua~;CiXn`JUm^&UOq_!yjht63o`T%nT}@h!x79_Dz{SLhG|% zKfg1VzOB0S5J90ZPj+qmM0w|jT4FEr!y2=^Pq{#aLSG~^js9~*hqkOc-(TxAmRe`2 z#SuC6(K!FeY5xpV7Lr=H>I4#rZk!wDurRaNCsums9ZhR`)@SE>Nw{4jh+nzm_E4#tOJ9t~RPU3mjxPj7C z1htoP!k_skz{k;=6$Z`7%c*nMPrKeXn=&>)aUZc1nSI5k+Lj4x53KM)kUT_46$$uK z6@PwPRHby8&NDJD|Ft9GOJ#1yP+so)_hyv-AMgLskdc#s9NFC5a3{H1DZ? zqCl-Pb#&zCRt{VJ^HLWoK96}kY$}g7Qr|lY?ICDYwrq4N+!6^>ul{_*nTW@|3O}AT zF6X>ynI!^k^%#6F>C5{frSL3Jr(|%+B@yC3u05f9T;{Wuy!6GyYjKL64P0^cRKfLN z70nfa7rZnE$C@Og>$E`Vbcb3=vl3qw`yHUYlR z?55a7fW*2;R8GxhdtvSI5c317*O)QLqTY#tnV!W{Uz981#++z%iI1S!i7XY4SPxOu zxg%jrnaNs<7uH3{#PiF<&w5(l9$d_b-^Pqe>QxX&U@-7)NxhBZIV_j}=8wG;Q2E(1 zCZ_Q{`p@iD(pT~yNzY?GU9%kBTz%0Hiv5U-tjBDvs58qj+PQb3b_3*bSZvdaS@&Zv zLk6xHTt1TkmOzh8cM$ci_-}4ktVruY1*29%kLz(VrRmQILq;kh5onj+%op{niWhcZ zvn&>W)n_gWk}N20Grfp`dh+NcV|GgrVW+p&?yKsb?sOjnwl2a(`R<0mvzS&`l<>>K}x zxEDJkB!MT2kQY}LO6e8z{`-sglSaZ|&gVVGGFx+IVkn*K3g{1h09I($JlID_MKeH& zirvJ?={R9OhFu(;M~xf@t*CR-^M-9XK=b-V@-Yh-(X(}KI-)68Ra=|R)~eUpqugK7 zPI+!F*~gbHo*0~h>Xvu72M7CvND-vpO514wli!@|Kmr^U?+H1)%HQvyrM3T^I<NskI+}V{Jn5{etX>06?ECFY((LU!X;GDN0X5@x%)BRDS_uy zR-TmsCg+{$_xDye?Zi_?ohUy#`xDLp)wR;&4>U@3eYi6U^ zHMT!|7hpLy?|*Z8xj&1c9m?(Px%z!fcy>@?p~$wJ;Jg|H(5E%`;j>bnIkE4)?1gv1 z|FtSU#a)^zAItvi{qjVl{kH)L(3SXdp{9n^y^6c>hoFDY;4pJQGn97=9?R-Y{@1+}6^SyJMm;wJOs7JVgC@*}FlYe2Z`$NWdx zo2MfoZ3Qi(tEDIW5wK2=P`N)w=Oep^H#hy;vz|6#r{94rK*G>^#(W<0LlRuf5UaB7 ziZ^okvffWOsjC;6X!&S(`c5dlE%7YW+l&deqW~^7VSOYHrc7j#sX}=yI6Fg$7-|JmycQm=}i^T(!WV?yf;X?TuNv{?{!oa71uxB`QrK}htP-A3GqmY zU%Y+^aD#pE?@N#FvMkI3Klsh!D5H4;MK)Ok4qJ`|b-UoVB6JIoFZi695YYg8(h`ub z7eb`eS($f?*;c;`MPp%@A`YF>~`zuCspb5B4a9bf? zTSX3ob$4$LJLP+%IG<{y=jSghtiBOmuOi4WcV;K{_1*LCH8$7Yg8?~-nZ6m+7-RNe zuam*Tg3ix#lqPm-}>9=v)c}WnFx@Wb1F(pq9F)J4l9u5 z5HqE1SV;pffA=5V(j|}Vl+*vI`pa=P_W+7FOX)vP5p-lfO<208Q2LeQ&|Nm?a&6>{ z1L4)058797a+RvxdF5-s@x6dpgLIC_v5ao)m>#&$42*E4mpeFbeCbzt?E7!mGYk

Ss)2%fFS?1wGAW+%RX+?4#KUzC45t+j*fWoQ4Hqdf) z-Y`xk62K=M%Bs2FTefJTlu6(&IC!@Ac`su3oZ@LrtOU9EkSzC423oBWLIEO+%2s8~ zOZ6-ck{$N?CSY(3ikj>tUn2;WZ~bmLFS2?wn)f%CMC_`!DZZ@*CD<}9h%TMRureT| zNOV=7agyOHSf;#{qnZoHw@=qjTTwa-^OAb{9x0w#>n9!m=jFCi!cPtDUSv5Hw$vyn zTAQ2LzmTC7MIEm8Jk%6 z0V^Y2flZt1NTuahnAP}I8d?Oqc5WMGpPO!n&c4X;q>+K)=YhRK)34>wvTK+SS%^>C zQXY|4g~CIVEfpSB1MKOqSqyR?;Z;@c*P*5vn! zS>`cjTRV>vfukFtY{gyo7_(Fqb>0a42HEszulcBe**#7V&sS6-WpOaS+Pn;n3BK$g zPgt&>qR!9aI^Twio*-}?j!eQ1kH<>R}_3e@~utKI$A$URl2x}7btmN=9~>DAj~ft zZe;ekCkQc-w59AMEFlsf4KiDbSr3pc<0$t0lpDq^F*_G>Eb7Avb~Kw6R>!Xg92qt$ zPGp>oe!|Ijg&zGKnaVB-$4!TwEO_EV0(#p;vy(LJ%4*zkvlB&2HQ6Y{hwO#rD0Gcy z&+WC0NFff}po@yyZuTG-{;(xE!9WBuB=lqmf5)=P3K(^sRqMWXjJAnPWk0?efUD+8 z#1~M|ixuxd_6zJDL)*5QZQC9ATJl$h4zzEZ!U+%l9o9gt;MU&mCAN{c4m}Tkf1t#A zlyS@=HI)2>BQUjtM>WAQ6CJ_4YA!CLtjw$@lr$Y>i6n^c|bo_w z7=xvlg|2I}%hTWDGdOb+QLRzm`v`k@3a39atGp+vEW~V9WVq#Bo2;X9DgXBYKuD1t z)i%s_-<{y_{KLYxg~w0HlaHQztB}|UsP?_TgH(UZSOGI7{mMx4mBJrI5R&}~YHj1P zS8c~T6Gf=Ya{7={(bD#bi>#b_J(gG<<6)0L8WOxg^0x;8HwPYk`sF=CJM1kt z0u_rU(A+qoZFZ>n51y&Tb{u>5P=+3go#tsHDIEQJAxJ>VfXJ|2e{T>D{;C-eL7Fx- zzYsWO?zkX5?78Xtre(^BO@pyFDSpJsfhN-M1%9uRdrNi$QX{|#m7^E`ZRyskj{-#G zG8pbF#K3;Y>#1w!yv^L{0Az$cR4eW|4nMM!*2&Q|&vKXz1eAJBbmj+*jwo_9WQyxg zHM^&RzYJ>8`=yJcn%jaM^%MX7PMlC&R4RCuvf}UKlgXt+#9soD+9==O;~{?W ze)SKa{s0$WtpVfg`v#27vAQp2_#Y540@wp=Z_}hCh)r5b*vOkiInxZQQ_vJYF0*gU znm2!R_Y;2z=bbsVbcU5-&1WO_eaaN${bIEE=}XHDHTxI>{hXs-x@vbW3&7 zr6vT^Pe7QoiaL>iHja?h4V z8TDoA@4r^D-8%&q5AG#b3zaA@{vjpU%(tSR|iDZv%OzhvRhW}d# zzZwd^8uJVnIjI*ULwL@2->zKRTs07k+1JKGD8KI@sK>_fYCka)`l(IdkT=TUWy!Y+ zS_VFB|H&y^4N=Ugb=l+YU{z<0JAC%`_u+2`?yQEf?2KYzO`L0Ckr}Jqjg|))1L>M^ z8PvZ`NZZR*V%2M1a%QzQTZ_qV_?%vtW%_It!`;~WNuN5KF(ma(kBJY?O#PRyrFU*v z))+o470qexi5~}bxf^ZE>nYuC>L(BNE8UKYEcFE6V^8Xh@sR#)<1J3i(el%m`W--1 z_Laww;gJ-(35gGNhq)%o{k|x1R#JJ27Y75ks9}sUbT@B1qH~!`fQdp}b-wQ0StZit zKUL({BGxxrtI>XlUoz>=8j)yqpOW#*c}0zu59}`u)k;-XUKW;N^XQ9DJZhQcWW4G4?9+-} zQ7`C|KL%E+=XkZQn3bULOf@ns5zp{{@HKuVsqiK#QpPNd1(zfh$r>jUkVjc?N7T?4*QUEGUI^6yB#7 zr&a2BIt?fKsTFp+rFW};d){-q&~v_ZoIS_Ea7Qd;aYp4!pc}SdJ;yYn$~jb>$C7?; zKu}XojR^L1%|-FyLFxVBCUkuB$=O+opPY8Z#&OWm)QaqE1yd5y&EGu&RvGq=3V(2c zy^Nn?9R`>_$O_!*+r1{Exjjr#yxjSIyfIODb3?mSIL;p$QjKaDzTP|}aMDFt{fA38 z>qQO#hKloccZ>a+4mBmCAn#x*gvo}vs3@T+`~kEE1>z?)oKP)ssf#7+A3@KhM#P$q zV2^ZRs*}$*_bz-%0EWDd#Iz);s`wE7qsq}3qZGHl`^<9mhdAeQ^7S4qIg(+^#;^6`T2@d(HGrJzp1}Vd z0lr@dR)IR3+k=NS7Zn>2G|tk8G-uu;)BD%}5f9wdCGmuFYtY^=oGy$}NP*jm|@kVGqtdKO-;Lljn9-Y_S$L z2JRKyTwM7%4ey^uRn;WO>~GbabmEEQ42x2m-A8paI@*K1;WIwzV(H7S6;txuAL}#C z>AcLCz$2kPHHOX6$DMz10>CU7iS7e{vTo@M#FVMN5s$ZWs4m0E|9m5~sg0!(Axf z_6WttD%h`?N` z$JdhK#GK=AMOozJ2oEb?iAG6aM84^%Q172Ii*NB(HCX#&3Y0`ploH9pebdIVurtk|N=G(l-TmZ?`C zlN3#)0G3(2Psz+q$dZ)uQ`y*t!I6^gcj?lc*+C}6KSX`(UwDF+*$EOZ9w^BBPc4)qT9i7KY=o)jFMM+;SMB8J z(o~*pJ|>a$JGog{y1gN2Q5#n?Kk-&OwCb~qD$`dA8b=DgHUZJ%rO7*h5;R)z9(V^y{1Lj09l)cA0ZnxKL z1U}2VPjgNApEyU@X=BmE)Qa!pnr+qyBvmlnE=uz3E^R<7ajsg44xi|)oApB)0Sl78 zZ89HfnOM*ia45MTlPK;ptxyoY_*^W2ovOCK&R!R-i0 z6=#dk5jZy5lAJMb)M%4q-*6fKuLqM(M3nNX5?%0?3sz7jM>Z^nJLnZ3(`95vWy!}T zz%(bX53fAjY9i1m(;WZ15BIDe#a_-oW+4CUzn|S$b^dDaA=ixfr~Ds5kFByQ?9+}h z5^r7!U;W)9S6n~5fPSu5%iwimB5!a)2j28sE3B30$ zfqH6;?g{bVRw-P3LJF3H(7^V`h!WH(yq-rp<^3jnJt1=W7BxC5c^xY&g9|oH=*B zkQ+Bt3Gqh^Ar~+?--Cv{9HAwI0B^|IHEsCekP@-iQwqS$GkJc2^|5?{V*@u+;eTHz z4-$MaV>JfIq2PN775=Y9rI-$1edp|*4&TUjIW$_Koi(^CYM73@fhw&;x7<&` zO8D6KC7$NFj=)|EqQz5bQAtq|CrBxgIBZ1N31#~k?zWk{L8!vF!5-`+ZIqfLGr#WQ zH(sqo^uqSUeiO)kw>(2#o<5K}8yn1#5O}bDpmy-N&$na1!BZ}06%mnQ2%l9TCT`o1eP2ojO;U0ggZy8TI54(s1uRWZE+`4wib z>iu}I(JnaD0s1xBbL(|``!D>u=XR{<`q0EC#M*7++f>rS@7R1-Ayn3g0gX%x04LP< z*?o+=;C#U|Jm73kPoUwX+5H1s8yu_(qopdT{fT<{;~U)V_XNS)qB<}cWRWbx@obyG zeV|7}dZSdJXnN;tdc@-jTY+b7`s zNRAU5-s)L1WklTRet|#PKFz4Z2MRzzwqM@dz5m2_7x@6a9G$@t(N38-o-JWe8k(J( z{bKDk0EZBAO;L4JJWcHF-F?p4_*B>vs!*@~jFOcJD#;!^5KARWJ zu$;s}{<-Tw=Br9(EBrr0goGmJFaG*JbY(bHi}P_om>uVjkou6)SIn#SX30pZ;gs^J z)&5Xn?F2*b;?m5+g7Ue?mKJKxJSO{K9`FeJh@b@sU@tL-xCh|!y3OPwQlQgd`0gAi z;kEO-BIIjm(x;#8PJD+atFgTp^MP`Xf5v|@G2PQmh5=vfK1NW1{F|RdTJm$fsAbS0 zhn>+0#|Cy3JX|$O)O=31Ob*I+s)V9dxj&HA`7KC}8EdOjC*&j|Gdum>GI}|b9iRU_ z9lM|Ms)mp!>sTm{BB9f@FRQ7cO`&y;i*UtYgnC<=o13YFT3Lw3kG+o_#d?|i+Y9!Y zWO0jIWqWDd1v^<~j}@#hjGP_|MhL<;T%=ouhJs~w`-Z(A*A4a4t9EV6>ofMQjmi`L z%!MgK{Q7%kE?ZU%wYDBHU%pPQFD5Vxcsq0Fj%m{+cb$cB_x29K@6ap$c%PAzwUm>x za{=^WNW1ES(z`w=SdxI&ZvPNGmY=O4gv)wKOtlqkq^pc`2n)*csc2-*8zEkk%k6g8 zmdP>h@t3h|IaGFNC%5@AbqX$51o=~YLrP_8Wf0*q!jd;0D*=uF^AZ5@_z_^Y zZlw>P=YVpT!m`IRs)0V#kwmApeI(G#vL|QLfOnrw&Q_9&%dfI$A`gfxavr0g5 za3*wwYNU%+g8Nfh47=zmXXB#pRLSRctzsq%6TOA5Im@cNp7!v9@^d`?g?B4$a7cRl zXEoW!L2>-{dvZDSv~JEUxyk^;G@c8JV?Un|1-rWYviA?TnX7hSn|8HZYMMbN_7x1x zdccBQa=FM>7t|=N*xfbaK4Wc~_4v1}U^4d5wRZm7KT{lWWVVqx0xl7Fq6OPBz! zw7OnW(6TV8nU-BNa)jSpoK)@OK1lwD9Ev9#)lPZNGtWrWy+TXy2X#HS{g&IuFZC(O zpl(x`d2tTNSA;wEivi*nMo&oe>h#o9JG8Sp{4C}6?;iFEcwAFv`})+YLogn&<%<6b z@c^iXHVLD$Dv-^X#eP-P>_ha?N`HQBH-yG7uhEX!%Q*I+qoaS!4m_YHbbC{GbM*J$ z!D)Y}&{TVEn*wxj-=^n=@aoKQD{|W({XW1kXry$M`AXcbe!=VC&h_K* zQL#<)$OlGmr!n`r^uJBXuy)s-e?#6 zRWLEXt$)R<+EA!eG~ub}Vf|h!XrJ16AdN%b8sIQ!#Kh7s$ z7+{7OuEksYe6kIKqF>RxrGm2o!4JFnqFZhhhectp5U3ie-{0{Us9A&{es+`RZ3n#d zEh=mbk=0&^Ode)305?zVEBI3**v*Ttg-8$|)hElL0xZp@C!XgSko!h^>|Q{qx>{);K@Aah$w@5FU$#EfVg`UUM7qQdK`Y1Zup0L;)ui zl@Q9%Mb2<8ESNPgWz_XQjko+cw0ZNL>JdjhH$eZ{C%QPS5llk2m+knJx+tNml$<@D zkJ8S!?7OSeO^Lt_R*DHKnb4y$cvYIMV0G0$@6Br*-B+y;w*LQmN&(jtdkog53KCKG!MqtqXI&w7+x^8S;I-hZu*bKeL zL)WGrgobF>ri6YXOnm#C`bfd1>|(qddCC;i@nF&J_N2_==qt^p`9rdsn?_*|Rv|8h z<*fIUxq(v2u=?5dT1Z>y&d*PEyb`(p2BS{jTfSUM?pi#6ix$cwFuCzY z&5Z*uX793ZM}gU7Wz*)gQ!L+QIlEQ9MZcusO@au-X-BBY~bF@Q4 z!tI9B^ivj7wwDg+1o-$i=bkF<%%y@-X2&V+&Q+Cfz7!?<%6z@YmbvUqf#s(%#K2z~ z%;IijIhBhcD&Ko?Mis6vRaXO7+l{@_>^jn@iZNNlDE5U%>xKmOwW2JQAe|NE*U!CI zJrk;#;ygWLsCZjNZy9~70$UnI_6mMxX!xXl3(ix5u2&4-HExg>pzyA}Vox%2s-qZq zOAK#ulviPq+%!>*W(j9641;u_WvwZoK9uaVzye7z3 zW(}G#W6O{h{9`m~Wqv8Z?%*dxToB{ zW#@2)WaU-=e3>-|i`@A}Q(hJAkQ|Fsn@_Rq&F^oC#)RgBbTgJ%utOLt8tATQA;Des=))V>=)_G3d@{iFgM900!o+T-bXtd~ z`DSbVL-v5nT1_(azSpo z;PFkhWyZx{p3gb{VE*fe?~ZF_$arW`LalShNoajBCa!`9qmnv|0qZu=(kfnai0{M) zo18o3aAn7#iX16N=?v)R1oy}Ye!E6#k(S>@^0Skc8{^f@O6F_M$Y@`O$pP0r(31aD z13<;M{_3X$?UA(*>0f3Mv?{xroy)YTCCoqkQ_W$fS^@U0L4uaCbu9RW3FI?OHJ#!O zg)UNA39)CYyjrFg`Tpn1I|e8vzE#2B*IG4Zb;tkl(fNiyDkmnz4~Kg_YPfrh7PsJZ&@O7eS zFgQT7hK2X|Haq*kWBxlDSz=By$!Dw=J3gj42ab=Av^CY$=lRou{WOA8qC(Z6y&gG( zzu$jS|F;Y9s{R~LI`TJjh`5n!LCJ_uuZR+?_!Cjbi{E=WCGEJ=P5yIoXW$cU2^O?n zbY-xa#Zi!Tc4*Z`9V0?nx<_bs$V(~fV&`JNQNgf!Z+~iF#ignMQJU2qM|9Wm?+xDK z$H#$wHUn!Y{JHYBmeYq z3OC=C7ob$gxA6}QInhsP8l$KAC6y^JEny3432kc3*4VQAD{2|CTlJUD6cw9w!$vAF z9Y2WutI2)h-DAucJje-#?EO>Co}u6DFh6P1!g2e~&)>nSF}m3%u@!p4!a7rFj{~D< zVr_#5LXqqT6R;N(jEWfAictTf(^I|C5S5~0^+BnD0$Yi5!NgNb!BBv3d$?bd)LD_YrNBMqTFDW zR4xP%9nY9e@(0Pc)2FLv{cNBS8+P(n!RttJ?1Zh)6&P?DX);2WwcpNDyEsb*_Pl_X zWIfPptd7&CJ+dwm+ZOKvI-Fn|YGd-IzLuC+2)kby;C9EYW6k2{EzZwSvbPtsvP(Dd znf^eb@$zMh7t!lI-jBY;7i6S8*xtW6>v!bK=jW8Am?QU&EoC<6{qOxVvM|%EkacP+ z=>(gvmrld%&^3VzE}?{3iC~+Wt%tY(YDk`P8}3y1`$1L&=mm}jn;Y~JiI`Gjh&D(s zqo)zbpk7vVw*p;*hg{(66+X@07xkf1e;JYo!(_mTWC$(s&STn{YZu|%XT%+%sM#?_JqUzHDC{?%7B0<2e zKVE1-{ujZAsC;mY9LbF zV&7ujl!ueW6DCg4`^#yaLfwx4)-2yGv^~clMlONPnv#jxUY;xUtyk!TL^{2=Y7c)& zshvJ6d3;=75^jAR)ZgmtB=M|*gHHfFRDpskw?M<-r8H$3%o$YO4ARi+PGY;uX&nHf zQk%tf#XTGNcaC5z5XtCKDZK@gOc0*ceu~J%GkK4;g+OB~!2%u&bu6y|G2h#&-iKAB zpkko7iX;jg5A_t^7%FjT!|StreMFiuF`IJlvVOf`8PpND+IQ(Hn_vs4gZB6ZjCbem z((_>#PgO*D!+YX{&5d&Ap-*u2`c_nCd^tQBuaV1h;-(Caqv@U<9IVU|GFci5vCeLA zN2?U`7kKCFt**{_A%CuIUPl^{>~;YOOJ~bkFXMt3>IK zy)^=m6D9N*o(+QRK=}5Lk5*dZm|e!gl{_8U?iErvN|D16smDzwwX<;HY z6sOnR)>QquzJDyYEXEaGs!xrw@h1&1_VS?&)Cm;2S}Pt^TXPjgk+VfZPX@8N=zu!` z(>Aj4@=?~HpMa?2Va_j=2fQxcKi}MB3@P^oG!ICw@VmdSN-=IyjH4Ji-$>I zaeh8EnvrLs!>I{9Sg~%aufKmn{mQ-}5Kq+}m(c)*9IgBZ@t=x5ccln6lVdaGmcyey z_h(D@+a)SVgj=q&+$D^mlZ4qF4S4Zg-HTnl2wrS;P>^(5H8EgD4Hp_5+!*rX<>;?t z^1-@g`A|7fACJv)zK>2X8`0?wNraM=Wzt4suJ!@(NDstwR)S$!yi>)uY&?~ zM7FN=qrNf-tSnG>M|DK$)l#E29&vy%C}vjpQ%G7*-NT4~pE}P*!}nspoE{5Z=XZ!y zpS->HHs87&FUwO)|7v@yBeL~u(((NKs1tWY=7Y+sl;T-X18jaZ%n9zw#Ph+KV0cP& zudM5)EKHD-lG=r*5UNpe+#0qQCsG&t3l|0I>RBppMjDu$6EQGY4|nt(yml<;eU{Sf zs}g}Rah}LO6smp<+lPBF;HjH-vFsSRx9uxQl#YACrNxX=WuG0w2=IQhn8ABf)9DF; zF+#WT7@w;Gpwc(lJ%xX&vD-3+(~@N@PHDbhX!#If&WAMxpZ$y}rB680<@|+6{fg61 z{)4h#QN{mU(?x4Ne>)}w(IV>4AgA7IfF2W~Eay@Ui? zy4kVlxlA=Y@|bzl%*&dPW~hS5Oz9dLRWdvN=^Ku5vt&XFzbBne(d1!s;Knoyk_P?a zvaJ+# z@6t~}dNj;2AYWlgJ89m;=DZ-(J~<9$5g^Ddm$57jsB=SA_*nKnO1M<*eOP*qEXU(( z$7}*b#R^4ls}%Dsv`01;4T1XmiDQV%i7i*+bt)@I_Def@8i<&+^z0N~QfxIL;ZsXc z7LUet-a)Afb~XgyOYC^~v=SPC3@Rp|Kj+^RD)SY%j*t7uQ!wQw&F|f?O?^iqWg2Ij z(+pq=1r}p_?P6`&Ff#mRoU1DVR~|+&$Gq}V6i}b!y^=!N0Ff>0T?X8mlYvZ=GjvL!O`L5S7i?44>q9&tk23o!* zupSvTnIn~CU3CdwGqR`TaJ95+RiVgF=2Gh|;(ILsnHMrUUFBg<-F!vYiZmJ?&{p}y z{Ycu_MY>=m`X<}D{nJF6yeLJOhN6E|ot&t1KhzuK#$agcL72i~Hu3np7g#VErg&7f zDs;24(4F}yb}OUq-O}4SD1!{=i$_yqKVJfsYjWFdGDe7brlm-dHQ2PcBjnr;p-w+L#v=R`CAWrI zFSe5-LoW`4X!_-dL{BS;fIH2RDwaRi=E$|YlI=wk1UHCN%|KmlYX3lkH_}&%*kbFG zGv81W`ES_~P#E!wYEBmJ$lOI<9^fF*0BUz$mcHPb#5ln&70Go282&xc3qb8h)DGq* z!^sT?HZE18xXyQXrW@jB26sd>hzewt6gy&N&`&KIZkgK~n;cJAs;e-uJVaV{z{`P+ z0-zidqZwTI*}|2HXdd#ywOBr$7#`6ZMc)za#@ zHEjbZm2=^3#Q5%RBeX5RkDS1*E1QnWUCz#jVX*D@sM3Qr*rAAp#)GyRAgy?roTBhW zcCP1}U%m*)uF;{)XaK`2F^PJAKLRw@ZrkYf#CP&Dh}4Up-Vc2Ia0Y3F9oD;A80O}} z7|h#j@zDs<;Bb#wPzAw(hoYtBH^?4tOR=$hSiy3s*d?(xC^lxS!LTKKwtdVsk2U8& zZSX0f#p}j%1`z3K_uwBrN0rk(_0#&wEHT=szJ`t)Uz-H?j^b^xA0a{O6Vy@e;(N3A zZ52d1BrD!lw;$P5#ZmcYKjORV@r41PYY@Mh6I}Dw%o%fRid7;WT~Kf)_q3Qx&DKiC zRI06wcp<{5V~~n=7VW{Vhe#c3f>8e8Zvk~U}( zvFF-e$e)%lHAoCnC^E>)(HT>wVME9$r{YwHAMm-1nTw1%d1@$MgQ}h6Jk+m)f1xU# z3$GI04PdP2HT#RN8F-&)=cSeR1om-L5@A*}Jz|#78LE z>Aj%|-_JW;;8Y&H5IDhxrcWiH@eR$(G^cM-S;bU{=dG=P)~yW~t&&Y6PoC}R01T`7 z4E~|Cggt8mlW3EF)Wg$VwCXq@uq}^*-71Im&m+a&@d5` z+f*iX>m#kVM-{i9BHz$@OVJ57^Vfz1I9oXsBgk_rGxx-^9E-d3WI^P?S;pYnWsU^1E)Y7v`kJIBNV+|GoOA zQa>Vge+}+rX|d7nv!0ih&;y|N#Iq2D2;~~YpP`_tAThyrNQIHCMNLjD>`T86TCs@F z#B<%l@^zp;=@1(Kn-Cz;SIJ^Tjc9pr2xU{GiMgz)tE=J_#fcEy`c!4EnNWkg-@m|Hk<0GpiIX6o~ z)dYECKajuV^!3#+#@da*(#*Gfy&x|MT0M`65A+(!0M~aW?9ELst_dU;Z7MK~0WoTBT6T@kAuFrCErqr-Y(7(SIL~UYW~MJ*`)wqn zS)vRR*Xkzd+lPgP1@+cj4cqfLn?ehszKp&7I09Na;Xw%Qr9qFg`6v0abdg(0EaV z?%|3=-0JWM;qlO%yZd5$Uplk>nHQx0i7S~_9lAz(1*BW=ca3i{SitdOepTC>{5 z)AQW|qmFB=Z!Gid7tv7xx;B}TFy|JNx%##m3`80cOY;u*UzCl;Y4%E028)OVux4l{~(nT>NNo3k(ep3EBN)4eT3%mC)+B z8j06QjrZD_|Ab8}#B}8{6;%v08}qi&JwJDOha4CdE{-ukGVpC*GRFyg_T$c!ere|X z<6=9($Ao|WXya`6_}Rr?r?sPdy8jteSD)@XW|OvXYD+lxwbQr&qG^Y4Q4~t(O9E zE3%i4Hw$+u*`THio}B;MHK7Q*M9)UUUW!kk3lVdvFPfWt7V{;`Xw(s{yqq8k`g~c{ zd_r4{+wx$6JFtR1vNLSZ4j78{66|2kx%B5e5lfFzx%Hyv2qFSGwcw~giv)&W120fYjQ}Q<)JT!Z^ zI!-sqJ2d6~NXLu66#zr1+m10As^NA-cmmRG(xGJEcM);g%6=bQy#BgSEO%>^k#~Ol zZc%ey^*Y5{hp}E4UHKeraVvN8o+a-#{|k3Xl*DSXVE3%lSZGHxF4OZqD7T44S>$)R zY4VsryO5jQ)l(K;PFDT95^UCuVwrCh1cn&YUwZmfx+WJLSR1;MYr4?oS=n&EJ${Gw z;^am1iRy)UKDVXOUrMIULH6r%myy%*t4lBe3+XD4M!8ugSsCa{Iz$=Yftld-4K)@K zlR=tI%KN5nVUPz>`wWtMszw|-1z)EURbss6!unFy7`8~KF{VndT{>+|S3nHbc1GOX zK$~a(+6l2`j0yhoTCEK6$z12Fc}2atgJjOk%>Dn~&H%JTFN6NIhM*z>PbKsj#MG>a zP-VaOdSShp+2Ey(hZ%KLx61ffNCh%-l8-zT3pj{k;_?KauR)Y=;`w!6RDJCwr#rM} zq2dxM5iq9b2ueJ{n~uAhDAsF~l4W6-@!ekatd@5IfcF|}nH6nrZsHjc?P328H)a~y z1iyX?{r#C55q`AVdJ9|&chyRkhdQ+a9{eY|VGW7}E(AuNw38*b+cYFRORshiRliMqN^h2|n#V6R%QhJ-akkVU5y zT|3yv%&%GF>>2P)Hi4TcNmsUT&S8FjH6XsI5Io%Zw=r_(!9`!>@ubCRLosmyT6(B9 z9;V34x21q`CA;RIEPo6vV5NU|=Ida}dQ?7dUijev`-hA3nXO{AW?D@!CMej6*_jP?ba3)er%P^_#badjt&+=^ z&3+nulwVkgqWGw3$H^|hv`uYRq=NGM6k9EL`}kB;dN>*)#mgs}Cp^4Hz2p_(^AkZ( ztn5!Qx-C@a1*C*hsecKPW)h2>o^3GRB|W-|WP*y9X^R_^Z!gvQF6{k14BZ{imANO! zF!-07ke3tqSv3UR_*syOuV(OQe)@6js}`jl-TLy4y4!P-&6$c`%GGUyHG`CHf*Ui98I| zwuk#5!9Y?EES}3%EUt`=2Aplvqly#cbaNYCa`Q_3#hLLg^6b~`Tu72MTr-7>esS%S zph;d}h+Cu1?}Vcx{!RV>&U>+MAXG2(9BpF*ijE9#K)sGKUESkuTcMptrT43e8+AWf zUJP$aBt$D|QL}E`GZ;K|;$nXfmFjy{pedUuU7g7Rx^6_nCo{|hYM4(~0$HzlFULn| zAoeuGloEUO<>9x`>)AUX2JR^@AHFCOhLA`l7r-|vA@;E45seuwDex`UAwdI?jmg8Q z{U?4#IRJL`3P9=!LD@;#DqN?L+iVzj8 zf%NpDZGl9u8D+yfg7Bxl-jpo%IvG(xp;~B+1{_Q#{ng|Y!+;S9??UwgH*k$N!S%Sw zb*!xMGlc6$O?3H~I3XUM*nFgm!Ms@AjL90hg)Pnr&8)1=?*ZqQ0(QjsMkb-gUrDP9id36%`t8HIqmOG3m>T2pah;F zruVIgyHV7>G-Wz8;3PbCZUGil`$X_gjNP1bAG0(RFsL2-Dr)r}Eu5bp54;0X9XS`f z8=mcj^X(@N*##H^kX4`Xa;5m*QZEj>C3rnL)Gy#m6 z0YKcu1&`%D{T^7xSe5r<{0d~THbH7x zkqeb{gX$U;k{gW{F+T__YPB}WTfx6gz9z`sLgz-}={8@F_?ytUm6|X!KfM}WywW^Y zX=6lW&Xi_K?j3q#@`d3Wo|Xp0EKY0#nDX^f{nUizbcXw*auH%Fa70!NWZ{hB82Oic zD-D_<^>}~T?h|pv(SPZBS*ddbI{7oaks$hd@15FITXQ%%guw+M009#tPb$*(eoSZ?5< zeCsW`L(yo2d4fd|w9)|k^P5+F_};&x;9$Ls3Qtce5nx3$ttTYyC$U_2h34;Xj7kAJ zA`^l56H1~}1~WB3RazV8XXT#!aASi`_9oo>9=cm?(;BSdHNrd>0U*~MJXWj6ZA2?! zQzaT(AZ8ti#Fh62Y#CXM`WP|nDrX!71iWr7v_))y;+tybj1#{YHwNABwJEzLZ-FI) zbLRW;dDpn}0Iq5$E@Y%eDWTF#*o~+*E^Bx&^0tKDq*nly3}%n@UhTWh@ne&3)@MDU zA@j*eQ8TRhr9tp(R^N*Iy4lS#D6X!%1#GN<&er*~J15kELQS>Io0Hf^Bpmv7v~@-n z5`g|CDhQ`i1UyFghSRp@J7@CQEUBUpnammZE)BfOi{N+^Gr}5KPK-|vpENJOn(?Ba z$j~7f|LqA>;2KkpLJULN!|F?f@!}KN>z=pfl_L#PY3Y(deS^j&Hpc0o2$y*6GL=sj z)-*?@h*X_lT|ZOzCTnU{LFf&efnHMeoAn>~eTiG)03R9w556AMj62!g{PO1T@ZjI6 z!ul`OLt?Yz(8@2fbX$S$`LJ6hV%Mm(aBn!u^erSr^1{+8?tja19wUipz%dF}S*B7U~v z60neti7U@+pnxe4+y4^>xbmA>F7*|>jIs;2!$Ka=#gew{B1 z=teUgE_O0PwtM?)R>o2C{)n8=`SH%GPLJa{T5hvH*!SG;Y6o&SF-)#YS2;~Kg&gnDW zyk=9^$(->|iKQYiktzqrm-z$ys{D|m&y<(E@B!y+Sz4$DR2KfR_+|FoC7A2C=jD&e zj35CIGSZ*?UL$gf5ZJu4yYK7W9cbmSZqk1W(c;X8vE+a0I~FPra{i+^LIt(9NOX7R z3Gs_9du3Po!{|ehaIiBCoYQ&|m7^7lEv;TTyc7*}R}x1&EI@$Rhk`cUJk~(A>z)64 z6cT#6jhIKxfr;S8YPiXxOHEH7BKUuq0Upm>;Zr9!-9R+sU=s8?E`8zXqFG-KbacOw7K`Wmy%6q#TMS-_%N@>yq8f97Q+jJYr65bYZxPQz9a}aBFI9dKSo#Y*%depGIedC+os*Z^z78C}rgL2WB zk81p-0L{Q-$8*=wF?3=UgFgP*r;KZ2sJ*b}{RSDwJ%yNDYGgV;kRo6GHp|b?4^-_N zhy%+%y&D@FCuSb09aJAoVA7m6qyBFH^_`!1^RAjWXkx0T~*&n zz@Nh6^tO^o-&-AwXWXCkZ^<=Bjminv9A2@0Ht?&)t5dG8;A3dFu26w)wO$Q&?~xu! zo(2(@rsr=d)RgyqXv%LvFQb)7rKG4dgO93&KVjIi+Q4q~Nd#Kieh#*JmrCO&II5~L zZ3iaITYTabJa9QbPbO0zku9{$Qu$4yTu4J*hYj;pda`BfIhvPcKt+lw#71fhP&wrs z%q0NFh9iBF(A010)Q)sW{GMXm3mrIAmyHh$^P^er}ajB%?f z7rIZ2FqfF!$y;b3o&x0ZFSQ%^deh!Md- zwyCNuBdOWX$0;qB2<-xyQ$QJ5z-p%pV@UI|_k*anCvor?+=le)0xZi;VOs@*%Or=D z#i2y)US)7KaQQiHT(wpNphQcogT;~!CtqiET2A01QSH_>WUL$h; zhS9~An~*NwCVk=MhJg){1G4H7hi``l$RUM<<-m^cqq!p=$IhaaRiM^AAgjq8#V#N) zD?YFj($wCpBtFp7lhEk15PJ&bZAxb6IS9XGO@ZSaqXn4fEq4-xO@x5Jcw9SWBV;`J zVCq6YDhAYkVK1ueFCV(Jo&7XGQo*pRKY472_7w^U*73LTwfH|9i10ot-!|C{MzV=t9h!Q@*7>|mGM_HJq zN|Wh>(G1H+A1Ks1AMEexGbN>+7#^w>WPoP}a}(3M-d)1lWiWM@oeJcuJD%LeNNJ#r z~v`w!Sd`cCvcY3H9^X9dziBBrS{H< zJrm=A%qDRr(BXXRC&JC~+qtKY=q5vJdi|AD8JQS@#{2ewM z5=w(+aVKQ2F>>z}1^{Rr6j*HdZCJ4kgpXOwodL=NfZ=VMEy(U0Ywg%LZ-KMC!&N_A zq8|FVQ2T3rj`}}xD}TcPl6OoX*(f{wt%S@9&>4{%pRmMgY8?eiVxtOp-Ww73UskU+ zcx`6o?UtL6InhDLUZbeBL^Stnmjuvu%{6FTQXG4SjvE)RPveamu^pzfi*NV?m; zR#^a&`W1Ba;dWw^fKR9W_vcf($T`^sVH+}8@IKtx<$UQG2%8yh1qzl9a^M!~{+7NH z>MXLLaN8(_D(2zhCSBoMf2m&8!rT!=_quuc*D&j91!P>_ip|w(a{hN_$YQJocsd>j_u*rQ;RzppjeQA zNm|;dis(!NzQsf1a)wx&m&Ebxt(yT>}nr*sXis}?6sx2r3)J2^kEiJ z3cp$#(6(GrKRwxWtBFO38-Y?{(dCtDZIO|WRmR-~x*$V$fuH!`?JS^9Cs z4vn^n(`$)nDdx6ahEUE;cu`meCw7qRc2VEtg(1ZSh2S7!cY_%~y|X)KaX8vgOOx06pOh7GJi z$PNO*KANbu54G><8-LAzBA_Lq)07)cZT%u&AlBW|t1?5fFq@7Aqzjt#vQ1Qiw^ZeO zd(EwbKpNxT4KU~D%fshq?|i&Vpr0JikAW*iHAaChxuan(LX#^-+BmL-HushCqf2NJ zF@8RNVvwyZ+P9LAif`q#?;t$hm$iftn*RX(!uLp*gM|(>-6#g|%7D=Au8NFCTi6j< zVIlnVWN!Vy-}HAmCx~n2oRZcF^ng!0kB=v%<|L;TQdClR+PqoQuA<>ht4S}oIlb}y zK?0Ntg}sII0(Ic$-KD&$Uk7;>x%1C0B`G`*sMHEfi;}j4FewCkwpY5Z zOJH~xTJsoC0!?DD%P<#BP9X*-y}X>67G7(SOl7;rMnZ*uv+e$qDW92zq(h@?3tCp% z<}qbWM?R@jvwtlP|D0yIn!b@U6syy&vx}#62|OA{*)S&d%$ZrxK)N)5L8CwnGQoxo zDSU}5>Ca35ssRjP6{4~E6%sc^l0TZ`)JbYSnrFC!mJY8u*redeS|K<^SsF86@+^rB zEZ8a<2s}^9hk_i^b>?QD`UR#ZZryG$Vk2O7izbSEb)>Nauh*^^ibx2CdfPLQl6tWRAM!E^`tFLYXw4_~IvUhhkn=VMdnp_9rM?pZiKb<8LWa zvA??ddJX|a82cjOZjvcRO5Z_Rk`JX*0rVKyOX5B`K&M!sl0_Z1Mwerp`LWF!T!Svu?{f`BF_ zQnt_LE4J99+U@Sw54#@D*7yJnQd@~VbXw5A{B*Bf~&R`O;T)QT-fqwO{+>Ar-%?ZI_Wtl>;s)zx_p{-6P zhSFFU@9A*g#qp!Mg!Vr_*0QJXcWb#}61=smv8BaV-OGI19~L5`8brR{ zi2v4i`AN0?LxM4VRa@g#<=~Ewlihd(f0_wsKA@>)ugA#9vzm74mHJ5ty~61BS2RpF zc3O4Bvzo^h0@5+y`%5$tio+8<)xu{QfLF#Q0aV4^vI9wk`oAgue^M9g7yi?mI%3YO z(vFK^egVNo z43-QyJX|vdK0(r{pxgU@L)5O#>*CrXkNzA#2sU-FS|i9HUN8Q!xV{Yol#Yh^2^Izka94HZ1$L8|j7FoaB~sAmyb=Z{K9kp0a)7Gf=dN znSG=u&Bh@7B?-FMbldDa#Pq(9H0Em*QgBQK{H`tsUmM%aeCZkVZtfe8Y^G+=P^j1+ z!`DL7)pSV!!Qz0u&Kd~eR4JG4nG49#rF8MgEbu(tgw2suF>loW-f`pB%viP* zQSSEE@^ACX0UnwB8)H24kZjfXHwa*faFppisDLnyEg_uj$XfDvM235091J+TkLX-hZ%y zwT~!%5FjO}AWRCJ)UTjt9+Plf`w-Txzaea)V{j?jk@b;jC>6%}gX#-?5;sH{F9ifVxozTN7iD+^cl zqfemViqESdrox6EDrVl$%{*VO<^(|}gVsQJtcNIbu&aP`V4Am5(WQEBN!Fi#O2cX5 zw-V;gt@~I8`G1c8%l%<(@yae8QTam*p(J1^xeHIS;~(4ECQG-BIK@K@CFCXeM?cKY z^3Hk9NCITGhU%)5zki#mJ&K)w$qy=n z@ha@s^GJc#B0?%9QsMwczNhjY8d|^kEi0Y0Cp&qCK>9tA1T~YU#df$K)Ol~IwfT6h zM*zR)Y@2CsP$lwke^azX_2Y)7)E4d*b-Z#oKtH$iSrCv#R8Al7jGe}VVnbh`a~a7( zA+L8U)GrufFaD{Y{R>`*$cdX?4tKAv*LSl<1n8#7BC#b4YPN`% zQym?{m2!M;cgT1+>Du0Z_LxEA$fW~lU!Rb!iQrgPc`wcwG%)ADSD z&Li%8VQX;^H4=0!p#93)qQ)%;k^HIzoJRf@YD%H69*xeF*)5j)s#2RS?K;L_#D~yJ zJKJOnePfSXC-emK-*po~e>6u9@AGXa+{ky1Kt#W}(s7c5_l5~GS&tUdB7jzbhh9AXYVevsyro3z#bc@|m-Upad3){Z z*7ZaPIDe*?CnKWgPWF~+7_uCp<(%;!huw$`zpMXR4K5fEDWMsm&y{_0F1xn?quzhN zfRxxrV1yFokff1q|8Y;n(}xn@FBGGhH`-l3tJ-`+JZdXZ5iXg7GSqwtVKQ}kSykXc z|E8dNX8A3Z8QExHx64NKbCS3xF67{FZm{&T?61|pMsEZ=XJOf6hmwk}wPgN_{Yju_ z02oQueXWR*NB`cGL>$mY@u$*v{|Mvy{0p05V|0y2Z%JIYUFqx#|3BuB69cZclcCm_ zxB)cej%&=k)1Q^@w5Q#T-qd74WtU#M!yjgJGexZ zidNF*ZTCmFz|3*XeTu&xe-`$(!}})}vb2A-GracF_~pl^PobRM8X|@ng0JXhwP1W; z9z5g$r^wum;ij+RHtV2T5);BSH#AH!LQE}?}JrJrFDzL2c(2X!;=VS^Wx#kT% zt&@Id3jVWs+^Ql?ic4%xQFo#5TrpNhn?a38y-1_EOD6G%-ak~_4hBeKYVSQW&W2Q^ zq+!B6M%1AF+yKj-KLiYRskb5)Nk({5S;O7d3S^GW8Ck&ziSv%V|y9#|Q zBZ>~1YRf;H_xl;TnIyvj8{!s4Y*sJKnpn#iBHyQ3Vv;Q6a+O&3Yl`s6WpJiBY5{w% z#3s`K^EW}@7YTj!(h|>TNLnAfu10YI01fjc$y+G$zNXuIlg+%qf$Pkw-GcYU^B=ur zp7_xA2E%Fg24Z+6zoFXIttg?sth~Ho!p75m)h|GZ1~Ptg_6L;D#?Tc`_}<_~u`YFp z)9j7|&I9+`oP#oj{zLqIWIdRY=RpPtPMx6#Yk?iW;#yZ9{hJVFJP25aazd$uKv3O| zL;WZ%6|EAeA+oj01C(EP`9`@&N(x{c;RO|C%yqX{5Q@^TsU<$+|FkpC{%oxxO*gS- z&O9JSGo_qa->7ch+@j{(Hj45Uv!Jfl5pfYsXr2he1yFmhyAJA)*f55?KJ3|{EDvxU zv5HQ)ey*&AyM3kI&>Q(U&u{mU7PymD$w^V6)l8d9pUz#&VD$KE@w&AU`_e%4v*d$8 zTlShI@gv>{oz(XXAIY!Y2;gRKfWt~~qfM14YgK^*oR)*zXG?6be1Kb&Kno$uZ%avq zC8|n9mQC6K+3fu#SPEr2CKtnRx*iE|B04+&F2Ca)2ye>>&8~bsl@P)YE!TJB!*gJF zYY*z;`Zney*nBGV&_J;@)W_JM(#)d1`XE%ygQc|8zL7nlRY4BdwDsH_p*FN<>3IHo z$N&7F!h+EN`Z`tHqdq6<7CF}cR+7~sJLYGNR8^=&I!}Jq^G6(F2+LE0Fxyk9&J*L{ zv%>_-BUXU?Iv3hW)8(fW2vlTZC*7Uh zGz#MMvMW4nOqE&u(*V_P&crp+PHT!>*|)&dbee6Df~9H8)%cd>D#UDttn3YeRg)(6 zU{$FN zcFakKB3g=eo}GI1VXS;sE4=psc#*(T^N6w4^v3dBkJi zj3t_Ej;3)YA-O1o(aGmk)hKOkXp#8*+KV-$v!{ruh{mwXtR}^_kL6wbX>q=04!@K9 z!~9VQpmeS}l!JEHf`F*TRn)d(#xgrA|K7!GvM@v$4eH=J(xKGm&JV(zjYrh_YjNW#2Pk2imfnNpg2l6V=Oe zyStW!8x9i0IjsyVFBjA#cmXI{eHJ}g&wLu_oxTrrExMYjI*F3xrq(ES;hdL65k4%? zxW^Bi8Jya3HPl;^oQI{!hb{Q>Uwd3gs_k-YXL4>1UM>=Bl2O6k>^l=H_gty#Sw(T| zrFxJwY)+j>Tj65y%;F4z%b>q4W9tlNm``nqoIg1yN{f%T-=IMMjUKXssRKy=kSONy zvNb6SR^a85x;}Gf8w8m4>UFp6_NSwWY?^MeA5!|U?U>qp>6vn7n9Q% z57uwLmxMZmDWox#a8sY7zbMVITW;-de+2XjYxg4MX_iRP@9r;dbKAvD&r*7| z9NHZ4vA22pAMuSM>c)GRo7e9Ac9&;A*{NNZFH^jfl?arjbbW1TSr)?($qHWSI^bW< z^D79S3{iyYiM`$@Qd6(1KHdz8&Eu!+qC!4&SWk4t%aK0bX>A=}D1^YXhzNNU5%=Wj z;UlH_-zIO^LZ3(xCi@2ZXY=p(@}*q0uH?eZ;e z$M%2;_Zai@Lcx#6y97p9QmpGObmd=Oef~jUvAM&~=VaNca(e-6gC3vTV7h%dHJ!-~ z8?{K#;08#cLN4+y%p8F>nUD!}(}{*qKuBjj#%Qwd zhebwoi+v=(0ki=}-}1aI9-Em}XQ=JrEl`nTQl{`yC7nQ`!XQwkySq$#{8Vk2=|2*v zccF1h;d&MijL(4LskO|qv?$buR69C0p#nticFN6tnaNxl;)_s0pU_u^`lkI%7Dd{9>rV1UeI_XNlWWo=PYz_uCn^KH0c^~ zy=GX&7gN>i9YY3!Qsf5w)P28nvlL)AR{XF5r zmWRUG+T6vu+m(L~{pe$f&B*s&WPZGPTONyZn5&!ih$*+nS;AchLKi4p)PFUdR%tt6 z1q&xC)m{M=(tq=pXw9$MKr)hVqKHry$?56NnMg57o~8O9$-ChC-UTf74WXdsbv~N# zwCF^E-tLC_hp!sIwHLFc-;Q__}}5-pUv@)zlRKU ztLf_PmX<$U`t{hhrze!dqS+!s}lS^MEQ4ibzdGG z5#d$J(PvdLhlOHpEaXA4V-x`k12bI+tB}ElS+&85{g>OZY&0f<qh>5k zD~kWkjR8>Y?!T@+uKxKZPf!+`%|7D-8CWt%0F3%(P#73D; z$%v*8*65))@`^^?nx@mbw?}VmoNssgsKxiGaRI^4^q9x#pi{Z1@qX}zfy@$s32=$!c zyb6%l*H+U8{<7t->|g;4x~||5KVtEt0wmGR3WyI|d@?EeA(MK8F!}PM6m77#RX|xC z5q;j{7bueqsZ?7Vr1gN(xO5b>W z<9qv^dnV$Yu(c87tZ@_Q5;xl?DffqaKaI5UnyCLEbd*mO7UpYx=0j|Jl095L{wp!* znQ?TT7Wu$~NieH{)6rBU^SfHN#-Nn`#YOV&bO#1Ac_PK(b71%U|FF(~C!u2Yb=X`h z>iIFGFp>#sGe#~*njv7q(7!=IQysoNsXP)%0|GwM|B{3mec#L9wW%c| z{pB7aX&m}t$ejg+j(zdr=IiIFub+Z_!Hf#oyLr!qitAWaC{O3^@fF?ic*ITxO4*UZ zL|+~4-*d8CjDMHK@QVGHudmO$nn-;GzI0yNagN`4w-EKuKykqZ=&%dZ^fWN9ph^z= zZviabQwA{LAW@Iz_vU%h^?Y7$^d1e0ppH`2JN$Cz$e;ctlC5R@{5a}MUq4%n@$u!f zrW~n+;Dk$!bU7awHYEua881i+|#PZqtesUlb?$!-%x0%7l6h>%qQTN%!^MtZkRw z^Q&?Ds|`101!biO>#fItbprnPpX{dxoT7-kj{S02TJo@?pPM#^MsiBFqkHLss=nO{ z#pp39DtO-a%cw7PNgrA{W2^3|9j!- zdXs~hTN5j-?^>`mDvye4_=&@@cfg+9?d1v5mAdVdinBeGeUZss@vT+)vMk?YczURE z@Z~-VfU8?bF~aiXVnV*vvj5-}``sYt@|0g#>K8Q|2!jdt_VQATMefw$iLUnLiEnY5 za%h}A62RjGgfsD4SeMj-z(`5wvb%Cc?5E{L)k97giKn@T#jWYQk%3Na`rq%z6(J}I z-EH!-9tA zdRJn3_1#@=6bxop|9Orw<(R^A8D}HJD#~@ACgQGz^+9-+n^qSUf+Pjdh&7P*6fm6y zN@{8Q6?%_KYr+|VBmWMaX*e5&6t4SzB-Ai}z;S`97zA9I3N z*|R>hUH3nv%b&x;p4w&NlxTI8qcQ~uOpr^oEz(SS<|jk)S@N?GF-MnC8aVb!D6K&% zy0X+KSuR}}2}sLC{Rr<-NTZM3DxC6WBh3}^LB$afLWXOLoy0R8onGix+8K>IPY0jOBJj;!~obpxF$`cjJ4VuY>SQDKmB$a;# zGKfVfLb7tq$=Uj*j@p+AsmeQmwST|k0E?Y~SgT|sKtPqx${c<-Kksi;xxC}MEXg?zm4T{iMzVVu^lW@FxJI34giL0oe8ybpL|;8#{&!#|MBJ>5Iy6oV>87jT3_+SNN+sas@& zxrtdb!FrlqNy#IFrfC}!Huo0U!fAmxP#lFnKm)}1`^Pai6v8M$;#hru>j85EPo1}T z=zt2#S|ic2REyyh#dWNZX$OJbpz$@EbWgjqlBURNy!?vhABaOOsskO&=_P9`^1h!% zn4b+q6^!L5jv}Wozg{;(e#Y=c6^S~=YHjGNOf)q#`GG3X7#;Dlwubwg>-H96`SF{;$^SZ5R)^{H zI7R%N1gGIE;(Bblj`)SHu8Mfp2JJCAbg!(p%@F2NI+igBlCXM#@-NH(cURpuV z>XB-`hT9VK)^8s076v@XYxVQ$m!GwPAV~f+hE594ywO_Q!D_clLUgz1NCXt{jWLh& zTq(^YHk*qvcIodeWnzgPw5lvdIR=EyU<(;Q0>txsIQc;79_5m- zIvi;rJhTjCeBy2$59m-3U!<(%B8>T<$Ps0ngx;K6q~L-OwfED5aJrWF-H~i;@Q(@x zl~1b1c$z+0L9}|T?P7<9UG6!9`o%9s zS(PPhroGSseU0R?K#(`}^P%(^yv!h7Oe_gd zsWA{be}Wa+J@U&jndtQ3C#Bo5_1d)BG3@#M?D{IkQp`)XXlnko%=G?5wZdr~N=iR4 z_O(f-pXJks`Z4^_kx_rn7pmk-F4@IR>Hpl3;T|Gp6c-I%{;Mrb`Bh1Au(4T}Y0A=q zd=HxF$R_iLZN-nI&Zh$8^dk0J4>H7y6ZUNUd#zR4rS=OP#qQyLh1)e<>0m!}x@x#r zkCHl+zdY26bg--;Pv$Ovcv5_4fZVKNl38rN$M=={It7u+IANOg^8*G*&enSQ)=VkN z-NwJ+-RFVNWGVR-bMh;8)t2wO)K}_vhOtSlbyr41%XP1-cCo5I68$XyfB#_jHKvSlr!qmdce#zGr+{{et0{y~x+&uCNx8N)J!WW_p8%y|rN( zx}e@$Ls&Sz;rm+aWE%92T6*4dpX!QX7WDVXn47h@%aFSNI;kI<*pkJf%p zws^JJ15J2+Z!hK~bzjhiOpvfS%1I{xfkDl8N~pdL)F~~nfmnDU+M%)ayYl+ezr9hF zxi3xQga7^)^pnV#%uNEtqJ51`3iW6qlxG|QUyv5NQuBq=d&TFvgmA@ncGRK_Fdi-eHaeT7x)rG$ud34;fd*&x#idh@uHLuw#iU2@(ArO^& zsu`ViUKnOs>%Ab_FQUeeV@^faB|;a3zY(l+)zc#(Umc4kF@0EW%|XOY4;8tC#bKM< z_%x=mLu^Uu?c1*@w`yK?Y&eF>vUj$6KT27~apw)U7oLo!JNzV6E(8P+bBhWj75E{t zl*bcYJ2~z2_=>Ze!;+ixsnshZ-G`9B?h2LQckMn`AmF4}ZD)@p5s+itj;o775VUUbO!CpYK)m%11qX zkHxhD8IAHN$|$4~v4uP#^g;X#&2&E4xtW9V2R8cpuL5?4OBdt;;`2g@;k4OMlile} z`KQ&clv$`>3jx}r0>nL{+T^qW#uJeD2mx2=M|xyL`oA4=T7Z8dw%MOeKvmjR5~ZS` zmC1LMik39Ky(f#$`OkAZoB}sUL9TLV^cP9xov)KN8wX@2fV-_M18j1o0ORu5U4zHE zB1bFidtR?USW$Vrz8fW##i8?67!+xNFY3yNHA%K`R_3#K8mxWSu(l$ro8P5-I;kA@ zSf|>DI<}i1%xT8lF(c2BF#>2_A^ni!8RzlbV&T6zK4H0S5fi$Rx0+#aUwv~8SrqO^ zgpxWa(0JCbaBDj(&ysm-%Bf)}ZEG^PQ-CBK#K%jGl48}L z{&&7S2HI8;sVJ&{kS*!x{%cN=7Sk1;URoB)N7JS>;ZaaI{uPH|BGLAu%0C~!ga%QM zRA7)6QG=>bm7S?T)wp}cuTbSV@1F+U$;0yRqaSoN6!I+$XIhfWAOK$~!!GkZ^=&HZU! zlYYbiD??3zWpH>+hXOZ+cRNj(9U!J9bkS8H^GO5keSHK^kU{w~-JdpJ$iO7rGP zP0bti*L}XVMuHOhw?cmtXEU>_S4UH;AFotUBkMlU6xkd-CX9M6*CxWZd4u3VBxbV6jz^OJHZZK^hD70(8&ET_qT5w$ z$c*~cPAYwS#d(k)Lzb$xpuiAGfu--2QEkJ9x2g&Lj;~MpctrRPt+=AFCEsKF)pVF9 zK#&+AkLd2~O1fA9P=n=@EZONLp|^>^CBRG1nwqsv)6(S}OU0!D@MoKB zn%$i~8lX?E;?gM>w|5WKMWg7-qe|OvP0@ZtS{VUE$_B==fJsTGTYbMAr;XF+<^hbl zpaR_Nc)=&WEIhLEQ5Sh@EYxa1Q}o|OGH3Uvl3dqq$Ve5Hw0&R5pU-a~3_KJ%DzGF& z?D>lGVhp1um}F|h`s~o7;OE;RMM=X7?KewrzLa1U0XW48)21TSm#0%*sz_ZypsYkg z3hwK7HI;yl5TcF!)qwwTaR)z#zs^M8NHG(8L?;J4Z>13YJiE#@cNEJ6XD7cvl{0%M zOS@1qX*iWk$5CP|z0`dLDn5f~T{fs3=|0B+`jXEVmutKiEkmdJA1vt);JietsaAy$ zyf03{<)SX@hBWtto}D8djmcvH8i~tk39*V!ag^u5+17l}RY|AYI)a21^Te=? z>lZ=hC(*ZP{^mAclsz+?c`O)US-+Ser74D|a~OQ|>iBqb{Owuj!D`(+-5MNBUUPMo zOpnbaZWLPPbR0=6+Sv`;0y=C0EV!ZJ-6RNl;7##SN?Y`Q9}*80QyZMjtf#hWlBpk09(~<{ zl_znksA!w_ChJJP7NBCJLC}>t3enm}YO$#Q#dmRu{Pz2h;J1G}*kUq6`SK_K4e^k@ zCJsMwuc*KLZBs6Oy_r2Tf1ue(#Pi#JWGiQFMcl5cL@$Ab>pE3_;09 zN`nZ!ufi>C>VcUuC%Rq6U0ofqvP(<2M49GRkW5Asu1K1vmA7_+W6P?{z`cEZ zpR{>DVA+OMr3O(6hfrw@xTVttQfJEKypi6v*7my9 zly`zOG}u7657@c6Tt)f|PUd(hXL=4w|Wzon^%*&|&K>L8MUwp~58bV zjECk|bOC+xz++y++yBieljTq3792m@}FYQ)O#7h|CZ1H$|^TS1y;W4T*GNr+I*?iue*1B-*_0(}RagvW0CcOh>QFO;Behj}+EiXte1WPnV9i`g;tG_w6 zzuHSJ@snzs!>%qB+kFW8R8f4quL(;h^<$i=`tA4=gp?}NI_Tc2^`cd zw32b1UyP8LNwqxCg^z;W!kbhQODNj`!64{+jlo6U!|%IdbknCl@lW!|69TxwK7hGb zXh<3=(raPCU;f|T$^5};)bGQ!0b=s~=6mf&GZ|7hJntsnEr)zik@42UBuE@HZw~wB zQjO^pe`&yz&r0u0~)}*BcW95`^e;iUE+H%15#WQ{B{4-)gKQJ^VP(IgcatqR* z#yT3**V4pG%TD7V=Ewn(Ibic?(r?Fy--M$f1Vtl%h{ZxhgG zOYgL|qfD_s-kW8DK7^h=4^(Vl3^&(nW(81Idw{te`93O7B&Wb=&}gcZasvc@7kg3J z;gM0wyF?UmR5g0Z&K7!#sp*w>mpQ5cXHWAaOn1FK9zaQqG1;C|cMY&&;}6~Ty0L=~ z-^r1p4rM2#v8n;LP#!f{d)6h(5pC_SSOqx9cD`=SbZzW@lCo%4XMxtH)?kX+nez1H zG;EzP`1J#cQ~zfToj|S*QY5m^T5SezB*D9F8+m}$GeFB?Y%7_71vq)q=Fyh4#+o)o z`KA}tyi0w#0H6cv$9f~A{w5Za`l{ykBr*CLN_le=wsoZIx?<^-J1c6w z0E2(3BEGdn7v9wfsgd13s*CLPtXn$f<$zs`%1ges`}E{7Etk0Nrvl0>EX;rlukvT$ zDG_`*kfBuA7mLNt69uBRF=xd0xS_4I&q|`pAOEcFm-Iycov}6URQLUZpU4>RY#O~F z+9U}3i$vu3IX3#{>cX+imW#6lTG}=j$u$NHq=`KHNeIg`T@}Ms(`i=;Fp{nXdn~_6 z1v(@xOZ3ERbA^9Uzz0(#q}X@RV=MP?nh>#sCbO4Fky(if~Ms{wiE8W`+K zZt!f`ydL?(%AP4LFERv4Y}ZFaiC?S`p3ho^U3a!j#mNhum9Wf&oyzH)!e=60q2QffFB1m^q}Sc84W4k=urfa*^kQSQ z7fl((217wqz;E)sL@tB_@%kc1m|4t$3UJ3mURXJ7$JiWsaq+`xkhiJlxj5L!io;# zoa=sc^pa}sm|DRM%YGH9s%8Jkjp1Jvb7MF}!HqI)5+qeG_Cts@3#}VqQpKOVKci*h zd+9T?A~)%w6$!krc3FLf$aR>kzLE&%_fFnpF{qBnVpCv)yV*nAe(-nMpDaorn43VK z3MO2k0J@bON-nODM_{LvUR(%F=JvfpPN>Qu%_gtjx>2!4#Z1=P2^>4O)P5lP@89ju5ihw+9oVtG|nBdNTi53G~f_v?4Z;uSb0QeW*2 zZmq|qB)fLyU+AB2_9iuCWzLIH4^MyHrL&<<&&m`X zKc2WymU>CxVE+^GhWZNM_OT<O=fn(8qG^A2gDTn)4`~0Da0=;iQ{ba5=Pfa6+ zn=^StH4Kugm6Vp)D_>v8kSF=TL3QxJ-Bxc?8t`Mb{>1P4*4Df~2&X@}ZrX14!;gqb zpKC+=t4pz%ll$B#rnE*IF!{ok{sL0cxt+m#*T!)`o@g!*J<%vFL$x%{lMi=s9#=AX?msv;QmC z%gm`yt!ID*rNl6hhzP^DUSD5-73QO01t>-PJ}(OP@Iu?S)kj5@ zoL7_H+&j1cG$MREj$vYSw1OZkl7Pv%Y%f|~)l^$RBs`ay%ICLsmHM@YybH`ZUYhg{%(n2v`BCjJ+sySHd|Q5vXuB0aHu(&@f4BEd zkp$5`;(vvfart)_D4;qKOO1aKNU2+ea1;~QeTC~Gi)AX^uSvxWa+QZe zfN6k)Z&tV?$Y9=2+24~koKz3mY1P^sI3`!v&?%{?IA^tP@ckI0|K|SY-7CcG>Ogt+ z?miYh8DRSH2gA$au)nq{JI=@x`+Poz8VfIxioJ0o!J=L& z%^)awfTXvOnqHG|D%ro^=U1V=EB14gM1!Q5eRqWM?gyL5X+Gg{&+hPRrOG!jHq}UC zbx#UjI;#7~I5PYPgOfBl00JL;w$MRM>eGu>Tn`HB(=$nO&C7?55`V=YHo?4RdL75B z5LbEe4m^-yfGiIz9lzBetoFp*jM}G62=FCR_i6s7s;RqII$8h=*!bj6?<$lZ>xtmh z4VW<=8<<`^*_6;XwLj~$qsrHf{!1A6c-3}ua26GQLS%@I=r#k(lQwG)o`0~pIU5+b z951=v7+A+*4H7H#xQgqPQ-1$!erg~6H~O5aR!Bl?zS`jZ_0HygRlPltks|04C_-dH z;mZ~PDUxvDDEiUWM)swleIWYTqx7nQtK|u^M1MV<$fpF7-#>TBFnqj9M3;y)@u z4WLot{qHw9?$A*-dUG9zQe^|qaYH2VL9pnjOoSQlPZhseCatirSU0}M!;(B8WQF2l za#KW67U;*1U*G5ZrL{F7W|G9JUtk*yKBhrV5yl2DLM8w9-i+EOEnp@`lq%^mHF9DU1Z-{#igef>U zDyf4M(9An>+aD>{9?9e=cxUBR)pNvsm%>+D5e9ejM|qxZsOP0nk=-+>jnYun=G^{F z)UW0XV860euI2IyI{MTg`;b@(Euh4$2`mGt@w+OMxHvW_s)2=WzV3-o`-8QxxD=EV z_weVHJMvEL3ttNxK&+$Bp49PDR6srvbrE%x4#Kl!STDpFL!-8(wXU+>u0ECvuQ%S> zc!Io|bRGOv+CLLHzjEIk=re5wINAV-s&*GTL(ElI8^=FYuJ|r-Kha`S zm_NE0a$tbgM1D2Y!{h|WaxCMfYwyirv7A}G^?CdfCd6c78 zRY8MQ5Gd*JAromn87vkMn@L6nxiB3Vcw@iit?+nJnTGauY`=-_glBA4P2&*hX3_(Q znF!AMz0qy(i^E_`fdKpT1TS$|wewkiyb=9W_279wqtna$@@+2TyU8sRw18c+5doR5 ziMS7(9Sf#{%fh>518Z!6-GwkqlE>B$%)By`DJJW8@*92xSf)4{V9kqvFRgw2u%;%TL7dd; zsc5tOzqMN50#Y53KHF|o-fqmlj2pScmlX!16)9bV)8-)PT1@q| z>UO97RjHaHPBH{dPn1oM+n&exO7E-7IFIh8CT%{cY8q4VoO@gT< zsjL;Ko+0WA8rxlR(M1N8>p^h03;A!(=(6qpIku;LfI zk6O6v=m;kFm*>FHZwI`q^mo}UeYTDn4rx#TZ}=!g>s!`)v9devEuXgQNX z)nWQ$q3I8Bis~L6AWJIGn3M*}a+z|(;ieM!@Nz$%G#iwMqHlAV0 zNc$5H@z?k{`Zb~)RY(ooP*)2cYZ7J4)Tl#23>K_?IYzXo7u~i!D4ZD(JvJ=#0u{e{ z>Q|_@loZYN+kUdP?w4wkt4Tj9^#pTk=-nbCd9>j8jM{uePxkIWEs+hI)+ga)5`)-s-FU@A+Pqz}%4(+PVG;k5FJdl-SW9l!ridbr zo?kjLRnF{di@qP#2U|lB`FK<4i@f~!6pO-F^5Vw!^L|=~>yE_n>wGdl+GGFaJ6K8s z(h>n6Lx-A~qu4w`96Rm;8|0^GB_>&_!mm%K4P!3aK7O>R4^1YGg=cY;Swo0fw}5ba z-R_EC!^Un-Me-zQhi(fHk@bszC==a{H0r%{!ZOxUsQjhpO&$#xV04*qlzs z60MU|D4)q$4u&Kva&?{DM6rdLL{&St!RF4y5!({DA|lrztr(z=$zVp4_^$qIlr}M+ zHF+Q|G9K?>Dgt*&udLhIQ6(p`KJwkTa^zAVP#kZdKDwcUuO+$|KT=iI=&LzaF9QkM z7sN`+v~V`IB@gz`(U2G<4{ZJ;2Vf1f(^TMQUT23kChU0i8vhsC)%Uv?N5o4dymvd_ zd$FfC7kJDs*=xQ~l%$+}(<(t+8e z*kIqhuk&kdA<}C?snR3BORwmB^=N(V2WyA*e6jSYaQ~e?=oZlugHjp2>r)5Z0HfGZ zSOYi{(xYkYv{3xuv=?Qf14z;gvW>h25yhEk7B3*qzPmmE7*$mpFrPvzerRuDRiKWs zEI!`0^K%eT@12B1OWB#kaPg_wwKS7S&Ii)*;VW~TY-yF+Ov8*;^AyjtU<8! z?4gKt{MWBa!ajm|L|1s85XN!rd$Ka1-==&+A6;)e@j_c>eTjnM<$oZU#+ifkYz+xr zspl3wmzBN7FCyQq``-q$cu(Zb^}pZ6A10>Zal>kf`H8zZE&@#GY6D_l^rEaJPP2vmw_lYPQVxI3w7K2~zD@ou12|SsF2E6;JW{oK zwZDqNV0QnU{#JdR8e7Ez$iuy--u^@W1uM4yhdBQHy=WgD9w=Y8>%^vLD03m^WA_0D zq2I&Lj~{RI*E|0{sifyxNi_m*+j12bKT>=nHct@?8s*5RNPxeH6n;IV1pBPNB~)+s z0eG>y*%@+0^lx_8k(lW+^X)F=GW%=mNjh4g=%@97b6th{Sp0J|P*>11q{EIh$(chd zQ@5<|i5^IXo&FZNyVv-ZL3w$wm{{S57Xj!yj^Nz!NH(3f>3|a(%gj4GMQj@I^Rg$E z*#({FX_S2;KGV^P8ruSX8SigQqR@QHdoOm(bOqO2H*HtjJElW0Zx1P*f>aD;pX61J zyva}1=GA2opIP_{g0O?gjdb29_5uV~N&3-jN%?3Hu3}E`EY`I8af8*|eQ7=bDNJd1 z`A^S;$!6S(Pi0F#{@Y5htrCLb5@W&Iv!+I^6wcYe*H&Mf^Ta|e>oH;^t>>F-M~=4j zo}G2x9ir1x^H$jseR@6xP2Ut>F?@H-&&`{71|Zv%5ygqCp|WQS0uy>)hJN%umM$qf zare@s6C{Of`lnq2%1L+q6hY#RxM{QUjg;&xoK#TfJ=)FSRz;auzkP zs+#$OiX0{jQuhPr6qe$TTC(68vt3>jlgQ$dyku?0ieF=bsn|W z4KO&L>~%?JzIao9!7b9)CbAXhvXqmE+>n4c$NP&uPN%4qS4a>QDR}9~9;k^z@n2@8 zdP!1#9jzS^!hRFyrO|d)2hj|dV#IvgRo6xG^h<6Mhf|7f)&R=i`}IBlh#9pEy5Q#* zhif%4So-$S`hqff&J96cMEchOMr>D&Jmv012KBe_0$ht1 ztJAzsw`7)|T1w>Zqp4d?U+W5+$Xx>?TYYLQfL{X^f@uK=WBN1_Z@<~o6Tf4sWt3dT z;nEq@)V&pwuWE)I}{pE55aE!w2UL_LH^coXzxPD40=K3YyIdGj0ha0iK8blUB_o zp+UdNcqIg49CvW;&9$X{i{*+BfVVW*s4CLS9$AVPob_>Da9WoW{EM*{Kvu*((S$~i zfQurHX)P~w&&%o&2AQP5hxIG$BhV{;1h(cgTv;|z(+c#J*k_2{o2mIsmJx0}BouR~Wq{%xom=l*$4See_sJPvzQ5_7pJc5rZTb`+1CvyEgGLbeO<631Ia z?%fn|;fC!heA}A)fY^h9MC36c$U~JTHf0SBUspV#s$aeSeSI?ZXmt0l)rPD-XJhMT zgsng$gC0DL7=U0}C-{&+;WMn!5fRj=s;cJv!d4${lO}CFTP{~amje2fNcUYi?uUW< z{SZr#YpMc4-_(4quPGM|V>DZZ)n;OpXxPe@6D^;ddGsN8-B9SNrd!FgNc zUn({NwF8PmUqONI7P`)ZZFMkJh10>7uU#MG8@lS1wBEekWnM#-ev3Ln{rwYlvA!uE z*ir?o>B92P2>bQ{S;6!${kPP-4cy>X#2l1V%{-L}r$$k7(^*yrVL-1uJb^jUcnnRP zuitI;H`sYzJYg)?Gx>`NC&#k)QPbW0I+&Bi(*Y?vak5Z{ud8g&w>Ev5h9U-O3o6+Q zhb%DP9x*1;GUi}VU9Lr}3cRk#1ocRJ~jFFuGug=hr-l@_v`i9b{C;eMUW6sySXq+0awgP(* zHq@ZR$>PAk84g3;y7WT<8EZNUt819}NX=<)T{N=yquba$e7?Vg=5x_-$u<7Fb1uz^ z(jS(KZyVkNsGKj&u_t;jRy)4D6C6|51yVwOe*mp`Nv-pGX~6ZzL!=KbNz>4 ze{DD)e0fNi=qy_8jt=Y)Jz4u*Xn%z_^0FvIF0E~qe7t78*(n5=Tuo`eK9RZ1fqgxmJ7rUT&2rMTND0l-u8^uV%SrbPpME5G_PB z+x{U;{0;U_{N=wN^ZQSs)vxRGAc(Oku&A=5{&vOJG{421oGV)1*po{S?+2tsQ(JQd zG)9RM#s_yDaYaW81r}zxz~DBZK!sif6kJ}0_$%BZ6MEZ^kA*3gfS2+wsG(7T!z*_y z_-)7f#E16Q1yZhbT<~?2VBAckixrGi8^ewk^k`RjLZ&@j1foJyp@nOwZBF*m9S0u^ zArK~cApVj#cj50#%^`gBJfd>LQuOAG_-8KUg3_fi&#fnfqYY8QNL*YT;`t+G&UBe@ zp;so&2~1GLe>4P|-37E!UKm^FEn43Ld z{|zBn@IL9pn=y+RX2Ee5G;ss?66p@uzHslpSgD0E?7PS>FiZlgXX5Ao%{fgQKIOv`Gm~@9FfABpq`V zK<&pujG!-TL6L!>8E~j;p-ja8C^`>!Hs3Z32SLv_Iw z7C(($Zb=-`Phx7E+Dj*5pDXa#PIiAH?aE58WR~r#Uh2oJm$@IzmFRs7GFDlePr*luwgP+w#)v8_+&nhhU z3+gPg;n)Ap=DRMY=k0Fx?sic%pR8-d4mUNK)}P;1G4LPuQJUpaQ=gf6N<28bR5`at zbJL8{N{2d|4A>aeVhg|za|#L=lR>O98fynXKDFbL4>x(1o{t`htDebJ;oe_adF=C- z7QbyjpL=?JG#E%7K-zDWq#4qZKyG1vt`h^4%y@ikhTIP{2vGG zmg-pNeqzonL&>e8+UDwQ-l2k@$GVqGGCH`&Sn^s`O-oPKgSq)8Kz$mG%&$swnrZ|i zVPXoQ^;py9pYdPuJSzchCzumwDy&<6n!m!ckaM<`#I@%`mvG)>Iw~+CuJK;W&hQ91b7tYP1sjMDpRAdFyJ$szVv$Hwd7UQf%gX31YVMcTC$ zYdO*PkuHA%LLD41vG7<4@I4?KTPu`|6rFFYu*WbKjXi-oO1VU3Y{c$oh^+s18vSAHvdmdBy4UI!yZqEvQ_pm z2{8JI+wLlIJsSM29v1+Vuo{q9hasx+<^v5co{4>-Pv}#5c7MVDSfk8s;+YOFX((`O zxoO`pw8{U<%j11n^Bbt>qf7qS;BWALz)I8UQ^yBjO}$kvl4==$#tazWlC2@o(=#Bi zvAI*MEr>n~v?*QcmQ-JQUNn}l9H>+s@+{4i86#Qaq@?@Y)%)cu-S3xVtNgJwjb~*{ zPPZx_#`-o~t=k@gC`SGhN*zmrYBgu5W=(3VpszYrW;t`<``3rf>_cij!x$0Cl>Z*{ z&y`}!EN?G70VaXV0Ck3n9)t=u4ZwY;te{>n!TdkfiopV**i{`2Sl`;R1=PBJb1`|N zRPyK5%^~x6tPaHmk!OAL$NS&eR)mBZ238tWe|2@WI6Y3U8W9dB_O1T?E?4ac@ujCK zBc_(~6u=F)?bDh3-We=BRG4tiN6hP6{~%rpexFt?Vwd>eS68r;{*;WAqE2lN%O{jB z5>d>^izD&Ke}80n!v0vKhuzH@n0v_2uQlJ}G?^4$MBj?9J%u`N+nVq7$E8b0ze`uQ zZmW2xYYkx%!~)Uy-Z$-ObF!P^jX6j_4~0H^KWFood-Sc3=Dd{cG$$PWzW)e&j38uM zQ_jc)43CFA(i?sg=QITyWME}9l=;+A z-RNuyd+*(=75q%`RcvL(y;x1|F|$dKp6=z%^eHC18)8kE<*LG&yUXfY~a=0{PIF)@ldp=2&NFn z{ptKsEKmWHoz0`xj}Ca!FWE`z-r)uaLrzU?)65?aFl<$hE__u^bX8emR|xWVp%|eB z@qaO?etQhhjYgzLkvLV_=fM61+P1l^LKaf*wmJ+fD+^|_BMjwXfpTNVIl7;5*Wd2` z`F~PtYiGWUAVDJ)n$JHlizmsD!r=!sJL{Wy0O%)bfbOEMqENf9m?SR>R~@Al?Z3`X zZ)x`PBok(Ip9_&Ry$#oSBa0A%Yy_pqwde>kCx#t(xm1_>h)AZ>LYmV@i@gK#H>;1v7}oqc2r}B)zRix0XxP};ydXvI5>dCPj2Z#zxc?Fc@{H-~TMx3U&I(ib zLOw^*hqK{PVv`@Sjx1csS?@!J22oRAMBsi|mYPe3;iGt22>u7xF-{HW14IfkGcNx0 z4S%P4bDc%xS`0Rdb~C%(8o0S=?{Y*#tme6l$u7l*Zw`(A9jjh<%{Ml#AN||w>pS?j ze-1m|s>-b}s>0bu0T3=sJq95{?9t9%_O|4gOKS_Ked~Sr@edVxuaXR~bY7N=IVb>o zEAKUBV8Q^k;X*Y)SxXMf0}$rwmaYleeZAhQ_IZ%Aez>R6#uEP2MStz!po7lYnAoa}9-qiWgUDc{;}q5x6Id>YBh&46wxU-sc7s}@bK)adVhK~yA;5?rPV(z3Tdtn$Nr zPBMOwMj?7FfDH-x@mC8AM?Oe3Gku#6+BUVDqo*{yXs;TZvglHeNxV2i^3Z9-FmikU z-Ru2S;53@$byJOvUay?w0xw`(y;&WM%&Wy->p2&qILP4F1*?4Z4nEJip%;d{@zu=4QM<&rZH5@#oeNC0Z4K(rQmta za!dSsP^;#Mtqg_5{0k)*j2ax;Litk_6b5RZ{B40y5p6rbs5RiV@|bR8}}1# ziQSH1q}D_g>maqRLMi<<8YYNREC`q9Kk!1bVcjJRr5MhZiKW7uYhoy8dvB4r{=ZRS z6%G_41av&YChCCLu~kExuR6E8{*K$-j34ZK8SVdF^zQt-RzGhS@rSDa><%(uM`t(Y z(phe>Z`}9k_O?5Kt~YmXS+$5hj?}@nfP9(LuEhhfI~OQ(g7T1;tS56>yZ(#c%k8?j zJTm5%zQlSC;yw9?es+@DdyA6Ot%~)#e>bx}@QAIFx#q z_c7u#5WAE0si%OBC1dG?k;ExAfa7_qPYF`8cXsK?ub=+S?%S0QlPzLn(wxpYVN#_J z{-+9J(mmFc9o#g9=YXZV9&<+O8tfnhAvifqs(1YM$vA{Qi zj8<5IlNToiuJ2IVnf4W=fa>%2kDHl|CRZK+NNitAO##!#9eLv*7W*Si0b{- zP!;T$3wzdQ68pL1zhct{s1ha+5MeI4Ct`p~|I8zOPwe|T5A4IaOheUHtVs|E`;OqQ zaYW_S;EX8m0QR2mKSWx2lyUU-!g}4~fgQD;wyA-zlGU{+A`b2O04cS}O!V+u5U@U_ zV&cwgoVDUpVwSI<>M~nBupet0JG2(o!8<-*y%Xj(DkIyd*VLhJ8(CzU;_B{5H^u{zt z-}QJ>M48r}@MGA_zd~$`0hNXy{etOYe~G(7>t{CBTWd9adXYKaeQ4NNDMtI-zi`<= zlQ$^QK=}Ua1svaw!!m+7E2jLGiU- zf&1i`q6;wqwX?;m6HC#+UNu0-3mL5sqTq)$26fmtjeyU>@-|y1u)|AcCHyW1aYmTI~p936g+_qX< z6(av#mLJ@VHq!xDP_CYT25bLa6YCd#yke(ew!gLFDysG3^k4b~(@P*Zk-P@4I{yU_ zsb#iw{(&bR?Er3x-l-2U+0k4R8B*8#?ldvZxAVDe3s zZMoaJ30e+094=mTL=OQ6<4}d^8;54Fbns)YC@+l$8t9ACkKuW4q?ah2Lb3)$qcP59 zle(;|*GotI+mqo&4QUB3A^O(lw{)CVK(b?CwYa7lV+=J(x3r(U1NZPV!ff1EAxsx!Uo4h9g!^K+s+CM1d%0mykP z68RJEW8~;m!3PD+xYe)ODPc|!UE=!nJ$uIc79b6-Fqs#!DMNb~ksW8~_ z84U>1(KyqjVUePlviXqBesrss=D*^UGKXZPCgCU2J-R;zs=$p*_ z$cf^**SDm=8!bgxhVdw{*Bu__wVtt@nl)}2`fnJn4}5=UW{mC!s_Ge^f)EtqGV|)r z67PE$=Gt!3VQ*AYtaOH=V-*UG1Uq=BU@RGCqK30$rb$k@Kf+bH2v*w*56yW> zy3?y4vdP(K-FBvH@OflDWtEIU2~j2an3s?9;d&kcR?1w83bRE%${KzO{mE0LB>M{= zoN&Yj;w_@>8|%}!#kdBagsiUCIl7%WLfCywpXpMsD|4_!2_%SuLo>k}pQ(DTyi}<> zJS#Z2Cb;=!95eTEZr{idB_(8#JiK8wk5=^)m&)gPCdAN5W#qIe|1E?a{w1+5vX5W9 zOO@PI-^x{W{b%&m^<}|b*1cE@eROQW`tLK3KX0nX?SkB`{*c|fgzq7R3NX(^+q;hU^z|faUBO^(95N>W zKYs)N)Y}ACDmY+vMuZO8sB_R~9ffr3#x5_Z$L5tb??W1ycqQkZW(ogf%C$Nns}MP; z0^q1Pmj|Xltd489PW{7eIy)m-t3po-u$ebQB5wgK%-PnC(GQ24{yXQuv@{V^HUd=) zPs2J~20zsZ8y{s06vElFogqivN|_lxKgLKT-F2tBpLk7RxBgNfuJ_rvQ zNN*GbkRo|gt}fdj9kt8KV;ez3#n7ZT7Y-L8HmJgP zOkzdS}rdp}9}udK9@b1=;RSGcXV%5Eq%X znf5MJ3$WcGh2&E^G_ot?vitGu-=)#bS@5%~v%YEXe{0P*7q$Ps&fFYsdiVX^ig=V8 z^#0;_gF20C-rIQ=||x6OSX$_pfemT<~2KFBM7Bk@_KG<#4@zI^#ggV^iH5lug@Qqic1t%2{FXsgtB|NyjId}9A$Kz??POD zg%=sevg4sJ>=emZBrgS3Sco}u%8b~oRe9*c*`3oc|8UjWKOs*Mu!H=F2}55}@P|ja zb5{8&edFCSpM8-FGS=G$*Y7io=Qa)GX{626zAiIPI?WM7RPdN*_}Vlh52XT}`}~}L zhdtrNH^Tz!>a6U_e0>;)jSV!|5Q{A!Pq&OhmqZo98f!)uYCI0+{I)x5gkde`(nwTis))kY*n?+wM zc=HzrWRIP)iX7|E1#Zkp&U4Y8s;o0bni&bCx3^Th6iOv4aLMaMe=2?uV&nZ zdHSXs6rL2}g+zNt+c*iACzDu+jDMF4Z}^8;AnHB|Zd5bIEu021M%s47E~eKGg-qFsbtDcH0ihk>q6& zNDK;L7_uQvn?O$AtlwEv;MdNtFu~HQrxpNRs$GiU@W$A-1vsniK}` z@(WFr;_#Te{r*aksLepKOZCf$+fvMELCH@RJn3wGeb788!1M(KH{=kas0*^-4yd#Y!SM#|bMyiJ723*lJ_>ljN&Oq{{vb2s$Z2G=vxCv*LM zd@;+53k!H(mW)7KfseluhD>bQ-52Rhejheg=hBfpsch7dOFL}ML5~%)r*}IJ&A?=f zHBdA!!AmKLZ3|!p=P9w2YdWdXzeAns!A9SHf_Ae|GoK1d&E6Nxjt_2gu|NJvpZ06v zN^cJlb<&>##Zu(aJJx7O&d0_%+~HZPd6@?l|ET-G*}+H7K)r$+1cQ0@*cF^h-b-#@ zmt|g8z?$7Qu6HWUS7I0u5K%y;cy)Za0nPe-e&ou@HsO3=ktIr%GeF-9$wfRGOJq<5fR6%3a|T;w?Ix&*g`}> zer4yg^Sbv{!m*5w2GiVdQ?rkOqXvBtrXF~PQbS4yX;C^^ z869PU&E*mhthHy??OTchq-9kB-bI1bA|l=}LVUylY-EQ)6Tz zLk;}&sJ4>dAp5hsv)Twrf0M;$aqTza_N2!1J{c17*7F{MeBBa514Ckd$y%}d;tl}0 z1P{rV;Ecq26$iqc;)dO0jv=qM>Wui?+|L8U?dceu&n%Zc!lT9v8t2NCzdv@#7g9z# zypDurM#pE5YOCxgCYX{P0)F8esm?5s1L9fOa9s1J;<{a`=N*N zIP{L8na7@wd(9aa=Z96Tm=_hQ!qbz54Kjl??6J1h01>R-dF)}$89je6P zU!*UXro$t%Z+HEL^$P_veXu_Sfn14d;fVeAdid+h-VgOjjPkGc%V>@`0f`d%6Ea;D zg0c5j*|lbl*rJ@SAI;xSR6Za2{_W6J;-I$#jq8)RXJFlWTIN#SSTWZZ5fTy>adQ?4 z1u)kC4pe@X+lM9ve0XpSd_XZnsfJOoFHt6w)jbbleC%)sY%J&K;J@_hLYfY8ByCt( zw*8iH64683yykj$gopO)oYXe^7v(KRdlw$@3!cW$O|n>@#}&h;cXB>_OBO82Fk7n- z(PZjYf9FCNwp?18*c+xDk_@lmy)>)s+tGn2^}zU z(wRSd+;yvFyK+MQm?$)qlR4W$?pJS&*;}$8 zNGVL@=B?a|3=`+R5-Q45bl8ZPgH>$)eQ93~>-IArN_tuiPLs~1q9=Oa@A#~PCJdS+ znqZ`Yl8YZ2nO4L}zhQrV*W19isTKao$*3d$r3fsAP_Z;UVZ=eTdrYePfva}~x#I6C ze;=`|1nGO=x|LJ1`b+Kr)b!k{_3sZ4%mcrkBx2QWr;eC5e#lm24xFulnO6QTQmi0` zxhf1^Kt6nPjPwcag$^vw!#Smz+CAiC1g6^Ky(ehvt*E6)O2l+YmxvwWURz=s+5s@T z9We9@D3x$nabGSWdX9c@dNm~ECSdi_qUzJ%RV5jj>F$ri2ML<0tKY`n7u4ICb=|BQ zN&!pOZM?kii)4yk+l+Loa0;VR4p&42g%)c$pAK|yT802aOD1Wxc8xmwPNX&DSK>oN zh%CPQ3_9}jINf9VskxB=sLPP0G>o*2FG9>OSkdNgGFXf~BGE$6a8FSFXDXDOJ@#h} z>B1Yq;7CuGa*trJi#%^bm`<@$R|m8QH z-FgA;`U--(5;?oUuC9v2CJgdn5(j;g{c)?>pjrRZK2!jL|+XzqZF&)2|BE;aCT`aw-XVP@wNSaqZ-E5 zqcrE!9yX6HT_Diod$Kn?9j!2VLJEk`&*pi)EZQT5`>+?~VbV+@G4xjGYe3WY(DS>> zx8H~yGLEKJ1j~FL^Aqo*8S|wC#(bl?UhR9Fqpb>kfoiX4m3ZJW{+>IkGT*vQm-KB# zYkNx1Tb|5NX&E4OnvkZb&2s`mXKXho*9OeUpkBswFF(dUGt-GmJOLD_ZSXf21VFU6 zD?EHXSNrVr$92rjaf0ggMPI>IfJU*`d#;^@nE)Y~BbS6}-^iOs#Or)@b4ojDisX2= zDS--sqt2)ut=u=77nWk>B{SO_Tz@^ouzvzCDv$Ub=3T0z22B=(M}*YPh3i9b;fOy{ zS;b@zN>WX&Pe5E(G<1|c(-PVodCt-3Sz%4n@PncU4k|V89^6i)$i)=GX2Se~aW3X~ zm1GGs$5n2!r-W}1iGb-Lr#v6hgB`0Kn{wUR%=ZXf1Op}oC7P;igHy-Eh(|ara7=6Xd2&bLne(GheetUe^#h8h4lpe}pdYmUf#$EIa?~OXQsTz*!*TcS+6F&N1CXKVdcP ztYWGa#(R|n8gR{6wrvHJIkOiblESl<&2Ziz^q9}!nbJp1 z*qc)d9mFoz7H(|%zon^{x>zV&Cl71P3)X+B`R?4SDX5-`Y)b$2Vu49DU(v)O^Jjwq z+Zr7vx&^6JFxk|gm})vTgxFI|lRY=y?~u`xpdA$Z>31QZHrn^8k3S4G>!#ONy|1@>!TtFtC`z6t2S1ru2iC={fifHyC>`=itWhWMi z;Q>9flkQz45a`*tTuqw3^k}-+#|K#E>P&A0d;=X8T1XiXOA(YE7v!d5&BrAp?YB$x zDvWYR7v`tAmHI5-fqUxIN)}UGHPX>FxuLJ_m23qIaGw}+dd!ss`2tVB{7fL)AVuP| zWFa)rUjMRQ!gLbo?y^1S&F7!!pVqIIu9<9{U&bSK#(1ji^`9EtRiqOW<}Qna4k)Rp zRQy#>5&gpt9+yH4Ci)%akDp^9qNSX4lsP3R!a6S@Cy1_}6K?pa-?AONV zV#QR|)gqBL`t;%-B?4O4DP}vqRO)jOreFsU{d-2i zE%rlk76D-M;AH^ZC6z9o710YgTsFtNlObt7{&#e=wR&W{W6E3p=OkcgShq9wOEq^m zG!gjb4@PiHwsa^ov4%SO-i>Ud%3GnaHd=}k`UEppD4ceXEDM6Ju>Wy(1hB6hb{i4Y z6dPN@!{eJ6;T45!E(?9q^Wk3iZ?i6+Imo(Q0WKewd8%SKC5y@0`FETPjryklBTr|j z&h^$8`VZ!Sx+;ML_=BgX#78d^G{gRM;%6yq`m|(u@6N@IZJT;a{Cl0qga%4Ij`42hKJH z!5~6qowByp+HU|B)+gia)lnQPsT~E|WyQDo6z{#)tA0G#4O4J6lgh~))*4(#=Wk1! z3HEC4A9(epBzgz}Q=*7P5x7xoXrhrA3kKV3n$*2wc?&x026N$^*cWZqbdv_~mY}xbLcK#sM_T{A zzil>F)+UmBTuuLJGcwr^r%_`p<^wH>QM&n^(rR|SfyNn839w`MY;^&72{$*7SVDW` z6>(+*$=j3%Pj}i)M%v{p=ti=?vEt>gY4o!?g~al~VTJ4okJ$xU0}CkwyM1=t$qr|} z_xN+CugOTxequWA9c~SmGYJF{<>u%d$ls2?3vz=R)=-*Z$&cQbPX2(h61M|ZUGH$7j2z72W4C1}--$TMV>0Ukk^Jho(S-Jb*`uOyQ zo9BFKwTJcF$7lC{*8t>=Oe!4{>w>krzwi(%c(B69=kQ7OrK^R-W1_Ezxc#|8Ura{Td^voP zsrIB_8b+a*;-sVckBK{nW_##&YM~GJ zk@$UiS2-6knV}rpn1$C--D#PAo9sw?6@4CgSL8N(-ALkq83|7me3de=3a<8?CEVLOGDR}@Tr>*pa;8-7(FwJi|)o?Ta7`4 zyg`lr(<}Fv`}?C7f*N^Sxu0L^((b1DYlR&0&e#EH+B`9`jL z>bLxj7=<#iykDZRQyJnWS`Kfs>6e~73agCQ**6v{k4O`*nBDgh{4|*YbjT;(twa1n zLo`5M9E8}qN3XiKeTz6+nq+k=P}3qW26TGYa^`}Fk(H!-@l+WWPfQPUOf=}3D?}Jp z(4rzsF}Herj(_??DpLD@B0uN-gJHJ2S@vG5t$Ot>N)pQvKRyZaO_w`Kq@H&ijIi#n zh#(w$)5TtN|NT}rPR-y&xJ)}~5=N2Mf!f$1^d5XLpg0u}Y#i%IEPrueHvHN^QTG;H zb^s(aq)qR7QZB}jJVJanB!1h&L|O_`XWzc;)A)dtHuQ#4B5IbYjY{db?8H6?_(l^5 zz%^bs58F$h3zcF^;9|F1mXiAj+LSiEcVbWAgG)XTyIONH`=x$?u}E@fm~&yc~Ta;qC&YWIql0ngfOAHH|f1qfMpQ1kbyOl}LoCJuRoi zsR#-(Me#Wu_YFVCQs4*p2%IPiv1OIwNzt=$AO3oJ7T}*p&-?#|pPcF^dO3bcf5FnK zN42AFl_;SvOAGl2Cm(D4J))+<3iNP#d+yuSAj|&sjjc8@k~Q}^Yl~+`->ZmkCd{z0 zcvTYznC!vJQ0U32-_x*&3(Bs)$z2!YwRz%8(9I?iB&_t;NhWh58*FYaLtc{kUi{g! z3n=vaRXB#Zpf!AS$k{Fs%?zusqtZRwmu1W4Xu?Yf^11`h)JonA$X< zA?p0TNaM_mHxoq0DvXk;#OXl7Uh8+U0#pK=paC$cu$8%dxXP7xDHPf<#gD(cc8$^_ zR&agVpYa4zh3;pg=FI%Njgfh};pf=>Ii9@If_iV7&6>)W=?+CD#RFKn7$1LquG$11 zglJr1{@KYT-0)=8$D(HR^4fGX0{#$jpZqD^Q;9ndryImr{WY)^Iy+q5`#mb<=zeY=97SR4xFi2zLy@zhttZ@1|-r|kG7h#Ol z;`($~0jp zC{F>XFOge>^RS3S>s7ei0da~;dw^hwv=sykg4{1qL3q(?y(_hRl;ygd_)XMAS>&c}#tp`h_Sbg-t9&es z{6%6oZTMK@+fX#tb8CJy4o3(jIuysGL1FV{Mn;?U6%xbnNtB)!jV98s=62tBN-XEQ zxdh&;w>dP;TU+&OiVR&KgFL6P!a5j(KXypAXejb7pGOMLuWr*?C9{<@3m14^!Rgl! z&lZjBEzDgr9Zj7F`8yFM810}w?XF`=lUYf#<{3QHmjoGXO3iijiibNo$ZbdVYW&6V z!%$y}h?FFfCx@!qz| zi!eCXe5tM|Z>IZq#TTJPnc+qFFGLH|(~uC01;x8*(@{fjPn#;8MNC#g#Bu`-;gO=( z+n-K;c7`23ej(tGqOB@*Cw7>g~vNDpaAB%M;u3DouY z6JMka(~|o}v$5{|gCH&9gv+4h#<0skxO8|b;@Pb|E`NOa?K+!z{KN?(?h_E$T&yQD zgv7tpX=)PeK_P%FYW*M<3`+)Q_ih)@lvWbWX^n^V$*`$eaja%asUzgfsln4VuDX6G zQJ{Sq5KzDXk+MWe-YKmM%V%q?A|oSfYJws44&5uTA+3dY37xR{!Sd3Q`%%!h4F10& zZ?^vtx0!P>hO(uKliqOJ%_ddgo{;F(G~IwwzbXT{#oHQ?)m`CX`oskE}D}6=ZpZ z*%L3>^sYIswJiagK6P?J&2T{2PAwnBy*UW#Ed^lQ?5;Taj#oe*JV)CIMzKq88&gfk z41M&hY76FJ?V*Zxow4D&BTrS_?H0At&gowmU*#P8IV2jh`N}|hw@QNV26PAP=;zCf zc~?;N@%k&R`5pXfKH^V3v>)qG(yt&X*-wGSGW9@gZoP}ID0hTSHry6?&h|x%NsK2~ zvb2FO*DmEyXm@^@1f;vfeDcXL$0{ztK$`x`6Q?YCBBA5=30u;(mB-v?LQ%eLd#g7Y zl*jC2pIk8cj(el2np5{RWoX+iDap@+f>MTWQlbQO~&yHcsf zNW@=$T+con+6^T`K#%vUwGUR8n>_y>|88jHM*)n(%^Te+VIpH!e~YNX=N8)9cBIG4 z;`>di6K{)_!k1Nd&%?Vx{-8VpLzFzMIkETRY`Cw^uS4>4=Awp|OKfvt)vB=`w{y^) z<}`<0WvP2)gWo0-TRco=tt~W5c6$1Fjev;gf`HT92UVqU71{>18W#mN)MNq>JB7cZ zr6KmQwDqEB44HHP%O4Hqk4iq(oO;*2=0@EyA$9PNx_-G=Q&Y+^x4@!46?Y zcW5o>TFQbZl!e_K>Zo@9ggVI`l)~X~&a^=k ze=sZWsl}s5dJxY7gK6Jq_Bq&xNN}WTuJ>|Rm(p&J75^Zf;`We^OA;@LeSjn(Dzk9@dlq`;0fE)e;kwOs;J6K=e~)aY=@MAT7p~u}gRR6F6M3 zg-pfE&vc!2i)|{*3}=s}?`zqowCcap27MsQvlN_8SL1>CT7=~HYa>I`Qt;Bv)?uyf zWLnh>VptyATL8!6xUraz4@^d%TIsbPbY-&-&?~H-cP{1n{WbzU$NU=(Cc;@76IqnGk zd188g647DPyu6Kn36|nO6raHN&f9V1xE68h2ZBp8-OO5T*^WpSTAgRsJA+fQ_q>RD z237~|-NmnqPm3P&i_eNW2$g2%e+BaE#zpZqvrl)+>X0}NlRhEHYk7zlK9(yDo6GQB zhw;s>nqh(1M4qzjiWWp(_^V(_+-B~c0=ua^bhd&v1>#LJB+_s51jzP#&c3U>06Y8? zt@_|=rG!>S_iz>x<66}9>upJU^^a3X6Rg}?E-(P zxRzih{t2C}f3$4C`&Fcx+VW_A zSqA*7=$T}F;NIw0i(k{_X_J#iy(uD#UuVW32Y2^_ryHvEG37$P7QLUHo{hf*xm@g; zman7f(W8EmPyb#2rB~f0;0v0)%dGMQtXD<&4I1mFXl+g_U!E0YawbrJA5Te4oQfDz zPmCWY9Rz)cycl#R58Kh0QZ34kcmq%CTlA%s#bVNMk3dn0Q2+Vii5rv&y5t-uSul7$am+Amesd=j^reEV<6Ax-S zYIlg{IRO+-FY~Qlb;9}=GhvhScg~fP5O={PmUgA(uFy}95qnX3MI&&Yi!T!sgeTz>7i~Fj> z0fuIz&O6qB4sf`$dfAyyyQZnvaU?wxx4XdE^2JGRb9TN%4?(9#Zq1 ziGs;Kx zFE;;vL~o-`Y*mH(Ndbx;ibW~wVZfm6piZ&DzMA`dJ=}8KtT5kDAN^t3B240Dp(`X{lb6tRocl{`g@K91e0Axy49p@aKM3zf&7$Td z2S@Ky)+-tchYBY82*8&AjBaggU52_gkxxS(Bu~ETg1?&h@4v}; z&BUFfI*pX6RM6raAhE?J{AOqJ6}8DQm}a12Xlo(Xp}E%k$EWa!2!D7n*#SSKA}#Fz z=40J#^h&iWG(?{3ytdh!%fY8$!%1zAZ5EG69juj7WP{CG4Sx`CUJWT*GUb03(+7!n zr%7BQk+|h6_)&&|fEw{@{YjqB4UP_>zIAe>H#mL_l0*BBD0{6*v<{rmY)*Ml(W>Q(jLSQHkFLwD&wQYD!DtE*=($2Nb;;H{C?(d3V&sJ}wbv z6<}vu49}taITkx)YBgQwV(S($8l@w`b|)P(H7kO4YUb|pwEizJUYZLdwGKjbTlc#F zco;MJiVj^`HIdx_-WIAOEd@f(6OKt{tJvVvqje}&t+vo%7}VXc!%K585f+zSPxh^p{d6lzKh z1et3JtbBp}bUI_5jq0BHl1Fu?&o;cP&vquPOtne-ADap zS@xH>a8ZABMXALGsRXOaM3&W9=`O*_11HQ7&4ecWg-ftHT-t0SbiiJUMp<6gsbW{% zqyxqfYZg>`uO$qS)+;$HxVo%af}3ckN;G{gzF9^C(Tq(@WRuvmiP8~lqyzi}3edcF zd&8O4)N4?wyp-tu+XTpK@Cb$LmzeXET5J)&rG#nCm6WK$of5~%uBwi_cWM1tJRxIC z`Au_U(HVi(j>|YjDQfd`&R$5WyD#ET#!Xp2FS!RO&6M1(3)-iXF3$I1%@v9so%I;&uy+5TCHDhCq&=T>fOO^o6d0mo5Qp5zP`Wz4xXJCcz1>^7Rc=-{C(Q> zO7+>l2G7x>y4pskTsuAWZl=}cuwZ>84@5#A*=1P}Pesbhdhd`?8$(Nme1*ZarsmA$N3!g_lAKDs+#GImdl+eAIA8tXwNUbsgo*V{UWt zV)H73v7BxQaVf^m=!vZ+-PD3b7dQ;!wVh)w!_9aPL>iw3?B*#BF1QDV2$Djoy{i|! zDj}i(0~uJ-3{&3eT<*^Cg5nSS9o5J`5y8#(-t2sw34OM7@S`&_2E!g~>;6BA&is)H z|BvGv(%hSyJ1ddX z`l@=o54d`6!h$4jB9@mcf#j`kC~7rTLaWUx_=kMfEqiyj=^&oAbX88uPZHaYax*P4 z^~@Vq0A!ki?7JX&D(BRHl#t?dQXzhn&b@gep4he^QNw@L`@?tU`*9RqCqKh>rQ-0* zCR1v!Ga9sPWYMrc_g`w$L53gF8SF%K1Jw3Rp1Zfy&Ua@{Whc&`=xEE*S31`BM6yyb z`M28ZZJk!pR7*T8%>>D|gz~Iap)Y=0mAmzygEYuON92ob{ao;ebdZ)gK1jW7w zm9r~Tv~19f|Ja?MI5);FNK_Kq_MlD1j!G(s1-b@To0LqVi#p5Kf*NXXXbo%DnLkgD zgO_RPLlv0n;%qBKrR3B<&c$X`NWHJu9Y43pk_EI@B3)gw9rV8**h`qn3OEBoDj?Kz zq3)aYj=07@T5{UpRaWNn-ou$p(`}#oS@5%3qI34JL2Hh8s?OE(U2Wp>Tgb62cH@is z`}6A6^FyNL1L|E`Tu*)9SDaS{1B@h(b{QBHgQVER#DZQrt=al;pq#k3@Xwaz48gbD7rrAlA7B#w(LVZiCkc%nFm2w$C%oz3w?x`p=Iao zg|=13N_U;vT(h%d9LDEYHwNq^lJ{d4l8&d`Wpuoix~KtL%5@GY5=;HoVn!n+x0zt_ ziNDIvzM;NRv0(ptQhtuZeIN2h4f!re#Yhh7#|cI{pzp`^InaVH8KZ*mIFjqcmH&M5 zU-A|=vi$<)UhbUv$SxTM`yhJWJ@4L|vGR9IEWNA%sm zJj=DTSN+xyS0}1oEW7o)pp^kb{A$rcGk8rclclXUb68_rA5c4zVd7=`*ntIpw;E4r zP;|N_lKV4qB*IJ(rXY8eT#( zC8dA<(dQFAcu&yo(m8On;gnGd96mg1VaY^S3j$`USsAsfO)VwgwU0s5!)>qnkj{M4 zbzLbM*0UB3uh|>t)GxUx8L@Jco7>1k?YEZc80L+b*nfCgRT;^Ymno1XK`r(x)JZ+b zDLpblmf)m!w&_%`fcbxewBtoew z9b!C{kn+0Ch7PO;0PMj}-DZ_?Mf#uNL#U$)rWhOddcJx7GGtK*#-niYA`Hix`Iv!qU~f`V7nh2}q(^ju{P-c-d;uBOhzr%ueiwb)JF-=D`f+&I zP{4A@lNxKsq zOLI#zZNk2Xvu<{_;T!8H5uRwd7ufkorR^N_%QHfh-XCyxhO!S&;AC z<{#tdJ&?ubpb#YlhzZyo!;>7DoOpU-Q_|Jeb&Mj^hHN*?;1vy${{9*mVwA)4eaVUR zzv22LriLKG%o(;2yhG_n4U9!VQ#;CDg@0P{W|-J;QLuvOr&@()oj!S~GWQ+Tmh`8P zHgvHkhM9GBo)!b9a*!grY*W_^8qRcpn^DAWwy&fxzL@n12ujy<<$R^zxqn^z4A zABTk7$NIo%A7B}Ar`spLB(CXuCmkJ`Lm355mD1_ojG;TS)-;WtRoetnR(+q)EDU*9M698%kc674q$Tya#^OewUFQ7~ovG*1Lvj z;N|f(wUZa|&DZ-cpvx`i$%vwpzbd!rn3HIWq*bMHcscY8Z#8pT)^nSNR+tV3D3u*m z3cNb%BM6Wb;cOBMzYYsoN@}^b@D2A14UB4WX$bE5BuFpd+;^VqE^mg@@Im04V$J&m z(1D`6ylDemzThWYIJM0fgzagpzpbVe-4#k@^qtFX`KnvygaNR-Rz3|b!T{#Gri((c zfJcb@qW5V|0bI^gdfDx+zTD)C3%=g+rooEPZ_J2W(vD}0VLu#Ggi}f$1P>LKGg@*M z_cOvM(HWr3Bh6<~{9xJT$~^sh2QLfVtVR**f3}7ixzrYD&PMCmfPzci!%hB6h!Dl} z0fiL|rkS!yiQB|Yz)Ufcjzm2hy8d2k7;QR%`AcS@ezmtlQ0W&u2cP=xJnzaoYoyuW zdv&{Pef5WTDoeW>forxWMOp@BrIcn?fa9V4`BSbwraZuUXH%-mCbAS)Ozyyk64WOO zw?}+uIbQv_W~a$z_o8y+@%V!ElfqYc;=o5pF2aiAJE|zh7}MLz=&qNL(2y}GsfQ2}0$I;{g78HWDnxFUf`1)s zoROMS6~JLHKHlZ&d~7FSVBk5!z_}ikC;uIwXoo4)-F%vMaeTaP=&asP9_(|7DO@To zKZsM<0wf&ceSazLqryL7dxzQQpTn+xgE%G3tD%rM!^QPtn(l)Es$;&kF{YLsVkw`V z%c*c~f%9JvH9ME*P3Z1JVwD{5V`%qd^>4A+VNvKvSxL#~6W^K3H7o00Pdsv?s$bu0 zdl;YX!f+M+qe(w9ACbhZ{tdY$z4OQW!c=8r8OR}2{H{h3s>wUN-akqRQVD!or0$^! zMIzDs;l$LrRPeeGCK zdNxxGNSm~CWI0a;d2cQu1EA?&INuc`@J+vu_Xim?8t+cHL&3!<@z)j8$>f7gs`ENn z_s_;vX}s6ouK$d<3PS6wMd64-OU1xwSk4R3!Zg<$X4sQDDgDlx_dwujGl~NuU6Am+ z_J+xi*9+4j*M_7+qbehWykW1;;n;K-vd%3OLB1u;-9rFk_7@x8F_rdgmi%IYSxhZU z9<{n`WD9sXpPs(sgs_6y!%zmtAPu=2PS&{`dN54m*?Z=BX%{WLZT_x@&X&3HUZ??F zAR%Dr$>b5}+hIxa$9Yk8%@z*>o)3s#>7{{E;nLZ!eE<3424-_)NHd|4F-BrA72^oe zf@kn(-`~5^(1>(d7N`tj8f`RUU>>Z4oL|vWPT+{sjT{SVHaPk)t_)Y>m@cze#`uQdR_iW=Z~=D}&f@;9 z5S3pzWLoRb64RR9t<`z=%Y%`42LWHp8c|8jFzI8c(*S6TVb74g-6^srhNMjEo2IFJ zx!HlUE*1r{wr^ndY!rRNuJ&iXXKV;gQ*MnfV{^)ajUIv~+RFa0=y2&7vPccB3&Lwk zw0Guoo-h3Mg*|p*0o(;yU-!e57Ud(`_^bG=K1eBMd=QaZ^kt+9YXBh!5nle3Uv^4HpZuRmVWC5A^ z1xrfYVDxwz>bv-?SQ3wRj}8t@M-A?r{N7EBkB<*>Pe_dI_EPQ|NOHL9tsM#H+?n+n z@o>ReK4RwkR+vW=?@90rB|QsS*j|nQq7F1uaKCH~?!}jvwiE`xf-nKi=<=|LEvh>(kCbg{PnT!EnONaLVyttYt6sgmiY;p$S^Rvczrw%8ORS@|#Y&+|ryAtr*SL+xXO zUfB8i%X%|zkbaX=wv_yLYq%-P8!Y06Ab2UO0L+lo@aKLoxdU68I-uzP9VdFmc@sjw zOZy;z5%0WUq`ey(P)PI?;zJ>r2Ox8)7ITTu{f5ZWphw8&aWE1iNg@;)_mEgvM5;#X zrwvQ$;**nwd|?RLv9vpJC;3S+NVDpJ|2awX&lB`WDXf9DY2Z4?V1-)B6q%4Z4#L8B-7g#})7% z59OZnjb8prT>h2q`2=I%vseykKzzvaMaXLEu!x14oM9@guqUj(1!U)-8}^_Js^?=p zk=WgL_;+c!xNf{Ki+hLG46c5DsegA?Zg`>kb)>@GyO#?Fv=tOm3xR#&MRKsI?52^f zAHjpK%P&hJ<^V3w?y_M##A~7iO$Tp{h&y=ga7c2QkpjmOmMk>BxmoQONoHSdXE zfAWU7!{f^q-cmWw8a@C#?ayj|GVJ*kK5_r`M`?bU6iMO5x+qCwm+{jF!ou+!;y0XF zv>h1sdd!zut*na}?adg}M7VzD$DH$)A-3I&U>fKYVjX`-n+wrbR|Zg zv(i)LyS?4h8m%wh6{qbFjg-L!l@-x6JzZ38ML|%68i^bKfa;)KU zm(r(E$WWI2-kM6veQdpt)EENyF8tMGX?ofT5V&}CtJsF41Uy+16`-vP{$D+-EoJw zDrE-2i?+WFW0_-T2be)GNzL^X=1Y%!x5m^Ng8(X7Vv_wcEB|(PjjfgI=owW8@QS2= zJMXD=Zlj4E9`1j;%Ly}YN2Tn$@c3<>7$6*Dd~uzRjmi9rBf}Emp-kS|BEYF-MbO2Q zX0L>8LYX9I3V4Z%vIEpt0s0A@*&2%8MYV#2T7vr_Ljladi8%))c2gw8x8|EDpNRzb z7F5PDH?xQ^=&BH|L(-`Hh5U4%`q(-W(6SqZV(d^KaHYruY(#dq+R6J!gC_8 z!f*1QGZ2!R&F2Bn$iI)-#k7f^h+INlwd!}of`wPle-lcx@cQ`Tk(>C34x#VgO=0)6 zj=r^!&gF|j8C*J#(leU53M7UGR6(KqV!)V69Atxvt=?CHZ=8cbqCOHb4Jd7jP+Wb# zqE1e3TQ71!Wjs=TC8}pDhMxSlF8ORHrqxi*L0(pQZ{Szb>?rgbfy$|34@#N({Yo+= z_AvZr4%Cqi?cR3TWg9y=*@?pVkDp&!OsbbPsAe?t$H7ZW*vEyIG5?WU$M_!1G4&Mi z{J?zo1t=iiP@A}-)FmIZXGC)uS~_^0^hV@rm3r=Q>23=M&Q7z>mTbg+38noN zE=2@ZzEGU=69cRKZXi2mTiay%nB1>umjXbofv8WAn z-U`aps~E>?)}&orqdjGVNH^8qcxMdHndmNn1_KA_C&I%s=!!-bSj)-ZaD(x#aPJw- zxpWs8&aIVr?AwRxshCbP8u9Iioy_;<-ekk=PYzJwZ z11!qOIym6#n8?%g`NvC^8>`}(ExOf6Z9A%owtB+X&1|Q;!IT8&-Ee2^C@|7 zIT-*FxjKCg3RW{~27SI=<#W?fqI&YS*+VXL7Dcz9!Ka&I(w(;Mzh9aZr%un8fcnXw z(N~+fir8JZwFVB3=u8%toI7t8E~#!+H`A=J^@_4-$__Oxd0B{ftv$T%rewl>r(B-SmIfZ))x-+k(XT|zG2PEgN|3)z} zaT+sBJ&}|CTFus<9ky0MnPGdXLGOR4g?&rp2j@vm@-6OO8YEX;VNpXwCp46qaKNom z!jjKL&q!Ns_3>VXU_~GTV2t&}(yJwYCYQgXjAWp?#JTAyX^d|9dyuTLdG+m~fW&hv zUsAxeg;PYV{Zvi~9L(znL5c)7h0_5t{V6F>0e^KG*yED_O`4_kBUksBZ%{BTR)cK z@h7tr((q1=1h*&5dRbK`&bOv zNfG>D2cB@|LV#Ge!I&%Y5F~T{NM1}=^{@71k{>Imb$C(!sp=U=C+)izM;_0W%;iSZ zzus%Z=auvCBZAWuylzrGHBCMguav17$~7PyCUf8LBjwVm8EhtMbeDt%Lni@`^GzU= zEG_vrtcJr1ko$Krh5AneX6$t}?CA*Xksa|HYNXKm*;0etZ1>|$6N1Etf+ z#vXD^6PwiKyWsyQ-1MW^{b7ZW8NwKoa^1=X*+Fvh5}sH;?G{22G}dEWdi9CZfN<9v zGugFm(=XgZf`_fe6#^T0HjuiWl%z870iFD)_w=O)apCx%^FkthFHvW=t;nqnZHfsh zd#}F|YY~B5E9`4HywA9ZqR$cmJ)6eS+BCWTKXlV9yxs+p<96ft8YhR zWRkB$Ywb6!ybAz+q7p+d*J2&X*Cx&f!EgA%6QHIw){enk55rZmcK}IF7nW?6MZx_# z{AB;>AGfWdv_->}1DIkoH0>5H%xky~jF)o~~ZE6p?q!;zK zb20`tns?{j7IGh}Zt_8)xhZBT_|V|hN{)HOeK5IGVP-GBgFp_RWqCavM>zB5uI1lnJ{`qRQ3hmALxhRN}XWq_6Mc1s}+DzO(=+7mooTQPb}`y@mWJ#l!$x_ z8=Ja>ti>d1bqVJ=3Sa^C|Nd>`+aX(gwNBeN7=Y>DV!O(g;{NN0cx7PN_%*o^#*^81 z;7iWwa}a7Feb5vDtC{@Pb2Fhz|DHE25D2g*^K&2nQH1k(WSx77yodZ(GmuA2?{k!>frQ)4JSPe0dhcZ

>ZGg{v$Pf~mE-eV{H~eX>&pGnjM)$-udcP3k*2-8N z=my_;CrwcjjF^I%yj9mSH{!M(o{%+g+d-hgg6dJc_hRAHiP^iT#aMFrw%*T}1Q??? z0(yBlKif)t?CXz7z08+5ApQ;NZ9BhdSr{_(-XWJ|xvzzr%X%OxQ6 zj=MJcaUB%RT3t8M>dvU8|E#QJVp*0|1io58I=pNJ7mbQ((VeK_W`myyO*J+S=@cnL zNahaxz_A9@qLL6^A826 zzi?kh=tP<$5*dTYhoMSv%p( z8l$#ShJ}~MyT$pXnLwqg1?AANaRvHlg|OsfK$LY2y}Abe9BgO?ZWKMTdFSC35lw#T zhaCJkA2D}}P+MfrF$2J+MrF;Auk?lX?!5>q|LqI6$e#;^MA9-NYbuSdz&PPSX!y5` z>>|;7BQnM5NC3l#?bg`rLu5H&sv(qM*@j1p1LLx-Xn56@@QqMP8{W1S_Y>;SHA<>D z!@&_d5;-TaZ*T`hB=l^{$-$qss3YEgW+siag|-qXj3q0321ssJnxVDfX`P+!vSsP3 z`OCwgm?wr}V^__;%;aCcmu`4TonOZ9sqx>Pa2~939bbxpc+Bdroxib<>((YV;^Xx= zQsy7W|``pai2!bSWQS@BGBfr*{KnS5+)-yy|i;l0UlC| z8#tRsvF*}XIv-Uo2Zd#pkdiS4beCJYJju5V>X*dnjS|jNNpq4Z?}5VL z^J%Pq*%&_!My9?1srB%6JVO)jG$^CUQN89^RZS#iL|2L{1GTj`1JSq>E3v9;qtGo4aVmny>tNWYCBCl*6%n| z=<>onB@iVoB?-*SGUQ9IDU*biAaf1nyYV)o+MnYZ0vqsq1Wzo)f(rghg%kv`z+@_o zM4Q&VmF(=u5AHF8)Alz%On70Y@a(C&=`GcHjd=dYh2CakfxKq|RRsWGevtU>PNlWC zC%>1AmDl!GqM}2Gj(ay|6+8XO=j(#=jCK*N6Dzltw&%Nhj=hSf5Eodt&|x_iJ){ySWEjLql+}8yl;vfJ^*vpE7%r7;&R6qcZvC$tDIB4~R4a}OI4 zv>xh8Ah))zu7ba29<;TtF;?UO?3skI+^XY$?HtBn{LEzz-Uuy;(K+45=I6D;8tEDQ zrMOH0uD&S&lnpFc3wF8mm(7dgfYr^cae;dmzqN%i!>yxiA&T!b#zQ&hBaJKCo`;{U zaUSZqDT=7czuc!IHSYB4iVtg}?AKOTY{v$&M^y;&N1QZgvb4%bZLmkHYb(}1_YbKM znaC;QmgsxGQEIkaLHMV^sT+_h=23iuV*}o@&TXKQ;TL#3#ytVSTgR*Mm*ctBOaf{HhjH_Xj-mt9%K=hBh$WDd6Lv(|@xzV>Z( zYeCP2Rn+?4l-)I#vR%0w;3B}vn`)lIKt6V!Q(x{+Gd&u;vG*?b*$yP1VQsUr{uy`< zp$ub@&#yJ%=27pUO;x1;%DSv$m^}&P;x#@i0r{^75zXfBbh+{M@H~$Pys;RQ97C#g z-o>D1U)D@9qg^ag@4>CmkXg{uuanp>HnTId6~w2HSrv*$j9qXhkkW?s1p1W8vFLaz zDKp*!HzGW>LqCv6gTos^Y#FFKh8BQ}0C`L;78{PdJVhFLUA`7&jq`aiExNUM^5@qt zMmVjQwnv{3VmTcpF31R0tM%SV{L45yU@uo9e%vXK={jC&ud;HM#^TGn)P5I+%s#dT z$os5(OYJGYL78F4ZLwkm!Z$eNf~W3w$hfHzrR4uTOl3V+u8Z851xgCVc1T*^`T&X_ z(;E}Z?LXEmU%HWH`lIOQ^a*$E{P2Y*s^MM0oBdbclxp!(^uf7vT#?=;@R9emPY5P% zaF{8~lu4ns3!=Bv2*IP(Upxw_cb`F6u72zxAWMV+1afC67lZyc??PJ_CzV7U&%ON4 zB}jZ-bmSF`2hYr-iC6fa%xf-OV&T5Kb^f}rW(h`DQs>z}(!?DY+*T5b32e%a_^A4h zbqu@cTEU_U$O9=qs7FUWf{JL}HZkL)HWMT1G!>0cu%Fz5Z#;tW|6iIZ7v5}YIiLgH zJDp*<=g(4-dSwbh;`#^J3`#CV2rwAI_Vte;9*d_b*B${j(LO8n+5aK^bIl zv}OKAIm&g#RlSLm zj3SRJk+Xm3j|u!{tZgzHC&>&3de8h>W`vj$lU6%hHlYk6JZ+`+hLk*QmbkSg$!RvJ zt~_osYPy){Nub=Y`r6sXjr|*;7o!<`{%y>oHN+})(3;ZuCmZMXg8c8`Aj&i1EIFV2 z06X8qpJV8t9dMM*lPEVS>T zLsKpq^Bbnbkqh1Q%@)9eh%cY->WGNEQ1&lF`1){77r}}>ur5eY9wm+%nVh``LR=cW=%&_73{gg~ zN75+eLx%=OC)@Gv16_^achf&t5<8U`{xPYrKzb{DY=!?uUts=VTm@8=Q*u7>MuJ zkypqWRj?1fWp3N>VFp3UQ+c-Scu8ey1EsyW>O!EPsU-n1`XvV|@n>MGyja2N9*O8k zTT{%%j}C)}mP|M0Sca-!o`dt4JIeByC(bp6?=}GJG*jx#D-J)+|JXq z)o)X{1P;?T<(E!`OpkMCZ+^pm^?{i`4OKiY(>D1u`XIdEiJE#lG$*-OCI918n<6P= zN0Fp)bJ^ZEgGt?(K^Y6>xAKR-$W*JzS7>&U~=r(H1PGdL(tOsXZOnWx<5yha5PI2 zSXh0lMVHupJ+=2agxjMxgXi7)yp*c09kqLS&rRu{{H{wr*iuezYBl?!F}0kObn;vF za<^}7kGxyyTYXGWU!TL&UZ={l%>HSYFL*SSKx?K5$o;?vj(v%yrf>-P|ROK3*prFQ0fOD?$~X@{kZCeQ;ofv)yY$pE4*0O1*V z;xGS=dxZZEpEJ?;I#M{r@w0_yms`xmr^DWauU9EbaSxruRZ;I@>BaaJx#V z(NYVHlOmc~xsDveAa*@W{<|5oa0!2eD`2x)-Uv$q@0~oegTnoV|NebRFkk(^ z3BqWM(ow{OxH#Pk+sJx`=^DqAjn~#DqG-*%-H&CCF}kayH&VaDM}+5bo=r!i?;lk! zw%hDT>T7hKy?3|4a+C?Yh%>j9WKzv(%J{YRT_JqkHRDJerS*oN-|%VQz0b-)MayDh zb?3i<0r6ww6SjK4%Xvw;qQmYZ4CBs6T6%@437`{KCD6ZovejR!#T9Aa5=!B{kvI_X zN=SPT-G#Vt?nYk$e3PMJOjq#(Pvx23zeQO7BQ|NaoUPK+ZQ^LPe+#r?zj`k zE6uIkEUnWmRWs-_{cFmM`ne;>94h(SP#&Q+Yj;SAdARj2B3^4srhx5R?rgc*qGKJ^ zk(#ZZ0D2il6G#JiP0QM3!vbc#y>QxL^M)xPgL#SKKt;ME=3j&-kxW^3c793T`=Dau zQ1KyPN$lQqmliZm=O=g9#>Pswr}Bh`L<}9-N#I~930jqSos(vZEd|0yYWctLr;aFC zr~ci$kfx1(A3HcVm4BmT<_U9dc2MZsl-tb@6*9_wxK&JD^#61%Ss>2OA9yP*_if&B zP+!r8+z)0g`PLY#Y*Nq1FMwUm!7P5o!f2=l(CRyuqL`N%(yYG0y}Y;ifcCKNhg<4g z+1Tejb^Iy1JiO9SZJPu6YzQ}dOZ-#8f_mLvec$&M)+yn>kPkwMmF&M>FHnK}J4Tgx z*5WMo6SoL~iz_4&dwbVedp&m5G|<#sMg^$f_hM?~@2awJo^$!g;Mwv<{?b2Pk4KCo z-N4TZEh~d;)pD4o5EziKx}1o~aCdoV!-Hs(mkf)kl!e0dEd3xpC_c1&pBvTCLlj)K z_MNrPhFR8=n|*dWcz?}HJ7#!ChIpd0a}9m!H=ev))dqxZl&L#@s}72hDIF`0bna@} zJUNNg*Z&cZr;i|7H4Pio)(U0qa6#Hr9nn)%lv#h3L84^o$?9*Dd?u}RKj3p@`ujO9dvYeSA0#qv>f2m(G^^baEx5%!0Ruw9ofdm_lvdWRJLf=)U5zi2iz!iG$+?+tZhb4+Fo#=eCQ` zTrQ&{7(;2{*XIKLt2w41SBv zkYHNg)BU2s#qOVx$s%B%)PYF@PNX(Oqa#L;1i0JJxd0mG6$ZS%{0c6=^>3EN9Fra? z-UUy$7`3SY-B9)(7y}x9awhR(vPMNiTM3c_q=v%2kwad!-2g{j~9)a zRcq9)jAh3DL0NIc9iX)RzeIcHs(u{hPsu#v2lL71q#0ewTB>>mB%U)U#?A|5l#Be% zySd8w%ht|xf;$y^Ps7pz^qr*jI-vvCxflB0mp%48{k!t4q}HOa{lfj0TQ5rA4h*~* z2v;x?14_?*E7lupz7Lc1ZB8^%m0Eg*a=m(sqScy1^igHptZE7w6JAXH%?h3Zrmk}Q zleK}3<)7N_)~t^}KmOZXHrDnTJVy0J59HLc-@YXDpC@Bdx9`f>1h-*s!^_sT&KS8% zP{87By`UT?9TdscZy#EFzw8Wb^zNnZ@;FIIhARUHmF-juqI=XSz3t(%DrJB1b_8RU zm*2j~!dhcu)>SpGOy2Xu(b|$ukG<=Hn&;IxzWT$J2s89%5A1rx)ah^U6K5HyBxR+`(kn#wZvUc$EioP{EnIPIDO z%huvNi%$P7oa~dMuf3p9Ui2Q+CI92;^emza4PJ~|@`eKdDF6$Yk>f;DFjUe5s|B$o z9-xQ@6=O(CtijRF#4=^y`_A6pUMJW%R=6K!&~dlauC+5Eq8kRApuOe9;*jF7FK#bd z+v9O8yw~MreY5MY`9f0Qr>ZG%MHyXFORnc^bjnan@XOuJ&QMBc$Jd()ZbgBF3Fol+ zw&}c`c7ydjYpm~p-+#zb_p(-8SsV)=3=@27H17hhi8{<}x>ZnX)b~=2o*HS!^uc0t zXdV4bW?el?f3xPMC&WU`9X?3L6QfdxS-1_FVwv&C+HHBg|q8{IZ=RaV^np zy6rIL^yM=DzOZE7umGx{<*`3{6E0$Bq?;TSL(c)kWJnj39O~O4KCIO-4#R@-X@t1A zc+#=KQQg2vdpWnUb;T*0E=8yOBA5) z=NYzO0jIE@KHEnHcS{VA$op@+BKQBZp=Y0AVH%csTE^2Tl2;Rx6m?-$JNq?{S7^?A zQjp7k1tu8oG3_s~lcl&vt9`;LfepNqoxIP4#*@q^c1CGzVy9Gof|BcSYEmjtFJ=O77^JRgW}5a zjA`*bN<{x8r|793&q+W3A%yw*WtSVH!x@~2+udpG zyu{He1xg;Q{&LRs4~aZZ&u07`*|UEIsZT_*ti&S zzO3+}O!D0N+{m(x(pzb$%7u|UM{o=iX2cC>YAJH6e9~X&p~{gK(%xYukJYhjN$7s8 z6r=oKXnTzO)3`(Z_QJI- z90~hR`#J{Zai0_OsFX)=A3Q~cUS`8~#=n~TTZ~(p4XHS@w7VPl)oU6bSs%90)0a&D zGrH_=_Gzl~GFni+V^_X)HRv9wq=KJU+Ka##;=3~+-{qm)y2Tw6w)4_`Ou3L@4Nr?0V;y%xKF_}zelUmi2SgnA^ z=~V!JZBm(y=Ik7F{>-oH2_cPdT}{=6mB`DmF;1Prsxd17Fg}T_{oa(fBaiK}Rf)9m zcD$Y^(}d#ZRNc^0h<(lK_je1jOPxxu(Av;19#vNCyAJm;+3NPTLT5sSLf1KJ9y zw$P1n#OP1a4<_3lVdJ;!&4DSG6s)%dr^^o8h$@{{~XWX z-IWc-NfaiL^RnF6GRl7Xt0)ATp4sv#B9djCKR}NAK^viqdUCoeaZ&&eMAlDvoE}lV zPx?;jr(x7K;`lzpv_!t6JnI5?Ia#4HFvgAT0 z5^&jUCL36BgGqvpXZ=Ke&iMWfGeo|*9J{|&F_z9&3vW+nR+l}E%ROI8;snCJBlS5tYn^f~3_=HV z3J`uM9ls{du=|Ap>bQMvsOi#8w2 zM4ix{-OU(*OO4zbg$3?Sr)0B9hwpG)#lD2g*UBv@J|K5T0U4UE@)%0n*OK~nk&pcn zNF#ln!X6^xekWJ?s-tvM$j#N=fyXb3@vR=0()XzP^`wIXM2#S;ncphL=DvMN+w>{| zSI>w(E8v1r6B@Oh7yv23S#$I1(b`G?irl<6ZL6%jM5*I3xOV5jP@&Su7tGjt-OGIa zWYhxrFRI*LxxT<`ghyCikMs|a;<>ws_<`eK!AME5Wk&Z+)uv+4u|!DK%XqvLl&7P3 zWSoBZR@T+SA%4E~Jo`M#<5j0ar{kxz*S8o^sqNDx?bG5@+UaI&viFC=f&}O!#s?$a zoPCou%rXIB_)zkj-V`F@zU+vYk>Khyq+>6X*wKYultR!}^2(EBJSl|@X@ zg20?IPk)w%80=S?VGP)GqujNB&c;(P!UK%Sgo63ROI1exMvm1CvibH-$MfJXY@etyk>mOMgF(x`)i}Q7OcrPu838YYCZt zw9wGfF2_p3UDdv}0x=sP{#7zlCHCb zOe5G7>nv=UG0`w)IQlL1LDuzBuBF>D9RQ{gF4g!Dg;W_h+{iFf`>eLKjG<#_13R3- za!R0StSdfizhG=_V6dzni&UUIuUssf!!7$SxzOxV&v!aw3 zhp4(JD${ot1??P*la`${$6zTX$bSu(>%({1Zi_JtvMh!$!yFzrXU-^|mTNYxja`&q z;gU-_86D2NOkm{Zar{-j3C4J}`Cx`b^3g#ZgLx8ytN^P)^TW4~JDbRiXS-T-H->JV|j++@kb35Q2eFnA;zJ4u99UlGECgOZaP~}=& z<-?evLK6^KDKaQ?Fo~i%;&m~R7 zCW*>zE=BI-mRrpIR<5~)ibC%9yZVSpxusm@(#?ci!dx~?rVyhVQ)Vs+G55K%?RUPv z$Nt;-XP0x{@AvC@H3ko*-lJN(l^1>tE+7sbL~!isGdDde>BD(aE4l0iASEhL3`~!- z4B3VFo`lB?YsxTaIm!mA!DA#k*Ts^_}`JBjTuK*D=wWdQ(= zBxL@_^lsDo_B@ln7z%FgID6{r=|S*ns|c-{`uZ9Vkcqu?P(?a#P(UL z(@#t~+zOirGiUo3q|e+pa(adMI}iCXl3k_IROlH{Y zUE>J3JuaGbe3N4QcW_a0?J0;%a$RN3|6G&>#(N1r657||j`8~@7H$laMBVceYnQo{ zaOyw69?~oK*n7F`-&x5#xN>s~%&@cui}i0q4POx-5nxFzQ@idfHYATKx*s8UPPvKw zBOY<{C%7AAb2)i^`E#DwIKO&)u!jZ99|M;4*I5rBg}-&o?yYea2L(ntzjs}%8nwvk zH;!+bO4VX{y};3pMqn-Z%+uUYv&rbR2n79C_k_JOkc5V~-6~Y+K_zOeTgr&)C+sth z*6YV>s#VzDs&b0eEX_BU)DM#4sQw}qR(*Z~Z}D{^3l=1vBMkBqHPORKCLvm9B^oRH z3_@;!z2hI)3n9M>xk|LF2rF8svPriqM8Hq+-8X>biNUFzJ5%wz(T1m@eHDxmON`B= zsr8H}#wv|I9wfkxns9uuNR5;+2F58=tX`=ilL(aEbyDkb^j%)Sp{h@x(?7;fGiQ%P zyYx$KjLkq3Ek7HmP`*1UEyIps)waDN^$BOKIAaaJBTmRUyaa27OAoP{UN(il)>^nz z$ae%902uicExy+7v>2sp3G9RX2Bxp9^uAn>y%O)*eIwjpaMer0QaK?N8*R87!u_#l zK|nBkcC8|Eg>>SKqd2%n&PSH0Gp2n#rTUY9RUY(*@#FrhXHUTm!K6~ejgK1db7A>x zg1iq!@*xR(AwcMMfIo@!vRl@*R1)TyQc)8`Empms3SiC`#S52OUYQsQ5tZrw+4O}Qby3X?=MfaRJ-&|oJxFD4LATX|fAq0TD9nz8vXT3w(p=)DaUb3Ml_^4YOP8=~FP z`>MRn0=vMGpRIA-53bE|-s~m^d-tf`R1 zLZ9Szqk{+MSLcve&(4}3T_tr9lY8Cn4M=Ptt!%FFl#*FrGFcccNKi6 z9I;>KF+u#bdyy_tFFl9P@lUDimXiQXHlgqef1Fk)=tsx&OIiNLpb3oXb`_*eTt$Mj z@}W!nEAaXRL@@X93GiO@$ubN1FAlbg1+lmq+UHVs(2I$3@k@B*qkR1B=u77j8O|P4 zSJ=ewYTN^f5!t+ds2dI|4RgoskRiWVdK&vey3H~bxz*C!P4tpD0mIRWI5C?LaG6n{{ldrTl zdDO@+lXBz67Jdn!Wn@zODQY;GSM7nPO{cCw27vO@GDQI%7B^4kb{`PmuyNhd*n-1# z+|@CW+(J9}8At1utuV_-i7j5Jf;n*jqIGx19{p$pg=?;1;b730zFtl>U_B8Ww9Cy4HjRShv8EfO7$xXU? zdb)|XK~KS4kxjO?1@I3=SPXB8iZaLio6ixJUMb*ge&T2AsMjEtZg=-Fm8$1I^T?`+ z=O15gA1VCgQ4ia7Q@iPPPc2er;j5(HMti>ey0sKXy+Pu|ABz`_te^M3xAsGvxf@VZ zGhemlYs1q%tsGqY(olmf^!gAz<(|BI!0BL=Nxt{WXZZNMg*)^jn_y_WLXRU>WwV3q z2pvB`QB<^@X6`7~?v*@}>VXx6`(8J51MFpLZqJpt7w&0dtH2Vfw^IAe0PU@LkM*ir z3ryr|3jcMhp|`pyS2n|^Z9*^twiAME4bc_hvAg%X<>Q90%wFF+tE-bJSCAJ)TM5`_ zkLJpmTfi^lGB(wf$XsXAam@YAK79r?({DTI=MF|r($T_4piU_6GtaXiq3@JtsYz+r zM-Ay7KcG3O!Ld!5OZn3HdEyPTzsrb{Pm@s+?0^5MM9Tlx@B9;Bzt>7$od^@YB{++p zjfrJ=1^xPb6Y5p%2nc^zBbp*TfN-e~=noS*=VU5uUc|R{RoGo;A3I7BnwkC^#r9m@ z>e)pIP@mi0B5N05O-O(@DCN!|bfu)rD%;U|jrl_sRhn|*4dhkJ$XgJDF*S$9o@nfE z?l@2UORvWb)TM|H>=~%RuMTwWiS8uaNv?*F2wsQ(fWz!Q!0!J%$`4JQ`4>d#RC&H1 z*xGZK{1_w{RpQUD(FALL7sFhr6L6_Nms`kdzs_xCbu-6niYiA_GcT39vl`dsjtK(9 zU_0A@9USY)Tt-qDTa8t)6JGvYDdq@H@kx|yB0!AjiSYKep}PVTU65{trai~ev>@Dc zuv-B6l)+4hmPg@$du8ma9+k3R)+obLaf1}s;7nOV<6eH=YzNl2{I-EWD3x}~Nq4gE z0QK$Y_~zD#YM=U7hgm_QKRA`!Ix8H8?EXprXCB}xD>0;VH?+z3 zB8xCs<1`l}yhkc+_GFJ$kyd4?q+u$zRgqCbxTrGk2Vc!sunNgb;yZP0QV-Tpx**m! zNS_u~AopMLIPafyc+B&cj4viS4o9yHI5F;Jgy$BKy!I!ZVQaq1Dy2O`rd#=y5N$*u zMDQIL#wLz{Xd?J~DHt`!Qq-R{fGW^uY!E9)%H!72Hvf?z4Icw36OGMvx~!-nYt;f+ zI>mtt9eCmyJ*oJq7`*h9szh&#yNvs(OQCVN>2tIABu^A4=!w>q4AS9zm|v~8fsBvF z2$n7aRxF^-hnu29j{h zWjZ!EiH-!4D*y~}ek%r;Bb=Fw<;jPe^*|!wVzwxbKGuq(Q>GJl7U-EB3CG6+LksvW z8`W+B1JDB=Ef@0_7nFUpp&YU7A5^n*N+JqST?bdA9|gJHHW=v*ONB@a40D7|nr7JC z>k@h_agtK)8epEyO>b1=&?f1k7bj&Cl)TVv!*F{tqScU?KlbTyu|c<*7x zW?biP9Z~13j5+R85JaupIu-p2Jl%3NM;;=BXlM;8{-|wH`&EaqL~v9E>F6f^c9rn$ zV~HMI|1md))-T~&@Dkw8eN3|Z);}cE%ut}jqX z&4FZK5iG!BkM`E5Rf)j*Hx{p7+LapU*%^=1U-48}BwbU*wa5Qjm40t#uc-_mZq%q~ zO`S;!^J=X;0(5IIRim-DPtlWcNYC!yiSu|BNFIN?=F0*$%zs}$`I1-2muF4sHkUjv zuc*y$!1kc+gRxqhXDNK}QSR1|-Oe>wFrUl|r~~LxZ(frId-x@krwWl>KDY6UfI3BqxU5UI(*OH0G+gXbl16Z&fX%lD(Ovu_Zic7WNJH7Crm z{GB6|QD$3dJ7Sv^5|?y6B~}C3mz3=&Ej0Ap+fL+%a{X<$-f^mIswL>bgY(j1F&BbT z^OZ$Bu072GB*Iiz)PU*nMV8bZsA`GaK#9%$*3Ttpyd}k=FPYGEl(l*?9+osbg5?bW zNhhGkMRDu(J-_nIF`$WSum|7JENs_rxwc&ISu-fHSUdKme3W<|BI&GOUM6qwzU_(M zNN3!nL*sAr0y}%}`vNYIX(gW7Sx8nE0+@-H61<@s`}?$X(WL$BV|!S7xPFlqpV-z! z%i#Rm^A)461I>41swx`ZU;Y+xMKC)%x_$Z^kgav2o^BACs|&JC1Y&M~hDo;>9+;CxFi|AX!>21E5(uL6EDx#yRh+ z*hs1V%NYT2#_m$$SKb^%1UzV@6BIN-c7JW*u#P~a|E1jHFAlf5Zyb2vrc@=)xFh}w z6hU$*|K;l5Z}Ki;n`r**m2Mzy+<@?7+k4MEbE>H={hE@crlF?qk>X=;b==1%+$K8g zZ;=IlPZ6Z^mb1)vm!>8O!p2X{ZfgQIQc1ImvoaL|1IpK6cc2PZAjZ~lb1fNTe@Iq=XW}&^nR)2R7%UhWflM?kzG)ed82G$Dl1~~=Ng2u!(`M4cEe6h1!eBRMDsA;~51oH`5m(6?o}KOSkZNBB zg;zf@Rxh}wj!0#*)dfY)s5k%aeI^tD?(dtG{2Bx{GzHMPFjBNx0o z8`Z8bh|!u!N^YALjiR|*9BgmS9fTF31|(!J|G7z-?uykZwJ9&tYajil)>KmGEH)%q z^PV+KCLLc^6h~DYydz@9YNp~S_Q{7U@8Wy;TF=%NBsEBuB;Uby=&hf;zd!Jixm;79 z6@4V5GkG>eC&%0@XOXs}^Ys~`AMxyO;UKaO6LogRFm=c+*p2Ez7*$!13jFFIc_$`) zE*P9L*Nr*h4dlR3&b$=ie7p4~R-wdy>_W_gko43Xb;^T~=-dp8m*P%|rQUaoFQ)wx zpM*+0L73;LbJ4_r?2onD_IBmD*Gf~PDWDt+W!R~vm1h}W@8_;fUO(L=Uw%w15i$Q`jRA+UM^Qc8YR`fcMlLyKBS?p|r3h^gn@Zvi zhJ7Z*r|DzB;DX&)%zNa7!zpE_T#8zI^;OFH0v^wH6Jp@zMnx7+2?Nok?d7L-BhStT zC(~DTY%BOp{A&{#$Fby=X3kf3yGMOMrYm`Mat@!KO~z#OZ-zwci0oqN?v+Cyiv4-C z#W8lU>%dA*BNhagdopIH8FT;h+5?XiEtxT{pLovUGrsw* z%5~7AM~KFsI6^s;4ZXwio_~0A=`?q0KR2`&C&p{PQbPJ8*Z(tiXb4(20jdJu8Lpw^ z*fCCJ6o5B6D8VIiOtP~1+Ito;om*mH2{FzKa6x~RFevxd2ME?rLPL6}MJ-$~Kt4M= z+g5Rey}3Mk&1=G1p0A@g_fqc*#7T6Q_?>lEcq-hnNb1WOj>1NU%DUf#HF}NrNZav0 zu|IXFWk2;RunH%Qk;39S-APWE6@~Rrgyy7hmTC052UaUjEz;?n$!g+0S9c21_Y?Y-pvqrgD3X5`ad(xLkJ zWA38MB|^V*g6dXW=Em+`{R4MI4`XphSll&Fh9g=EeLsMVxytSlu}XP8l=Ua&`rjEu zz5l^q6(^aqCO~hk1RL4wOZE#{muYi3sjm6jid_-b%kl<~3{rCqK9DQSMU)8kh7%|i z@o?%l*x4s8l0*1TD!H$3l^l#r?ocqHc;%-^zU=;?6W5y2KScMv@Tfoa@{r>0SK|%j zJ`|K|y!Anzr~rHL-cg2>#}+%rq0#e92ow{8@-o-HQ7FWs{+zahX+A9ojipQ!5NT=z zhrbD0o9=R>fKhrr%ynd3@#x4inMT%LbqmmFs^6PZ5t3pZc&8-Q>*2^{;KSGKvL7VN z`;ymD);TriC}w}>s1Hl$kx2a^F$`GU+gZ1;d!AC~|Alo89q(WQ4^FQvR&|xw7I1B^ zDvb}qxS(puH;cMg!k1KWGg}AdjaHHFij0Ve@!{;#AI%;=D*KkAe(HcVeM;w9rtXF8rU5t zdQ4`0vHyI6U54XJPLo^FDcFJ@--YE?$mNT}LvTaGXD>n(2445M!SklQJnC7B@Z#Fj&C1Q4mQCf_@+S35x(s1Cgp+lEtzoyIVXK{U+8IL-1g#n|zq&3YX>j z>CY^z;ug8B!Baz6p~)81UUYOH_O6L@65#TgKLBD%z`s`i_ZEJ(dr0JUwPI!X^1?K5 zXr5lR_-ZBaqptQUEQtK-{M;I2GM@c*on?Sqq4;%~h+hC#&l?^6zZB$h=5grGNL}Vn@>k$#v3j9+Z+cy|Q!5YE?u!L~5_J z9gE(Otd%Zy0Y73@Lf5~UKv?Rnv~JhF$)LM!lLMrEWuzj%n{i)m@8sI!=RPSZs3w^{ zHT!{O#sle(MB&$y_OR&%=irx(3ow08f@b zX%wT)C$cUYP+RSalC}Rm@f1XPhqTj#vaqpS4S@fljQc2~dwKx>zF%2h!S+_zAyain zl<-s1B~Ch~u)4Si>$#P?50h~5addJzd+Gy+Ny=K;l$Sp8;Io8;N{!I#AD7i`Ncx2w z<_?w=6)lT5-N4-x&dkrrfA$PPO(Hmq#7-}2m5n65iHi-ma^2sRCFDhA@_zcXUlZ{l zLX&Vbcbu-D*rI=NT)R9gy|V8JziA$pbg(?O=$WJ{nyr7ZLSrsXAJ-5!_y3G>q2Bst zT~G19Zfz-KXEQb0VQT1}8YZybv)H8|xz!*iS=w@*bZx$p_V@$a?+%4G;&TG+P0OWK z>9^nhr<&Ys%XjnJZ>#Z%$1jR^`P`=Oe!x_{xN+&mS5=Xjuo9z)a9&f6Hg21;_7B2i zxp|~-f-Yf9n_?N5Qkt9GAfJfVB9e?$`L##m1?AdTJ7#={cP~@yEQGVZMyGWbv?|`r zo)Le25|m?_T71tB?WiO+q(z`{gWAnN%Ab&XzxU^{Yr5h8ar|&V`g?}$)s4DR86hhc zeo*hf6=TH4ga}%rf+bP?Ir6G&`04bCiH^GTxlQ$#e&-1_U@N;}OJf6HvlRP3*UdVG z1jXLJPQ46~_~ZD-qsQYj)o(KJE47A_N%gpM9ntj`SEkN5HRe5eJFu>fZ?MsAF;+_? zySC0ulZOo&`T-+6yqM|*{r44QY)AWG%>v0GJWSTXR@E$&z<;_E#$Do*IZ_bS$oL^^ zijKK&s`2RsZGLn21!LaIvuu5zIub&Oq=P{~#{X|FJJ@p)?l#J1pO;TjSeG_kLoIEH zeHLjlNCgbm#gh1bn0&?qdtCG1>pJcVr&-QOhs1H5kFXt_?@vEhXsmz!&k4#n%gK?y z`rhr$FA0t>KNQp{+{g+QOv;zx!#8dGKAOuDGx*h=3zJfhb~N%@_Ax=A&MMfUNPAHf zQ+l$@d)oY|>6_QijV3|zJRhRym!#;QvJ$(p`r&C076u#Ryu=pj-HR1 zx^`gfd1^|b@I=&UfxP#yCc;(*5FPqniQ#9Q(a~14k3X;(WPnL z8>m_7jLOi$uelI>^E)Q{+`@uCmRvWujLE8as7}tOX^#_^_V@RjKW3VpPoMQ~rL5NF zd|*>y3u*eTlPx5(IC{yiWtf@}3rekOK~0oVCsI-@Jy^sR9}7McH&+r%3C{yopGrWZ zr@(1D7g1KQx`wHv#_9ofFh!Nh8cGyS$NUjd1s6vYt&j0xK{nn zJ$NLMXCXo&rKBSNr}1WWjHa-{{TnN%6>Dl^9lWy|s?YXIiF+&EbR&mD%fpB1V;8(3 zZ=MQ1DwzZz&g|r4CWFbK#g>h&?3f1s%dAh{U;9rfar29FWNFcdBiQ=-F70R8ZY6UL z$Iy`0EEA#S1P2EvZPLoway_btBw^@U=IsqZG6amm?N54Gns$1}zU=~hXmALB8J^d;TM4?T6pv|PU(jJ|S|_g+3)v(A(jFQ}YGt2Uybk{wiotAAsnm4r9=guzmw)ZxZ*g_xz|{2bpF zjq9BbtcCu_If=gIzdEi49ncz!97C1lnQ6Cv3Aoz}uCX5bMnEa`E_I|8wY=;}-We=6 z`qx^XBu%wtu2ky#)s~8neg+lG7{$cc7{gz4L36}PIBSj$67)*4LaWq^!9qD&@AE6h zl>=(wDI+~-1zB9qX#^q{kb=DeB7W9i8$;9>}GC!|ni;QCC|OplGB8jTOY4iQaDCf#0=R`l+>4VQc8^S1WEZ9gjvO zo{Y+sl<>Gigh!ZPmk4(nBRwopm#Zr8J~H~K?-?$GzI{uHp6KZoSna|mQ6IQkh=|o& z=e13YWH9jQeU+88j-f><(6zTIW(z)Aid;=0vNG41PbHy}kTEb;|dt zX1?x0GSyiz*zyml`bGxWw+gMRyULqxRNmIrmiURX8a8}!)>lxO8?R~Qb2-mWU#gHq zJNPwzQ;X}2zOK#`g@T@NtqG_d#N7V=CKY4|c{qrzz9)P1|z%!g%rzxrEzxd87y|S%kN*K=Jl)ACbI{OWs`G z8H+m1^rQx7%D8pb61U@dGqe)%oe}z>GecW`_-<(tQe|MsUGe?`IZQD2=OQ}tbgRi z8^(mRFHrNh$)BgGrq&ecGJ!GtPHv4pnq%qtmec1w@vsG!0k-?5e{?Y780o?9qK^FN z#3^ruN0S8pkw=}+4zmZHg2G-XDWABhB*E8K)Bs^=GvP(B6rvZt%IRLoWcL;D0wprf z%a{wekaivys+~2TPCTOMGl)QI#_w~>KZ;4D2{N$er=v0UAD<}(1tUU|H65SW>eKy8 zqU_)mAM*KrmPb1vF`GkedB4JRiHjPV? zyvFe4R{>(;9Hy_q>DLU)KWd(NVj2+GZ=^35H#{5Y;rm+cnS_|Vv2Mw#RD;T-8)o7jd!W-rRcmY^ooRAYOSnD?gYqRDiI+AHsi zCTc9LCHr&Po@49lv)5lpvX*l&$EZ|ispnp?f5$54+-r?fn-Nx<%W+3>8dE{v#?+n} z*$8r{um~TF8)eFv%d%Cnr-E*}`Q#~|i=_hqPI4P8@-8BQe}>nz)LFm2_Ml_g z6{#J=uB-|gxMAT^J?6@CW8?8O3$O1*OO`)ie!eDyKY2}lZUrsT7c6Cq7SU2)kB<7_ z8%TZTm?!;DfwTFP)rBYf3x3HTxWJ)&CQ4|~KZG${R!iKVkduqGesIF-iOi7Qz)NxGPL=9}S~CnJ?E!a2PX#S4pr)Ob#zWW{MJ#DeV=Ug;!NoX?Vw|Q;AqtsgDLy!oztsT z0X9WZqBN}Rhfjbek7FjseLgF}nhk88+^`A9CdceC(nTR5*G~7Ev%h%v_h+<4C2ezK zJ0g*mH@HntudEDL`gDYE4HjiCk0&#hzwF@Xu!3(EENgl!gSb940iWcngizb<+& z8_ zlZ(wQQz2~dR+7;k=ZPV!D>s*0ry1DyBJU*Gj8YZuSjrT|7CHr-i@LCPvH*LHW#_Bm zp8np|ld$UsxMH!i^hfa)87#NV2uB&GN&we0LG3%`Y|r=7+!Xer(>XW8{?L}*FBJLp zOBT6Z)1Sim^x#Fph3voePG)`}YUbPZBwo}YQ?9ZO_{PVFXR<5;T9rjtq)}UJz^PeS zDREN9?1wG2*9ufy2fAUQzSr)gdPe&F#trnsXYbD`rLj{?A5P`W7|olv1L(HQC4KrH zlfipLe3)WZ5$Beb8-HD4qVF=dfu7EfE;#n)^HvDlHi`nsu3 z?9rHFvQ#hW+8I}t4zBNz@1qW5Mg+1{$e&Kb>r&b6&se-LX)Z)#v{snosToqW%gYkP zuO7FykW&Vd%ZthHQ)g4UOQoURk{+b6qB)i}zc!9baCWd^nDcUNnchg&>rCN^I35#3 zT7E|Ve~)%PrxAfFY0$ZM*J2a!|Zp3 zOfK$b$Maw&g1GLZ)fH*vqt^4!!F3M~2QmW%Z%VRrlUmd6XT9UhVg5MX^5}xLu8D?GfK(4FWS*5(#y{k?IOnJ9YFty z?-le;&CQb=v~yb(*7IE>e-;%lfOMA-vIuhKr@~L}FJN2I{#Y;&=I2KAshVgWzkzTe zG`r}_a>S(Uo#zlgv4z>~l742?-Zj-8P`XIa1iz_@io&ZXqD}+BkW*(JRy*Wq9Vqv- zH%jkXCOjow>@2h2pagq*xar z8P4Dqa=wphprm=q%e+CQ1s%ly7%Kk>QmnFlz}0zpm88rOaf<@*5_@#sKKkcoD2yxG z7kJLjDF#h<#C8gCz%&V!>&Y=k1P!1&OmAg`y1L3G%~)8a;2HZboHYRUe?cP|P(>!~ zJSh_4O$Uyo$@F^W?mCl!>q^{PTiBhTjP$+myW(@P&##Jlb|vTEk{|PMF5A^3ioA)Z zd+ePRv02P~EMmaI7hX$Md-lwM9M_)3cIriJs?df{u5Ir^8azcVgRQ?)oVBUh>Dn*+ zQ2C+=|BNp3547{>NBW3viCoE{k_+jFUnZ6o0`?~fdPVt&pxM>Kx7rV`I$=lO`Pla<&_m=c|yOJWSEDKNj-2lP`)+&Q> z2MhRg-da+LYK9IF^hIA?*Q0N9nVJkl-p>8}PT5dEYi+OMFN1~U8TvP!aUAktQ6QzI zZt!xF4uQFm%}j`l+&SFoOWqkz=DKWs#ZU?rp<15dva!#i$ z$hoG>R;TPtWx=;>z;)RtfXP7{c!aDgXipVO!z_9_{}Y@=Ed+rfbqlkJPoN&(YhZk! zHCZ_kIw_{;G%Ek_Z#(+bx#mo<9~1tsETzoRMYY7{L9MYSQZ=*zi+MU4v9qNtK2;2&E9k`H>9a6ol72X0QWzSqLW_h>h%9htQ$xz z*cMg!6qkDPj8QL!)ODtttL5}kl+3ZY`1*ip>cn^LBQbI zWKk^03?#Kkt+^uYO4?aXzZP=tYu=Zu>P7-v%Q4l__X>>P&&O%iFUG;q`(?h| z>#Kpw27)m%oVz?MFAN?ij_uryPi`?ewk-aL^K`r*&Q)pia?1dHtGep0g^PUi`Pb|$ z4Gm&ktdH1xzrUM#QwuX3mVsD%usuHGeaD2~<*MY3%V_`3tmQc6Hoxx)dQFn=KcGv8 z!-4QW&(9{6;>EGw+TS-TP#YDDh864T!(JUkNN$NQBG0uFHAf?VkMtpaJ&Z~@-d&}4 z$o;HM;*-1a)ZNp=8fm@fADQ$q?v`U&-%u!Mz5=GruX&C27L#QV%`vVIa1--;w$g^2p@ zL}#bd;%+aO(|?cf*dF>gP~IV4w@~ zeQ2S&4DS@DoCwVQ+xJ)*VP7(F6vZ;5Rgr&aIWFPPj(K9X>?s2K=K*ybi&8#abDj_7 z5I6cXqu4b$l{2?E(j}2e&)mq|xnf{Ze_>sJa+;z)xw@DGRjM82iI>q>?+E(mXea!uI2x4nhpp zNBaKAtj8g5RC^o~<=`JcJxEZ1^yW7;c#XV;q+!Aeu+YedZtGi7egQbc-lq9aEpfLO zCeEKxej`?HyI95VE8y+cftHrZRRTp_i3zBs20{?#larH2PbtFTd3GGpHMKAE%i)zKyqfZ!#Nxvd)=Er_)KA*`32=cMK(Lr70peg1?l16?FML-dn-X(T2 z1+q}GmBRrW>M1C#q=qL~h&CV246rEvOA3#Sl$17S7nAYe_&CzqL8aE<4~|20CRd`w zmViyR5ame6&*{1P@PpLCrEOp~r@*PVsHeJRXgZ2eo2s_-*Y~*_oWhFK-?3UWVoIt+ zl6MxqFlqXXdM0g}u^?Nyj$^Df+3H_YtfV3@$jxa#ul{N@@i6)bKjmU+qnomVdJH5p z$W)vdbsC|yH#umxioxLT|Gu^ULV${QxV>sy%#8=n+nL6rT=)Lw=?7pG&lGKRmC^=l z$}mHGm^HN?hj#a*WnY33q@ix`Zq3l2pheoG+Ykt-ZR#+Sjs2yhnsYJ*xhJCp9YYtj zl$EZ%35e;cLb_G{V*XMOLKhboNB`$47A|tq!n)KrjQ5s!9d+W-cU`NrM*Rvb}VH=WN92 z5h`g>*v_sg<^3?dG-{1=t26mI>2Q`pMhL1KDHs#7v-?O^n&PZb?z^${XT+#JznBTpWFXduK@`R9Z_L(Jalpgc+J!6q_-Fl^=m25L!0+h~ zRnARJ@+_uT&QYBgYXZFwcL{x>%p>MT^3fpfkigvU1MpZ|Pr1cNELOYrRP$l$bQQ_- zc>9baJtUfWuvpIc-?D={9>g7Q%u+N@(*r*Q4zzD;C0IQ0ubkW5ov#TIO$$p*M2|+E z536_ClbG_kR+jf9+~UnkmkuXs7joFFvDLS0$-nmOJz08pL%RNXC&ypU!UA+y{rA$~{c4Q9vRASRii?z?t) z_vE_n&F~A~0GVc*_+B8_C2whSPF51_na^dTxWkPY&5_Z;s+6HrGa!87jr*O?zKQ)T zBfCz|rLFjW)!M?EDOQ87xq_@==i!a$e!D0n^&m!D*d{u9&oklhi^>R+zz;pbd%HNF z;`u@oFk1CQu2-YP+5E5 zQBxy_l!7ml_5EDTp78Cr$iMtL_ZtdVs!>3i!L)tEg?`M>UV-_Cv2 zQ_byvUIW5mM@03r((~7Y90eH6ujJlP zP)a0=0!SrNhx|k}<@-x3=z@r30XwtF;?ZNBjzN!(tHk`n!-}fgo}Ro$OWAw|AE{5- zd!LQ=!4P!s^C@du6{zPqted8K^D2rJ_rTvtlO9M^zJj2cwAk_=o!+uBF@ftgT1D;9 z7w=crAhkHt0DJp&zO1tG;U3{ZV+2tRe1FdiC0;>NGW=28@)ZuQ^RLEyjI6DB|4BYQ}+bi}B>mHOf(I@U*V3F3R1%-%lhOxede-5UH)}Ni^j2 z@iem_nYoG7U>@7z;*S_2?K6#OjsaBP%c7l0y7hx}Iz_`Xd8fvc{`(+7r`*@@RsdUf zNn-<;OKi6wAm)8t5~% zKc`+*tw#IT$rqn*2i{KKjZ_qa_$61vLhm@AJU& z8fF1^^Cp!o;{H9^_<0|)+2y&VDUuC~y(l)X0>^FMW%m)dk)A?h>>o|j8OVB^mWWOD zw|u}=lqgdN}8`piFr^|-@7^+~h>VCvjV$5xK$9jzQL%x4yeKA&3vAY&9s z@8EBx{?YGCts3Jp747Mj7{rp0*Wy|z^sR$AH$1?@zve)LYY_YLr7s%>h8$sSw|sPZJ{B1{lcQRkaaTk6 zG@OaPq-*7KN`?P5u*U@uSTeK#eLINTwC@8tBP1QBbTiz`AeWs5`qj!vLF!!86c3TG z<~uYuyY#*qxw<udmZ}nmA+y60`~VTr7TLIcs9d49vy*=kEyqn?hc5 zLziS;KJ@Zr4$DQvfJBeH)aUO!4_Aawf3-YFx)#Voi*p|*_*C#RUUR`+r_EkYfqD-?^4I^h2b?)}?@O`rR741$RV}ca z^l;-srI=|UJ;BFAaziZaCaJwvQfD0<)x$FdSS48}XLCXiIMkl_TCn(BEVT0ouzB`+ z=J{uqk|z=!E<{v7fTNp&Pu1$0w=JB!%EReeYt{L7W(X!DPfO4%x3p2bJ*o1RbT#xL zD)eF>IG(xY!TyYx3I}GFSQnU=Y8L1Ty4CS(>kLReeusHY29&0(05{=cW zSTpIAxy`?8waeZ(<{D5Zf4EHT>+1vEvmq=se-w65WvTw{8t5V`z8XdLD=E1_5H}>B z_g!o;{5KXpHgUX5@i5kozS1ACohVmb? zI=Y5&v~;=AA8{4;K$oS;^4n2B2OcDLNAADfcdBRZo?_)?^8#Q1Xec=31W2i-#(3Yi zKT^Q(&H^gRdrtLCUXg^cjm$->S`9(nw@wugp*JlCq-q1=Ii5w&hHX29>#%WXm+hL; zu9xde-1N9|=0QMWnA7;n49lg4=wYoF8?FIj>_0G0cU(q$#7P_mvvq4JByxSCKty}m zQwSHPNXC%6LP?di#xGj-;WvjgJ3F2yn9-80#*c*jC+cJZ5b&h{WKjrz{#!juG8Gu% z)@ajA>R~ZQd&f*S_B9=$qg(w0%mS8!eb>cBcqAoN9HHWm(dzb6(wSwy06gRpRKJ{x z8M5vx{c8tzx=yybd#!BTXVn-;g4BJ5=4?_*EAqQ=Q$H^DDi2agf0E+Uz+ClY5}3#O z^i@y#)(i4VdZlRB{xUW?cs)$yc4ty4-0FGX_WJq@tsC1#!v2LL9_5 z!)3R()fd%t_mnV4G`0&9R9ia!^zxkoJtIxyf{utiuG>Rq8wtE?%Ax;0*}}s9{TjRN zw3XFY?JqJ#ec(~^cJ1-|wSd6Kzf_@S>|mPCURb)ApWFD3)ueo$h*|aW?Eb|LLj!xM zuZvINKzOdQSX@zOxPP3OCAzghA%sKq>RytXIxR@X^u1EVlTTJ6LH-0M#6SP1b(e?> z2AA_Eo5%eBLZ_U<17%Z`6t1qq!Sl!&;L@|0(eL!wq}^=wM>| z(7%-&)NSP^oZf*n3DFnkMLT)rmlRa@(N+Ysq`Gg|!; zau%^lmHZw#IJ_$1fS%6WB791t;R@AV z>;p{GP@Gdm`dxy1{8Gu%osYX4;O$`zF`GNkg;#cWW(HSPmE3bRD?-P#zNefqU&c|< zb!#S4_0`t$J-IoBs#`kFrwMtMGRV+pnLlP(w+#%kFIVJC%5dIH_hu7!)%VnIec$tL zEKP8>I}gtSvhIv4UEGPr(dlz@M72N>QQ##q$=Y7{Wyw9e(#K;W=o+UHrM%Eju#Z}E zfi-JmVtKN-NHn|8@1iq$NRu1cE@W{&Ev(wNh3}FoSzkA(auR7~n{HpOaM2dP&i9in zI*Lr1V%q+HxgrO(RMHM;Be(?Gf_3!0B*yRi<;jeLsB6MJroIF1llnR#3VELbZhoJP z@OhSpmB(^Qxc_^bnhO18`Cu*g*2Nwj1Nlq4|K13OU^lMXo#$5?(h3!OV9$EWF`J>? zq9lntw#7-L4^r|g9Tnl7P_;FMd;X=BS9(wWBL1S}Rv`J%~ zotS_*!ZlzuK^MA|9Kfr=%i|4eCAoNYme_?&GrrB&``1-J`o|-}&QtAS@H6_qQg)w2 zFFux}H!LOr-xB;f_f+_~jB+G@kXrBvMNc;G2M&SWj|Ar&05SkgLXrba;`V15o12`F zYyBRCu(gO1%ZHV0Otv5YN6~rsQ}zFG{Nl=0;$F%q^SY9<_uge>l)bs+7B}0qcjAhy zWL+Z_GA@d1uUuu1!Zj~lvZG}4#qa$7gL@v2d(ZuR-tX7z`6LospRa^`VJZNuX~Mt0 zvAx{*nqkn=VcAXzi(7Ha?g8R=uMDqhgIX_E_P=U)@s#B^m{vg31;A}3nJ8I$O z&}uU|v*C^*VA|&yA`kkECBLq(za9A3w|}5^E_MN2L~$Jv1TK&H$W!v9W7y9A@p!;2 za3#g@xnBC%e*52_juY9oW)4~07^N0K#T0L|nc=737ZrM<{r)xQ3*Orl zc}9gdOO(f`^w-)8Ig+uC*Wg^%PgVvQy!F!ToXTNQ>`?HRYuj;AjH9$|B{LIyh6J(a zFQz$3B%29RWzPKk+&8Hplf zogtYZLRT4g>eTXA-uw7$Fcbi zm_INQ!}~s$56l;!`ZV5}`buKp7iQBe51e>u2ClrKRC0V}r91ew??0=ny_-K~g$H#9 zNdqF`o(yf@?lWQ$3A?D=72?v4a>^Kr{k}rAd)rnf#3c5h64?_43iv1#B3~;m_~Ck* zL3Fln$`cyZtwNyfG;;p;RIOlmv$=i4s`Xp zTM!g~5SRGjA_V$J9I_(bRrQXm&B~fb6>JScpOofW!}^j+PR`FM>*7*bcK844Lkuw; zYRr#3uEI=6lhuwFe~CaR05|D%a4N_I)CP{#7RYV<+V^u;$AAfXJ;_*gdVepksoVk_ zdbS}u_w7>#AHeJtRd_X+V`VmJ#IxDhllMXFo&denYYuR>qTfqUX3>ld+65r77z-lc zF{4hg;$C=3Cq3axQ^c27B!yG;1Z*9?Fm8CfH zppb}>ZO5^|XqL@bJy%4E!JwI3>uMK1=Sj;zMp1DXb&{4wYldn;#cg)jMipx}GZkxe zHyJV8M~4ha;DuunAbeCo9wGvIgC19Dm=Y09<`^k|9utYcYZ>qX$=BFl8S(!n8q_{+ z1T@%AzQH~}!I04SB;jj7byK->s3QKJ3pL;U?PgmrZ&G8>H2uBH7!yUz?`AtEEVetp zbK#he@vqoV{!Hz35;08=k#!h9R@N@I#F(IR19mIThaLOLO^N4lX55fv#&~}2{NHQ2 z^dGZ-+Q*jvTjl9EPXXRIXG7Hy5vI3?Mzm`XI38SfxI=`HdX_aAo zrXw=y{=;whoH-;}4P6va^UnSU#xfb;J0Livnf5#{iQeO3@M!u@~Xms24AUj|mox5N3in?p4e$L?! zu>AE`RqvKI;2uA{xpMVOt@k@TKiUDR12%C0)cfCk$fg? z=or4UUSSDjVMZc$zGf|mu1YnwhM%_ox96hw?`SVbo?D=pP#-wH*@7# z9_b~dX|vB|OY%8#o9OEOa@Loo5r@3@oS`X4&w9$nj{(QPLnfw_KMQS+oBAL8${AR7HZW+LvVn5XBq z5kX`#yZCY5r9Mrbj@GkjLA7xC!9e5F!iax~h9%2?>t=t)6@?uhv_POm+IxMt$#Rj} z$i07;bG`$Zh$CYKK|Yd2&rltSsr2l4GV&Nl4)B#i-|v=dn4B?#aB{-4n_n^0@Ir>5 zSemzgLNsNdFH8#;BoAL?E2LH=K8D~4aXK4&T;BE-(8F?*;bRU3CY4{h5M54K)lfnx z?W>!#0lCl~&!DO783n*YNz%O;izORF=dHuEU^MIbFv#Eou3ymxtUUK+hnJicvp zYbK@V7UGvXYG!PJn>6k#8b;BRaBd7uXBc(m_o3p(1z5NSJz0e*xtN-|7>)SIYAv=; z8JpPI#=>uD=<8nIMsC_PiQS*0el)A!4YjH$%cY~ffk~-8%Bg1cKQwaxT>YIno_pO|f$$+p062J|k;H=dpcnFQXl z=E}slZFI-{^1xfR3=L$-(HDvCy4dRXb(z>+?F@Fq6A;pGv?-v1IAqn|!;3ARi=$(? zIA6kSvM^3qtSZpOXu#LuNi9&$n%%tVr+h(5y;I_t=-iwzaZUeUfvS%Ph zs@c6-^5YwuyYY@*C1+&f))tV&4U9abY zGfrp~Suh!Y_PU(To2Mk%R6%tAXzzCHB+jQrFA8>l_ET%{*`m+IJ^SI(CCa|>!dh^o zsxtF4Pp|qedd{rf1m153?FHg`w*0!;_a^JxxLGIm<-G!w8V-OW#x4+MWP=M)kCf){ zFqHzjQ3P;wc0)-*2DKbRMrMG)Pcdopam4pvW!%Xhm08k?F^IjjXZCY?PSlBLf z#LA_B78C!L7)z>H{7Dl|dAQLijnJ+u=>`AnM(9$(DBXNC3tMW+D%>iZUD=OiFE0vZ zu?m`A;awbeJJn<_GWao84Xc`WtE7Ttv|;&9Mjrnuj65^jT6N1_^?QzY9{3U? zoG{t^tLRxk%QE0KY%*xc_z)T-7NdG!>eWNI2I$t*@B7gamG7<$8fd$ZiQ+m2hdzoK zpXyF%oA7L%eBC`wivF$a!X)=PV`y%I-$wt1ZZ@8HdECqxqpOewoVm9XtPF<+BkYWL z4`NIY-EmqW2dRzL3j_OZ3M`HRir#wDdra%pI$ybe)A;MAL)C{!Gucnqb}G(4zOu^v zO6H)V&jGVoPJlLwa*G(YbMM)h?K^2|h0Fkm?N!FEE!$OBJnXhm;xAAhI)6kE)Y{ZU zvLNp553mXC_^MKMqjDimk@7RUc2BK0Z6*}2Ti@hw4+-|Goh3C74+uTs=qE{65;A@u zRqF|_a!P%O*o;fu^$V?P=aawy=Fftf_YLOnk|Qz678;Y2VvcimTbaQD4QJ;*7e_T@ z&rYPAQ3+X>syJhoeBgFrhDZ><52Z-6e*0m&8}(DQ9DvHc`?zV=-z|yO)eYp6aKQyMx>qG!?NNbVfH&*fzg^|` zcP=BN+$uI_Y?OG6E#o_{t3|!Dn7fzt3OG|O=cq>kG__K%Dj#-Gxp}JaJ>G7S|WIzLV!9;W5AGyCZ>YFhSyym^NLHE z%E)p1E-bTX;bq=LQ_*v9fHVQB`mU4rM6OhY>~6i8j`MDT)svcsR~oJ-6gGK*rrwLM zY-6KzslJ=qYl9++5@WrY>Ae{;y3zLJ{CikV@yz2eMHLzRZ7>pV!Pn2>w@zx_aB}jb z;5D3kb3&`AcbdjmDdl;mH;Xr$nwE4=kL=}(Gg4_oYFcjw^upEo>j)M%nOP_gU(z|q zovnS{np4d?>{+%;`zk_0ZFk=WaSnwn2-`W@EdVF@0!AJDt2w4qV#U@G&-p}kWDHbPhv$GBN|1lmsIF8Lhe9|?M(d!e&e*9&Jc11}IWU+;b zKTu=!#pBDwWlZ_{UGr9p4@V3p3`SP{>@Cf|BA~!?z#6BwscS4$#0Hx8x{u8-Fm3Z5 zyt!4I4?l}H*r4%@qf)DvB9ukK->sF5S$S3mYWhT8IAoW6LS%aLrq9fTDc+w#Nv&w( zho#9kUIAg0KNmYpgrpjI7*4ozYjyJJ9gz_Am4%Jb`=cU(M zz&>S+?M$IjfDs3!C^k^8noNPZD<->$zKwpqQN$IJQeT}_rxwBa)(fiGFY25#f}(;F zNH3J0(DQYfUDb%qmHj=Td(>gF#*IVoTfr(tVAfMRaT-G`Vqx}i%pPy@=Vb|(T?#Pk!YW_?ozx<>e| zw6#N|Qgepd#GfB8$2f7bQ-O7{|F+N0F3ug#NB*>5P=O5I=l zFiIY>@kBSNpy>I-W+XULgBTR?|qQ@NTAZx%gHeuGLz6#|9zOU zd;E+sT1cImqfd<1=>9FEGrB(xlP{f;f_BstF>eh_{l;{;Kx~&7xp#xNcTh~v8Cw~C znl9&o>2`L{PYZUrWn)Eq;sdZ-hS;ijgq?`(#HArj_o`YgG6~zdw5Kna{?( zi-GD(*#}Y5R5aQ~{-K1;baI!;rak^nEaTf~RxXz{L`JL$hvC^N&pbaRwSVmFJ@mi- zmQo^oRrIFFr#vgcMnbE38#B3LnlOdp^!Pl&Fsskr9+P8Oi=#*}g?Kh5y+U-$nQU%( zhhzteD!@`I$to`IWKG}Xa@5%I9BeV5udN}NiuX?XHv`Ss@TD1-K`nr3QcqSHS^YEqf_GcyPd`Le@h= z+CvWXXwp^bRcsVYPkzv7cWwa0Iyy*w=5LdG2P*k}+r5Fq&wiD)o+G~K!@118U&pNh zHRIBZv5%>QjIZwUT>RY!n8ur^)PD%T9&0e|c1>u|`VyaTmqMwqCUmzIAu8|$xe>=E zb!D<$aMN0lm6;U|ri)EAdc{nS#oz}%=NZ|Vzrbb@f}_^=J$LIGFxL9aVEMw8rJZN2 zp5Ds&=J+ZZS7g!b?5x`jTzMJn2c^*wZSHqPy2tDiiaV|-^sC=+=~$Ust$aep+zitG zj(`T%)jA~faZ3xT3zNeEkZ+FCI2MZI4=e_iRn* zr4Q%5bhkFMbH!(-gb#b~p^IIeI|JLTez*A7aDO6>7A^)BBG1m~RuJ$9_MdrqAxOdTmn zPI~=e=NlX*v>hKUb^n{NT=;L%vZJ3nw$mVX%0^{GaFcQk0y=y+Mj9Z@kY*U@^>1i? z5Cm&a8KUhUNhbK;c?~6r2g=AfZ-23$h&vN0-l~b*V2N&5G_N%Z|FccVOB@z-_MP0u zufOkBP4Ohe+4ADu-P>A)^h}7wfwS7T1=hia-*~s5ir;PuQpdYfkSpNW?sSRYJ*Egh z7~Bb!LK05>z4%nH5jqX$%`K-9|0Ii%QcrroEYp?rf|#F>@ZErZ`iqiIV@K<$ij~z= z-<-U}rOhAkwUZOYv7@8&@C93bSbD9uzFRLGo7ANnO<5Ti=!R^oW)wL_h9Xnhb6%Kg zrh~xJL97P>`?{9$i4b0@PTIqapZi+`T(M|)L{lV(KQ8>bkf;*=Y|ey(gAA+_vrg%_2$uRzfrdc4aXUiHYHFqm$&D1;7n$fNF6 zeK4T4Fu`o^|3-U`@@O!8aq{^3*?WxK);{PjcW8^U;lmFpiIL-uQi9s>N76bgq9f78qGXt{~F0;N5g>}i|Y#v>!e_pOtc6JB6#C(I({GxQsK_`uJJd=lCGzQ&qlU)emrY|`-rj4}s;HM$8G7KJ z1gOR=$VNbhN)BH&^xwutdCth;A%Rp;ag*jh@536|@URg03ecDP_n!<{{TUvf2$7?1 z=PalkL4Whcll!3y=IeD`ciXWFhybP;H84SY1zs)K=Xw!n{2<&_f z%wc{MGrXIoX)>I6P!q`}ugDuUxi7p1b;sY|)(QHBfW9qJ&_q9vS}nSwt3TbEbKv0i zE>C--c~8hlo>!xx`Y`g9s?8tcU`>A}j}@A8VB-OV&c=A=7idbItjEjano81T7J~{# zR%Nad>)Ikh1HDM{#crI#gy4ox@OxIg%LwF9WpOfu{s&4$a<)<;CdcOO;Zzba)r7mT zU{m+RSFUIbrSuzU-+sUjG-t=_=g1{Dp-9KXBXm2WVAS5I?IH>o>Z<1M#}2go4&uPOS$V7j02I)54+ zm|Ak&0*&VW3B$`oIc+Ry3C4Oe#YbF!AWWE8jTWGHG1cMYt7qMqVWQ&7+5|z<#=QN` zn6o}Rj&zQaWDAX4rh~`Q$BfmEKR9kGUrPf)XxPOc=p*cvCL(JGPjJ_>b+M6VcU@B4 z=|VuzSyu%v-R@KdGfOt^qFH(ZP{Eigf4izX-SUZAu?Qc5WQsXWh(< zoEK}#BddxyI+p9*HAgF_0O@x9{VZCah+dV~K_8CDlR5DbTecL9>TX}Hvp3aoo(!3I) zdDNOBS+mQCy#KrTZo6F?y+mhdNI(94S2i|*{tE~$JO|>i3R!-hN?z3IEv%30^z^R3 z1Me2{HxDd|;3FnpFhQuZ-x?EPAo}dvgCaI`=fxR=4D>>xHY8OqMCNd}o)Etz?|u%q z)}CW;#5G-^7fR|MF)2mRugz6LSG4_ew<+F1L5Zv8iv8KBn_dK?M18IGXgYU-9J20r7iV<8D<=XM za=goNDUkU9b{i$quZTf1S7)Q;II84`slLwK`ZEjyQW7p3To$+Z(lek@)N{fKA2N7j zj;wd*jB`O}x!BlP1f07~Ue}+)rjz8)IRI>B@il`7>R zA3r~!ayz9aLdZmdy6xg{j({ar!& zqLOIJkO6fXyYir|lQPZKpFL@8d^ANlK!@DNesb8jdMKo`{ZCY=F%7-&yQ*B1JFW<$ z4fadck`HB5d4F?HV&^j-QsZ}vLaqmR7dyC=o?j&W_kL3_Cc(_Mkw&kft8z02E3llL zzs@8SFS_sD_TMf)Xzz)6ie1$6xU=Qk>BaFCt`H_jDWza`K8%MZgqw8ZDehbPl@W@&5i&;Hh$!%G0cYjukrUX`N@f(R zii>e5+1c6gxd|#MKp(ay%Y2i&C1nx_HJjW36x6bpbF_0_X~7h zxA}PP?CzfM`t>*3;umel_CG+^7*qXFM&Z^KT#TMWc2vvn`hGuE2dFUYFOwHcAW}+& z!&jN5FhAqGmHiU(Nhg-A#fj_ylcwwUAngP*_63X>I*vWsZr^n7Pq_i!z`k z>CsZzytTXTE$ns#&<7ZqLThhbNU8-&z0ZrK2kCVWWx2W$GEJb54EfZ3bxwgl>n7f! zdsp6yZj`8FRsAwEK!byJ>QVD?#o`W+17Bte=*tlH32=T2%|%rvTqjj2#+} zMtU0qJ3WWwo+bHfaIiDXN6vgJ;g94myAL+;Ic3c(@^LM;%y+czKVApFcEBY!&gmfY7kny$!R(MKqE}$Z9kbO1rNVdF$@`WbS)Z`jT)t0q)It3T(2y*K z&eO*eLzB%1_ax!sF8adV1X4E;PY((VY!Du34!|{(W|+xP`-cC++3Y+GupE_sIqpj6^{ ze#hx=Ks^}VGE08;%?H|kV{8l6nET`QV7EZ2L}|LKZWlzY-ShgCv9a?Yo7et=8cqydgPb14H4+c{TDkfs zCXBQJ4Z?!i10exdMo!;p1A3#Lp3*?z)Ru*0Zy1v<7#@bt_(#*ypUep?|Bl8J@@mUh zrGq@+Q_nnx31TXwVo?oEEzBG?ig-sM8 zlYf*7#x5Y&17W?kn9LllY9)&bF&I?5YDyok10HvsMx zHkI)%>Y&t$>hC2*(thYn+N|J-SI$T0Zjp)w{V2|Oc+Af#f+m=S&IwHW&hg`SER^b3 zG~OC4UJBX6k_N|zS_z7e={pe$FdNH4P5Hf1Zt$ZE5$ri%i~X851yxd&j?fcz{p&4d zM=IudPc$@n6jdcxg)f*r@mDTbaWT#X>XEcj$IHb$SVlg$9@tbeC4&D+X4F%Bnr$ha zdo}}KUNeq2K|Z#n=_YvJM5~{EuJdZ^BRT#?mUrSgIV4whedk7l4@B5tn~utDPFN}} zkxv-QPDTAC>tvf8e`p+di7)#(HkyS7=`ZkT@KvfxD=GJ(qznGKus$~gmXls{$1x&I zSAz;-aHaxzFn;ZqZt|U`Q9lp*lg-WqQ);b9#+&vJ|JpxZjPN740%+S)v#RfByF98+ z2s=upAwMhHjkBaXP8WvDQ=4CLY;O|5RLJMXKj|BJNW_$+K}tX|_nS!(5Ej2}XcwTLhd8^93>lLQIB%pas2sO*?k z+$?gi!eZUabn)D2&9X3Y{%7z?n8t~Uix5R2E-binzh3lP(~wg%nj|G5te1?onE zo_#Ch@l8+=X!724=huY)AZ1U9*6OhE)ndiLx(0?7h>)H5=1C*}Uk4uE} zUfdI1vtd2nF_{vAaxspoP;F$riHxLjbdolhGG6Qs7wIe6X541yh9%I_WFJY7Xtq`o zf^SyUZ+{wByfrMjlKw3n7){Gdg9n8;cAABYljg^B-()znhxrs_6F0U#F*@#5~b_z*XcR!@G&Tj&JgGA5gc_lKM6IR&_L{iVR5 z!0oDM-(UH@?H3>6W%om0xmQ($#{p5h>(Z~ZM1vdhZP0@zkhBkke&HyG$$lsUBc<9C z0KwD5xfc28MhT}6?<(I40T5fN+y}dEB3eQmrJoRd>L&3$WANaN?wFmGuh)xMo<6d% z%e*`gTvRh8pNTx$-kM#0yPNdHzO{RbBjMWZkJcf30*Ee}yJ7r)#C8yTggOU4;``NZ;_$YT*s=$7n2Y%k3 zgpKCfC%33zV?S<(|7VSEtEmiOPW?22=~zG$%>LDpm4}U?yn}?Q`5S!uJr(u4%nslo zYn;j38u|}UYQ*bjPA2rVLqZBuIYE6-%+=lP(}{ZKu>bZGZILe_!_%x%m~9@a*s_BM z_iA&Qfs)L}UcfLt3-+qIRnngxw~}b_65=5&e!UMNCU-m%`R@eiW&xy%t-Ac!rwSuN z*l%SM7>D7N&cXZg9sw6Us|y$Fr-6n)BnwUYaXyusWilT6p5Jo;%^EwMjQK=x!M!Q1 zt3CxI`5Y4+?5@2Di(mKmb$Ht53~n-=Zf_m_AXd5Gz{1N|9-G-CS^_2RLSbFBGt=Kk zN5snpTtHBz)tM8g9Fr&YRO*e7oT=kjAy$*zkhiL3_bBEQ)a+Exn_nc#m${qjX#4-Z zt>O0|q43eoJHnSTAJ9KG>LcUaI-sj&ido6Uji=4B8!?6(GGL~7R}XmJz*PJXa^tiY zrfbm?+_>`8Q>(?V4B+0?|3i64f0I-wJ$!`u<5A~^|Gd}ueP6>(RSxa z)$Td7`}jH=aU@LKaCZTDPhf)EHEjAM^ZNO?;>+Ddcx~{5qnqKZp8(z1OYtjwd{St1 zAkTc$I;w^LX9eHI;%hko7)@CZswa4s47CoL6G+YO%2&)KQ>#^c=&&Hy-d;NsxKOe# zl{@QH?Wu0qBich>7#)<}m-msW055CKNMbyrt2p>~o^YmWf{!+>TmwY#B}O43_zsK4 zYAv?K+Meu=PTahVN_eO%%pHIp4w zwo~L+wt?$g*k13CnqmrUvkdfUeUh1ym`laJKA!ob zOIwE-CN-OUyq{P>9Aja*bP3XawDyKhS!Mg`)0t~9zP_jr+o-#+2h8RmKTwo9wXe8d zLOQQsq&-?2KM<_YG}}B3p{a$^(+CgDCG)8^BmJO%o+ zGC{p^f{c{Y{mX}{O4l+;HhWgj5rh>gs_|Sc5KJQbp`{VV51o-AhAU}zY5t%(P7L+r zFP>w9u5@yxv3J79#366NzyGEBO=ClrwP2g zf}r@wcz|53XORWf0bfz8D_YN2IziQf?fSe>o`rT$15lL_Qw^~;QQ%<$DoBPL(3-L) zsweJ9_4UU9S!#PMJVm^$_?kw5Zy}E920&3Zcn!I_bnAgL#qOUt!$9lcCR3u4v)0;; z>%OMyKxp!8Z67z33T*2l44u#-Kb?6@CNTJ}VktIq-Qfs-S7k9G{K>rFlW<5>)_~8R zVca(;)6qc*)QbxL!XR1%{iq6@gfiopE8yA$1KnU84*{fw6*Gs`HYuqm!}}3 zjn`uQgqGyb*Bq%qGS5M2<5Kb{R*j(~QKe2!Qi}8KlDi=bSE1NNL$s7vMS{sx1526M?`-Zgqfw~RMc~DXY*7_yz zyw{Im)Tt5AwV*2|aT&}o$wU8K=p)`ewwk6yh}8a|0=&eob#nIw4OT!;RM((j^g6M! z>L(**kYP<%{v%ztd`vus)R1SSJ~cQp;9LUxZX@%4b*2#9)hwzTHE5Kvd(_v%GwIc? zGK|jlC~<4=pgR=JakGy=xJh(`hG0vH`}>6ErE*+F)~`+w>|58S)G7lzm0MYJeY5C5 z>VSBX@Go8Sq1>+~DW$c!RKMeqx?R>kP_@V|$R4ln1FB0;si>%s*De*Um6Nia>_>|3 zHafGvh|g*IEKaKfl8Ux&1YV_?IUzSxTYnA0(H?LP(Q_AVZ7H@s{Ccw}FGrdcC;|FW zYyGp>w<;i)+&V?k}X;8t!lGGpL|?AhsLkG@&9(#AG0!6DE-P)C?=GLYD=Mq0|256; zfl4q12X*iGD{>}p%x~oSUWUMp6jELv+G4apkMyk#Q}zeflECUf#6V~nYk~H_+px96 zBwV7Z&# zmQF1SA%Tf*ocB}<-u>QE+Ed!h;8m&600KpU^t3h16cm`FL{?=Ip6O3}hklw3|8HZW zq3~iISKWRzR$AEJ*xpR`0H&vZzFozjWHd$BgB0KR9HvAb{j2@6uq3YeR<@Yl*XaE# zt}uJt{mzKU+e=_i%Dns5ui{-voRBG^NSH>Vq4+YTZkmBuLs}-!27+$mWvG92+2elPEkF$mw+Xu32*0?KLGB{PBdsP0xZC=D+fYU_?TY7_bZI%Z z1mi;+3hdPG%6)qygmtgbpdC13df~(ZkYKOU7--whbie-!8#{wfk!Fs1xR+i@W z`u%dVK6m=GMTC&vVd{b1 z^}taDDJdz5*T5&pZsp*uLC6SZBH20ZXiPX1t_M2Ni$ANlKXSh@Cr4WjDK5bGi`Qy1 z9q}eF*BX?xB+t0pTD4IxdY9R={w6|S=xVXIe4!qQomFr7W@}|J0*Nd3nxM+%8sti^A#?k-bZTZPMnm4{BhSKmmv^Bm__D|}xVFSr?8=Vp<8}5PfoS?;n15^V!Y-c&^ z&-?=I=cZRny|J6E2v`!kuVTU7OHFZQZ}KGQKUHoT^e#!i{5xZlneh@KbaxTeui4FR z+6O3ng-eS?ffkILqKatb(c-MutCs?)61-QG)Qy6z(fGSkIXxAYVqAZBcCc7?(oJiw zt4Mlg7OJ>YUu71rjr$uA6$F}39pR@3?ay!Puj7q>_@pTGzZ@nY<8uwS6AI` zt;qb&KPcU06u&>dNM1VMMn*c!oGV-I@;b5JYMf89#2J>FJk(~$5m!~7s~%W?ZwUkt z=g%*iPHJ!UrSBQAoXVQ3^vF8G;Zk?+)?R+`hAi1OWcN1)&aVY?@II!ycCPPWdfSjv zqZ7gpd3)ufCe>}TKoue0zSC6A)YAd82b~|p`@y!+8TNa;K9F9;gHI|53#rP z#;;#BN<1%hJj16UGUAY)XnYfc*6^lc%SLnM?2u|_0XO%bZO?;Q^329gkZ|!Fxnsu4 zHrMw~;Nju!=dj(Dg*WL3@gAbf6bjKbH?ZZo9e+f1f=34Pe(&3YUjfU}wAvlAgH-`s zyH(fPE^)C)(L53?ugkQd{9qw9*Nnftx$^l1PAA@DIWHb5j(K}`USZV)eVw&027)JL z=$ra6ZKN%g>moAft>q_d%=g1oBR>n%yp?Us{74AU0O+E6V)+e+JX3a5)YXxqhI-BU zW`+P1YnP|}^nCqDSZFKol=RGDvP42;*97C{8J+$zKF3<j5cjxqY`2_OzpFR`QDuc)Gj3D6cI2|nR)Tvc`L{3 zJB?g&z$DTTm@c?v@;zW?rA9eJ?mu>h>BE~48{bu@=q`SIN*qkm4lf=V)Sy7Jvs0t7 zsR9CsxVsYh)#@$YyIIc~xLJgAV_mTSA$WR z!FY^WyU#++92*O+UXd&wOxkw524 zJudnSD|ud?o{XnBvggIdc|-&G7EIi4(whjPwKB6sD@ZVxmG^c=2^BY2p6$EM*x0_O zYdf&`29#@RbgDa&!rlQchY&Db_Uyy{W)iaHG_m8T0)Vq<{M?kQPB_{*s+$EM-lr?y z2TOyS?2HmVi4G`>1O`?QhT#X!$R5MTfXehwDS=GxI5|>oi7xjs()13TvaQ&@#ojUm z`AON>#^o{iSfiMnSYz}27_}w|*8bk1=$F{6U=@{cY4qUZs4-x#xur$WEo=OOshn5X z?T#l`5%ztXph0+y!(C>5CX`^ex8?@Yxx00E>}=9;9HS@XA&c9dy0=FhY7dP7g+8D7 z{6Vzp4v?hjRx8xiX_spqPQjDUh9FRrIs|3WMK(8K~PAc6yZ@&5%N4pF+Vb>HIL0)?(tM|aMEpoSGb)3POs0;WL=E( zAoVy20SkL5UrZLJ&8~!9U(fole${XHdlUw!z|PqgWjiRsq44)H^EQgn`ZJ<>|J194 z0{W!r37|=i$98vuLR2L>OOlpqG`daV%4Ldo0LXyX#mZLbJXu1&76r-*zWTxB<=y*7 zpM@794vAvtKc7$>CS{h#J%(4unsQD0wWfaqFCI_N^<#Mke$9`^b(LNVYL(_o{;ZHX z$a)+L82mVsjo??RrYLpzl*o3YG|-FL@$m9wM~W`j>9p4-Xxrg$!HcNq&X{Xx^fokk z$_1*FLiaoyJyw1RQj!ejI^+{V+U56KzNlBK^{27~T!a%gj-pOh=zD9g=wv3M7~zM; z@4d7`K8a9C0UKkoO!$8eo8<(Rh%n&UM9ep(S2J2VpLpz=;B7wY+|dG;k$gDeoP2S4 z2_Y7*9o-B;#n+xcH8+=`Mpr28Zc!e74zh*Vr8nl3!JoX4p4bmkt@w)SJ^jR4Uo0Mi zRuDk>#Jk#*4y0q_e5P2Z8I*w=0H{Orgg&Qg{D>QC809aMwrKv9G5TzGt4Tt;cMW=t z?}w#!Z$fm{hJ9ld2=XPAMzD*NS7UorTz!|CzZ4vG$ta(en4QfYY_n3tuq1A`GSJ9D zZXY%TYT=Db-C8&%NtuMU$gnB$yiqw|wPRTHvln>dGlke?tw1U} z4r^Hdy9F&BsI3>OB$3AVbF&gYAXhAnyzEIU$>~EhsS|nBAxvc;f~+H(n*5*GxYfE`6fz)CV{X&+6956>@*LjabKrc5LplFbZsW zHvAuSzvaoV5Um8(c#Eyv{Y*~Y{>iO5PBJIF{E6(y7af?{m`SpHY)3C7FH$bFI`g|m z?Kjg0R`+&U{4l6jb5q&g?y!aIt;U(sv%7vx7d!U`hrBe&JvB#Y+WO;(eLJzdTNpM? zS0((z-yUcNUV@hs_?WK%zizpj$^2{GGHI1J%P22D`~F#10fBlS|BT*-%)_vjL$wFI zPyH(DGyShJtOeQJ@~?n3SE1E{%gui)?84y8>g5%R(uX87c`~;rBaYm#5Y{nxMP2;) zoosvl0)5uPiCoyb4|e9C#tH|aopYxtLxZL8g(fYZ#@JiJiYR~G1U9$pR|iqcp<44G}|l8^sflc27#@fB5ut>K^Z2~m(rR4 zZLBz<2a0BWTASKJ!~J%b0v7;q@b0?VpFh~C@zmwd-+)fzLSBA7V@?cwT#H$5KQBxw zv)O>l_wRpU8)r|d;|Jd)tPCjs21j$Na`O}pEA$OffHM1zT*q-d;@=LrhXmkDwY5Ps zpgImdI!7N%?35oukNuQtequyyAkNc#kuAo5Wb~nV%jKN+QVe9Gd34kxYDiaw4gN&) znAc;*<~jh#6kLyMwtFege_!Z>xhWIbKk}1WQ*ILmEWJP6m=!%37Cz2N(@O&p64(|| z0yWY^*-H(IlX~LC+`4rdcc&2rl1su^6v^ zaCr03G%#ZywwdEaoEH5q&q@2?nO3OEi;CT7!S1Wkq?z#{+ArQ2vwR``;gy%6(f8fV z(@o>#A*>UKhftY=GXyH}?j3h-1<}Txx~hpkK33ynfIF@KZ21FIaBU_8Hd}Uk7M>Xzx-#uvm;{Bi$8#;^{V#SZjhVma% zD_2Jzm7RKe8EFv-T9DpN)Es;IOq^QaDF&pqm{s?&HFU=t7*YWAj{|=GgntV_GiIUJ zIyCHFqdzotA$W3nCa=*Sot2lT3fz*KBP~a1*~`VZ(m&O>+xS}5rc86r*za=7JLY8{ ztsEWVSkyiC1F>h9SvxQOI=B_aUT5X2R*Z5j(dYepCE2PS8Cw>icAfsR@Ps77V@q7_%=^$HCFg8k8Mp~{U-ff z22EOhY#BGr69b@0Ug&^8x@@BREemoVYI}3Ne|awX{~(nJKbc771J>4F0 zISyM$kBMA&yfVjnU@nWcGB^v_$BA1!M`sJ1@dQI!8k3VB1ye~m;oz$H>_vH6P+KF}8a{8?M77FvlZ z%S!r8q62~+uK7k4J0{OS^ba3DT@!qe?Ze=vGbc zxM?*_UQd)A^{2ZlwE4_UT${0ga=g8wQrkMSvB`oJ+)-c*-h(eZo~EMaet>cx>-ND; zmG{Ix=Fk$+T+PG9Xn>dm1_!6#wSj{!y93OHCSPcN=xzb02xPY4zQ(nh>a)KP`8zfZ z+^i0pFRyG~=s&+yzs!1Cboug+vas@z>L*lJGSj^SePDoigHcEEz{_Y!-!q`2;Le&- z>Q0+VL&aGHr*U5$8MrSuz}(}x-CZo20gB5 zItY`cJKie2i<;>}D|(5z83(f?Bt0Mc0!77EQ>3Wr!KvR=d8#QmQJu|t)yUAm_!QSt9$mW;uUQY`f&_92rRqq zrgw`?zIpvif>SK~iMudTNaTH!^M?$a-}T%8>_TwS!pZY@Qj zsI}Tg`oYx8RT82UO5>ELTIkN|*-7BVvHiu=#o5JS=SkYd96%k$N7Q=}QkVX0tC+bD zoFb}B!_^8c&cCBhw120drf?AwM|GdCkn$^QC-Zge#;QyUH@55ORz1AsQbLqfpGjUD%|P}{Z!K>P{TRD%45;JueuA%L^{UCn z_Mr;={FLC%G(rl%zOgEmeJz44%d762ycPRSNT-90c*&@oBfusnN4=^fUCKAzEID@n zcp#-Yanerndojn;VlLkH4=@pU`t4_3tiFEfKG|v$o1soD0!AjKYgKXV6|yPJws0W^ z5Zg{Ox2ZV7;XU%oD^Ue@M^4jSBd>gl5q{OwJMdZd1Um3HVshNokC5~R8I|P&YibBB zhWSgxR^>PDth7la=T$zeFK6~01OE3s?lmlC=L#yM6g+Pti=>)|E*i;g;5M`j3@=j+ zc6WbMb(ugkGCUctd{#LYtt9yG=~-|-f3cJhu#Duo+O0s9)8X7 zc6sh7B!cs1X}{sIdt@AxmfRbqXIh)D zLN3FJf7%9#@Fq0EXxOhxih+^2XS2 zLWKPk&;Tc<(}UJQzmU=;08^WI?p`gO(=TVR)qnYc@s zB=H&l)OEwF$V@uk_t1|BZ<=Az=I|rBS|R_8nG_E%l}!lj`c>*mD@$VbKosO`LU%#w zRF$xh35Sr5&SZEdGECiudv=}wH zq#nW> z2-{wbQP6}u19wO&5MG!C28-Wq=)sTv

BW3PFm8&so8jlW zGS8z>jARg!Ns^WeAifL9GWx=}(U6anCKkZ5Nu0lGx3)g7IOz{Pmy{*G2bwk;ivl5d6Wx!T!*SJU z>3MuFr+r7UG1`0Bbp`j9bT?-?-mIx$f4U*+mq!zHUdz_6d6Wgk=`Ca zHI$K*khS>wATd(yt%iu=j@pCrp^eT|lt#_E{wkMT)bfv8swa)LiPYp+$j7ZkAtud` zYvjB)2uF^&?&nhde{&cVqlCetPU*M75C#C>)x`h1^ZU{MK4s;w*#OTaApXuolK8og z$d~aT+l3(;iy~K}!ec{ffE&o>(Ou||m9SM>Bir#;V%=I?HDR(sc5@8Qf(QN#LX zj3BN)2?KXixRzF+a|Yh!Rie$qMG*<8WsWkHw4*H=BcEea2TDLCSipa6BozEdsCExp zEO19-(32#HR1#(^@(>uJEmstGPDYZdIU&!o%N9rFe%<%zB6q*z`jL^;J!s~bF5u^j zw4}(V*Cc714CO2+Ari8NhKEhfJYU1!fJ#T4Y$$a4Pmz2lVmG=bqQhs2n?qAZcW`r$feRkC-NPF~h11=ZiZDx@uk=6C^ z8%nd|bp4!g<_RBdY~9WkrRs&ndsMdG4&nMYeL8nHF{A|i{(YMEYI2HZoi{5*LWD}k zPsP^KyK^RW3f;mE77l02H`r;A&>zsn>?;L2c<>4P4#Zr}zc0kB&2TWJ;k#w=lqUD+ zboVqjGX>Y&SUEOOkgKe++$v0Yb$#`pM@Mu@Lk%e!X{&XqDUhWJ`@@E&_$< zgeR7BvmYb@kouLe7f7xs@Q<@?RX+?SflnwVh>+c0qZYF35#-Q(KNnh(r$w{$9ARh% zf@LN$&RNQ`$F08bQo}}`9jTw5OBtKKtGn}(^*p4Bobu92} zIsT={;R?A%p0-rN=U3J%vBa~0&Z$L%vd2K2YYMkn!wJT#Jc3D9Vy?D6r8S`r{{~m_%B-CL~crZgt_=ry6rzT|ZtEp`sT;n`C3FJ3VCc{gxem z{9RSGVtzj0G^qUeyg$S4O8&+kepFP)rV z3m~4~b*I~_c$w)TK^NPCf?mADNe!Tik$QB;Y)f2AD91R{> zB7%R#Z*2bN-kf&Uf(5#Bo=0h&+3|s(~R~Bql&BD@$ zB?tW~B}d3HYgaQu=^WPXO3p*#y+J+RvM#Y#Xvwfr1!@PmT2F#9troXJws-bK%VSQk>L)~W2Ve=@N#cQn6AJwOnsWyYO3UK}F=vHXG_ zsU)ojUmEq8T<`bX0R26ZMPE9=&dt@S7~^;D>Kk>nD8zrkB`|!<2@n3>E;Fr< zT_E-3+6}#~D8cTsMH@XmT@&l|M>dloM6 zIkJA0$RQpr5D2_ZGHY`W>7oXe+qie{^q2`xXj{zD9vCiL>_c}&s2@34T+Q${);1qg zi+O%Yqjj@MmDCD~LGHeBB;JrfzwnUr&(SxgP0sQ6QfH~Xrro3A?eF*yLc_HDmU=mP zCriXY&_g1)i9A~6A{Kd=TpWIJ;w44QH+e`yH!~|r!K*!)^xE7<3063ASz~r~T88Ar zN{$m;stkqxVQP2cIP89WUu&})HK+V~v(S6Z&S;4s?GZSYSzl%MPO>AeCH9dRNU0>R zP?C7p;*q&@pga2Bd_Z8--r$Ici{u|~-<_ki;jxVszmU_vz_5?7Nzq)WjSSWag*Lg_&KR`USD=n0-i=?8xxv6z!1UmeA-=AviNZUpj8G6!L07lNVzsUZ zObOl`e;8+oF?x#xJ@LiBb>d=Dyz99$zciJjgB@;B>sq;^a$Kx)T0cEnZ<<1#fqyih z7Xpat1&XzH)vE{UVL2k6P0p}}NompX#_!4%PfeVJ9u9UJ{5rT_$TlgO5&oSKL=xw= zRSarZ;}%tE^IqksLkK-JeXY@ZJ9LP(?d_v}vSm%fP*L{c@-O*v@?kvhKA-B#u9|ym z)!Q@E@9X($w}PeT9h(4Q#fsl+Z;lW()p0=mE6l2kzD#NHXyoTtt1*k*rjPWtev!M> z;-FFBEcvK?y7q_A6KZu^PByq6fqvz>sU{^RY2{L#|a-u zGN{&e)-nJ?dpN+G`E1ozXkWd`ESpQgJ;C|Y*E8;^M*esRQZB0nUb%qH{&qLTh$=H4L29A=KeH{{0WDavb%SZ919UFyfUeZRuzA_iArqOQMgyflT1ts?o$nBI6i z{?hXo!X(q*RVRT7?do*AK1RM#!5(4@f3{S7wn!NFvQsr$JMycb~6nqOL3w8rmZ7sTE~XaRc8L=nsT2$}yKx)?({sYds3* zFHTjzII=)j+7F@5Hijd&lg|i7{)S1Wd~rm;KWQ^QASC`rVjfO*dBJC>N~I%~v+e*N z`wVt+_a(m8L07s>dD2&RNU*$vHSx{H`+faF@IIKRE;d}+*Px-VSk$Zj2W$x}!Iny9 z=p<#(zlhNK8T1AvtVIIPhDhO>^ow6@Y-yj!e$|gV_jY(sTKyv%Sk5jastdJqcPFtj zWRNup6j#|X(ue&JQ7R#kGOa&8VXZc)iKsIIL6bB5Vl#1RfoS=2|4WnX>0a%{`GI=J z(yNp&8|i~#RwM_(a-ja2aIf!=?A3{Pbynjp`EVnXt4kQQLP_m;oa4^%-rD7Ifl=$r z%4sfnIrwcQ=HS-PYm*|JLeQqD(PW-2Qd3dMtRwh2f-ghl0c;qR?5!njMzwC1-`>y} zVWzA(Gj7*;r(5!XQ`ZeK=Xce_wrm4EX1wioX2;?UTSd1>Yc{w1k)NZ?};wY)EnBA$?jRkkX)>gCgLc{U0_1yS%I%?Y`oTYf+IZ6 zbQj*mZVWZ>biF7JS2N7<>OAB4b}&_#w|C9Z{hBX#-`7PLjcSAqQeSuWjL6uU0NHOk za1#-R6TJhCD%TKdRxN5{+9^FfQ6o#n$bUa)+2X*_*I+?8fq*tL?6vJ$6a+-IEH?&1 zGG|5Ko?8*U@;>xeH@7BIp)5>At!Z-5`qt4(kO>P15Pks$H$8Dt*o}SiRMP_dl&F4+ z13XXjT=@utu^SB7S}bmSZfOwV_gAhD#KS_IesYP z{Cw-pJYl)Mo0T~&Osp1YAPo|1S6Awoo|P*-_~Qr2fvfkgPG;|`wu!CC{jqT@p}9bc z?w79u7l{xcVP##>M=?MuyJ%(dB|B&5imvxc^*)TTl2iHR6{_POpW|2W1>mn~QOn$Ce94z5?(`MRv z`!;sMtS?8LdY=LsTJ?)#Ut5mxMqYdbHvD4ld4j>u0~mlk6U0nHN=SNT5P^#6{2KJD zq3%k}jc?cBmmy|3By(7E%LG4js3Tag2h(&FWA2>y597{hLVCiK%Ou42(s6f>{u5?rm~7Oxorlh0rH#VHyXn&-xgyAx0t!Vg zm;N)P+uVM&o49hf)=;ly^Hb6ixg5hW2Ooevc#h=;@SQ+$Yn?Ot8!fyZrfVw z)_P+(IEV{Prcl~DoKrceuLgW;uaccz@mqC$&Cj1kd}@ISwp((!gX=QD5Dm#PVg1~K z2G^QpOwo5hiH_?IV;jw2h%E|fkRv(``m%^FRYC8q9JM`t6D6{H?ArIX&YU|A>a>;$LLT7D(ib@uae`5H49lqy{Tjb%(F^^vxY7s$gDtyJp6;gl&?blYHC*Akc!`+ja zvrXf3MIfPcR?U+3Xv{j{&BIv8^8ov0ld-$m@~d!hiy=iq#9&-cyq*{2a_iji`iEkHI{?P8vi8l^D~ z{yst#EFaf!MoIJxpv4n`u2XU=KYnh5=er2lqtBUL;QL*yAMZDm>-y+oi;yFuNy}qr;kKNmtQ?PIz1-xSEDiW> ze^2h^Dp@eM%#0DXn@~fM70PVfAH;l#z&izr7=ak(rf+tu{$>NI+OP@@;@+12z8dtbf|tx!lDny%r$IkE@A%XtM0lfz$1azc_JYq=>k^*2(-OTq5xB zrRmr$-Osk~cvZs@TnX0Ic^muB>X&9tDdUQX*l@|bxd=sx90dY8##9e=_a5Sxrsry|v^^DEwhTwG{?!G$}S*95U`L14k3Mfys{a$Z0r2 zA$!^bH`{7jA!{1;mRg-)uHHri^pO&)uk{*JG!F=>x0t&>1#Po4${}u-A_-uffRi&q z#M*1yBQFGxBrCkeT^{aZ5eUub;PP(bTq*1bGy9RqGI;&B=>$#4f0w^VRAX;a%=g~b z4Go577FLyKnd+f29jT(F?yYRC2i_@b=?&NNqI8km>MkuEQBAFz0(K=t3c(N>mB;#? z^xyTU=Fn(lGtSAbpzlk~ZPtYQbT*PpE>_^b+bh9VX1qf=E#_~t`d)Fq`V*-;Aj(-? zop93#rZlXtk%9}by(M%*w`#Jb(jUEww1I551*rngPLq0@0R)!p*(dRIT(p6A-!qEU$bxlIdR9I+UP%)!MbzfA<@!Y=?wO5QPa-t3vMzRLQl6|(f$~p zwy2V0lgWElVRul|baFZ(@oxI|VnfcW9EJ}i3J z>FaKKwie_@$v|x4^5d-vE4UIb3shoWHgZ2KEkzem@JdXbhac6C-`bzP*QtX;Q*|86 zs{oUS=C9gcoA!$GH;hcWscCauYS4BEtMIH${FlhRyxO(2Ep>x5ey@GS;*0eq=NQkf zzOr`pLP^sTy!{zqBHVnIW;r=O_i0dzcsCft;3s)`?<${$>1B=Q3{k;WaZuh~ayi;s zZ^9$NKhKvb?+C7&cCNCSNk)BKIScBpIj1dAuvmNWF z_|N|opUy{~SIXu8{Qt_NBkt@;`*eBZ>$-(qZOsp6HG#P3i(3QS_eJn&KT>p9cR z%`@u1-hNaXA|mp%i6Y;hnq8B&HSB^RXB}t31^35Za6JTlo;+4)(tdp}%)@-YRlah`%z%^dAxy>6gE%PKAw@7V*Hp@)Pc|zn{jE3Kcb(7VoRN@Q zypN)+o#$=-*`TWG)3I{I@qhT}kba;&N-9^0WqKngr3UrjIDX+UqFW!lutxr-5k>kg~f? z2l&39eR25S*JoO25k!k3Z@#70QnAaDFGo%fTRE+l^HAJjN3G^UAuacl(k0r)dVN%# zsI)S3-C3kpI|n7Go+-xem}!nHV$YUY(z=$3^vK&UZ`4>@FOJqL{ms{hER!xAH9+ja zom^cf-o<(D=*?g4>q1r^V>N3wlmtInv9hJN z6wqq~*0)L)_8$TZH5PugY~a(B99NZnY{@4sx73pq{ezv_>82Me&>s_hexsh#nTv*s z;by5jLzpc`8&mXLYwLz`<4{k}bnFKtBW#*!f6bu&9Ym<`0q`2z57**Zu( ztHw_KJ7XcL&7`C2`LvV10CZPFNc`2ryh^kn>^)HpS&VTciUCR!P>wDVCG!F#lUN2awFkxSM&YH zk^;&(8D-KL3{p}4RvFTQk(V4>Ro4S4!#ORBA^?!hR5E&(IBXwzwmH|Ac46sbHF|j+ zN8IM5_a7;+i zGVd3Hm_HjQELwT-p(Cv94}xr8%%+U8Gu6JA8@12l`=PlW4gHZH&`!F^`I6ISFR7$b zZ@#0&*)gak3Ol3?3yJ&W-%8T=dvg%^|0NTA<-ca+nsx1|IYm-tCH}vg?~N7D#Yd)*pg)eoHeN&B&>?kj4PJj&hVLVL*mk- zySqMD(qE8SST68V7m~aP=0q&sg$;TD>1xS8#z%l$t#WXVSoP~eeG7LDosG1=@A%1J zYSwMv#~QtIE%8Shjw!jau-A8{RHp{V@%~zU;*nLime|^P0!@ z{lbcXg^0f$N}2T_Y->5sPq_^7jYqG$sND{=I6casoZ}>=ktgsPiNBkd7}C5VP1#Ud z?^WS|f}WK>24=>2m6G+r9*v=(kb}9PFtrL<4+UYFynKNWn30XiB;{_l4V&QZ$wyS9 zjF6qy^^@Hqh)gTisz&nUylD5kcbe}v&NtcGyQ+^bvO?^**!C$Wvh!35APH{&=ujqsu(C8$&c(#t>?}l$(1acSb+lTY^G5kz8VL@D_hQs)Lk4N3A7lPx zVOH=Alq6Bz?bO!>NqMFN;X)yzD6oy>O}3qv7%QbjMmGByBPw5ZK^FUL%wo@=BQ%af zQ^SBt2V;KFG0>U+T6$y}$xUrz2u+Ysz6rDljTh#PW5in}QTHy^Znf3bteg1LYi+t; z$@qOhEeLKd?ND4^v73$!wiN5WN6!noDt~igOQ8b)0Cws9M<=dUWhy#p2P5UOl=dBc z@eu5uQ@ocOx&}^1acsM`h{HFN`#^T z{x#xcgAP+Q*=pVf{qUSl$Up(pXk>PN+@x;s^e3Di0V#^lduhG8NUWTc8wPceN&XwKxydYKsGoV<0q?U;x?R!6=v-mD{F_=gQaO)I z)OYV2$%5?XNvvHN4A-ZxW5rjC{eJW#!zX(|T&$pS&1voQd*O^pam_Q$FfB2UDlk|> zW6@2h&Ta_4x|)M;Ft;@?EcZveu}W{gxXAJM2eg!yK43+Q%Zh<({ew}D4_;o)8~a?j z4?EaQhQ{2_k7z^Y zKA$fNxY2K5AW=+XMh^5Zw6bS(JI>1bGxDBS%4B7kwWgXU?%q83i-^^Dyj6l@o57CC zu;|T~gy{xMYfTbyZM>F$rX6czq?U3*-o&l^bmP^_dnU)o;w>#AQkwNT6oA%V6Q~MC zS7QT<3@UEC%gjv@SCOz$ewQg1hw;C91%~j%Kv+cB;4;LlAkze(UdVl^`JF`J-Utyc zke*~}6y(qMaS;Xv7FI2C)sM1aJ>X=wXYLPrq$}b8)os%CZ+Rooz}nxR&BF6G?ZfxO z!j8Se!cP94^Pg{fMegquU!aOFJWkb5je$1t`M;dbv$;Ad(R|NfSwn&?tAG&6_9tRNn(Zw%fE+Z_ z+^M#{>9s$j7M?|zy9xA%`}l#L?Is}n+#aj8n27`y-h5&IU^kzN@{L|&n@lj5Y8-3+{VBkhC^#xr`;``Dh#+a5~?r7 z9;2oPYYb+J+&5p5Pj^2a#}gipD}b{yv2T@G%1BowJ-6c7XN zWX7qHy?@E*v5topatlu1G2u9%% z6~qx(u`j9Lbg)bTh<@*~a3sR9M8#F4PXf2z}#wWAk)pYa%QTfRt z99GZn7aJ>{kYf;4HnNOmXMD)v9X3rfTj(IJ%`W?%M{ibQ)7I}d>KChf)mRs`#M_#m z%ovOcGNnn4t**_o!i=P{jQPix{I2n^g0z$#TDl9>wFc7&D$@u*lyg`YR7q+%F^0Cf zh#eCKyzg`X0!|6mq?%Sf*qiE<{=sjbS(G5Pvv*eS|L|`9KwJAUQ`&(|qk%I+j>=|9 z48nXRdUj~~vUtMabuI+jRrhFR?p6K`4cAnCxj>O#fxF1yd~I7~9PO29!!h2^6-R*K z>S2^Q<|T*2CM7#A!EhqYrMWd$D_7r0mj*ZPg6?~iCWd?H(=v!(*naAc&!qo$otaYT z8b7D4=bXJ!?m;gIfk|j)p5Ct~JvH2VdYM+HUA@5=AXY3W`g^O<;7W~h(lw)<2jtcoE^6%UtP#Zp^SCy7 zchBo4%}wUiShA7IV=}=S#OCey=SzG7Hr^T*^=(f^Q4V^;ss4uD-_=3p9Ss2{CcUGv zv)zDT|4>t!q~&eDuzQ7S=l|Jv9Pjt3pA#dGB2U!M(mId(X3o^lPrc5MQE6_H;<5D7 z?FAps=C|~19OhqPd3}mj&a8 zVCj0r>E|8%4vLgBrNyVnvdF&7L_jU7lFQKTXNwGJ^BK-&4uESdJ4-{>zS$rp<^@^j zK#0-Fd#uys>ngtuiS@H*8;DRf)%Ng z$Dv;zIwLjXIfnDrrp< zU&hl)R;7NO*@~x$&dt=R@^3UJKT)d4OjSpL$2I@2`y}{GaJ^UA>hVw@>%jwVScR^++4{4{Hw+dYEX+3%_a^ zjtzpo4@UEX?MNLX&cW9;K<&m!wS2OWsL4Lrelqf`p$WT0ov;G|YG=NL(q(^VMs49yJ2+v0U=Yk-y$dohjX# zT~R5N%<;Ev!oiy+@=4NoaZw?QJ6hV=5|`_0xI^>JU0#Iad;f>3oZyy__a}`hNtW)WRHGQtqyit)4f{Y-{mi(Uxqmc8 zzD9%A?jCe3vEJ_f_e-a6HH!v=X8sz_DH04yEuEu%n{mrfx?Y-;akMRGbJeXVmNH9? zG1A*A!V@!W+prn<=$z8H_>>GVD-Qmk#_?eh7jr&K|Mz21E7$e~N;_?}S3lUe4o$NWWF-~mj- zkEVhaR+7(7FW5Jhc%`c+lXdK=@VY3Ms2x>NRC>Mw+xox=M)Y~6uO;jDTlp~d2 zaJX)W=JlRvbg%#S&Oj&nxBj+yHT&*DqE736{gSI45JD5L*unh>VdmRq5-rXFvJc#k zGOk2H*7@LwORNR@f}1ZmduBz`IHEEob9PSp5_b>>l(Fg!xUi6fc#ET`5d%ax=I!q1 zD!w*rvo+ZYi9VvM*;^Z<3U(PiHiIlT_f4>$N}*?Y=hsfJDh(51wd)lZr-~PU?awy@ ze`oYx0#{nG11h8JtLexm7@vW;E&z+1X=>^j_uDVEUrWmfW*-jx=C_Z6iU{4&=Du&l zw4IOziA%&?Kb4sozTS-pE6OJ`6$6yqoHPaY*q!m9!1vK7X{cXX#j~8bhH_|^0cA9; zFULdB0|EY~#qqwpfRXC`eVV(zPOg;g%*$kaO^3tgLm#cD<3*ZG7XD^_$1gQT)ED4w z{WLXk3(XXbFQGHfuvodh{3)>5^vMUl463EP?-|BDHyV5kbSu_ypVKC&3UZB1N)W`=LXs_=l zmZ8xkNUd2?&z0;lp`3_#OIRXPX^-el-5w?+!rDp%`kGZP-^$p)8YSce64f2|9r%$C z?t*i1*KU6oFRQ`pWGahxU(pn@xm~-~okCHS;5HEl|Dse=44+*cXs52h%$xnoH_-!j z?{~~cY}|<>q$q$Xa<){wg%wn*n0j` zP@Apf&XwB?i+82VNS|GkrK2pf|12c3B>2~J4}Q39G_7rTN2r3v;#(HAhI@AAqD0io z&GzXPF8M%>P42FIpSLO86hIV#F&FeOSqqJruIMy1vV z8ZTxJ>^WY#g%?em@fMHhO6B-{!XS+keWd{lPq5;Qy@mZ|m)~U%oq5Vm9aj{!&DRIz zm=%>fwSn{rw^!abk_MGx(ydG+t8d9;AZi(Mo^q_PjcGIo!S?}usn zQ`}P`UMnXZK(336u5i3Xgl>3ch&(x1`}inDhii=dl@Y{R`|EQK9U~Y=#jP5%vO03M z$a6e;*rDUQV9K8Y8;NGQkhrz|fF+*!M05CYxxS`Ucudu(VYQt0|8GS5zoXnag(@Y)<8w71^sfERP_B93lB2K z`udSQQ7MIw1KKauuEcWv!d-<3wy}&{eE)BaxhU!4JHNsBSGN6`E7z$Ym5(GN++MZD zsFp2m$EY%JUOA~arBpLBf!cg4XRCZ~g3^8=9tx@q%L(XKSZa@w22W0A#_1a+{6n=D zlp=sg@c)g(eg%2b63DvJI+!P+D?e1A&OLcVWY7v3#bp`dIG3qZVbo-GeA@N`U0z^| zo7NNWt~00}Yn<^+lnof+3{<7c2-X?Md`L+ViO$jok4%yzc^Uv^72$fiP*$$tTHQ;& zgpGi^iWtdK5Iof^e!}$gS{2VKVw1mkSUqBgEbu3-Gw4620wp2ol0{itNnlBPejIkX zV%%}K!hgDb0X#Em&;J|lJU+PC9=@2GIa@el>$7^Po-#$gC#6w_nOH9TJuCAOe z^DQ*%>iQUUb8}T3?e3qYCC(+ZID0?Fpx3D8`xc-)toE^bg9Q&tD$WJjGO-_oO|Ab& z(Rl|#{r_?NaOrHP5;D#?LNYTm4o42DuaWF6d+$9Cr$k1aQ9^b{#@Qx$;e^rl=@gVFL7lPsy6C0r}BL1BQyq+G&?9*=!*`YfUJaGMX!9N{({vZN0uQ zmZU9mf!RK|1Ob71F_RFuKGaeCRn+JMs=+9<$G)VBqKff;8XdezrW@{97td_13G7{4 z@h7`uO9y=duR`ibd3t>XHwVZe5tYmBa~{tfj~!3XhOgsfRy%jf$l?qaFGHM}9T;Ic z?r(V;j^6c_0?h9(C+Rb*qI>LeHX|N>iXw|QHq`&N&R2Kx@q+?cuOIP^!^zuh)MV}+ z^uPNGS_6Cl8YD(3%;L}~YYW5Fs%{~gvO4IfmAgwB(RfFOpNrgm_~W_ZW3wVdpo4|! zaJ&sb>b5)f$>V9#7au*=4;A5l$O}m-zvE3*>C`h6K^=3x&fyrYw*_X6zlr*>nVfaw z(!rVq3|8V|wQB1|IeBSB`LQegtFzi08H;$ybLsc9<>5u$hlMABGSCXMz*&fb#;hIC zRAexT1G0Ov8>VQ<{a&nc-i6J)BDSeGBh%l1Kcl*>QG-DVGwM(^Xqw&Yj{8A)&Un7% zfiJjax8;1DH6n}*pmzM0H&`j#D3n-XS>j-s>2H!N1L1CLeM~M(oQ_Ys5ukX@F%_i@ zSxh;T!;vW?= zSjase_jE>x0QxHBoqGTaIqa&*m`0k*tKD9t6PBTV!u0l`c$^m&cP*-XrB_G-E?(5y zz}kyR6Cd@jqv*;*IauVDr@}G|u`SW9p<^OKsn$^k&a^+pZ_+)2q_|G{Eg%rv!p%X? zT_2;yEC?<})Lu~){^6k% z`KHIhAk+Nm$?56eS@J=Vu=w`{d67_2obEW|A*U?QdaB8?L*&(9=Q(MA@uvue|EVDt z=n*o>o=A_UpsI0kt@!bQ4?%|7{>Y;djG>W|LU}XCxLPzMw5FjHZW8@D|3*AKD)4rH zbspbrFcfok0f3B@2LJ%j3uk&Mbz0z>-;`@ThdkevR3s-xrhD5iW$P(p?mX$V{^7K; zlF|%!$E2q$h;a^qR|CQDtYF5Z1#Wa|9WxRoT?OU_BDkLt?jXt<(7)Y}agR48v)5bl z#w+&E+#*ai*(qJkL?$E1&bA%HxBAb2IY$gRhfxeYhm<*w4t!hxnrRopu!sP#vSx7) zBSl(k`34r{CMD~%>b~jDWI*&;5cakJTSh6_YQ`>aql{>ig|$Arwj?Gh`nd`PzV524 z4VH0-_xGfGH*0pC<28R(>uirYvH<;H)L#1@CbEuySHkTUxaCVNmjO>90%^qh1m2wN zYcL}U>yLzhhpyOBzy4Ubu94VdUK*O~O$|ol!PD-y@Hw^!*FpWP(^^F(C8e-1$>Sb^ z4fa;lo2Cpx?r>+=`QAmK=Pb{YRoTF5nI1^_exSr^Jy8Lx!x8_0zDEwE6WC(PL|KjX z{4`bT7GERU&=64m79ivaFe^jOc>SD{KxhBTkshni%d5Nw5XDK4)qtR|RMWR$?eC+t zpB{t1>(x0=lej-EId?oT3nYF4w29T;399gS{=i_!V7U3<{^+020*~)&m7$ z+KGK&^frsGIy8`m3@>1yf6n3)2(a=UWNW=Qg1PoxA(DzF*E#<)uZ3}Z$$iTJ7JxN( zyW>Y!DdM%5ajT2_7qIG!Ukrh~R-K{B$n!IY9z1h2m=i^C&wjos@Z#n3p}SI{%3LZD zSERijgGxun=-+sQf$i-I^`UUunSCyr)FyA@y3qZ>fVOW?UOu`&EK%pywDAy|7pdcsb#-Hi z$lILx!G-q77vbK6_+EMfvz(GG5D1dt+%^^0!8#dgoBPx&o;uCt@9-gvB{ zxxORISd;Fkct(M}Ds75`2f~fog}c9E#`{Nwa1vDa*P>EC0UBo3fU3v8swm9>3j*#x zo2Lw4_0U6fb+etR(gms^r|{^NtyR7NjF?kPkq&#*Eu_;l=Qs2hzZe98Hb z+)ASEsu_jF;sxac19}7MY(3XEt8+b5-ruK#-!dGM;$xcX{&-0IJb$cDF8;rsI!`@!zdQXeLCZ*Xk8=plVr z$5z_EHs`FrO9Z#=Sh3I*(eb5wJ+GJf^N9BWwgiGgVq|k+dA^c#Qn@ATUd*E_B5!~u zEp^K|w|-Vb)qnyW&xRdeqL9yx9^QYP)&??*P36SyPf!=OUL5r}U;ZKFk}Zde4hpl} z4Il}6_AV$Od#vs1WM-Xm9w=fCfPZWnH(DKC_I|I2p9kMmx_29+GrCI7F(?i%HYJt~ zN@lz2IZP*|4DYZ1Gn}feCwE8#ajv z`v!9wCO_T0p! zguFBKU2q)>6o6x5ZuV_ONr7t);3T`a|9C09c^T=?8LweyTs_i;5fH7XebNfj3(q`Y0)HEJI6mPOwzcq+zae zQ^WoPF^7jMhG{=ORq7d`)5U4*ZH~1NnNNXP3d33Zj_EW_8LA;w^e7XQ9Hihg8a?@z zckB+A?fTRO(J(jD2EL&R;GR!*diVW~p4jj3z$ZeL6%ZG>8q}llK6P^w9i^Mmn6ZBY z7i(KA-qpO-*33yB`XT@Q>TrW=oH3pl0A}x!nEe&f@03NJ=g$5Cp00dGp1!xHenMIO zJ9$gLQktRAP)_0y!;ykct7{!^Y06&Wq?i;gD(*ce$DbKRoUqX$==zn7IY?TaxCY$S zy@k^iTS3O(PRgt*CKrL4b0Mo4BwaIpAPcl4uM9RrF9b#jL2e{|grP5@b|QQA#GnWP z8)SD(?~0yQbW%SW0R^F#P)geD{B%<7Z0TJTI^5`W;mN-MS|D@bui#0tW(H90n(j%e z`g3!~Ykc?icmPlOhvS9T8`TI{l%bhe_B2+dZ=3P=Z5<+ECb3LwhQ4PsSl<}7ofYwf zKpE)B(YNPga} zZ0eX|!yG957jDdHX7bIbP2(M(jaLIhlVw;LoWAyu{PF?YGyO^vOOmUv6p{&I|E$xi}B^i9;8{iFEO;8$STbBP5 zX?Aw|DAb7LF59~06&Yx}*)p*O_|8QUov0Lp!RO>0YsGd!7^^pz!^}$(K}dwF|8d^` z;4f5x&O`>^_xPI$JcPn=gcFW3cw<3iy-I(v?X0oh#-`4^;O) zWG-jr&W}2M%Z6sSZrHVZ7H^-1|Ar%6Ry^<{aa_N8gz-dwl1JmBT)?li9A29)X?+=G zc_u5wI}j%`s5c6%+5^XrK2TEPA#alDi=*rHpW^KQQ;}wJ-xZ$1@=Czltu=kvCspb{l{wdbc{ zC6yN~qqhngTJFT_OTvM>4kippimv;)$-!C><;n^hw%E#GZJ|>0$_8A?){mT{m7CG& zTDCd+_!~tq}Qj_~nbC_UT7iYKYeh2pm8wY3Tds{gcnc4e1l6>L`rf zOJ)JMTL=kcYOv3Y$K}vSFS5>9@#5# z(Hh*?Jmo?55|S{U z5z#nBpQIYZu;99}>g>D?r=giT{nV1Jo%uNDP!%o5nxiKQTrmx7M@ zaJ9Vez%s!YrGzA3dM7^}U&uClF=;Vg&y5bk(Mw%j`8R^NI^(#Wy;i-?37!UjE;9Ug zHWeT(jnFG!QY5^^fZO*s0ENys0>Qj6u>3ncN}l=f?aKu8$Bc)l^u#w> zKG5!f>|Zx2(7(@Yhg;y$inb<1lp#xG&cg3kl5IC`!f45tMcv`$_ijK&TNC=Mu8vyC zh_;KZ%a!4XqX`cwh$;9dhxAW=jiD)TGwzh(tKIY2ibgUEY&qcJKj0?(j%$8tTEy|* z>93nw`s|8unCerfLHWDuJ&NY8{!E}3DA+<3@Qtr+Z-#H>E8mvR&WC>jiH_8}XdBKx z?{tH|6j12uQb`Ge&01aAw$D2ITQLpfa|os6piw5>%y-wZ`0N zaJRa?o{(zS1?l4H%SXDSFrioWYLZecFW*bt5iQ3ATii|2@SoUfu=fg)JG{{%Ej?O- ztx}*b`Y3LuS30;JtnG}s>%Nt-|FnV9sYqw+D?{0SrJP(C?(m{7;Z3~AOucggg_YUA z$blut4QU}T`4z5p!QL~|{0K^iad3zoJtNP@F&%a5iFW8Yu;YN%jM0FS;JdRs-1)4! zK+t?irgp8%1#HJ}O|)W*5fJ%*joR- z`4xh#1Ha!=RPwIC7|%WYv?&E&2~fUWAG3qh;R^=-C$;q^vK+| zCJV`74EtTxMD2ndRDt-a1GA$QEai{f^=&}aDLD6uAb zFf(Z>6m$}!^C#Uk&G*=&yLRp^`UXXC?kP|vCin-f-weV_ISuyN*{^YV} zeQfLB#6`qIn9b$J=hHBqKxKCx8R^nKfhAJ`~~4i#EO7`D?!)vEj!D1(OJ@Uv9Z@}tzxNby5357LYJsX*nP=i zkk>4IPpu*7g~0swV;EIEsIaMZ;<=ft!RT-Lso)4xBVWe zWyB~ex02Bvuo2fAbm6NS3nLuOjZ#>@U0Q*dn^J}J5PNm%%1qHO3KLg{{_c(;o%{i2 zNqxB}vf;Grz3W!mJQ@dRLo0^Pa9fwu$VgD;lA29-g!UE zeYF^Qet6r{!)oiO+pm80+2&K~LI5Tkz2}vkMc!Y@DSzW?OLF4PuF&`n(u(XYO_=Qr z&EX@k>WopRqO|%gdVUtI9$1`eFUpk(HLpPDDCmJvY{v;(1V=c?Yc7L!v}uBwhNTn* z{G!$T@xA@+A8#;Js0k9qPpfBBH@_1k{tejs^1=MK80Ugg3CB8eXn@2jLMZ%jVskuD zc2E*K_-}%ww|TZgaD~{X23sO*8t*HG)2ZRcaF$Sx1#0HZ5H|%4LCLq#d~OyTfO>__ z@GI=MkqvFO?h&=DlF^Dncj?w$n(|i5dec^M*M1V_i5gi7??h94 zKM{6uC*k=;*-Y^JvGFN8hE0z9|r{aXcgXB z!&V4X*49e$LHW9SZkuhlR|rF2%*UGVi%_1j^go}*SCzla+dmKIkvNe9idBkIv-i1E!S|+^L8g%^IG;myhg&3;cywvuqp`2GuPcbng zL+d&0HL!-y(@5i^adii*L{TQ!(dP1GH%Rdh=q_(>)3@V1GHH%|+@(jmtO9B87eHmJ zTlwwiJx!{&p8~=Cf&W+xCcb@0S3}a45>Uszz33e)xAsFzt49|JQtgqW(>+nlE@12I znn!z$jk0|6jZ3_q5%f7uf|)8hK3)bQ$gD&rIj01QS#_Ll{N1};U%XgvJ!T?5>5U0l z2_#-RsW79F&HTvI+n!btikq$HWX!6HlE;O!d*u-vUeJ6qaJHf0c>+1*&Q13EE*^5e zL_2NMKq$l4G)RQP2KP5oC~^@Z!d@h=R3G#cEax?}F2`>pVp7tZSpKnhQT9)ZnY|{* z(fVG%F`Nmo1Y*wIeR}v(Y4wHj`7Nr4&$fC|dfd@-HuDr(Zg?LqDL~BJs_@~e*{9PF zA}xp8#)1b=#p#EnzIAGq+9jDv01?0Eycr(#_{xPkMRj*YCx-g^a1u_13{$W}YOu&zQqp&Tj^%Z4hT$|Gzcgh7OUi+x% zU9Y-)XFz{zLn66!H*W)B`xlLB`!q!sBok);T{6w8c$PVC^MmJ{;k=g#?@AwtCW+ear|4yqaa9`JZ~~(rIs2(T1-tBHOzmF zl^QXZ`d1x#-9WY|`Wp*K185DEW}t)mcsbJ42RG_Wn9bc5^Y9EZ1W>u(L<-Tkd7YNjOHezzOv0TAsjv0=1Ri3NeG+}DF>9_&B8=YF{T zrnEWm`hLc0%hCSn!NEb(uh%f%#Amas7rLJ&Ev&J)arc}XEG;fkd@pn3I4I@hRQh-x zhd2K!t}02sE-W8 zR=`nKmTqg({Dt=w`IO!dkqP|8SfiV&3l5~ulY~^n$huE?`+e+= zkTjjNvATcR;~v7#BLgtpXsW@n2={fm@tY!gdc4xgm|;AFg}E{zdsx>VIJa+3tfEHr znvKtF>ifUfX7L;QIsD9DdRce=_ZZ%L7vwWU;XxedC~QwTSvGvh;2loSDR`av>eV%X zR2;jCCuPE5_3>q1g3BTUS2U2@qhI~s>WZ>tjf=wjQn@3S@k#b3_P_qWbRRIj9)4X9 zUfg4QknSxRW{Fm|0(%?IeluJ#)#Okr1Bln(vb`s!kvu(fw2 z_hdpR?M~)}L}c*qtFO1k-Zz|GuK&9rKb1K4xyi{whC~e(9ZEnA9tF({9`##rHQT~w-NA-A zqnaQFkh)w`R^Gt;%H>p`1tC?`RRdW;wiSi4AcZPlXNggx8zz=z zh{VFXyJNt9%3|$C*=(3!VXo~Nx*&YFUmWLaqpcrbuf>>(2}%67>BK#5O{dui>dM=9 zscb&lw%{*(ffShzwQBTloN9gO&9V4YJ4Ore+jJ94GkQ^)!ZX{MU%HqvT9S3OGkdkN zyhX}y4}V+CnNpL-J6$S2Q8q~_pqD_}CRfz_+qJZ?Y70|(azpWcA~@t;n&(RGCo|}G ze7iK$6Zz{bP{x7a%3&1%_J-}caO3-fV@r`+# zTK^yqH;BXyYkG?kc4h;`9$#j@;-!l5LXNLZw2xd(V2bGOUjh7ErGCvKD#7Rcc5NYt zP>1J@*Gt_yJ8uaqXWVBqvuMdT>kM+kH4xv*zm>dZ1JF*>FX_N`Q~p2+#i!$zX3lFZ zrw0`Y@*3F9KBj#AmqM#OdsU}BrmieD1F2F%f-< z_Bp7k;z<8-B4e2lH@wS>o==*uVYXSZd8fM6#mwG1e@{Ijn>78Ux^DUZB-M#qEPhy( zdLoR!WK$;4DfQO_D1s%aHBB8m1(M-gcyOCa+Lb-+VMRrGrO@X`P0wCK?4f7`ERjTB zou1KP1G8CHGmb-RdwHJB@d@8!{=-eZB)Bq3klFG#^DJ@p$;Sj346&%K%$SXPdFaDAv=DLyFyrOGG`7=jgsJKVh%lx{?6xIfbHCec-qS| z$hW;bN}F2O(PLU)HekGp{>wy?`=>K#Qg^-*=iB z#QOSyaIzi8a{yzoK=*vm=lyk=+0Q~GtD(W8%e}uRe$5a6bOw3*HcoMsWJujYsdpPY zM6~L72CR=Pjs{;GEtX;rJ&0VKBcP^))`poc=V;-}R8l`cD?^-9oJ^)={z6iW6y+lD zdu|GY3^Fe=j`cdaYYg{7s+<4Mxr{^R%$MY=z2E4ZCq%@A?yaYQd=8y4^)iIxU_ z^44ITu?TbYNMr9QJ#SQk{K?vjN(Kaq3Fa^AfV~I4%Z`0#4fh(KSxLLU{A|i}O7zJp zZrfwv^e?O&&GrftTK#y*#lsn=%63CFDu9WJ6^2;IklG1o?d^^AoE)PtCyYHpHfO6E z$NrcpO{+DJ%ptgKS~O~F<=G3<135|@p$n{}*KaV-`KH}qjz+C6OrC0p-jV_Rc;n>s zGE?DLURsM#)Dr`F?wyXazgx1AL0JI_io=^~S0{^o)t7s@t)%%a@0{AXh`zj*4b#S& zSUB9cePUf}D6=SlgAc7xc=pSwmkY@tryh!WatiZ<6jA_}MzfGBbKz&z~3d(}_2?Z?I z#YNmnA$JKI^W2MsC7bMz_>$KM@c~6?OXHe6qO-g;gphzns@M4Mo%b< ze3Ut5=J}lMw%JtCd4q`9J!NqLJL((8zW+5H6Zw(al9%fEKf^lhHdIuo)Ot090R2AJ zL!>uDk@HUr9o(_!vqy2pKXc)^FyURz3gPpJ<;4~|8etHubh(v`g+6OWYh#fVw% zaRQ!%MjO}&rGr9qLE9^Nt7&gu-oea}wgpwkv;`Z_FBVJBE#BvxTNLJB{=J>C->A}Y zb$Qu~Zj^1*svW}}DM#6&`hoM*5cwycc!L$JWZoEZ&ChwL$pKu zdR*Q5+Er*AycZ(Ip~l@b6UA3mP~ob?2?2yXcd4e@Se01YS2>@6&yDka1m=WfNf2RL z1~vthy9=Ka7!xEd8n$ZPX2Z!XVdS;)aATP;e@F*Sz9*`>epnPz6KGw5LsU1-|KuM=FJkr z7ViiUr|SZA&zpKP^jh<7Qmk=vfRw4nU1YSa?7HKN_!yJqX2g;DfcN8A&Yg<0n{(W2 zslL=78$|7X(4$KR5Q{Akyv9=)Qz^SrWKezOLnqI6930sV5(01*=2CYP%~01u0tfMj zK9{SW9gfJN$}U7;qnKrTP~9w#U`+xqiurZMGZInt9_N_*xUvIvFVPgZltEELjDN`| zk&IvbBZv1c{%Cc%uzI!2bX9-Q@M(t9KJd7)^L)kFv-9`h=Iuw{<>k?s8W5$q=nLZ; zP@oq74w;4+@o#s#FHmH8`|JE(!f{L$h4wcT>bnx=>sY@MGH2w$w?j&0nu|YMmH8ct zLu+Em3G&`fJg!LF#HgCP9D9I;|1|G1p;Z@}lNIAks=V&)dl~PTs->@pT+qx z0FmMA;$k}H zKj|aZ7`P{a0M$ilQ%kSz<{9k#v6*z6&hZ&AvjVKki-AxdE3ndE>?;zN6g-&}8;|zp zq1|NjBSqN1?aLdCa_^k0mEQhaXu4M>CP_d(hh^PdY8$j_3-&b~QVBnI@HrF9=bk+M z_e!?<>P+lX1f}wRI3bN?6s;DW-Y8&&r7xTv1bGPR*=|g7 zn7)Vse8M$OR`1fFu|zm0cTes00plK7{a&-69B(ncJ%z{#-1GY5|@`*sR_ z7RIQVTl~B>o2pUm$Ia+#Faf_cENypFuH|tkA6s`K6|u&qNnwHl@RMYqcj6Y{9|+c5 zA}UBR?ExgOLwG~zDZkxHI>S#x0vm%??kVvMe7kV|pz;f<#$IKz7EPT}^MoD}Wh2n> z7o!Ot2=jA`&+L1|K?ks-cegWgLF!}<8=Gt== zC248r&l)E;1fT{(r8tI6hs<8+ex5`|;%e|fRW}~-8?v0&Lw?WnGEG)1q+Rab$l8w-mumyVW1HF_mwa|C}!CiDHnM>-$8L(Nag+J)fw3- zwY=x4E$=A63jsSyZnYCDh&ZVl@rOV6j#eT_E}knHFcN2a)l~2V+jl~#vuK*>q4P}Q zN06Jp>JIxSg~#i!Z|FR42!jm)~YHaz2jg}2r6JPJ~Qj}uZb}u)yJ}zs|xK88jBHQj7=hD&&wu1^lDhAEflERdr)AVA8DXnA1$#P11Qu*E;qI|{VS)|yW=?X zu4PkB^WO!3*>85ezRUa55#GA;z8`#jZ(NacH#no8yQBi@^Y}Nt3NtvVzPYas{$}1~ z|MhAn@MZsRT?V33vdYKv7A(2{0Hx#V>B5!T&=UVByEgWi=p(9YU%}Yb>o^&Do!Z9v zCuMZgr;y7ag~(u8(#%YloC?nL*reNg^S|LIISY5K+WswP1&+cD|K|T&&PS#_;ZbgH z2|qo)TJ8)#SxKi+*;0Aie#%;j}vc+iQ~hl8@W<5e)co^7%bYGXmCo_11Cb5 zKB;m!N$t!r)*n8@#MO|o)SK@>>ANGL5RA#m+O!(##`0%9zvR06$#}CL2VPx;s`Z{fj`=cpy(w zaC`>%5&9yA$g%V<>>*bNU#Ev#4=D=JL**pmm9Q%R?N$0y&F2ArKffg`3QMKzuHOIp z+s0g{a`-%HFKbt<$+$=FVQowPcc#U~{w4M00uR?MMr+4LgKXye*6VyCRHf2Z4QEZn zKF^Nxy*)A!(Xz97iYN7$WyYTcZ!X7tMC+ZmdYVwGV--5Fg(*LFE&VqClEN02Yz=-1 zsKfs|J8BZ6oHzL1JMB_!)~@uY6vkgeUoW5RFONu)**w(Q+Z0V;mXG0ipLyQbM9G6%coOYw7p(moVI^0;>Rt;^D{YwO&F#Cm& zxu2b1E>MJch{4ZSh1-7-oYuTf$=nl`=NW%Iq4;fH>E9XO=V4t@w)=g1Yev*CnO9GZ zkn*MVzucYPfsiNz?c^v&8=0VYIGKGw$&8Dhn(n?K(O-Hi(J9c^H?;0W%lkWgP9}|| z`_72+`+_Z%M-*~nO zI3C)^XNZ>4Ny#@CU3*ih_s7#}`f_7XHn25Km7j&)KvtX7kOxZSTQD`#to5pY+VZU! zhr?C|ga(C8y1Y9kXWI84JdwZ4Vv`eSLWR4-m*K|{in;Uq7VzTF-=d!!zZC@LsPM?2&rX$FP{FigE7UGS`>=Y&S|WH0y4j=FAHhTL+CZQut5ly8l}wmwdW5 z@hYT0*c_}~7Gjb>4y@-WH!FK%*#WBKmqAy*AJC61jtFv>Ja{W<=9=yNzI3QQ{;Wa2 zH_Nl*_tsxxs0eQI?|goT@+ps{wRSmv-_Vj9p}$6KNOv}C#0Bz${Z+(VwVe&B+pn!M zp42~MlJk-I(C0uml$vJe^}cbE=gAWQJn_a)58>7{*}6OfpIBUOFo1aug`qjYLK6=fw)smR->(@w zk>`D*%53?aW4ue^chh+?#gH`r9INX5P;YGJS7@{S*h;<%LtekNp_P|gYBgtXjbGPi z^t?o5@=;g>=`1KvIep|C6}YP9+Menyj%%Z{6hpcM|0k%DdH}-*VpSUBN{r+HkP*@@7$DmoE?`9q2sPKOvx>m8`%*5 zzC@&MW_YBJv^8obrMANXfG7YGp=!v;{icGSJD-B>KX8j_b(PTDFMH1JbZApq1A};y zN44@l_I^YH$wOs0h@hbkc%L;GY(h0bp)ZPO_TY2)p*!J}vkG=$N4`?d9Fg>33DG(p7@Jn_}(Ni@&VOVbfdz#rR@QrhP-&GJ$Ro zOG|wIUNAKs0P#Gxfru7DyKYqxr-+R+4HfK+3AqLTpa^aKm0!gBg}lD*rE?|TrsL_R zzOrD=r$9%b>^0by1*{-A#TpDYpBg!D5E-oX$nwu#`RG_WS{|q}!#UFtjQcd$vrYlf z{?5O;YfXu;6)qhO@Mw2doDoZCqYKIuZppQ{9=aVp?hUqRFd{gyu6!Enu5QfRyt5}| zEc(YZ{d&Fl(?{R&Ff|Fs0ukU5y(dM~rayl%LYdOb``z8*gJx?kM_m{cD}BfO-Op%) z8+wQg!GhiTsYI=6tgEbSQom5{nRr`M$U>X@J@L1S&E-d5MAH4;wf6QF6Z#S~Zskx2 z=4C#n10Vsk`e5Yucg3*FgLyJ)qp!1lih$a+Td_c1N6NOIexCAXc>zd;Seh!m<6$Ul zj{Zo-C%hYfjmk28RGOW~{Hw*CBdrwP5vf>gVP4@*8he*G&4&jR*?VtIZV+s)Y{C(U zM)5!a_)>=bpQ*dZm6b_NDlL|IK}XH9Y^?#I`$nbBKfI8T%wFt0h^ZXoTw+2_H}28n zen%G^J4F67H4=__O}GuDu;I|eN~p0**+_*pl<`sj7_++$j6Dns4`=)|_PpoyFt;Z2 zqLTz)j7c9@4Bq<8Fu#t#mw0g+7s8b$&cu1b15Q*X{~rJ%q=in`HqEOYs7mK#0G5o5 z$=C)D@~W_RbR>85Dk|vr&dZO%B!N`U2uBdM8;_0-PD# zp}~lh9&#OB*;bPARkOBjB|ePRfFYasOES=Evu!Q+)kM`$BA-^k@6(g$>0Yk^|3aoL zCIrCT)-EV5eAKV|neGp*p5Xx2UGR??%x_#=bB1b zr(R6{l-RJ`%}hRwdOQN^MuXQqM4E{K9iLz{pf@I@G3h$pQISMbS5n?D{_oF&Zj46A z@)b~FT*d?5_E6F8L%c%}j6bXRen@u*Zk(;pU-Cp0MIKeEgn9>-ZrFulsod;s?8J(t zm&wDstxfVgc;3ZP+2eP7mq^cLQ|=)0@RgV)T~+@32v8yIfzxi}Rm#;?WmaJ1t$~#RCXMWuC$j;|v(t3jR@Rj>j%>#(zX7yD(~1cyN-)9i-Avgr4}yTfC>V z-E229k~Q|1o&V(6F2h4-3Pw7wPqw}rmVPY1{O-C!?|%Ml)e9onK;}A6)`FiY@dj45 z^)?iFAGR#9p~fv0%#$UCq{*(1y8%dIdz<1FIQpfGT}O$|q1Ri~9%-lcTKs*qdUqxT z+`k{b*D|X9KniHTQ1GUQcSxXHqOsSNH!eGL2X_Rn2{0<2ulRyJ^a6De-#(AJxZIns zH#DkaQ>Qdna+#$>-pXaSUeXowU}CAc^C)Z%pO;#qSU1PPRqzC%>*l!Gp!|+|-2ED> zsPB(S-0S!QXAJ6Qt`=oImX4YiS*=LXnfG;_YNrt zcoXxRlH$KH=pLX`M{mYlzc30H04~Q;TI(e~C+lv1Z@y$ij~Mmvy=B>kqX4e%$S~n+ z!Vk6ZXk?s8y-tM9blx48!C1yP475!#jIYWYo`1hu$itIxzacYOyy^#tj1GyFr z;2Tsf<%w5z^u6TCcdemG?CVhvUyqbAoX`!fiK*Ps4p*G)eg{r;UUI#P!?99zebbn( zJ|N?hu8C9j5I~#+QvLCMvvSp8##a(up~h00(g*Xy!wjI#I?vFiyw~H?>2sCvs~++m z(3+b|)~?UfAl7OmfXP>f4Xg)#D99d z^fnA%Fl^?jK*~36^kc6%DRQPh?B6%D%ES_rwbU|#m%46VBh(5q!>1#X+U3Aq`cvE zLblKlfmo7|2oj_;8@E6jh-U9=h^dKhmG&scwfeWZ;c}qm@<4uokFU4`xX^1e*%~rK zg*HV6Tw8;Rvz$VKApYeZMfoU|*v8g?HsxtH{5LoqrP>{9zgK}g%6|Kihqohx1*Q0M zIVhJET`@V8F19d7CR$k=2_s{EIFLopf}tCa<+z!$M;M@pOZ{4!jTM3fVlLMW$?UlG z&gqN%OHyT<`j3pe3%$|XGa>0h>1Mnk-oI7c^N-SXiJP+H)Qw zl6<&+9#-fsIIbFmsH+DQe->nSiq)BNslElK-Uc^WFYCdk6?cj_1-tSz_1 zzZGL=91D4c=7u5(h7SQB5oVRq5}?qc<5^Rlu`>8wh-wGpH2PcnRI6{33m?ZgFWX6O z8Z8pqy(V1lM*HMRmj4tr%fz&pYv}Ri;f^GObw?8$WPiV&!anOP;tdb1_uX^!Y&Vmi$BwZz9h2drvaz49{f1F29r zkAr$%kzlKhqJiGk{vZ~h4^;HlMn17)nd4(EafcsZ9zEk)Y{ zM^?x`#lJ>lAkX&f$FccRC7Qdsw9>v(xAnkXsO{|nI0C=Of)|O^_ahgMhOcx-YAQ5c zm-^?uBClUL%r21vpz8hLS_|KsFh$$pD6bZ7y>kM^3tIjbZQOEN3E5c27?J9?8A^Zpgn%^@91)cKP8Lgr*u70Mtv<@iFqV zTR{E)^UP0CwQet>7{S?ajL(8D=8c4+B4=Jz$+u03K=ZU;=7dHpZW6`6`aPUkFDOS_ zTb|Yh%NC)?tt|xp{OJyaR%f`md|SuEQMHNuM{eGL&C#f5L}UBZNk{VKN7&-5KK0G} z*!B7K_2n%>MB{BBxC%S*TMJydeyad}a~@lTkaT(O&A==Uk&*Sfu`(R?w4~CE4)DAe zEJ8_@DxT%z>noLa9e5z~ZB4dOmcFb#etj_4Zv5wiZ#daQ zII^8R^$q=Rog~XIa$7~GKhi0>K)Fo@p48cb@G<5B4fS5IojfNazD;Cq8~1E0UMrfq z439?*vuZR&hMkbe;fc!pt6CT%(3j^KecgZD9j0;=;)x+jDllnEyp0#Au(kMqXZC`5&iT4bJ}CX&bA}9mvQm^@De&z%R zX&0AC@;gczLlyrV!{fqB5)34o+U7dKb8 z54UEc63bqNkx#ilM+VvN^->z%2l}4+DcqV5&H>dNcZksOo8(07@A2uK*XD|4jm1rn z5v_3ZhGzi}ncOPS^&a5{PpQWAbYmzg1m*6|tomW-f|_#G%gT?GGc6JQswLdujH=mt zHqVUgxwQiXa!Q&HJKS&ia;`QnO~32<*%~-TG#$ET8@B26%PG{@Rv231=rL?yX-vhN zT0>;dHHLBzgJLixweBPro}ymj}cSp{6*3|^qE zqwE)=^9Ghsh}#O2uUaJ#1Ck33^D9vp1Rifz1W3dfu<*j)8wl%~2~&BEnHq0`pW?ER zL@cRqv_p3~V|S898{ZnNKjxhCTONYyvrORw(N~RFsevUxfjin!_AB6@-)^(ml=PbW zu(!F~6yVNuLi7Qe9?oRuWnowHBnJ%#P6iSm74g4&lK(z)Iji#|Jxz*oDebtmSv0HR z5MQ552G@SY=X@Ql6@+JU(HvJHL14-Pa<|kPxe>1n`*JCxu9;U-4e+7CtIkDbl=F|6Ua}@bd=4 z;cZRryuI!wX4uIypO&`HNT;cQ-?IvE3t3)4G{+C4xI~)lFB=W5Q^o(I=v@4n`u{k- zxr^B7D~e%8NbYjK&o!5F&HWMzZSLlN9VW^pxrfS~QP^DOmP?eOax07^gxo{!zw`SO zw#V7o=ktEQUeD*3l0hT5{-9B@<|#z2AUGubQ3w52| z0Q}%xCsRN^Haf~Zgo_hPjnlWY2Bq>xGCaaNJpv;bTv^iQ!c%^D&V9++oT6XPFsq&x zA4;SGuzY1V|85pG1ewD7k`;fOV~noraaGY&O3|xZBQmS8>2?;jFogw=5$*^xM5Yz z&+RRM1ii7Y-hQ$&AAPnu7Wwh{1C=802^J^~XguRPH$MXv$!kiDufE_~OOgoat(~ih z$VBy#Q1r>pTu)nF%BuKMq_3!f{`lEW;H%76z4(R3+Y0(0||^{&6v zD@&!zdDuRZtZTny6ZiLze8|DPYWTH?@wo*X#3^?V9QmlD`n&D=^vUwhYC};0ota3# zYWHPeozOG)YJph$!|{IGnp%pI4YKuR^|AYm`1+*U>{WxZD*$4G>PO+3_@N# z@MR?BVUnd*%nHNR&#D94Jr*MH$v>yj6D+#*7nVyW%loe)}pC!p|LXcmKa|kv!mRO*OJ- zL5|FqzC=Ppq+Wa^XAXjdXdDHHGcQSkHLg>uA%m;~tC`=)lf;gYlgSQ#%})tb{H{fq3W8eEXe3Rbi?jb5 zf-ysYD?{VX32$zIiC~2VU1MsxO-u6=8nj~#RP&SD@?zpU{Pq0(x~3=kqI)K@<6$)g zBzD>_&M)kxYPQs(DuYfwT02R7V(61(H3)!Ymj1z~=-cnhS?>@N*FneBkJp3$D}D6v zZ?6i^;h6o`&~?CwSRRn(12eqasLrbET3CXIcqOAtaSZH;FI;2Lr#gg^)>zyi}U@XS>gE!-a&wV4?qSP zhgLgm6^mEw5$iS@>^0>ol*XBD;{+?__h1gE6 zbvxIiG3v_gWm%5xp?f*e`_@%PUX%$ytkm&yGN?wCuKZh1vB|>>vNWL_slhDPRO>B1 z!0;_QsFZ40pv+9;mh8tmBRzTl5(l^wh&v#E{LJ4bD&i0fFQ;twr&!O`;h(nbS{}Wf zm#|~nzd7}JaT?%?p8A%`Zp);lv079#sQ*RBzZ`o-#bz$;?H3vBsK<=^J(?^Z7WF%? zwFc@~%xaz|PSV zH_Y-fJT5l5#aqaW<=sBwsMp;gXD>?KXE-jjSt9lw+AeQ-Z&K2@B4{XdbWFURh>pdH zP4s9$?|o__JjK0tb?`0<=KCyRNmG$!SdRFx!01x^>+UB-IOChU*0eE*uUZqXvyQVC zlpFA77?jM|gH=q=)OV2o)ssnSs?t=Dvg?2&%sI4-4EU(hy|)%MIHEqVuz)HVt9t1}aX5l|3^wgK71=4jQbh@H+o6|;b?eL=XM{i&(Hav(ElN0&9 zi&pdM4pDxXZ;VNnK{;`KSsJ#eUPp@=j?IKn^{X6=uRVOHr`d~5SB8=*PO`qvNX(Zi z-r@gtt;!RmB>6eFQ~EW;$bgM0AgsM>OO4o!z>x|ovam53Y^uFI!1#)j>oNAfJdquO zRyrHqql!6FL2LNB;bV;}G0@Kwlat~@YcThG-2DPiYra3z&G+73O^QmUrG|kx?^~yIPoLOc|GLL2 z83PFrxu&+X`}2T<`h(ssFrYi1@iBAFSbPCa&iVkk9`pQ)LrONygGH2axs57>~&Y5^>`bd@4(|6eGO1Q_g=eQM*glgUn?2{D3?o?=6qzHwq7{ zJ1`Zpk8aelfCTpR4H_64HIOsJDT%G9qsC&D!l{D=mm<~<0)h7PY4OGC%*BG!#mBCr zRqrO4SCvu15-F)-=;$l?gxC?f14l$&oU?z4xlNtqhs>_Zhnu4!4Nrd_48F?qhYQ8M z#~z8D1->1%-dmxe3v8Usmoy)Vx!=6edv-Q(LSNEA>%c6&)^%PnFI1|!aOFHKCoS2> z=b6YISD5I!FRR=jOEvWE?UQ9nQuOfwC28zQsF}<|u`z@q@3SPQv$Y&j=yX0+9H7$5 z{Mu$CPx~-HpuEhA^-3OS?5F{YnQ&~rd@maqwE8$_Rq5lZ%N^LnUG9H^NvRf@426B) z*lRnmnu&TgecWc>bP_UCW}m&Gr7tFCFgP)6SVY!%A7JQl&-wpC3|!^JAxygUT(jGOvKd1l^r z-f}B!pp_x_164uh?nzYNdoE4<3zg}jDIIN*z|O+uk9yDhdB|dA7B%$*sT)5}1C=^1 zd@uIp)#ucUq5y(;)pzx?gXSD}MOa$S^0d;fb0y6UPyFE=luNOyl_X>s`0Rr+XakH#k$Qf&%&r13u>czJ&15Eg281xcTUkMkFNE4{Men0yKS>O6Gz1(Dp8{bE^2j zr$aBQf9;%6bMymyb_Ysj?FSik`!7akuJ*)A%Hh5d2?bncm;argpP%+qHik&QC!0QH z-Ft+MLkLa{&mtA1Zj=}HB{zU%(oD+|oQNAuwI{taG;r%B1q12spc<|2J{@0i#g)^L zP8F4xQ+S~aG4c=Db9qRb{vs9&_7n7azsE?Io6FXE@=vrb;Gdtmu5^H01TeB3GMk83 zc#-^sQwpS1QNpK{>zqFk-fX*0#G78VnXJkR&1VC@vRQvrvtSvDz;(|#|GvLxfvlrPBCa4YsxM*B({bdz!hkCL#B}(W zRIUpg9;H(3NX#L5UXx}ba59}s$pQkHQAMsNRs3&C`^w`bWz^P2rLVIs1BuI=`RI^{ z=!>ShnWczieZ=a((r(u|#V2~de}>T=v}1&?vqJ$K4)fOVqvM^xs83Wq4p zy)S?9W_hw}>2ex1R;h6|WXvI0s;QAr2@|8=#tqEz7$(kb~5V&&< z9%C#$%e9H-6i-!rXzn5;LmP;6&e&*(I!W4^!bbzcd*1nIefM+9)I4F*EORe(!Lzx* zQ1Cb2m4;F4KDHn!i9aKcDX|qpOLc`MzH&y?Y$WHTAT$Y$MyZ!&-kEQ^G=O%ztt!`S z$xDp#oIdf20e`mD11c&2@xn@{>DT!8u$n$CNyK`FLVLa0@3#eM3G8hUh71Ow1x-m0 z?7Ptax_AEY%%{wSnbcONN*4vwIB$xSwcR#gO!wL{2yq^QXdu#tk|FWe?|d@wS~p_o z8D$Z;vFt}PDwr1prs2N^{vy>zE95plClJR8?v5yB_~&HhLwT8?=j%aktL zHtWJBFM;&MsK2l zVLM)1=$W>0TPa-d^L&}t0xj3#!+mM4>p;;RN!PzboQVLT6v$NH{zvwhA28I6z5;}_ zHaSg1RS+DBL@T_P_d1YVC!ov?C4A7N!Q?$5S-Aen>72}?6AUmCsx z3VLp9_o!jQ7#yD=KzD#LIu6a%Y|;xcR*cogf=gOSBJh^4)3)_N(;Ij4o(;s?HBH9) z8us88&bI?at5Qos%!g(9vN};g7VTuz^oE%sqc4)e}kY(?*i3QA3NXNXcG!p?RDWgbi|THKc3T3;^+WuwBA6xHpC zgyu}zTA!Lxb|(H;c3YuEdR!BbSVlf9Z8DiTS*NrnQAB`lsG+dg{ia!G2;q)gZFOn?G&-rvGe~gx)ovOzdL*l;_F1ztAo|^G1K{oSe%ghAskc1GlArJ4tEDz9f4XFYjD?)C;6a`_$7s=w}P5 zoo%^92h>{wPA)oA2&1m-mtLeW;4$-(d|Y#r$-HLRV)&I&z}V(oQms;vw(A=$twQ9W z7=t#Jv(ek>B%A%9ZjD&yON^zmI#e4UBxPhGZ3Ek{Eybi95?F85Cjp7@vhdbX|klU`r}) z%1-d`Gfoi_!Wq&E3sYFVneH{g$bE4R^)aSMUvnX-2ditMSYdz{ge-v5IW83OppxO0 z+o@Xy!9*8Z4!)~lr!F`d8vT*7a>jks)I&&ud|(`I<;nf)`sJfiResO4LDxbqF+_jx zbWptnU%b1riG6G>+~o9G90j*wL70P4}jsqN?gdTh%KZxT)=Qzw4}dJ?!Whb5S_#m<`-k zj?Uj41Y=r4{UY9#2iav$Dw()5Kh5Kt^?Q6bfvimu6w+eL22(>_`MAPq4gnBBFyhJf zbUuA;hxkw!!0ujKAxIFMyz1lGsWOnk{XO^m^KD?Ke#rPKw1i`OC5qjH-xV38 z-3`KM$k{ccgzbY#o<)~jlwr$fovGiA3`4@rpJf~0g0uzw6@tGR@A#xqDfVjR?98jU%v=e+5sIa*bX{qtCJK6kM zm>PM{>EiFkyP||DhG7Y(DHL!a`uVmP03-;>CxB=~Lji7Wmb5mhU1e@_%MsvgUG%q& zTL&n1=B3HosLq|P7I#LTw4v-!8%hvOiDg=g9v+4g4SqY`*^47K`-t2Ec!ii$?`TjsZDJx7*m>N~Gw1kIdYy81JVt@gUg z8%2hD*jd%?RWCzU8OseEf9gzw?nsuKn>!IzgZD7_QMXz3=PnZ4Lq6HZ&4=lgNF(y44KtJ28b{;}M*5MpmV=Vi%C^KIBY$J}WxD!PT#A)*8M(!}aoQ_aN2 z&iEYi6c0|*&^1I>I*et(Dpf3-MO8kt{!Q3&kx&7e#Z0vMjo#9f_4W#Sr zd8`Y^5DK4+-c$@j>N?>g9i4vt)^SZd7*g^}_O3~fG@68!VhLUhzN1eKq`$qAR=THJ zRw5E(?R8X#t8EWKOkR`kmGX3daQ&FtQa}5dQjbOq!%b}&JtV2EY22BaAGAG*XAo&q z)>>a%<9h7D-WU1(7U5;Hl^LwQuCEj(J@=lI)cP2&uA*AihRBnC_yn7t_vIxH8^WAR zCzNWOED0KpFK!*$6jDk5`k7RP9&W0!jvtzY!6YgOJ|>pNP!G`KwcEv7lk0b5?MGE<-5isl_)6V2S2ZT|B>&> zSaz!WJ}t41+Ov|E9+Z=6iV=N(!d?j zWX_7ro}iHg8?o+L_w?UTi1W;C`iPT>8rm$Tt;1&AJCtR(4+YoL1l@&j3yM^8xFBS` zV1gS)FEY}S-r!1tyUS-X5yR#tM#%%WBaQaVL4+vPQz{T0J6lOd)LvLu0VRy4c71s> zv$0LDg#OV+j4aG?funA{?a%Jhr!J-%EwpXvObhp6jkL^8{veuIdUo2lFwX4(U?{W3 zyII_Mvh6p`BU?%MkEgJk@}4^6oV2BOdVGSpSl(Dj+Pd&n^pOWTj=k(Lf0k{!XM&ge z+t+6#9M5)d_-yjR7(60q(Nx1#Q^{1>tVnvDLZ#04M1Wr@l~i` z#(34*AH-!Td?d?eY`d@-*a$E?7x~jbek}O@o;0nX&6u1ng2BdvL<81c>~yFWegYWA za7!3*xr&+m`H>D)i6rTSa6V{+MYEuL5C5?Aa!z||W4rzhsb4JBM=f=rD)tI;>dk)2 zv;fv(9nMSA@^VPHt_Cao!`u}azJbY~4TYplWM7;tV89CPnx9nqZ4bp2id3Z1Ai#ZM z-C$R|9O%()ZqdP5G9z~wruj0Xf991enVL+HpW*b`X$YEdvE8fgV|Vd!MxM2zafLFr zw7VVXS2||KY7Q>NTgBx*sm%AaN?WL>VPNg$98(^<_fIpQbND!Lc6jXcoI>(!_U>@u z)ErMTH(~4H;ltTY-PXU$vcA|qn~oEHnxj+<%E20^MCE@g=cl$y06s0fMxYG z1DrznRkm(}BKvc?9;R5kF+U@w?rqO<{X|gE4(x-9GBVV+(So%_ zcgD|Q-}{aMb;Ce!1g3GzJ3KTzVsDq_U@Rpx?4p-cF`)OhbAAhAh`P>hU?TO`4W$2Q zbNPp6%wq5*#g$NTcTr)*rDTRtR#s43?U3!d?Tne-R!0q5H4FkR>qDsIen`$$#A&gm zfdCK{gtd6|s(D9;>arrZ?JcN;8i~yl@&E@WJs9ypqo3`ao}a%#RzQBX9|Tne)e3@f z3j7(tG7}3_e)7J8*V=#8{OjWu7A^}NY=JPo$H;7zbL&?Owbfm#TV{Bs@wmFX)(hDU zd1aMr?%^8h7qE^lyJEnl&+X}b?5k9qeFwkds4P_^z9PaXw6@%n+M8J0fnjmm<9nTD z-eCbJ0vA0fZN~i{WI15C+@6y3DlkjIr0CcbG_` zLggM;6x>y~(`L+?4_XR@G>tY>1Jz#c`%5+1e?2-s-S+h|<|)x)&%?3#9wk4DYp)&RV?fZ*jWV1R9*}-cnk^&9ICJsgIGZ=?=$u2+ zX_H(@;T?~+(`zcmq_c>NoiE`01j!E!Z$O}*=zNLwCoVs*A_Z%AeWf)`a50K0KDQHP zk<68#Zmb8(0#Lyuc7EW$-{fU2BdRR zWp&=_r`r>Q=Z=GIC#Oe;tPD9g`$d;`w4V&>kT;s_$Fp)Ps>tRd41yK!dZHZ5*g?$A z;*sL^{P3TaT0fq*Hc#4lR}bGZlCxl6@DoigGg3bM>}w*DJJ7Sgv)Vpcgb8n)YaXK@ z>V1M4hu7^J~EdrZE>K_7KD zht+Jxw{t)|Ax7ceNGc7IgJI858B~k!GPh;r?M6gg99m6n!=26b9g8BPAo!OjCL(XQ z#+o>Bu)26+S`2KD0cVygs6&B>qoIL8o@LQ}_Iat|DiO`+lyCRt#>>yP2*Lk+@SMN+dP;l z|3fVJS~qyawwHC#zL;dL)qQnfNoAv<n*Og|LcCfR}8Sbv`0oQDUIrP1Z+9 z30Iau-tcAL-v`VwlIgMG`}<$-yuW9kEf$IQl6=A3jVCAuuze>EtHMT^WaeRLMQ$nq zP)2bPOx^)=^1zx$l1t7wv&bR4tVPLGj@nyTioACzaSyb)&Y9=MUw`!{uo{4EI(YB*)YaB)@fZ_2g`90k9 zOZ}0dwOlIa=$6pwz*c*-4;t8Um-DDJkOf!fypYWLtd%#QLB1RD_&Ye$l{;*0dFJa5 zZ%hrEs%kb?v<(C;GRm4A%^L0oV0Oz5;jtm@s%jBRHq8O+Y%%ENd$|axnUcEW?`4fb zI9Xs8FaLl}^f8`yIWR!M&0}h$P_jD=_~w^S5UeY-&r8KPV|e^OkwPz^MdX)!xzO%p zPN~_ZtmNeeM{$mHo#}ny;i_LeW;~@Id=1=7%MJ6$+z>2dkDeAoFQaGlw}&oJj<6^j z$F$%F6Y6WNNM|+CN90U4)vIRCk9RF^NmA*G$AIAwnm#@x;9-B;Z$!9Ni|E6;A&YkZ>A+JI?48)1EeeGSF1VwCvW<_v z2ss~BZ@>9>!ZbL*gKP5V0uvvSS}*M2senFLzqqZJQ}%vu@7N(QA)hUF%O~~kCp>vi zrg=2E(jc>emj~rmnEBJm8)!kbpT|$#V@$31_AM#;JTX;ZFoaI>p6lET6*s1#hFi1D zZ@p_R0}SK`dT}loOcKcX{QFfg8w2rmv#Bz-xzOA=lz*)HJ5CX@3b7^4@6)fDx?PIVgaOZ3+#faS-HM7K3@>bRbY{%m#+%8+>{FJR zN2ZyyOkU^=jJHTUPJV!*GAykY+*3$X!kqp`1MWk}=K`zrc4R=e$|>k+ivC<<#Lm|F z^2%29DQnkx(iSgJy1b^bb!(;2Jo9a)ObUb!doWq|bEnQI4mlk@Rm8Sp4=R-pVv>`a zl6|r&j4tx;G~NT~XfvGL{W5PJi!}-+Lkfb0IBzNinkvd<*4*pfIDdQ7Q+{dBJyJ#0 zVZ@Y|7v{r4YAAlMg^lxd%Ma_ymnCQ6=rgCNDT^~ema$;|eDkYpdcH0yad2^=Fq4K8 zK8ael1uqPgT_ib0pWV0^=!)9;xxARf3rGjY0H$w$QqlmbBP-Nw9V5Rtc9yia+P|@( z$zN@o7P9xV1&yzs+i==2G~rAh;NqlE4fbA^?CBv;%gF3n-o3V%`bamKA{Hi}_iZ*o zKN+eSeuKt9nrN)JQb3xaoC|@pYw9kUaWV4P)_|UMpY$#a7f2Mrq8jZv1|E4xZUmD; zhdv$$1aj*}QN4#2`cLC)BHIc%{i0QLw*d|2B`PBR>c^9U-W|x0?ei>)%y731quJ(5 zG=2ATgvu(i@<=4s=jioY;PR~d0MRn-Q`E+-H>F~B_&zhk>9CaTZH@Ap)BS6?mt_{S z0zTqz2d!7fWZa1g3$xE&8cxZ9PV%4yn{12FwJpc`psUv*xkA$0hnR6k&Z=029 z%B5RLN87hwLohtnu>cBsb^@2IGdrGmoi&b;(w67`3UoIhhVP2WYcF24h~s}d&%L7C zqw~un#kpcw2MSn$In_X#sp5<2lSOysso#8{&2lZ~1#)U@s()40!i4c+{A11-y7z-? zUt~32^g^6RbPxr0cuJFYOZBoRTmxlEst(U+ns{3FE7W$Xj&e_38Ym;vIxMEoAbddahAY)W zxO@QqM4Iv^m=|WM^$n_8tDIg9QGVW5xR1r`9>omSMyvEzzVO(j=IkobG#Fu0rn55?|=PmV5T=TX17@FP)2~^ z>~Z?z9)T5$@GawnvMjdhP*DJ3{8gbU)j;LL3gr8WL^kBPeb*k;WdRFHy@JL0f7@7T zEto0~xVIrX=M$_9PmPh9GtCtIkGiRVYp2YMRYvHb@al55884Uh_Oxh>)Q7C4kZ(36 zp1qPBhGbmw#Ao;)MXh;D@0%yGvynX?4FKat1%`E~-!uN>r}6lv*kTFfU4P1p)7`g# z-yt+!g1GM=tya~aGV?|<2Z6})cl6@H*~tuv@W+v!>MQd%O~ceEQw_G>E-zy*{;W7f zwy8vggy|=j7fK1aezMU$)JP0dDNIQ8v%)e!)6LjS^s$1b0|hyUhsCz@Vzw~?oR^6%uK%7R_bN`CqdxJ!I4UGDkP<59(&{&G*Q(C~qj-j1IYo zUF`2f1p4l^p$ZMRl=OwFc?`xoNTl90UbWNn!|30aVE#61ZI-P58jTr#

L4%gfHh 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∓uW8?<8?4)qQ)TK_n!O1Rg6ix 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 (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()), + ); + + if (message.cloudHandoff !== undefined) { + const origin = message.cloudHandoff ? "cloud" : "local"; + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild( + new Text(theme.fg(message.cloudHandoff ? "warning" : "muted", `● ${origin}`), 1, 0), + ); + } else if (hasVisibleContent) { + this.contentContainer.addChild(new Spacer(1)); + } + + // Render content in order + for (let i = 0; i < message.content.length; i++) { + const content = message.content[i]; + if (content.type === "text" && content.text.trim()) { + // Assistant text messages with no background - trim the text + // Set paddingY=0 to avoid extra spacing before tool executions + this.contentContainer.addChild(new Markdown(content.text.trim(), 1, 0, this.markdownTheme)); + } else if (content.type === "thinking" && content.thinking.trim()) { + // Add spacing only when another visible assistant content block follows. + // This avoids a superfluous blank line before separately-rendered tool execution blocks. + const hasVisibleContentAfter = message.content + .slice(i + 1) + .some((c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim())); + + if (this.hideThinkingBlock) { + // Show static thinking label when hidden + this.contentContainer.addChild( + new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), 1, 0), + ); + if (hasVisibleContentAfter) { + this.contentContainer.addChild(new Spacer(1)); + } + } else { + // Thinking traces in thinkingText color, italic + this.contentContainer.addChild( + new Markdown(content.thinking.trim(), 1, 0, this.markdownTheme, { + color: (text: string) => theme.fg("thinkingText", text), + italic: true, + }), + ); + if (hasVisibleContentAfter) { + this.contentContainer.addChild(new Spacer(1)); + } + } + } + } + + // Check if aborted - show after partial content + // But only if there are no tool calls (tool execution components will show the error) + const hasToolCalls = message.content.some((c) => c.type === "toolCall"); + this.hasToolCalls = hasToolCalls; + if (!hasToolCalls) { + if (message.stopReason === "aborted") { + const abortMessage = + message.errorMessage && message.errorMessage !== "Request was aborted" + ? message.errorMessage + : "Operation aborted"; + if (hasVisibleContent) { + this.contentContainer.addChild(new Spacer(1)); + } else { + this.contentContainer.addChild(new Spacer(1)); + } + this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0)); + } else if (message.stopReason === "error") { + const errorMsg = message.errorMessage || "Unknown error"; + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0)); + } + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/bash-execution.ts new file mode 100644 index 000000000..b46a9d0b9 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -0,0 +1,220 @@ +/** + * Component for displaying bash command execution with streaming output. + */ + +import { Container, Loader, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + type TruncationResult, + truncateTail, +} from "../../../core/tools/truncate.ts"; +import { stripAnsi } from "../../../utils/ansi.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, keyText } from "./keybinding-hints.ts"; +import { truncateToVisualLines } from "./visual-truncate.ts"; + +// Preview line limit when not expanded (matches tool execution behavior) +const PREVIEW_LINES = 20; + +export class BashExecutionComponent extends Container { + private command: string; + private outputLines: string[] = []; + private status: "running" | "complete" | "cancelled" | "error" = "running"; + private exitCode: number | undefined = undefined; + private loader: Loader; + private truncationResult?: TruncationResult; + private fullOutputPath?: string; + private expanded = false; + private contentContainer: Container; + + constructor(command: string, ui: TUI, excludeFromContext = false) { + super(); + this.command = command; + + // Use dim border for excluded-from-context commands (!! prefix) + const colorKey = excludeFromContext ? "dim" : "bashMode"; + const borderColor = (str: string) => theme.fg(colorKey, str); + + // Add spacer + this.addChild(new Spacer(1)); + + // Top border + this.addChild(new DynamicBorder(borderColor)); + + // Content container (holds dynamic content between borders) + this.contentContainer = new Container(); + this.addChild(this.contentContainer); + + // Command header + const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0); + this.contentContainer.addChild(header); + + // Loader + this.loader = new Loader( + ui, + (spinner) => theme.fg(colorKey, spinner), + (text) => theme.fg("muted", text), + `Running... (${keyText("tui.select.cancel")} to cancel)`, // Plain text for loader + ); + this.contentContainer.addChild(this.loader); + + // Bottom border + this.addChild(new DynamicBorder(borderColor)); + } + + /** + * Set whether the output is expanded (shows full output) or collapsed (preview only). + */ + setExpanded(expanded: boolean): void { + this.expanded = expanded; + this.updateDisplay(); + } + + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + + appendOutput(chunk: string): void { + // Strip ANSI codes and normalize line endings + // Note: binary data is already sanitized in tui-renderer.ts executeBashCommand + const clean = stripAnsi(chunk).replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + + // Append to output lines + const newLines = clean.split("\n"); + if (this.outputLines.length > 0 && newLines.length > 0) { + // Append first chunk to last line (incomplete line continuation) + this.outputLines[this.outputLines.length - 1] += newLines[0]; + this.outputLines.push(...newLines.slice(1)); + } else { + this.outputLines.push(...newLines); + } + + this.updateDisplay(); + } + + setComplete( + exitCode: number | undefined, + cancelled: boolean, + truncationResult?: TruncationResult, + fullOutputPath?: string, + ): void { + this.exitCode = exitCode; + this.status = cancelled + ? "cancelled" + : exitCode !== 0 && exitCode !== undefined && exitCode !== null + ? "error" + : "complete"; + this.truncationResult = truncationResult; + this.fullOutputPath = fullOutputPath; + + // Stop loader + this.loader.stop(); + + this.updateDisplay(); + } + + private updateDisplay(): void { + // Apply truncation for LLM context limits (same limits as bash tool) + const fullOutput = this.outputLines.join("\n"); + const contextTruncation = truncateTail(fullOutput, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + // Get the lines to potentially display (after context truncation) + const availableLines = contextTruncation.content ? contextTruncation.content.split("\n") : []; + + // Apply preview truncation based on expanded state + const previewLogicalLines = availableLines.slice(-PREVIEW_LINES); + const hiddenLineCount = availableLines.length - previewLogicalLines.length; + + // Rebuild content container + this.contentContainer.clear(); + + // Command header + const header = new Text(theme.fg("bashMode", theme.bold(`$ ${this.command}`)), 1, 0); + this.contentContainer.addChild(header); + + // Output + if (availableLines.length > 0) { + if (this.expanded) { + // Show all lines + const displayText = availableLines.map((line) => theme.fg("muted", line)).join("\n"); + this.contentContainer.addChild(new Text(`\n${displayText}`, 1, 0)); + } else { + // Use shared visual truncation utility with width-aware caching + const styledOutput = previewLogicalLines.map((line) => theme.fg("muted", line)).join("\n"); + const styledInput = `\n${styledOutput}`; + let cachedWidth: number | undefined; + let cachedLines: string[] | undefined; + this.contentContainer.addChild({ + render: (width: number) => { + if (cachedLines === undefined || cachedWidth !== width) { + const result = truncateToVisualLines(styledInput, PREVIEW_LINES, width, 1); + cachedLines = result.visualLines; + cachedWidth = width; + } + return cachedLines ?? []; + }, + invalidate: () => { + cachedWidth = undefined; + cachedLines = undefined; + }, + }); + } + } + + // Loader or status + if (this.status === "running") { + this.contentContainer.addChild(this.loader); + } else { + const statusParts: string[] = []; + + // Show how many lines are hidden (collapsed preview) + if (hiddenLineCount > 0) { + if (this.expanded) { + statusParts.push( + `${theme.fg("muted", "(")}${keyHint("app.tools.expand", "to collapse")}${theme.fg("muted", ")")}`, + ); + } else { + statusParts.push( + `${theme.fg("muted", `... ${hiddenLineCount} more lines (`)}${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`, + ); + } + } + + if (this.status === "cancelled") { + statusParts.push(theme.fg("warning", "(cancelled)")); + } else if (this.status === "error") { + statusParts.push(theme.fg("error", `(exit ${this.exitCode})`)); + } + + // Add truncation warning (context truncation, not preview truncation) + const wasTruncated = this.truncationResult?.truncated || contextTruncation.truncated; + if (wasTruncated && this.fullOutputPath) { + statusParts.push(theme.fg("warning", `Output truncated. Full output: ${this.fullOutputPath}`)); + } + + if (statusParts.length > 0) { + this.contentContainer.addChild(new Text(`\n${statusParts.join("\n")}`, 1, 0)); + } + } + } + + /** + * Get the raw output for creating BashExecutionMessage. + */ + getOutput(): string { + return this.outputLines.join("\n"); + } + + /** + * Get the command that was executed. + */ + getCommand(): string { + return this.command; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts new file mode 100644 index 000000000..4d692ffc8 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -0,0 +1,68 @@ +import { CancellableLoader, Container, Loader, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; +import type { Theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint } from "./keybinding-hints.ts"; + +/** Loader wrapped with borders for extension UI */ +export class BorderedLoader extends Container { + private loader: CancellableLoader | Loader; + private cancellable: boolean; + private signalController?: AbortController; + + constructor(tui: TUI, theme: Theme, message: string, options?: { cancellable?: boolean }) { + super(); + this.cancellable = options?.cancellable ?? true; + const borderColor = (s: string) => theme.fg("border", s); + this.addChild(new DynamicBorder(borderColor)); + if (this.cancellable) { + this.loader = new CancellableLoader( + tui, + (s) => theme.fg("accent", s), + (s) => theme.fg("muted", s), + message, + ); + } else { + this.signalController = new AbortController(); + this.loader = new Loader( + tui, + (s) => theme.fg("accent", s), + (s) => theme.fg("muted", s), + message, + ); + } + this.addChild(this.loader); + if (this.cancellable) { + this.addChild(new Spacer(1)); + this.addChild(new Text(keyHint("tui.select.cancel", "cancel"), 1, 0)); + } + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder(borderColor)); + } + + get signal(): AbortSignal { + if (this.cancellable) { + return (this.loader as CancellableLoader).signal; + } + return this.signalController?.signal ?? new AbortController().signal; + } + + set onAbort(fn: (() => void) | undefined) { + if (this.cancellable) { + (this.loader as CancellableLoader).onAbort = fn; + } + } + + handleInput(data: string): void { + if (this.cancellable) { + (this.loader as CancellableLoader).handleInput(data); + } + } + + dispose(): void { + if ("dispose" in this.loader && typeof this.loader.dispose === "function") { + this.loader.dispose(); + } else if ("stop" in this.loader && typeof this.loader.stop === "function") { + this.loader.stop(); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts new file mode 100644 index 000000000..af85115f0 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -0,0 +1,58 @@ +import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; +import type { BranchSummaryMessage } from "../../../core/messages.ts"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; +import { keyText } from "./keybinding-hints.ts"; + +/** + * Component that renders a branch summary message with collapsed/expanded state. + * Uses same background color as custom messages for visual consistency. + */ +export class BranchSummaryMessageComponent extends Box { + private expanded = false; + private message: BranchSummaryMessage; + private markdownTheme: MarkdownTheme; + + constructor(message: BranchSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme()) { + super(1, 1, (t) => theme.bg("customMessageBg", t)); + this.message = message; + this.markdownTheme = markdownTheme; + this.updateDisplay(); + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + this.updateDisplay(); + } + + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + + private updateDisplay(): void { + this.clear(); + + const label = theme.fg("customMessageLabel", `\x1b[1m[branch]\x1b[22m`); + this.addChild(new Text(label, 0, 0)); + this.addChild(new Spacer(1)); + + if (this.expanded) { + const header = "**Branch Summary**\n\n"; + this.addChild( + new Markdown(header + this.message.summary, 0, 0, this.markdownTheme, { + color: (text: string) => theme.fg("customMessageText", text), + }), + ); + } else { + this.addChild( + new Text( + theme.fg("customMessageText", "Branch summary (") + + theme.fg("dim", keyText("app.tools.expand")) + + theme.fg("customMessageText", " to expand)"), + 0, + 0, + ), + ); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts new file mode 100644 index 000000000..cad613d76 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -0,0 +1,59 @@ +import { Box, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; +import type { CompactionSummaryMessage } from "../../../core/messages.ts"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; +import { keyText } from "./keybinding-hints.ts"; + +/** + * Component that renders a compaction message with collapsed/expanded state. + * Uses same background color as custom messages for visual consistency. + */ +export class CompactionSummaryMessageComponent extends Box { + private expanded = false; + private message: CompactionSummaryMessage; + private markdownTheme: MarkdownTheme; + + constructor(message: CompactionSummaryMessage, markdownTheme: MarkdownTheme = getMarkdownTheme()) { + super(1, 1, (t) => theme.bg("customMessageBg", t)); + this.message = message; + this.markdownTheme = markdownTheme; + this.updateDisplay(); + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + this.updateDisplay(); + } + + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + + private updateDisplay(): void { + this.clear(); + + const tokenStr = this.message.tokensBefore.toLocaleString(); + const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`); + this.addChild(new Text(label, 0, 0)); + this.addChild(new Spacer(1)); + + if (this.expanded) { + const header = `**Compacted from ${tokenStr} tokens**\n\n`; + this.addChild( + new Markdown(header + this.message.summary, 0, 0, this.markdownTheme, { + color: (text: string) => theme.fg("customMessageText", text), + }), + ); + } else { + this.addChild( + new Text( + theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (`) + + theme.fg("dim", keyText("app.tools.expand")) + + theme.fg("customMessageText", " to expand)"), + 0, + 0, + ), + ); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts new file mode 100644 index 000000000..73ec2734d --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts @@ -0,0 +1,39 @@ +/** + * Reusable countdown timer for dialog components. + */ + +import type { TUI } from "@earendil-works/pi-tui"; + +export class CountdownTimer { + private intervalId: ReturnType | undefined; + private remainingSeconds: number; + private tui: TUI | undefined; + private onTick: (seconds: number) => void; + private onExpire: () => void; + + constructor(timeoutMs: number, tui: TUI | undefined, onTick: (seconds: number) => void, onExpire: () => void) { + this.tui = tui; + this.onTick = onTick; + this.onExpire = onExpire; + this.remainingSeconds = Math.ceil(timeoutMs / 1000); + this.onTick(this.remainingSeconds); + + this.intervalId = setInterval(() => { + this.remainingSeconds--; + this.onTick(this.remainingSeconds); + this.tui?.requestRender(); + + if (this.remainingSeconds <= 0) { + this.dispose(); + this.onExpire(); + } + }, 1000); + } + + dispose(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/custom-editor.ts new file mode 100644 index 000000000..a9d256222 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -0,0 +1,80 @@ +import { Editor, type EditorOptions, type EditorTheme, type TUI } from "@earendil-works/pi-tui"; +import type { AppKeybinding, KeybindingsManager } from "../../../core/keybindings.ts"; + +/** + * Custom editor that handles app-level keybindings for coding-agent. + */ +export class CustomEditor extends Editor { + private keybindings: KeybindingsManager; + public actionHandlers: Map void> = new Map(); + + // Special handlers that can be dynamically replaced + public onEscape?: () => void; + public onCtrlD?: () => void; + public onPasteImage?: () => void; + /** Handler for extension-registered shortcuts. Returns true if handled. */ + public onExtensionShortcut?: (data: string) => boolean; + + constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, options?: EditorOptions) { + super(tui, theme, options); + this.keybindings = keybindings; + } + + /** + * Register a handler for an app action. + */ + onAction(action: AppKeybinding, handler: () => void): void { + this.actionHandlers.set(action, handler); + } + + handleInput(data: string): void { + // Check extension-registered shortcuts first + if (this.onExtensionShortcut?.(data)) { + return; + } + + // Check for paste image keybinding + if (this.keybindings.matches(data, "app.clipboard.pasteImage")) { + this.onPasteImage?.(); + return; + } + + // Check app keybindings first + + // Escape/interrupt - only if autocomplete is NOT active + if (this.keybindings.matches(data, "app.interrupt")) { + if (!this.isShowingAutocomplete()) { + // Use dynamic onEscape if set, otherwise registered handler + const handler = this.onEscape ?? this.actionHandlers.get("app.interrupt"); + if (handler) { + handler(); + return; + } + } + // Let parent handle escape for autocomplete cancellation + super.handleInput(data); + return; + } + + // Exit (Ctrl+D) - only when editor is empty + if (this.keybindings.matches(data, "app.exit")) { + if (this.getText().length === 0) { + const handler = this.onCtrlD ?? this.actionHandlers.get("app.exit"); + if (handler) handler(); + return; + } + // Fall through to editor handling for delete-char-forward when not empty + } + + // Check all other app actions + for (const [action, handler] of this.actionHandlers) { + if (action !== "app.interrupt" && action !== "app.exit" && this.keybindings.matches(data, action)) { + handler(); + return; + } + } + + // Pass to parent for editor handling + super.handleInput(data); + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/custom-message.ts new file mode 100644 index 000000000..a8fffc6f3 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -0,0 +1,99 @@ +import type { TextContent } from "@earendil-works/pi-ai"; +import type { Component } from "@earendil-works/pi-tui"; +import { Box, Container, Markdown, type MarkdownTheme, Spacer, Text } from "@earendil-works/pi-tui"; +import type { MessageRenderer } from "../../../core/extensions/types.ts"; +import type { CustomMessage } from "../../../core/messages.ts"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; + +/** + * Component that renders a custom message entry from extensions. + * Uses distinct styling to differentiate from user messages. + */ +export class CustomMessageComponent extends Container { + private message: CustomMessage; + private customRenderer?: MessageRenderer; + private box: Box; + private customComponent?: Component; + private markdownTheme: MarkdownTheme; + private _expanded = false; + + constructor( + message: CustomMessage, + customRenderer?: MessageRenderer, + markdownTheme: MarkdownTheme = getMarkdownTheme(), + ) { + super(); + this.message = message; + this.customRenderer = customRenderer; + this.markdownTheme = markdownTheme; + + this.addChild(new Spacer(1)); + + // Create box with purple background (used for default rendering) + this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); + + this.rebuild(); + } + + setExpanded(expanded: boolean): void { + if (this._expanded !== expanded) { + this._expanded = expanded; + this.rebuild(); + } + } + + override invalidate(): void { + super.invalidate(); + this.rebuild(); + } + + private rebuild(): void { + // Remove previous content component + if (this.customComponent) { + this.removeChild(this.customComponent); + this.customComponent = undefined; + } + this.removeChild(this.box); + + // Try custom renderer first - it handles its own styling + if (this.customRenderer) { + try { + const component = this.customRenderer(this.message, { expanded: this._expanded }, theme); + if (component) { + // Custom renderer provides its own styled component + this.customComponent = component; + this.addChild(component); + return; + } + } catch { + // Fall through to default rendering + } + } + + // Default rendering uses our box + this.addChild(this.box); + this.box.clear(); + + // Default rendering: label + content + const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`); + this.box.addChild(new Text(label, 0, 0)); + this.box.addChild(new Spacer(1)); + + // Extract text content + let text: string; + if (typeof this.message.content === "string") { + text = this.message.content; + } else { + text = this.message.content + .filter((c): c is TextContent => c.type === "text") + .map((c) => c.text) + .join("\n"); + } + + this.box.addChild( + new Markdown(text, 0, 0, this.markdownTheme, { + color: (text: string) => theme.fg("customMessageText", text), + }), + ); + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/diff.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/diff.ts new file mode 100644 index 000000000..54e88273d --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/diff.ts @@ -0,0 +1,147 @@ +import * as Diff from "diff"; +import { theme } from "../theme/theme.ts"; + +/** + * Parse diff line to extract prefix, line number, and content. + * Format: "+123 content" or "-123 content" or " 123 content" or " ..." + */ +function parseDiffLine(line: string): { prefix: string; lineNum: string; content: string } | null { + const match = line.match(/^([+-\s])(\s*\d*)\s(.*)$/); + if (!match) return null; + return { prefix: match[1], lineNum: match[2], content: match[3] }; +} + +/** + * Replace tabs with spaces for consistent rendering. + */ +function replaceTabs(text: string): string { + return text.replace(/\t/g, " "); +} + +/** + * Compute word-level diff and render with inverse on changed parts. + * Uses diffWords which groups whitespace with adjacent words for cleaner highlighting. + * Strips leading whitespace from inverse to avoid highlighting indentation. + */ +function renderIntraLineDiff(oldContent: string, newContent: string): { removedLine: string; addedLine: string } { + const wordDiff = Diff.diffWords(oldContent, newContent); + + let removedLine = ""; + let addedLine = ""; + let isFirstRemoved = true; + let isFirstAdded = true; + + for (const part of wordDiff) { + if (part.removed) { + let value = part.value; + // Strip leading whitespace from the first removed part + if (isFirstRemoved) { + const leadingWs = value.match(/^(\s*)/)?.[1] || ""; + value = value.slice(leadingWs.length); + removedLine += leadingWs; + isFirstRemoved = false; + } + if (value) { + removedLine += theme.inverse(value); + } + } else if (part.added) { + let value = part.value; + // Strip leading whitespace from the first added part + if (isFirstAdded) { + const leadingWs = value.match(/^(\s*)/)?.[1] || ""; + value = value.slice(leadingWs.length); + addedLine += leadingWs; + isFirstAdded = false; + } + if (value) { + addedLine += theme.inverse(value); + } + } else { + removedLine += part.value; + addedLine += part.value; + } + } + + return { removedLine, addedLine }; +} + +export interface RenderDiffOptions { + /** File path (unused, kept for API compatibility) */ + filePath?: string; +} + +/** + * Render a diff string with colored lines and intra-line change highlighting. + * - Context lines: dim/gray + * - Removed lines: red, with inverse on changed tokens + * - Added lines: green, with inverse on changed tokens + */ +export function renderDiff(diffText: string, _options: RenderDiffOptions = {}): string { + const lines = diffText.split("\n"); + const result: string[] = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const parsed = parseDiffLine(line); + + if (!parsed) { + result.push(theme.fg("toolDiffContext", line)); + i++; + continue; + } + + if (parsed.prefix === "-") { + // Collect consecutive removed lines + const removedLines: { lineNum: string; content: string }[] = []; + while (i < lines.length) { + const p = parseDiffLine(lines[i]); + if (!p || p.prefix !== "-") break; + removedLines.push({ lineNum: p.lineNum, content: p.content }); + i++; + } + + // Collect consecutive added lines + const addedLines: { lineNum: string; content: string }[] = []; + while (i < lines.length) { + const p = parseDiffLine(lines[i]); + if (!p || p.prefix !== "+") break; + addedLines.push({ lineNum: p.lineNum, content: p.content }); + i++; + } + + // Only do intra-line diffing when there's exactly one removed and one added line + // (indicating a single line modification). Otherwise, show lines as-is. + if (removedLines.length === 1 && addedLines.length === 1) { + const removed = removedLines[0]; + const added = addedLines[0]; + + const { removedLine, addedLine } = renderIntraLineDiff( + replaceTabs(removed.content), + replaceTabs(added.content), + ); + + result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${removedLine}`)); + result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${addedLine}`)); + } else { + // Show all removed lines first, then all added lines + for (const removed of removedLines) { + result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`)); + } + for (const added of addedLines) { + result.push(theme.fg("toolDiffAdded", `+${added.lineNum} ${replaceTabs(added.content)}`)); + } + } + } else if (parsed.prefix === "+") { + // Standalone added line + result.push(theme.fg("toolDiffAdded", `+${parsed.lineNum} ${replaceTabs(parsed.content)}`)); + i++; + } else { + // Context line + result.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${replaceTabs(parsed.content)}`)); + i++; + } + } + + return result.join("\n"); +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts new file mode 100644 index 000000000..77342b25e --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts @@ -0,0 +1,25 @@ +import type { Component } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.ts"; + +/** + * Dynamic border component that adjusts to viewport width. + * + * Note: When used from extensions loaded via jiti, the global `theme` may be undefined + * because jiti creates a separate module cache. Always pass an explicit color + * function when using DynamicBorder in components exported for extension use. + */ +export class DynamicBorder implements Component { + private color: (str: string) => string; + + constructor(color: (str: string) => string = (str) => theme.fg("border", str)) { + this.color = color; + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(width: number): string[] { + return [this.color("─".repeat(Math.max(1, width)))]; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/extension-editor.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/extension-editor.ts new file mode 100644 index 000000000..96f081a1f --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/extension-editor.ts @@ -0,0 +1,156 @@ +/** + * Multi-line editor component for extensions. + * Supports Ctrl+G for external editor. + */ + +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + Container, + Editor, + type EditorOptions, + type Focusable, + getKeybindings, + Spacer, + Text, + type TUI, +} from "@earendil-works/pi-tui"; +import type { KeybindingsManager } from "../../../core/keybindings.ts"; +import { getEditorTheme, theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint } from "./keybinding-hints.ts"; + +export class ExtensionEditorComponent extends Container implements Focusable { + private editor: Editor; + private onSubmitCallback: (value: string) => void; + private onCancelCallback: () => void; + private tui: TUI; + private keybindings: KeybindingsManager; + + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.editor.focused = value; + } + + constructor( + tui: TUI, + keybindings: KeybindingsManager, + title: string, + prefill: string | undefined, + onSubmit: (value: string) => void, + onCancel: () => void, + options?: EditorOptions, + ) { + super(); + + this.tui = tui; + this.keybindings = keybindings; + this.onSubmitCallback = onSubmit; + this.onCancelCallback = onCancel; + + // Add top border + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + // Add title + this.addChild(new Text(theme.fg("accent", title), 1, 0)); + this.addChild(new Spacer(1)); + + // Create editor + this.editor = new Editor(tui, getEditorTheme(), options); + if (prefill) { + this.editor.setText(prefill); + } + // Wire up Enter to submit (Shift+Enter for newlines, like the main editor) + this.editor.onSubmit = (text: string) => { + this.onSubmitCallback(text); + }; + this.addChild(this.editor); + + this.addChild(new Spacer(1)); + + // Add hint + const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR); + const hint = + keyHint("tui.select.confirm", "submit") + + " " + + keyHint("tui.input.newLine", "newline") + + " " + + keyHint("tui.select.cancel", "cancel") + + (hasExternalEditor ? ` ${keyHint("app.editor.external", "external editor")}` : ""); + this.addChild(new Text(hint, 1, 0)); + + this.addChild(new Spacer(1)); + + // Add bottom border + this.addChild(new DynamicBorder()); + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + // Escape or Ctrl+C to cancel + if (kb.matches(keyData, "tui.select.cancel")) { + this.onCancelCallback(); + return; + } + + // External editor (app keybinding) + if (this.keybindings.matches(keyData, "app.editor.external")) { + this.openExternalEditor(); + return; + } + + // Forward to editor + this.editor.handleInput(keyData); + } + + private async openExternalEditor(): Promise { + const editorCmd = process.env.VISUAL || process.env.EDITOR; + if (!editorCmd) { + return; + } + + const currentText = this.editor.getText(); + const tmpFile = path.join(os.tmpdir(), `cactus-extension-editor-${Date.now()}.md`); + + try { + fs.writeFileSync(tmpFile, currentText, "utf-8"); + this.tui.stop(); + + const [editor, ...editorArgs] = editorCmd.split(" "); + process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`); + + // Do not use spawnSync here. On Windows, synchronous child_process calls can keep + // Node/libuv's console input read active after tui.stop() pauses stdin, racing + // vim/nvim for the console input buffer until Ctrl+C cancels the pending read. + const status = await new Promise((resolve) => { + const child = spawn(editor, [...editorArgs, tmpFile], { + stdio: "inherit", + shell: process.platform === "win32", + }); + child.on("error", () => resolve(null)); + child.on("close", (code) => resolve(code)); + }); + + if (status === 0) { + const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); + this.editor.setText(newContent); + } + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + this.tui.start(); + // Force full re-render since external editor uses alternate screen + this.tui.requestRender(true); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/extension-input.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/extension-input.ts new file mode 100644 index 000000000..581ef4f5c --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/extension-input.ts @@ -0,0 +1,87 @@ +/** + * Simple text input component for extensions. + */ + +import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.ts"; +import { CountdownTimer } from "./countdown-timer.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint } from "./keybinding-hints.ts"; + +export interface ExtensionInputOptions { + tui?: TUI; + timeout?: number; +} + +export class ExtensionInputComponent extends Container implements Focusable { + private input: Input; + private onSubmitCallback: (value: string) => void; + private onCancelCallback: () => void; + private titleText: Text; + private baseTitle: string; + private countdown: CountdownTimer | undefined; + + // Focusable implementation - propagate to input for IME cursor positioning + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.input.focused = value; + } + + constructor( + title: string, + _placeholder: string | undefined, + onSubmit: (value: string) => void, + onCancel: () => void, + opts?: ExtensionInputOptions, + ) { + super(); + + this.onSubmitCallback = onSubmit; + this.onCancelCallback = onCancel; + this.baseTitle = title; + + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + this.titleText = new Text(theme.fg("accent", title), 1, 0); + this.addChild(this.titleText); + this.addChild(new Spacer(1)); + + if (opts?.timeout && opts.timeout > 0 && opts.tui) { + this.countdown = new CountdownTimer( + opts.timeout, + opts.tui, + (s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)), + () => this.onCancelCallback(), + ); + } + + this.input = new Input(); + this.addChild(this.input); + this.addChild(new Spacer(1)); + this.addChild( + new Text(`${keyHint("tui.select.confirm", "submit")} ${keyHint("tui.select.cancel", "cancel")}`, 1, 0), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + this.onSubmitCallback(this.input.getValue()); + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.onCancelCallback(); + } else { + this.input.handleInput(keyData); + } + } + + dispose(): void { + this.countdown?.dispose(); + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/extension-selector.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/extension-selector.ts new file mode 100644 index 000000000..d1dbdb4d0 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/extension-selector.ts @@ -0,0 +1,112 @@ +/** + * Generic selector component for extensions. + * Displays a list of string options with keyboard navigation. + */ + +import { Container, getKeybindings, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.ts"; +import { CountdownTimer } from "./countdown-timer.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +export interface ExtensionSelectorOptions { + tui?: TUI; + timeout?: number; + onToggleToolsExpanded?: () => void; +} + +export class ExtensionSelectorComponent extends Container { + private options: string[]; + private selectedIndex = 0; + private listContainer: Container; + private onSelectCallback: (option: string) => void; + private onCancelCallback: () => void; + private titleText: Text; + private baseTitle: string; + private countdown: CountdownTimer | undefined; + private onToggleToolsExpanded: (() => void) | undefined; + + constructor( + title: string, + options: string[], + onSelect: (option: string) => void, + onCancel: () => void, + opts?: ExtensionSelectorOptions, + ) { + super(); + + this.options = options; + this.onSelectCallback = onSelect; + this.onCancelCallback = onCancel; + this.onToggleToolsExpanded = opts?.onToggleToolsExpanded; + this.baseTitle = title; + + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + this.titleText = new Text(theme.fg("accent", theme.bold(title)), 1, 0); + this.addChild(this.titleText); + this.addChild(new Spacer(1)); + + if (opts?.timeout && opts.timeout > 0 && opts.tui) { + this.countdown = new CountdownTimer( + opts.timeout, + opts.tui, + (s) => this.titleText.setText(theme.fg("accent", theme.bold(`${this.baseTitle} (${s}s)`))), + () => this.onCancelCallback(), + ); + } + + this.listContainer = new Container(); + this.addChild(this.listContainer); + this.addChild(new Spacer(1)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", "select") + + " " + + keyHint("tui.select.cancel", "cancel"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + this.updateList(); + } + + private updateList(): void { + this.listContainer.clear(); + for (let i = 0; i < this.options.length; i++) { + const isSelected = i === this.selectedIndex; + const text = isSelected + ? theme.fg("accent", "→ ") + theme.fg("accent", this.options[i]) + : ` ${theme.fg("text", this.options[i])}`; + this.listContainer.addChild(new Text(text, 1, 0)); + } + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "app.tools.expand")) { + this.onToggleToolsExpanded?.(); + } else if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.updateList(); + } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { + this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); + this.updateList(); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + const selected = this.options[this.selectedIndex]; + if (selected) this.onSelectCallback(selected); + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.onCancelCallback(); + } + } + + dispose(): void { + this.countdown?.dispose(); + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts new file mode 100644 index 000000000..299f5b2ba --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/first-time-setup.ts @@ -0,0 +1,145 @@ +import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; +import { APP_NAME } from "../../../config.ts"; +import { type TerminalTheme, theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +export interface FirstTimeSetupResult { + theme: TerminalTheme; + shareAnalytics: boolean; +} + +export interface FirstTimeSetupOptions { + detectedTheme: TerminalTheme; + onThemePreview: (themeName: TerminalTheme) => void; + onSubmit: (result: FirstTimeSetupResult) => void; + onCancel: () => void; +} + +const THEME_OPTIONS: Array<{ value: TerminalTheme; label: string }> = [ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, +]; + +const ANALYTICS_OPTIONS: Array<{ value: boolean; label: string }> = [ + { value: true, label: "Share anonymous usage data" }, + { value: false, label: "Don't share" }, +]; + +const SETUP_LOGO_LINES = ["██████", "██ ██", "████ ██", "██ ██"]; + +/** First-time setup dialog: theme choice and analytics opt-in. */ +export class FirstTimeSetupComponent extends Container { + private step: "theme" | "analytics" = "theme"; + private themeIndex: number; + private analyticsIndex = 0; + private readonly options: FirstTimeSetupOptions; + + constructor(options: FirstTimeSetupOptions) { + super(); + this.options = options; + this.themeIndex = Math.max( + 0, + THEME_OPTIONS.findIndex((option) => option.value === options.detectedTheme), + ); + this.update(); + } + + // Rebuild the whole dialog on every change so theme previews recolor all text. + private update(): void { + this.clear(); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", SETUP_LOGO_LINES.join("\n")), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild( + new Text(theme.fg("accent", theme.bold(`Welcome to ${APP_NAME}, the minimal coding agent.`)), 1, 0), + ); + this.addChild(new Spacer(1)); + + if (this.step === "theme") { + this.addChild(new Text(theme.fg("text", "Pick a theme."), 1, 0)); + this.addChild(new Text(theme.fg("muted", `Detected system appearance: ${this.options.detectedTheme}`), 1, 0)); + this.addChild(new Spacer(1)); + this.addOptionList( + THEME_OPTIONS.map((option) => option.label), + this.themeIndex, + ); + } else { + this.addChild(new Text(theme.fg("text", "Opt-in to anonymous usage data sharing?"), 1, 0)); + this.addChild( + new Text( + theme.fg( + "muted", + "Opting in stores a tracking identifier in settings.json and enables anonymous\nusage analytics. This helps us to better debug, reproduce, and resolve issues\nand bugs within Cactus. You can observe what is shared using /privacy and make\nchanges anytime in settings.json.", + ), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addOptionList( + ANALYTICS_OPTIONS.map((option) => option.label), + this.analyticsIndex, + ); + } + + this.addChild(new Spacer(1)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", this.step === "theme" ? "continue" : "finish") + + " " + + keyHint("tui.select.cancel", "skip setup"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + } + + private addOptionList(labels: string[], selectedIndex: number): void { + for (let i = 0; i < labels.length; i++) { + const isSelected = i === selectedIndex; + const prefix = isSelected ? theme.fg("accent", "→ ") : " "; + const label = isSelected ? theme.fg("accent", labels[i]) : theme.fg("text", labels[i]); + this.addChild(new Text(`${prefix}${label}`, 1, 0)); + } + } + + private moveSelection(delta: number): void { + if (this.step === "theme") { + const next = Math.max(0, Math.min(THEME_OPTIONS.length - 1, this.themeIndex + delta)); + if (next !== this.themeIndex) { + this.themeIndex = next; + this.options.onThemePreview(THEME_OPTIONS[this.themeIndex].value); + } + } else { + this.analyticsIndex = Math.max(0, Math.min(ANALYTICS_OPTIONS.length - 1, this.analyticsIndex + delta)); + } + this.update(); + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + this.moveSelection(-1); + } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { + this.moveSelection(1); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + if (this.step === "theme") { + this.step = "analytics"; + this.update(); + } else { + this.options.onSubmit({ + theme: THEME_OPTIONS[this.themeIndex].value, + shareAnalytics: ANALYTICS_OPTIONS[this.analyticsIndex].value, + }); + } + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.options.onCancel(); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/footer.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/footer.ts new file mode 100644 index 000000000..f58366109 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -0,0 +1,247 @@ +import { isAbsolute, relative, resolve, sep } from "node:path"; +import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import type { AgentSession } from "../../../core/agent-session.ts"; +import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts"; +import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; +import { theme } from "../theme/theme.ts"; + +/** + * Sanitize text for display in a single-line status. + * Removes newlines, tabs, carriage returns, and other control characters. + */ +function sanitizeStatusText(text: string): string { + // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces + return text + .replace(/[\r\n\t]/g, " ") + .replace(/ +/g, " ") + .trim(); +} + +/** + * Format token counts for compact footer display. + */ +function formatTokens(count: number): string { + if (!Number.isFinite(count)) return "?"; + if (count < 1000) return count.toString(); + if (count < 10000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1000000) return `${Math.round(count / 1000)}k`; + if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; + return `${Math.round(count / 1000000)}M`; +} + +export function formatCwdForFooter(cwd: string, home: string | undefined): string { + if (!home) return cwd; + + const resolvedCwd = resolve(cwd); + const resolvedHome = resolve(home); + const relativeToHome = relative(resolvedHome, resolvedCwd); + const isInsideHome = + relativeToHome === "" || + (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome)); + + if (!isInsideHome) return cwd; + return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`; +} + +/** + * Footer component that shows pwd, token stats, and context usage. + * Computes token/context stats from session, gets git branch and extension statuses from provider. + */ +export class FooterComponent implements Component { + private autoCompactEnabled = true; + private session: AgentSession; + private footerData: ReadonlyFooterDataProvider; + + constructor(session: AgentSession, footerData: ReadonlyFooterDataProvider) { + this.session = session; + this.footerData = footerData; + } + + setSession(session: AgentSession): void { + this.session = session; + } + + setAutoCompactEnabled(enabled: boolean): void { + this.autoCompactEnabled = enabled; + } + + /** + * No-op: git branch caching now handled by provider. + * Kept for compatibility with existing call sites in interactive-mode. + */ + invalidate(): void { + // No-op: git branch is cached/invalidated by provider + } + + /** + * Clean up resources. + * Git watcher cleanup now handled by provider. + */ + dispose(): void { + // Git watcher cleanup handled by provider + } + + render(width: number): string[] { + const state = this.session.state; + + // Calculate cumulative usage from ALL session entries (not just post-compaction messages) + let totalInput = 0; + let totalOutput = 0; + let totalCacheRead = 0; + let totalCacheWrite = 0; + let totalCost = 0; + let latestCacheHitRate: number | undefined; + + for (const entry of this.session.sessionManager.getEntries()) { + if (entry.type === "message" && entry.message.role === "assistant") { + totalInput += entry.message.usage.input; + totalOutput += entry.message.usage.output; + totalCacheRead += entry.message.usage.cacheRead; + totalCacheWrite += entry.message.usage.cacheWrite; + totalCost += entry.message.usage.cost.total; + + const latestPromptTokens = + entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite; + latestCacheHitRate = + latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined; + } + } + + // Calculate context usage from session (handles compaction correctly). + // After compaction, tokens are unknown until the next LLM response. + const contextUsage = this.session.getContextUsage(); + const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0; + const contextPercentValue = contextUsage?.percent ?? 0; + const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?"; + + // Replace home directory with ~ + let pwd = formatCwdForFooter(this.session.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE); + + // Add git branch if available + const branch = this.footerData.getGitBranch(); + if (branch) { + pwd = `${pwd} (${branch})`; + } + + // Add session name if set + const sessionName = this.session.sessionManager.getSessionName(); + if (sessionName) { + pwd = `${pwd} • ${sessionName}`; + } + + // Build stats line + const statsParts = []; + if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`); + if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`); + if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); + if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); + if ((totalCacheRead > 0 || totalCacheWrite > 0) && latestCacheHitRate !== undefined) { + statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`); + } + + // Show cost with "(sub)" indicator if using OAuth subscription + const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false; + if (totalCost || usingSubscription) { + const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; + statsParts.push(costStr); + } + + // Colorize context percentage based on usage + let contextPercentStr: string; + const autoIndicator = this.autoCompactEnabled ? " (auto)" : ""; + const contextPercentDisplay = + contextPercent === "?" + ? `?/${formatTokens(contextWindow)}${autoIndicator}` + : `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`; + if (contextPercentValue > 90) { + contextPercentStr = theme.fg("error", contextPercentDisplay); + } else if (contextPercentValue > 70) { + contextPercentStr = theme.fg("warning", contextPercentDisplay); + } else { + contextPercentStr = contextPercentDisplay; + } + statsParts.push(contextPercentStr); + if (areExperimentalFeaturesEnabled()) { + statsParts.push(`${theme.fg("dim", "•")} ${theme.bold(theme.fg("warning", "xp"))}`); + } + + let statsLeft = statsParts.join(" "); + + // Add model name on the right side, plus thinking level if model supports it + const modelName = state.model?.id || "no-model"; + + let statsLeftWidth = visibleWidth(statsLeft); + + // If statsLeft is too wide, truncate it + if (statsLeftWidth > width) { + statsLeft = truncateToWidth(statsLeft, width, "..."); + statsLeftWidth = visibleWidth(statsLeft); + } + + // Calculate available space for padding (minimum 2 spaces between stats and model) + const minPadding = 2; + + // Add thinking level indicator if model supports reasoning + let rightSideWithoutProvider = modelName; + if (state.model?.reasoning) { + const thinkingLevel = state.thinkingLevel || "off"; + rightSideWithoutProvider = + thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`; + } + + // Prepend the provider in parentheses if there are multiple providers and there's enough room + let rightSide = rightSideWithoutProvider; + if (this.footerData.getAvailableProviderCount() > 1 && state.model) { + rightSide = `(${state.model!.provider}) ${rightSideWithoutProvider}`; + if (statsLeftWidth + minPadding + visibleWidth(rightSide) > width) { + // Too wide, fall back + rightSide = rightSideWithoutProvider; + } + } + + const rightSideWidth = visibleWidth(rightSide); + const totalNeeded = statsLeftWidth + minPadding + rightSideWidth; + + let statsLine: string; + if (totalNeeded <= width) { + // Both fit - add padding to right-align model + const padding = " ".repeat(width - statsLeftWidth - rightSideWidth); + statsLine = statsLeft + padding + rightSide; + } else { + // Need to truncate right side + const availableForRight = width - statsLeftWidth - minPadding; + if (availableForRight > 0) { + const truncatedRight = truncateToWidth(rightSide, availableForRight, ""); + const truncatedRightWidth = visibleWidth(truncatedRight); + const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth)); + statsLine = statsLeft + padding + truncatedRight; + } else { + // Not enough space for right side at all + statsLine = statsLeft; + } + } + + // Apply dim to each part separately. statsLeft may contain color codes (for context %) + // that end with a reset, which would clear an outer dim wrapper. So we dim the parts + // before and after the colored section independently. + const dimStatsLeft = theme.fg("dim", statsLeft); + const remainder = statsLine.slice(statsLeft.length); // padding + rightSide + const dimRemainder = theme.fg("dim", remainder); + + const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")); + const lines = [pwdLine, dimStatsLeft + dimRemainder]; + + // Add extension statuses on a single line, sorted by key alphabetically + const extensionStatuses = this.footerData.getExtensionStatuses(); + if (extensionStatuses.size > 0) { + const sortedStatuses = Array.from(extensionStatuses.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, text]) => sanitizeStatusText(text)); + const statusLine = sortedStatuses.join(" "); + // Truncate to terminal width with dim ellipsis for consistency with footer style + lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "..."))); + } + + return lines; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/index.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/index.ts new file mode 100644 index 000000000..067ee8940 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/index.ts @@ -0,0 +1,33 @@ +// UI Components for extensions +export { AssistantMessageComponent } from "./assistant-message.ts"; +export { BashExecutionComponent } from "./bash-execution.ts"; +export { BorderedLoader } from "./bordered-loader.ts"; +export { BranchSummaryMessageComponent } from "./branch-summary-message.ts"; +export { CompactionSummaryMessageComponent } from "./compaction-summary-message.ts"; +export { CustomEditor } from "./custom-editor.ts"; +export { CustomMessageComponent } from "./custom-message.ts"; +export { type RenderDiffOptions, renderDiff } from "./diff.ts"; +export { DynamicBorder } from "./dynamic-border.ts"; +export { ExtensionEditorComponent } from "./extension-editor.ts"; +export { ExtensionInputComponent } from "./extension-input.ts"; +export { ExtensionSelectorComponent } from "./extension-selector.ts"; +export { + FirstTimeSetupComponent, + type FirstTimeSetupOptions, + type FirstTimeSetupResult, +} from "./first-time-setup.ts"; +export { FooterComponent } from "./footer.ts"; +export { keyHint, keyText, rawKeyHint } from "./keybinding-hints.ts"; +export { ModelSelectorComponent } from "./model-selector.ts"; +export { type ModelsCallbacks, type ModelsConfig, ScopedModelsSelectorComponent } from "./scoped-models-selector.ts"; +export { SessionSelectorComponent } from "./session-selector.ts"; +export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.ts"; +export { SkillInvocationMessageComponent } from "./skill-invocation-message.ts"; +export { ThemeSelectorComponent } from "./theme-selector.ts"; +export { ThinkingSelectorComponent } from "./thinking-selector.ts"; +export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution.ts"; +export { TreeSelectorComponent } from "./tree-selector.ts"; +export { TrustSelectorComponent } from "./trust-selector.ts"; +export { UserMessageComponent } from "./user-message.ts"; +export { UserMessageSelectorComponent } from "./user-message-selector.ts"; +export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate.ts"; diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts new file mode 100644 index 000000000..22601abd9 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/keybinding-hints.ts @@ -0,0 +1,48 @@ +/** + * Utilities for formatting keybinding hints in the UI. + */ + +import { getKeybindings, type Keybinding, type KeyId } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.ts"; + +export interface KeyTextFormatOptions { + capitalize?: boolean; +} + +function formatKeyPart(part: string, options: KeyTextFormatOptions): string { + const displayPart = process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part; + return options.capitalize ? displayPart.charAt(0).toUpperCase() + displayPart.slice(1) : displayPart; +} + +export function formatKeyText(key: string, options: KeyTextFormatOptions = {}): string { + return key + .split("/") + .map((k) => + k + .split("+") + .map((part) => formatKeyPart(part, options)) + .join("+"), + ) + .join("/"); +} + +function formatKeys(keys: KeyId[], options: KeyTextFormatOptions = {}): string { + if (keys.length === 0) return ""; + return formatKeyText(keys.join("/"), options); +} + +export function keyText(keybinding: Keybinding): string { + return formatKeys(getKeybindings().getKeys(keybinding)); +} + +export function keyDisplayText(keybinding: Keybinding): string { + return formatKeys(getKeybindings().getKeys(keybinding), { capitalize: true }); +} + +export function keyHint(keybinding: Keybinding, description: string): string { + return theme.fg("dim", keyText(keybinding)) + theme.fg("muted", ` ${description}`); +} + +export function rawKeyHint(key: string, description: string): string { + return theme.fg("dim", formatKeyText(key)) + theme.fg("muted", ` ${description}`); +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/model-selector.ts new file mode 100644 index 000000000..327119293 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -0,0 +1,337 @@ +import { type Model, modelsAreEqual } from "@earendil-works/pi-ai"; +import { + Container, + type Focusable, + fuzzyFilter, + getKeybindings, + Input, + Spacer, + Text, + type TUI, +} from "@earendil-works/pi-tui"; +import type { ModelRegistry } from "../../../core/model-registry.ts"; +import type { SettingsManager } from "../../../core/settings-manager.ts"; +import { getModelSelectorSearchText } from "../model-search.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint } from "./keybinding-hints.ts"; + +interface ModelItem { + provider: string; + id: string; + model: Model; +} + +interface ScopedModelItem { + model: Model; + thinkingLevel?: string; +} + +type ModelScope = "all" | "scoped"; + +/** + * Component that renders a model selector with search + */ +export class ModelSelectorComponent extends Container implements Focusable { + private searchInput: Input; + + // Focusable implementation - propagate to searchInput for IME cursor positioning + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + private listContainer: Container; + private allModels: ModelItem[] = []; + private scopedModelItems: ModelItem[] = []; + private activeModels: ModelItem[] = []; + private filteredModels: ModelItem[] = []; + private selectedIndex: number = 0; + private currentModel?: Model; + private settingsManager: SettingsManager; + private modelRegistry: ModelRegistry; + private onSelectCallback: (model: Model) => void; + private onCancelCallback: () => void; + private errorMessage?: string; + private tui: TUI; + private scopedModels: ReadonlyArray; + private scope: ModelScope = "all"; + private scopeText?: Text; + private scopeHintText?: Text; + + constructor( + tui: TUI, + currentModel: Model | undefined, + settingsManager: SettingsManager, + modelRegistry: ModelRegistry, + scopedModels: ReadonlyArray, + onSelect: (model: Model) => void, + onCancel: () => void, + initialSearchInput?: string, + ) { + super(); + + this.tui = tui; + this.currentModel = currentModel; + this.settingsManager = settingsManager; + this.modelRegistry = modelRegistry; + this.scopedModels = scopedModels; + this.scope = scopedModels.length > 0 ? "scoped" : "all"; + this.onSelectCallback = onSelect; + this.onCancelCallback = onCancel; + + // Add top border + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + // Add hint about model filtering + if (scopedModels.length > 0) { + this.scopeText = new Text(this.getScopeText(), 0, 0); + this.addChild(this.scopeText); + this.scopeHintText = new Text(this.getScopeHintText(), 0, 0); + this.addChild(this.scopeHintText); + } else { + const hintText = "Only showing models from configured providers. Use /login to add providers."; + this.addChild(new Text(theme.fg("warning", hintText), 0, 0)); + } + this.addChild(new Spacer(1)); + + // Create search input + this.searchInput = new Input(); + if (initialSearchInput) { + this.searchInput.setValue(initialSearchInput); + } + this.searchInput.onSubmit = () => { + // Enter on search input selects the first filtered item + if (this.filteredModels[this.selectedIndex]) { + this.handleSelect(this.filteredModels[this.selectedIndex].model); + } + }; + this.addChild(this.searchInput); + + this.addChild(new Spacer(1)); + + // Create list container + this.listContainer = new Container(); + this.addChild(this.listContainer); + + this.addChild(new Spacer(1)); + + // Add bottom border + this.addChild(new DynamicBorder()); + + // Load models and do initial render + this.loadModels().then(() => { + if (initialSearchInput) { + this.filterModels(initialSearchInput); + } else { + this.updateList(); + } + // Request re-render after models are loaded + this.tui.requestRender(); + }); + } + + private async loadModels(): Promise { + let models: ModelItem[]; + + // Refresh to pick up any changes to models.json + this.modelRegistry.refresh(); + + // Check for models.json errors + const loadError = this.modelRegistry.getError(); + if (loadError) { + this.errorMessage = loadError; + } + + // Load available models (built-in models still work even if models.json failed) + try { + const availableModels = await this.modelRegistry.getAvailable(); + models = availableModels.map((model: Model) => ({ + provider: model.provider, + id: model.id, + model, + })); + } catch (error) { + this.allModels = []; + this.scopedModelItems = []; + this.activeModels = []; + this.filteredModels = []; + this.errorMessage = error instanceof Error ? error.message : String(error); + return; + } + + this.allModels = this.sortModels(models); + this.scopedModels = this.scopedModels.map((scoped) => { + const refreshed = this.modelRegistry.find(scoped.model.provider, scoped.model.id); + return refreshed ? { ...scoped, model: refreshed } : scoped; + }); + this.scopedModelItems = this.scopedModels.map((scoped) => ({ + provider: scoped.model.provider, + id: scoped.model.id, + model: scoped.model, + })); + this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels; + this.filteredModels = this.activeModels; + const currentIndex = this.filteredModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model)); + this.selectedIndex = + currentIndex >= 0 ? currentIndex : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1)); + } + + private sortModels(models: ModelItem[]): ModelItem[] { + const sorted = [...models]; + // Sort: current model first, then by provider + sorted.sort((a, b) => { + const aIsCurrent = modelsAreEqual(this.currentModel, a.model); + const bIsCurrent = modelsAreEqual(this.currentModel, b.model); + if (aIsCurrent && !bIsCurrent) return -1; + if (!aIsCurrent && bIsCurrent) return 1; + return a.provider.localeCompare(b.provider); + }); + return sorted; + } + + private getScopeText(): string { + const allText = this.scope === "all" ? theme.fg("accent", "all") : theme.fg("muted", "all"); + const scopedText = this.scope === "scoped" ? theme.fg("accent", "scoped") : theme.fg("muted", "scoped"); + return `${theme.fg("muted", "Scope: ")}${allText}${theme.fg("muted", " | ")}${scopedText}`; + } + + private getScopeHintText(): string { + return keyHint("tui.input.tab", "scope") + theme.fg("muted", " (all/scoped)"); + } + + private setScope(scope: ModelScope): void { + if (this.scope === scope) return; + this.scope = scope; + this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels; + const currentIndex = this.activeModels.findIndex((item) => modelsAreEqual(this.currentModel, item.model)); + this.selectedIndex = currentIndex >= 0 ? currentIndex : 0; + this.filterModels(this.searchInput.getValue()); + if (this.scopeText) { + this.scopeText.setText(this.getScopeText()); + } + } + + private filterModels(query: string): void { + this.filteredModels = query + ? fuzzyFilter(this.activeModels, query, ({ id, provider, model }) => + getModelSelectorSearchText({ id, provider, name: model.name }), + ) + : this.activeModels; + this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1)); + this.updateList(); + } + + private updateList(): void { + this.listContainer.clear(); + + const maxVisible = 10; + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible), + ); + const endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length); + + // Show visible slice of filtered models + for (let i = startIndex; i < endIndex; i++) { + const item = this.filteredModels[i]; + if (!item) continue; + + const isSelected = i === this.selectedIndex; + const isCurrent = modelsAreEqual(this.currentModel, item.model); + + let line = ""; + if (isSelected) { + const prefix = theme.fg("accent", "→ "); + const modelText = `${item.id}`; + const providerBadge = theme.fg("muted", `[${item.provider}]`); + const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; + line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${checkmark}`; + } else { + const modelText = ` ${item.id}`; + const providerBadge = theme.fg("muted", `[${item.provider}]`); + const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; + line = `${modelText} ${providerBadge}${checkmark}`; + } + + this.listContainer.addChild(new Text(line, 0, 0)); + } + + // Add scroll indicator if needed + if (startIndex > 0 || endIndex < this.filteredModels.length) { + const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`); + this.listContainer.addChild(new Text(scrollInfo, 0, 0)); + } + + // Show error message or "no results" if empty + if (this.errorMessage) { + // Show error in red + const errorLines = this.errorMessage.split("\n"); + for (const line of errorLines) { + this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0)); + } + } else if (this.filteredModels.length === 0) { + this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0)); + } else { + const selected = this.filteredModels[this.selectedIndex]; + this.listContainer.addChild(new Spacer(1)); + this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0)); + } + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.input.tab")) { + if (this.scopedModelItems.length > 0) { + const nextScope: ModelScope = this.scope === "all" ? "scoped" : "all"; + this.setScope(nextScope); + if (this.scopeHintText) { + this.scopeHintText.setText(this.getScopeHintText()); + } + } + return; + } + // Up arrow - wrap to bottom when at top + if (kb.matches(keyData, "tui.select.up")) { + if (this.filteredModels.length === 0) return; + this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1; + this.updateList(); + } + // Down arrow - wrap to top when at bottom + else if (kb.matches(keyData, "tui.select.down")) { + if (this.filteredModels.length === 0) return; + this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1; + this.updateList(); + } + // Enter + else if (kb.matches(keyData, "tui.select.confirm")) { + const selectedModel = this.filteredModels[this.selectedIndex]; + if (selectedModel) { + this.handleSelect(selectedModel.model); + } + } + // Escape or Ctrl+C + else if (kb.matches(keyData, "tui.select.cancel")) { + this.onCancelCallback(); + } + // Pass everything else to search input + else { + this.searchInput.handleInput(keyData); + this.filterModels(this.searchInput.getValue()); + } + } + + private handleSelect(model: Model): void { + // Save as new default + this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); + this.onSelectCallback(model); + } + + getSearchInput(): Input { + return this.searchInput; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts new file mode 100644 index 000000000..772e3af06 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/scoped-models-selector.ts @@ -0,0 +1,360 @@ +import type { Model } from "@earendil-works/pi-ai"; +import { + Container, + type Focusable, + fuzzyFilter, + getKeybindings, + Input, + Key, + matchesKey, + Spacer, + Text, +} from "@earendil-works/pi-tui"; +import { getModelSearchText } from "../model-search.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyText } from "./keybinding-hints.ts"; + +// EnabledIds: null = all enabled (no filter), string[] = explicit ordered list +type EnabledIds = string[] | null; + +function isEnabled(enabledIds: EnabledIds, id: string): boolean { + return enabledIds === null || enabledIds.includes(id); +} + +function toggle(enabledIds: EnabledIds, id: string): EnabledIds { + if (enabledIds === null) return [id]; // First toggle: start with only this one + const index = enabledIds.indexOf(id); + if (index >= 0) return [...enabledIds.slice(0, index), ...enabledIds.slice(index + 1)]; + return [...enabledIds, id]; +} + +function enableAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds { + if (enabledIds === null) return null; // Already all enabled + const targets = targetIds ?? allIds; + const result = [...enabledIds]; + for (const id of targets) { + if (!result.includes(id)) result.push(id); + } + return result.length === allIds.length ? null : result; +} + +function clearAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds { + if (enabledIds === null) { + return targetIds ? allIds.filter((id) => !targetIds.includes(id)) : []; + } + const targets = new Set(targetIds ?? enabledIds); + return enabledIds.filter((id) => !targets.has(id)); +} + +function move(enabledIds: EnabledIds, id: string, delta: number): EnabledIds { + if (enabledIds === null) return null; + const list = [...enabledIds]; + const index = list.indexOf(id); + if (index < 0) return list; + const newIndex = index + delta; + if (newIndex < 0 || newIndex >= list.length) return list; + const result = [...list]; + [result[index], result[newIndex]] = [result[newIndex], result[index]]; + return result; +} + +function getSortedIds(enabledIds: EnabledIds, allIds: string[]): string[] { + if (enabledIds === null) return allIds; + const enabledSet = new Set(enabledIds); + return [...enabledIds, ...allIds.filter((id) => !enabledSet.has(id))]; +} + +interface ModelItem { + fullId: string; + model: Model; + enabled: boolean; +} + +export interface ModelsConfig { + allModels: Model[]; + enabledModelIds: string[] | null; +} + +export interface ModelsCallbacks { + /** Called whenever the enabled model set or order changes (session-only, no persist) */ + onChange: (enabledModelIds: string[] | null) => void | Promise; + /** Called when user wants to persist current selection to settings */ + onPersist: (enabledModelIds: string[] | null) => void | Promise; + onCancel: () => void; +} + +/** + * Component for enabling/disabling models for Ctrl+P cycling. + * Changes are session-only until explicitly persisted with Ctrl+S. + */ +export class ScopedModelsSelectorComponent extends Container implements Focusable { + private modelsById: Map> = new Map(); + private allIds: string[] = []; + private enabledIds: EnabledIds = null; + private filteredItems: ModelItem[] = []; + private selectedIndex = 0; + private searchInput: Input; + + // Focusable implementation - propagate to searchInput for IME cursor positioning + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + private listContainer: Container; + private footerText: Text; + private callbacks: ModelsCallbacks; + private maxVisible = 8; + private isDirty = false; + + constructor(config: ModelsConfig, callbacks: ModelsCallbacks) { + super(); + this.callbacks = callbacks; + + for (const model of config.allModels) { + const fullId = `${model.provider}/${model.id}`; + this.modelsById.set(fullId, model); + this.allIds.push(fullId); + } + + this.enabledIds = config.enabledModelIds === null ? null : [...config.enabledModelIds]; + this.filteredItems = this.buildItems(); + + // Header + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", theme.bold("Model Configuration")), 0, 0)); + this.addChild( + new Text(theme.fg("muted", `Session-only. ${keyText("app.models.save")} to save to settings.`), 0, 0), + ); + this.addChild(new Spacer(1)); + + // Search input + this.searchInput = new Input(); + this.addChild(this.searchInput); + this.addChild(new Spacer(1)); + + // List container + this.listContainer = new Container(); + this.addChild(this.listContainer); + + // Footer hint + this.addChild(new Spacer(1)); + this.footerText = new Text(this.getFooterText(), 0, 0); + this.addChild(this.footerText); + + this.addChild(new DynamicBorder()); + this.updateList(); + } + + private buildItems(): ModelItem[] { + // Filter out IDs that no longer have a corresponding model (e.g., after logout) + return getSortedIds(this.enabledIds, this.allIds) + .filter((id) => this.modelsById.has(id)) + .map((id) => ({ + fullId: id, + model: this.modelsById.get(id)!, + enabled: isEnabled(this.enabledIds, id), + })); + } + + private getFooterText(): string { + const enabledCount = this.enabledIds?.length ?? this.allIds.length; + const allEnabled = this.enabledIds === null; + const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.allIds.length} enabled`; + const parts = [ + `${keyText("tui.select.confirm")} toggle`, + `${keyText("app.models.enableAll")} all`, + `${keyText("app.models.clearAll")} clear`, + `${keyText("app.models.toggleProvider")} provider`, + `${keyText("app.models.reorderUp")}/${keyText("app.models.reorderDown")} reorder`, + `${keyText("app.models.save")} save`, + countText, + ]; + return this.isDirty + ? theme.fg("dim", ` ${parts.join(" · ")} `) + theme.fg("warning", "(unsaved)") + : theme.fg("dim", ` ${parts.join(" · ")}`); + } + + private refresh(): void { + const query = this.searchInput.getValue(); + const items = this.buildItems(); + this.filteredItems = query + ? fuzzyFilter(items, query, (i) => + getModelSearchText({ id: i.model.id, provider: i.model.provider, name: i.model.name }), + ) + : items; + this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1)); + this.updateList(); + this.footerText.setText(this.getFooterText()); + } + + private notifyChange(): void { + this.callbacks.onChange(this.enabledIds === null ? null : [...this.enabledIds]); + } + + private updateList(): void { + this.listContainer.clear(); + + if (this.filteredItems.length === 0) { + this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0)); + return; + } + + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); + const allEnabled = this.enabledIds === null; + + for (let i = startIndex; i < endIndex; i++) { + const item = this.filteredItems[i]!; + const isSelected = i === this.selectedIndex; + const prefix = isSelected ? theme.fg("accent", "→ ") : " "; + const modelText = isSelected ? theme.fg("accent", item.model.id) : item.model.id; + const providerBadge = theme.fg("muted", ` [${item.model.provider}]`); + const status = allEnabled ? "" : item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗"); + this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0)); + } + + // Add scroll indicator if needed + if (startIndex > 0 || endIndex < this.filteredItems.length) { + this.listContainer.addChild( + new Text(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredItems.length})`), 0, 0), + ); + } + + if (this.filteredItems.length > 0) { + const selected = this.filteredItems[this.selectedIndex]; + this.listContainer.addChild(new Spacer(1)); + this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0)); + } + } + + handleInput(data: string): void { + const kb = getKeybindings(); + + // Navigation + if (kb.matches(data, "tui.select.up")) { + if (this.filteredItems.length === 0) return; + this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1; + this.updateList(); + return; + } + if (kb.matches(data, "tui.select.down")) { + if (this.filteredItems.length === 0) return; + this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; + this.updateList(); + return; + } + + // Reorder enabled models + const reorderUp = kb.matches(data, "app.models.reorderUp"); + const reorderDown = kb.matches(data, "app.models.reorderDown"); + if (reorderUp || reorderDown) { + if (this.enabledIds === null) return; + const item = this.filteredItems[this.selectedIndex]; + if (item && isEnabled(this.enabledIds, item.fullId)) { + const delta = reorderUp ? -1 : 1; + const currentIndex = this.enabledIds.indexOf(item.fullId); + const newIndex = currentIndex + delta; + // Only move if within bounds + if (newIndex >= 0 && newIndex < this.enabledIds.length) { + this.enabledIds = move(this.enabledIds, item.fullId, delta); + this.isDirty = true; + this.selectedIndex += delta; + this.refresh(); + this.notifyChange(); + } + } + return; + } + + // Toggle on Enter + if (kb.matches(data, "tui.select.confirm")) { + const item = this.filteredItems[this.selectedIndex]; + if (item) { + this.enabledIds = toggle(this.enabledIds, item.fullId); + this.isDirty = true; + this.refresh(); + this.notifyChange(); + } + return; + } + + // Enable all (filtered if search active, otherwise all) + if (kb.matches(data, "app.models.enableAll")) { + const targetIds = this.searchInput.getValue() ? this.filteredItems.map((i) => i.fullId) : undefined; + this.enabledIds = enableAll(this.enabledIds, this.allIds, targetIds); + this.isDirty = true; + this.refresh(); + this.notifyChange(); + return; + } + + // Clear all (filtered if search active, otherwise all) + if (kb.matches(data, "app.models.clearAll")) { + const targetIds = this.searchInput.getValue() ? this.filteredItems.map((i) => i.fullId) : undefined; + this.enabledIds = clearAll(this.enabledIds, this.allIds, targetIds); + this.isDirty = true; + this.refresh(); + this.notifyChange(); + return; + } + + // Toggle provider of current item + if (kb.matches(data, "app.models.toggleProvider")) { + const item = this.filteredItems[this.selectedIndex]; + if (item) { + const provider = item.model.provider; + const providerIds = this.allIds.filter((id) => this.modelsById.get(id)!.provider === provider); + const allEnabled = providerIds.every((id) => isEnabled(this.enabledIds, id)); + this.enabledIds = allEnabled + ? clearAll(this.enabledIds, this.allIds, providerIds) + : enableAll(this.enabledIds, this.allIds, providerIds); + this.isDirty = true; + this.refresh(); + this.notifyChange(); + } + return; + } + + // Save/persist to settings + if (kb.matches(data, "app.models.save")) { + this.callbacks.onPersist(this.enabledIds === null ? null : [...this.enabledIds]); + this.isDirty = false; + this.footerText.setText(this.getFooterText()); + return; + } + + // Ctrl+C - clear search or cancel if empty + if (matchesKey(data, Key.ctrl("c"))) { + if (this.searchInput.getValue()) { + this.searchInput.setValue(""); + this.refresh(); + } else { + this.callbacks.onCancel(); + } + return; + } + + // Escape - cancel + if (matchesKey(data, Key.escape)) { + this.callbacks.onCancel(); + return; + } + + // Pass everything else to search input + this.searchInput.handleInput(data); + this.refresh(); + } + + getSearchInput(): Input { + return this.searchInput; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts new file mode 100644 index 000000000..9b5bf2327 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/session-selector-search.ts @@ -0,0 +1,194 @@ +import { fuzzyMatch } from "@earendil-works/pi-tui"; +import type { SessionInfo } from "../../../core/session-manager.ts"; + +export type SortMode = "threaded" | "recent" | "relevance"; + +export type NameFilter = "all" | "named"; + +export interface ParsedSearchQuery { + mode: "tokens" | "regex"; + tokens: { kind: "fuzzy" | "phrase"; value: string }[]; + regex: RegExp | null; + /** If set, parsing failed and we should treat query as non-matching. */ + error?: string; +} + +export interface MatchResult { + matches: boolean; + /** Lower is better; only meaningful when matches === true */ + score: number; +} + +function normalizeWhitespaceLower(text: string): string { + return text.toLowerCase().replace(/\s+/g, " ").trim(); +} + +function getSessionSearchText(session: SessionInfo): string { + return `${session.id} ${session.name ?? ""} ${session.allMessagesText} ${session.cwd}`; +} + +export function hasSessionName(session: SessionInfo): boolean { + return Boolean(session.name?.trim()); +} + +function matchesNameFilter(session: SessionInfo, filter: NameFilter): boolean { + if (filter === "all") return true; + return hasSessionName(session); +} + +export function parseSearchQuery(query: string): ParsedSearchQuery { + const trimmed = query.trim(); + if (!trimmed) { + return { mode: "tokens", tokens: [], regex: null }; + } + + // Regex mode: re: + if (trimmed.startsWith("re:")) { + const pattern = trimmed.slice(3).trim(); + if (!pattern) { + return { mode: "regex", tokens: [], regex: null, error: "Empty regex" }; + } + try { + return { mode: "regex", tokens: [], regex: new RegExp(pattern, "i") }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { mode: "regex", tokens: [], regex: null, error: msg }; + } + } + + // Token mode with quote support. + // Example: foo "node cve" bar + const tokens: { kind: "fuzzy" | "phrase"; value: string }[] = []; + let buf = ""; + let inQuote = false; + let hadUnclosedQuote = false; + + const flush = (kind: "fuzzy" | "phrase"): void => { + const v = buf.trim(); + buf = ""; + if (!v) return; + tokens.push({ kind, value: v }); + }; + + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i]!; + if (ch === '"') { + if (inQuote) { + flush("phrase"); + inQuote = false; + } else { + flush("fuzzy"); + inQuote = true; + } + continue; + } + + if (!inQuote && /\s/.test(ch)) { + flush("fuzzy"); + continue; + } + + buf += ch; + } + + if (inQuote) { + hadUnclosedQuote = true; + } + + // If quotes were unbalanced, fall back to plain whitespace tokenization. + if (hadUnclosedQuote) { + return { + mode: "tokens", + tokens: trimmed + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0) + .map((t) => ({ kind: "fuzzy" as const, value: t })), + regex: null, + }; + } + + flush(inQuote ? "phrase" : "fuzzy"); + + return { mode: "tokens", tokens, regex: null }; +} + +export function matchSession(session: SessionInfo, parsed: ParsedSearchQuery): MatchResult { + const text = getSessionSearchText(session); + + if (parsed.mode === "regex") { + if (!parsed.regex) { + return { matches: false, score: 0 }; + } + const idx = text.search(parsed.regex); + if (idx < 0) return { matches: false, score: 0 }; + return { matches: true, score: idx * 0.1 }; + } + + if (parsed.tokens.length === 0) { + return { matches: true, score: 0 }; + } + + let totalScore = 0; + let normalizedText: string | null = null; + + for (const token of parsed.tokens) { + if (token.kind === "phrase") { + if (normalizedText === null) { + normalizedText = normalizeWhitespaceLower(text); + } + const phrase = normalizeWhitespaceLower(token.value); + if (!phrase) continue; + const idx = normalizedText.indexOf(phrase); + if (idx < 0) return { matches: false, score: 0 }; + totalScore += idx * 0.1; + continue; + } + + const m = fuzzyMatch(token.value, text); + if (!m.matches) return { matches: false, score: 0 }; + totalScore += m.score; + } + + return { matches: true, score: totalScore }; +} + +export function filterAndSortSessions( + sessions: SessionInfo[], + query: string, + sortMode: SortMode, + nameFilter: NameFilter = "all", +): SessionInfo[] { + const nameFiltered = + nameFilter === "all" ? sessions : sessions.filter((session) => matchesNameFilter(session, nameFilter)); + const trimmed = query.trim(); + if (!trimmed) return nameFiltered; + + const parsed = parseSearchQuery(query); + if (parsed.error) return []; + + // Recent mode: filter only, keep incoming order. + if (sortMode === "recent") { + const filtered: SessionInfo[] = []; + for (const s of nameFiltered) { + const res = matchSession(s, parsed); + if (res.matches) filtered.push(s); + } + return filtered; + } + + // Relevance mode: sort by score, tie-break by modified desc. + const scored: { session: SessionInfo; score: number }[] = []; + for (const s of nameFiltered) { + const res = matchSession(s, parsed); + if (!res.matches) continue; + scored.push({ session: s, score: res.score }); + } + + scored.sort((a, b) => { + if (a.score !== b.score) return a.score - b.score; + return b.session.modified.getTime() - a.session.modified.getTime(); + }); + + return scored.map((r) => r.session); +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/session-selector.ts new file mode 100644 index 000000000..4949eee51 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -0,0 +1,1031 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { unlink } from "node:fs/promises"; +import * as os from "node:os"; +import { + type Component, + Container, + type Focusable, + getKeybindings, + Input, + Spacer, + Text, + truncateToWidth, + visibleWidth, +} from "@earendil-works/pi-tui"; +import { KeybindingsManager } from "../../../core/keybindings.ts"; +import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.ts"; +import { canonicalizePath as _canonicalizePath } from "../../../utils/paths.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, keyText } from "./keybinding-hints.ts"; +import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode } from "./session-selector-search.ts"; + +type SessionScope = "current" | "all"; + +function shortenPath(path: string): string { + const home = os.homedir(); + if (!path) return path; + if (path.startsWith(home)) { + return `~${path.slice(home.length)}`; + } + return path; +} + +function formatSessionDate(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "now"; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}w`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo`; + return `${Math.floor(diffDays / 365)}y`; +} + +function canonicalizePath(path: string | undefined): string | undefined { + if (!path) return path; + return _canonicalizePath(path); +} + +class SessionSelectorHeader implements Component { + private scope: SessionScope; + private sortMode: SortMode; + private nameFilter: NameFilter; + private requestRender: () => void; + private loading = false; + private loadProgress: { loaded: number; total: number } | null = null; + private showPath = false; + private confirmingDeletePath: string | null = null; + private statusMessage: { type: "info" | "error"; message: string } | null = null; + private statusTimeout: ReturnType | null = null; + private showRenameHint = false; + + constructor(scope: SessionScope, sortMode: SortMode, nameFilter: NameFilter, requestRender: () => void) { + this.scope = scope; + this.sortMode = sortMode; + this.nameFilter = nameFilter; + this.requestRender = requestRender; + } + + setScope(scope: SessionScope): void { + this.scope = scope; + } + + setSortMode(sortMode: SortMode): void { + this.sortMode = sortMode; + } + + setNameFilter(nameFilter: NameFilter): void { + this.nameFilter = nameFilter; + } + + setLoading(loading: boolean): void { + this.loading = loading; + // Progress is scoped to the current load; clear whenever the loading state is set + this.loadProgress = null; + } + + setProgress(loaded: number, total: number): void { + this.loadProgress = { loaded, total }; + } + + setShowPath(showPath: boolean): void { + this.showPath = showPath; + } + + setShowRenameHint(show: boolean): void { + this.showRenameHint = show; + } + + setConfirmingDeletePath(path: string | null): void { + this.confirmingDeletePath = path; + } + + private clearStatusTimeout(): void { + if (!this.statusTimeout) return; + clearTimeout(this.statusTimeout); + this.statusTimeout = null; + } + + setStatusMessage(msg: { type: "info" | "error"; message: string } | null, autoHideMs?: number): void { + this.clearStatusTimeout(); + this.statusMessage = msg; + if (!msg || !autoHideMs) return; + + this.statusTimeout = setTimeout(() => { + this.statusMessage = null; + this.statusTimeout = null; + this.requestRender(); + }, autoHideMs); + } + + invalidate(): void {} + + render(width: number): string[] { + const title = this.scope === "current" ? "Resume Session (Current Folder)" : "Resume Session (All)"; + const leftText = theme.bold(title); + + const sortLabel = this.sortMode === "threaded" ? "Threaded" : this.sortMode === "recent" ? "Recent" : "Fuzzy"; + const sortText = theme.fg("muted", "Sort: ") + theme.fg("accent", sortLabel); + + const nameLabel = this.nameFilter === "all" ? "All" : "Named"; + const nameText = theme.fg("muted", "Name: ") + theme.fg("accent", nameLabel); + + let scopeText: string; + if (this.loading) { + const progressText = this.loadProgress ? `${this.loadProgress.loaded}/${this.loadProgress.total}` : "..."; + scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", `Loading ${progressText}`)}`; + } else if (this.scope === "current") { + scopeText = `${theme.fg("accent", "◉ Current Folder")}${theme.fg("muted", " | ○ All")}`; + } else { + scopeText = `${theme.fg("muted", "○ Current Folder | ")}${theme.fg("accent", "◉ All")}`; + } + + const rightText = truncateToWidth(`${scopeText} ${nameText} ${sortText}`, width, ""); + const availableLeft = Math.max(0, width - visibleWidth(rightText) - 1); + const left = truncateToWidth(leftText, availableLeft, ""); + const spacing = Math.max(0, width - visibleWidth(left) - visibleWidth(rightText)); + + // Build hint lines - changes based on state (all branches truncate to width) + let hintLine1: string; + let hintLine2: string; + if (this.confirmingDeletePath !== null) { + const confirmHint = `Delete session? ${keyHint("tui.select.confirm", "confirm")} · ${keyHint("tui.select.cancel", "cancel")}`; + hintLine1 = theme.fg("error", truncateToWidth(confirmHint, width, "…")); + hintLine2 = ""; + } else if (this.statusMessage) { + const color = this.statusMessage.type === "error" ? "error" : "accent"; + hintLine1 = theme.fg(color, truncateToWidth(this.statusMessage.message, width, "…")); + hintLine2 = ""; + } else { + const pathState = this.showPath ? "(on)" : "(off)"; + const sep = theme.fg("muted", " · "); + const hint1 = + keyHint("tui.input.tab", "scope") + sep + theme.fg("muted", 're: regex · "phrase" exact'); + const hint2Parts = [ + keyHint("app.session.toggleSort", "sort"), + keyHint("app.session.toggleNamedFilter", "named"), + keyHint("app.session.delete", "delete"), + keyHint("app.session.togglePath", `path ${pathState}`), + ]; + if (this.showRenameHint) { + hint2Parts.push(keyHint("app.session.rename", "rename")); + } + const hint2 = hint2Parts.join(sep); + hintLine1 = truncateToWidth(hint1, width, "…"); + hintLine2 = truncateToWidth(hint2, width, "…"); + } + + return [`${left}${" ".repeat(spacing)}${rightText}`, hintLine1, hintLine2]; + } +} + +/** A session tree node for hierarchical display */ +interface SessionTreeNode { + session: SessionInfo; + children: SessionTreeNode[]; + latestActivity: number; +} + +/** Flattened node for display with tree structure info */ +interface FlatSessionNode { + session: SessionInfo; + depth: number; + isLast: boolean; + /** For each ancestor level, whether there are more siblings after it */ + ancestorContinues: boolean[]; +} + +/** + * Build a tree structure from sessions based on parentSessionPath. + * Returns root nodes sorted by modified date (descending). + */ +function buildSessionTree(sessions: SessionInfo[]): SessionTreeNode[] { + const byPath = new Map(); + + for (const session of sessions) { + const sessionPath = canonicalizePath(session.path) ?? session.path; + byPath.set(sessionPath, { session, children: [], latestActivity: session.modified.getTime() }); + } + + const roots: SessionTreeNode[] = []; + + for (const session of sessions) { + const sessionPath = canonicalizePath(session.path) ?? session.path; + const node = byPath.get(sessionPath)!; + const parentPath = canonicalizePath(session.parentSessionPath); + + if (parentPath && byPath.has(parentPath)) { + byPath.get(parentPath)!.children.push(node); + } else { + roots.push(node); + } + } + + const updateLatestActivity = (node: SessionTreeNode): number => { + let latestActivity = node.session.modified.getTime(); + for (const child of node.children) { + latestActivity = Math.max(latestActivity, updateLatestActivity(child)); + } + node.latestActivity = latestActivity; + return latestActivity; + }; + + for (const root of roots) { + updateLatestActivity(root); + } + + // Sort children and roots by latest activity in each subtree (descending) + const sortNodes = (nodes: SessionTreeNode[]): void => { + nodes.sort((a, b) => b.latestActivity - a.latestActivity); + for (const node of nodes) { + sortNodes(node.children); + } + }; + sortNodes(roots); + + return roots; +} + +/** + * Flatten tree into display list with tree structure metadata. + */ +function flattenSessionTree(roots: SessionTreeNode[]): FlatSessionNode[] { + const result: FlatSessionNode[] = []; + + const walk = (node: SessionTreeNode, depth: number, ancestorContinues: boolean[], isLast: boolean): void => { + result.push({ session: node.session, depth, isLast, ancestorContinues }); + + for (let i = 0; i < node.children.length; i++) { + const childIsLast = i === node.children.length - 1; + // Only show continuation line for non-root ancestors + const continues = depth > 0 ? !isLast : false; + walk(node.children[i]!, depth + 1, [...ancestorContinues, continues], childIsLast); + } + }; + + for (let i = 0; i < roots.length; i++) { + walk(roots[i]!, 0, [], i === roots.length - 1); + } + + return result; +} + +/** + * Custom session list component with multi-line items and search + */ +class SessionList implements Component, Focusable { + public getSelectedSessionPath(): string | undefined { + const selected = this.filteredSessions[this.selectedIndex]; + return selected?.session.path; + } + private allSessions: SessionInfo[] = []; + private filteredSessions: FlatSessionNode[] = []; + private selectedIndex: number = 0; + private searchInput: Input; + private showCwd = false; + private sortMode: SortMode = "threaded"; + private nameFilter: NameFilter = "all"; + private keybindings: KeybindingsManager; + private showPath = false; + private confirmingDeletePath: string | null = null; + private currentSessionCanonicalPath?: string; + public onSelect?: (sessionPath: string) => void; + public onCancel?: () => void; + public onExit: () => void = () => {}; + public onToggleScope?: () => void; + public onToggleSort?: () => void; + public onToggleNameFilter?: () => void; + public onTogglePath?: (showPath: boolean) => void; + public onDeleteConfirmationChange?: (path: string | null) => void; + public onDeleteSession?: (sessionPath: string) => Promise; + public onRenameSession?: (sessionPath: string) => void; + public onError?: (message: string) => void; + private maxVisible: number = 10; // Max sessions visible (one line each) + + // Focusable implementation - propagate to searchInput for IME cursor positioning + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.searchInput.focused = value; + } + + constructor( + sessions: SessionInfo[], + showCwd: boolean, + sortMode: SortMode, + nameFilter: NameFilter, + keybindings: KeybindingsManager, + currentSessionFilePath?: string, + ) { + this.allSessions = sessions; + this.filteredSessions = []; + this.searchInput = new Input(); + this.showCwd = showCwd; + this.sortMode = sortMode; + this.nameFilter = nameFilter; + this.keybindings = keybindings; + this.currentSessionCanonicalPath = canonicalizePath(currentSessionFilePath); + this.filterSessions(""); + + // Handle Enter in search input - select current item + this.searchInput.onSubmit = () => { + if (this.filteredSessions[this.selectedIndex]) { + const selected = this.filteredSessions[this.selectedIndex]; + if (this.onSelect) { + this.onSelect(selected.session.path); + } + } + }; + } + + setSortMode(sortMode: SortMode): void { + this.sortMode = sortMode; + this.filterSessions(this.searchInput.getValue()); + } + + setNameFilter(nameFilter: NameFilter): void { + this.nameFilter = nameFilter; + this.filterSessions(this.searchInput.getValue()); + } + + setSessions(sessions: SessionInfo[], showCwd: boolean): void { + this.allSessions = sessions; + this.showCwd = showCwd; + this.filterSessions(this.searchInput.getValue()); + } + + private filterSessions(query: string): void { + const trimmed = query.trim(); + const nameFiltered = + this.nameFilter === "all" ? this.allSessions : this.allSessions.filter((session) => hasSessionName(session)); + + if (this.sortMode === "threaded" && !trimmed) { + // Threaded mode without search: show tree structure + const roots = buildSessionTree(nameFiltered); + this.filteredSessions = flattenSessionTree(roots); + } else { + // Other modes or with search: flat list + const filtered = filterAndSortSessions(nameFiltered, query, this.sortMode, "all"); + this.filteredSessions = filtered.map((session) => ({ + session, + depth: 0, + isLast: true, + ancestorContinues: [], + })); + } + this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); + } + + private setConfirmingDeletePath(path: string | null): void { + this.confirmingDeletePath = path; + this.onDeleteConfirmationChange?.(path); + } + + private startDeleteConfirmationForSelectedSession(): void { + const selected = this.filteredSessions[this.selectedIndex]; + if (!selected) return; + + // Prevent deleting current session + if (this.isCurrentSessionPath(selected.session.path)) { + this.onError?.("Cannot delete the currently active session"); + return; + } + + this.setConfirmingDeletePath(selected.session.path); + } + + private isCurrentSessionPath(path: string): boolean { + if (!this.currentSessionCanonicalPath) return false; + return (canonicalizePath(path) ?? path) === this.currentSessionCanonicalPath; + } + + invalidate(): void {} + + render(width: number): string[] { + const lines: string[] = []; + + // Render search input + lines.push(...this.searchInput.render(width)); + lines.push(""); // Blank line after search + + if (this.filteredSessions.length === 0) { + let emptyMessage: string; + if (this.nameFilter === "named") { + const toggleKey = keyText("app.session.toggleNamedFilter"); + if (this.showCwd) { + emptyMessage = ` No named sessions found. Press ${toggleKey} to show all.`; + } else { + emptyMessage = ` No named sessions in current folder. Press ${toggleKey} to show all, or Tab to view all.`; + } + } else if (this.showCwd) { + // "All" scope - no sessions anywhere that match filter + emptyMessage = " No sessions found"; + } else { + // "Current folder" scope - hint to try "all" + emptyMessage = " No sessions in current folder. Press Tab to view all."; + } + lines.push(theme.fg("muted", truncateToWidth(emptyMessage, width, "…"))); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length); + + // Render visible sessions (one line each with tree structure) + for (let i = startIndex; i < endIndex; i++) { + const node = this.filteredSessions[i]!; + const session = node.session; + const isSelected = i === this.selectedIndex; + const isConfirmingDelete = session.path === this.confirmingDeletePath; + const isCurrent = this.isCurrentSessionPath(session.path); + + // Build tree prefix + const prefix = this.buildTreePrefix(node); + + // Session display text (name or first message) + const hasName = !!session.name; + const displayText = session.name ?? session.firstMessage; + const normalizedMessage = displayText.replace(/[\x00-\x1f\x7f]/g, " ").trim(); + + // Right side: message count and age + const age = formatSessionDate(session.modified); + const msgCount = String(session.messageCount); + let rightPart = `${msgCount} ${age}`; + if (this.showCwd && session.cwd) { + rightPart = `${shortenPath(session.cwd)} ${rightPart}`; + } + if (this.showPath) { + rightPart = `${shortenPath(session.path)} ${rightPart}`; + } + + // Cursor + const cursor = isSelected ? theme.fg("accent", "› ") : " "; + + // Calculate available width for message + const prefixWidth = visibleWidth(prefix); + const rightWidth = visibleWidth(rightPart) + 2; // +2 for spacing + const availableForMsg = width - 2 - prefixWidth - rightWidth; // -2 for cursor + + const truncatedMsg = truncateToWidth(normalizedMessage, Math.max(10, availableForMsg), "…"); + + // Style message + let messageColor: "error" | "warning" | "accent" | null = null; + if (isConfirmingDelete) { + messageColor = "error"; + } else if (isCurrent) { + messageColor = "accent"; + } else if (hasName) { + messageColor = "warning"; + } + let styledMsg = messageColor ? theme.fg(messageColor, truncatedMsg) : truncatedMsg; + if (isSelected) { + styledMsg = theme.bold(styledMsg); + } + + // Build line + const leftPart = cursor + theme.fg("dim", prefix) + styledMsg; + const leftWidth = visibleWidth(leftPart); + const spacing = Math.max(1, width - leftWidth - visibleWidth(rightPart)); + const styledRight = theme.fg(isConfirmingDelete ? "error" : "dim", rightPart); + + let line = leftPart + " ".repeat(spacing) + styledRight; + if (isSelected) { + line = theme.bg("selectedBg", line); + } + lines.push(truncateToWidth(line, width)); + } + + // Add scroll indicator if needed + if (startIndex > 0 || endIndex < this.filteredSessions.length) { + const scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`; + const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, "")); + lines.push(scrollInfo); + } + + return lines; + } + + private buildTreePrefix(node: FlatSessionNode): string { + if (node.depth === 0) { + return ""; + } + + const parts = node.ancestorContinues.map((continues) => (continues ? "│ " : " ")); + const branch = node.isLast ? "└─ " : "├─ "; + return parts.join("") + branch; + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + + // Handle delete confirmation state first - intercept all keys + if (this.confirmingDeletePath !== null) { + if (kb.matches(keyData, "tui.select.confirm")) { + const pathToDelete = this.confirmingDeletePath; + this.setConfirmingDeletePath(null); + void this.onDeleteSession?.(pathToDelete); + return; + } + if (kb.matches(keyData, "tui.select.cancel")) { + this.setConfirmingDeletePath(null); + return; + } + // Ignore all other keys while confirming + return; + } + + if (kb.matches(keyData, "tui.input.tab")) { + if (this.onToggleScope) { + this.onToggleScope(); + } + return; + } + + if (kb.matches(keyData, "app.session.toggleSort")) { + this.onToggleSort?.(); + return; + } + + if (this.keybindings.matches(keyData, "app.session.toggleNamedFilter")) { + this.onToggleNameFilter?.(); + return; + } + + // Ctrl+P: toggle path display + if (kb.matches(keyData, "app.session.togglePath")) { + this.showPath = !this.showPath; + this.onTogglePath?.(this.showPath); + return; + } + + // Ctrl+D: initiate delete confirmation (useful on terminals that don't distinguish Ctrl+Backspace from Backspace) + if (kb.matches(keyData, "app.session.delete")) { + this.startDeleteConfirmationForSelectedSession(); + return; + } + + // Rename selected session + if (kb.matches(keyData, "app.session.rename")) { + const selected = this.filteredSessions[this.selectedIndex]; + if (selected) { + this.onRenameSession?.(selected.session.path); + } + return; + } + + // Ctrl+Backspace: non-invasive convenience alias for delete + // Only triggers deletion when the query is empty; otherwise it is forwarded to the input + if (kb.matches(keyData, "app.session.deleteNoninvasive")) { + if (this.searchInput.getValue().length > 0) { + this.searchInput.handleInput(keyData); + this.filterSessions(this.searchInput.getValue()); + return; + } + + this.startDeleteConfirmationForSelectedSession(); + return; + } + + // Up arrow + if (kb.matches(keyData, "tui.select.up")) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + } + // Down arrow + else if (kb.matches(keyData, "tui.select.down")) { + this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1); + } + // Page up - jump up by maxVisible items + else if (kb.matches(keyData, "tui.select.pageUp")) { + this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible); + } + // Page down - jump down by maxVisible items + else if (kb.matches(keyData, "tui.select.pageDown")) { + this.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + this.maxVisible); + } + // Enter + else if (kb.matches(keyData, "tui.select.confirm")) { + const selected = this.filteredSessions[this.selectedIndex]; + if (selected && this.onSelect) { + this.onSelect(selected.session.path); + } + } + // Escape - cancel + else if (kb.matches(keyData, "tui.select.cancel")) { + if (this.onCancel) { + this.onCancel(); + } + } + // Pass everything else to search input + else { + this.searchInput.handleInput(keyData); + this.filterSessions(this.searchInput.getValue()); + } + } +} + +type SessionsLoader = (onProgress?: SessionListProgress) => Promise; + +/** + * Delete a session file, trying the `trash` CLI first, then falling back to unlink + */ +async function deleteSessionFile( + sessionPath: string, +): Promise<{ ok: boolean; method: "trash" | "unlink"; error?: string }> { + // Try `trash` first (if installed) + const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath]; + const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" }); + + const getTrashErrorHint = (): string | null => { + const parts: string[] = []; + if (trashResult.error) { + parts.push(trashResult.error.message); + } + const stderr = trashResult.stderr?.trim(); + if (stderr) { + parts.push(stderr.split("\n")[0] ?? stderr); + } + if (parts.length === 0) return null; + return `trash: ${parts.join(" · ").slice(0, 200)}`; + }; + + // If trash reports success, or the file is gone afterwards, treat it as successful + if (trashResult.status === 0 || !existsSync(sessionPath)) { + return { ok: true, method: "trash" }; + } + + // Fallback to permanent deletion + try { + await unlink(sessionPath); + return { ok: true, method: "unlink" }; + } catch (err) { + const unlinkError = err instanceof Error ? err.message : String(err); + const trashErrorHint = getTrashErrorHint(); + const error = trashErrorHint ? `${unlinkError} (${trashErrorHint})` : unlinkError; + return { ok: false, method: "unlink", error }; + } +} + +/** + * Component that renders a session selector + */ +export class SessionSelectorComponent extends Container implements Focusable { + handleInput(data: string): void { + if (this.mode === "rename") { + const kb = getKeybindings(); + if (kb.matches(data, "tui.select.cancel")) { + this.exitRenameMode(); + return; + } + this.renameInput.handleInput(data); + return; + } + + this.sessionList.handleInput(data); + } + + private canRename = true; + private sessionList: SessionList; + private header: SessionSelectorHeader; + private keybindings: KeybindingsManager; + private scope: SessionScope = "current"; + private sortMode: SortMode = "threaded"; + private nameFilter: NameFilter = "all"; + private currentSessions: SessionInfo[] | null = null; + private allSessions: SessionInfo[] | null = null; + private currentSessionsLoader: SessionsLoader; + private allSessionsLoader: SessionsLoader; + private requestRender: () => void; + private renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; + private currentLoading = false; + private allLoading = false; + private allLoadSeq = 0; + + private mode: "list" | "rename" = "list"; + private renameInput = new Input(); + private renameTargetPath: string | null = null; + + // Focusable implementation - propagate to sessionList for IME cursor positioning + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.sessionList.focused = value; + this.renameInput.focused = value; + if (value && this.mode === "rename") { + this.renameInput.focused = true; + } + } + + private buildBaseLayout(content: Component, options?: { showHeader?: boolean }): void { + this.clear(); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder((s) => theme.fg("accent", s))); + this.addChild(new Spacer(1)); + if (options?.showHeader ?? true) { + this.addChild(this.header); + this.addChild(new Spacer(1)); + } + this.addChild(content); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder((s) => theme.fg("accent", s))); + } + + constructor( + currentSessionsLoader: SessionsLoader, + allSessionsLoader: SessionsLoader, + onSelect: (sessionPath: string) => void, + onCancel: () => void, + onExit: () => void, + requestRender: () => void, + options?: { + renameSession?: (sessionPath: string, currentName: string | undefined) => Promise; + showRenameHint?: boolean; + keybindings?: KeybindingsManager; + }, + currentSessionFilePath?: string, + ) { + super(); + this.keybindings = options?.keybindings ?? KeybindingsManager.create(); + this.currentSessionsLoader = currentSessionsLoader; + this.allSessionsLoader = allSessionsLoader; + this.requestRender = requestRender; + this.header = new SessionSelectorHeader(this.scope, this.sortMode, this.nameFilter, this.requestRender); + const renameSession = options?.renameSession; + this.renameSession = renameSession; + this.canRename = !!renameSession; + this.header.setShowRenameHint(options?.showRenameHint ?? this.canRename); + + // Create session list (starts empty, will be populated after load) + this.sessionList = new SessionList( + [], + false, + this.sortMode, + this.nameFilter, + this.keybindings, + currentSessionFilePath, + ); + + this.buildBaseLayout(this.sessionList); + + this.renameInput.onSubmit = (value) => { + void this.confirmRename(value); + }; + + // Ensure header status timeouts are cleared when leaving the selector + const clearStatusMessage = () => this.header.setStatusMessage(null); + this.sessionList.onSelect = (sessionPath) => { + clearStatusMessage(); + onSelect(sessionPath); + }; + this.sessionList.onCancel = () => { + clearStatusMessage(); + onCancel(); + }; + this.sessionList.onExit = () => { + clearStatusMessage(); + onExit(); + }; + this.sessionList.onToggleScope = () => this.toggleScope(); + this.sessionList.onToggleSort = () => this.toggleSortMode(); + this.sessionList.onToggleNameFilter = () => this.toggleNameFilter(); + this.sessionList.onRenameSession = (sessionPath) => { + if (!renameSession) return; + if (this.scope === "current" && this.currentLoading) return; + if (this.scope === "all" && this.allLoading) return; + + const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []); + const session = sessions.find((s) => s.path === sessionPath); + this.enterRenameMode(sessionPath, session?.name); + }; + + // Sync list events to header + this.sessionList.onTogglePath = (showPath) => { + this.header.setShowPath(showPath); + this.requestRender(); + }; + this.sessionList.onDeleteConfirmationChange = (path) => { + this.header.setConfirmingDeletePath(path); + this.requestRender(); + }; + this.sessionList.onError = (msg) => { + this.header.setStatusMessage({ type: "error", message: msg }, 3000); + this.requestRender(); + }; + + // Handle session deletion + this.sessionList.onDeleteSession = async (sessionPath: string) => { + const result = await deleteSessionFile(sessionPath); + + if (result.ok) { + if (this.currentSessions) { + this.currentSessions = this.currentSessions.filter((s) => s.path !== sessionPath); + } + if (this.allSessions) { + this.allSessions = this.allSessions.filter((s) => s.path !== sessionPath); + } + + const sessions = this.scope === "all" ? (this.allSessions ?? []) : (this.currentSessions ?? []); + const showCwd = this.scope === "all"; + this.sessionList.setSessions(sessions, showCwd); + + const msg = result.method === "trash" ? "Session moved to trash" : "Session deleted"; + this.header.setStatusMessage({ type: "info", message: msg }, 2000); + await this.refreshSessionsAfterMutation(); + } else { + const errorMessage = result.error ?? "Unknown error"; + this.header.setStatusMessage({ type: "error", message: `Failed to delete: ${errorMessage}` }, 3000); + } + + this.requestRender(); + }; + + // Start loading current sessions immediately + this.loadCurrentSessions(); + } + + private loadCurrentSessions(): void { + void this.loadScope("current", "initial"); + } + + private enterRenameMode(sessionPath: string, currentName: string | undefined): void { + this.mode = "rename"; + this.renameTargetPath = sessionPath; + this.renameInput.setValue(currentName ?? ""); + this.renameInput.focused = true; + + const panel = new Container(); + panel.addChild(new Text(theme.bold("Rename Session"), 1, 0)); + panel.addChild(new Spacer(1)); + panel.addChild(this.renameInput); + panel.addChild(new Spacer(1)); + panel.addChild( + new Text( + theme.fg("muted", `${keyText("tui.select.confirm")} to save · ${keyText("tui.select.cancel")} to cancel`), + 1, + 0, + ), + ); + + this.buildBaseLayout(panel, { showHeader: false }); + this.requestRender(); + } + + private exitRenameMode(): void { + this.mode = "list"; + this.renameTargetPath = null; + + this.buildBaseLayout(this.sessionList); + + this.requestRender(); + } + + private async confirmRename(value: string): Promise { + const next = value.trim(); + if (!next) return; + const target = this.renameTargetPath; + if (!target) { + this.exitRenameMode(); + return; + } + + // Find current name for callback + const renameSession = this.renameSession; + if (!renameSession) { + this.exitRenameMode(); + return; + } + + try { + await renameSession(target, next); + await this.refreshSessionsAfterMutation(); + } finally { + this.exitRenameMode(); + } + } + + private async loadScope(scope: SessionScope, reason: "initial" | "refresh" | "toggle"): Promise { + const showCwd = scope === "all"; + + // Mark loading + if (scope === "current") { + this.currentLoading = true; + } else { + this.allLoading = true; + } + + const seq = scope === "all" ? ++this.allLoadSeq : undefined; + this.header.setScope(scope); + this.header.setLoading(true); + this.requestRender(); + + const onProgress = (loaded: number, total: number) => { + if (scope !== this.scope) return; + if (seq !== undefined && seq !== this.allLoadSeq) return; + this.header.setProgress(loaded, total); + this.requestRender(); + }; + + try { + const sessions = await (scope === "current" + ? this.currentSessionsLoader(onProgress) + : this.allSessionsLoader(onProgress)); + + if (scope === "current") { + this.currentSessions = sessions; + this.currentLoading = false; + } else { + this.allSessions = sessions; + this.allLoading = false; + } + + if (scope !== this.scope) return; + if (seq !== undefined && seq !== this.allLoadSeq) return; + + this.header.setLoading(false); + this.sessionList.setSessions(sessions, showCwd); + this.requestRender(); + } catch (err) { + if (scope === "current") { + this.currentLoading = false; + } else { + this.allLoading = false; + } + + if (scope !== this.scope) return; + if (seq !== undefined && seq !== this.allLoadSeq) return; + + const message = err instanceof Error ? err.message : String(err); + this.header.setLoading(false); + this.header.setStatusMessage({ type: "error", message: `Failed to load sessions: ${message}` }, 4000); + + if (reason === "initial") { + this.sessionList.setSessions([], showCwd); + } + this.requestRender(); + } + } + + private toggleSortMode(): void { + // Cycle: threaded -> recent -> relevance -> threaded + this.sortMode = this.sortMode === "threaded" ? "recent" : this.sortMode === "recent" ? "relevance" : "threaded"; + this.header.setSortMode(this.sortMode); + this.sessionList.setSortMode(this.sortMode); + this.requestRender(); + } + + private toggleNameFilter(): void { + this.nameFilter = this.nameFilter === "all" ? "named" : "all"; + this.header.setNameFilter(this.nameFilter); + this.sessionList.setNameFilter(this.nameFilter); + this.requestRender(); + } + + private async refreshSessionsAfterMutation(): Promise { + await this.loadScope(this.scope, "refresh"); + } + + private toggleScope(): void { + if (this.scope === "current") { + this.scope = "all"; + this.header.setScope(this.scope); + + if (this.allSessions !== null) { + this.header.setLoading(false); + this.sessionList.setSessions(this.allSessions, true); + this.requestRender(); + return; + } + + if (!this.allLoading) { + void this.loadScope("all", "toggle"); + } + return; + } + + this.scope = "current"; + this.header.setScope(this.scope); + this.header.setLoading(this.currentLoading); + this.sessionList.setSessions(this.currentSessions ?? [], false); + this.requestRender(); + } + + getSessionList(): SessionList { + return this.sessionList; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/settings-selector.ts new file mode 100644 index 000000000..2a82f41b7 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -0,0 +1,786 @@ +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { Transport } from "@earendil-works/pi-ai"; +import { + type Component, + Container, + getCapabilities, + type SelectItem, + SelectList, + type SelectListLayoutOptions, + type SettingItem, + SettingsList, + Spacer, + Text, +} from "@earendil-works/pi-tui"; +import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; +import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts"; +import { + getSelectListTheme, + getSettingsListTheme, + parseAutoThemeSetting, + type TerminalTheme, + theme, +} from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyDisplayText } from "./keybinding-hints.ts"; + +const SETTINGS_SUBMENU_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + +const THINKING_DESCRIPTIONS: Record = { + off: "No reasoning", + minimal: "Very brief reasoning (~1k tokens)", + low: "Light reasoning (~2k tokens)", + medium: "Moderate reasoning (~8k tokens)", + high: "Deep reasoning (~16k tokens)", + xhigh: "Maximum reasoning (~32k tokens)", +}; + +const DEFAULT_PROJECT_TRUST_LABELS: Record = { + ask: "Ask", + always: "Always trust", + never: "Never trust", +}; + +const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map( + Object.entries(DEFAULT_PROJECT_TRUST_LABELS).map(([value, label]) => [label, value as DefaultProjectTrust]), +); + +export interface SettingsConfig { + autoCompact: boolean; + showImages: boolean; + imageWidthCells: number; + autoResizeImages: boolean; + blockImages: boolean; + enableSkillCommands: boolean; + steeringMode: "all" | "one-at-a-time"; + followUpMode: "all" | "one-at-a-time"; + transport: Transport; + httpIdleTimeoutMs: number; + thinkingLevel: ThinkingLevel; + availableThinkingLevels: ThinkingLevel[]; + currentTheme: string; + terminalTheme: TerminalTheme; + availableThemes: string[]; + hideThinkingBlock: boolean; + doubleEscapeAction: "fork" | "tree" | "none"; + treeFilterMode: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; + showHardwareCursor: boolean; + editorPaddingX: number; + autocompleteMaxVisible: number; + quietStartup: boolean; + defaultProjectTrust: DefaultProjectTrust; + clearOnShrink: boolean; + showTerminalProgress: boolean; + warnings: WarningSettings; +} + +export interface SettingsCallbacks { + onAutoCompactChange: (enabled: boolean) => void; + onShowImagesChange: (enabled: boolean) => void; + onImageWidthCellsChange: (width: number) => void; + onAutoResizeImagesChange: (enabled: boolean) => void; + onBlockImagesChange: (blocked: boolean) => void; + onEnableSkillCommandsChange: (enabled: boolean) => void; + onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; + onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; + onTransportChange: (transport: Transport) => void; + onHttpIdleTimeoutMsChange: (timeoutMs: number) => void; + onThinkingLevelChange: (level: ThinkingLevel) => void; + onThemeChange: (theme: string) => void; + onThemePreview?: (theme: string) => void; + onHideThinkingBlockChange: (hidden: boolean) => void; + onDoubleEscapeActionChange: (action: "fork" | "tree" | "none") => void; + onTreeFilterModeChange: (mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all") => void; + onShowHardwareCursorChange: (enabled: boolean) => void; + onEditorPaddingXChange: (padding: number) => void; + onAutocompleteMaxVisibleChange: (maxVisible: number) => void; + onQuietStartupChange: (enabled: boolean) => void; + onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void; + onClearOnShrinkChange: (enabled: boolean) => void; + onShowTerminalProgressChange: (enabled: boolean) => void; + onWarningsChange: (warnings: WarningSettings) => void; + onCancel: () => void; +} + +/** + * A submenu component for selecting from a list of options. + */ +class WarningSettingsSubmenu extends Container { + private settingsList: SettingsList; + private state: WarningSettings; + + constructor(warnings: WarningSettings, onChange: (warnings: WarningSettings) => void, onCancel: () => void) { + super(); + + this.state = { ...warnings }; + + const items: SettingItem[] = [ + { + id: "anthropic-extra-usage", + label: "Anthropic extra usage", + description: "Warn when Anthropic subscription auth may use paid extra usage", + currentValue: (this.state.anthropicExtraUsage ?? true) ? "true" : "false", + values: ["true", "false"], + }, + ]; + + this.settingsList = new SettingsList( + items, + Math.min(items.length, 10), + getSettingsListTheme(), + (id, newValue) => { + switch (id) { + case "anthropic-extra-usage": + this.state = { ...this.state, anthropicExtraUsage: newValue === "true" }; + onChange({ ...this.state }); + break; + } + }, + onCancel, + ); + + this.addChild(this.settingsList); + } + + handleInput(data: string): void { + this.settingsList.handleInput(data); + } +} + +class SelectSubmenu extends Container { + private selectList: SelectList; + + constructor( + title: string, + description: string, + options: SelectItem[], + currentValue: string, + onSelect: (value: string) => void, + onCancel: () => void, + onSelectionChange?: (value: string) => void, + ) { + super(); + + // Title + this.addChild(new Text(theme.bold(theme.fg("accent", title)), 0, 0)); + + // Description + if (description) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", description), 0, 0)); + } + + // Spacer + this.addChild(new Spacer(1)); + + // Select list + this.selectList = new SelectList( + options, + Math.min(options.length, 10), + getSelectListTheme(), + SETTINGS_SUBMENU_SELECT_LIST_LAYOUT, + ); + + // Pre-select current value + const currentIndex = options.findIndex((o) => o.value === currentValue); + if (currentIndex !== -1) { + this.selectList.setSelectedIndex(currentIndex); + } + + this.selectList.onSelect = (item) => { + onSelect(item.value); + }; + + this.selectList.onCancel = onCancel; + + if (onSelectionChange) { + this.selectList.onSelectionChange = (item) => { + onSelectionChange(item.value); + }; + } + + this.addChild(this.selectList); + + // Hint + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0)); + } + + handleInput(data: string): void { + this.selectList.handleInput(data); + } +} + +function themeItems(availableThemes: string[]): SelectItem[] { + return availableThemes.map((name) => ({ value: name, label: name })); +} + +const AUTOMATIC_THEME_VALUE = "/"; + +function singleModeThemeItems(availableThemes: string[]): SelectItem[] { + return [ + { + value: AUTOMATIC_THEME_VALUE, + label: "Automatic", + description: "Use separate themes for light and dark terminal appearance", + }, + ...themeItems(availableThemes), + ]; +} + +function preferredTheme(availableThemes: string[], preferred: string | undefined, fallback: string): string { + if (preferred && availableThemes.includes(preferred)) return preferred; + if (availableThemes.includes(fallback)) return fallback; + return availableThemes[0] ?? fallback; +} + +function defaultAutomaticThemes( + currentThemeSetting: string, + availableThemes: string[], +): { lightTheme: string; darkTheme: string } { + const autoTheme = parseAutoThemeSetting(currentThemeSetting); + if (autoTheme) return autoTheme; + + const currentFixedTheme = currentThemeSetting.includes("/") ? undefined : currentThemeSetting; + const themeName = preferredTheme(availableThemes, currentFixedTheme, "dark"); + return { lightTheme: themeName, darkTheme: themeName }; +} + +class ThemeSubmenu extends Container { + private inputComponent: Component | undefined; + private readonly callbacks: SettingsCallbacks; + private readonly availableThemes: string[]; + private readonly terminalTheme: TerminalTheme; + private readonly onDone: (selectedValue?: string) => void; + private readonly originalThemeSetting: string; + private mode: "single" | "automatic"; + private singleTheme: string; + private lightTheme: string; + private darkTheme: string; + + constructor( + currentThemeSetting: string, + terminalTheme: TerminalTheme, + availableThemes: string[], + callbacks: SettingsCallbacks, + onDone: (selectedValue?: string) => void, + ) { + super(); + this.callbacks = callbacks; + this.availableThemes = availableThemes; + this.terminalTheme = terminalTheme; + this.onDone = onDone; + this.originalThemeSetting = currentThemeSetting; + const autoTheme = parseAutoThemeSetting(currentThemeSetting); + const automaticThemes = defaultAutomaticThemes(currentThemeSetting, availableThemes); + const fixedTheme = autoTheme || currentThemeSetting.includes("/") ? undefined : currentThemeSetting; + this.mode = autoTheme ? "automatic" : "single"; + this.lightTheme = automaticThemes.lightTheme; + this.darkTheme = automaticThemes.darkTheme; + this.singleTheme = preferredTheme( + availableThemes, + fixedTheme ?? (autoTheme ? this.getActiveAutomaticTheme() : undefined), + "dark", + ); + + if (this.mode === "automatic") { + this.showAutomaticMenu(); + } else { + this.showSingleMenu(); + } + } + + handleInput(data: string): void { + this.inputComponent?.handleInput?.(data); + } + + private setContent(renderComponent: Component, inputComponent: Component = renderComponent): void { + this.clear(); + this.addChild(renderComponent); + this.inputComponent = inputComponent; + } + + private showSingleMenu(): void { + this.mode = "single"; + const menu = new SelectSubmenu( + "Theme", + "Select a theme, or choose Automatic to follow terminal appearance.", + singleModeThemeItems(this.availableThemes), + this.singleTheme, + (value) => { + if (value === AUTOMATIC_THEME_VALUE) { + this.mode = "automatic"; + this.callbacks.onThemePreview?.(this.getThemeSetting()); + this.showAutomaticMenu(); + return; + } + + this.singleTheme = value; + this.apply(value); + }, + () => this.cancel(), + (value) => { + this.callbacks.onThemePreview?.(value === AUTOMATIC_THEME_VALUE ? this.getAutomaticThemeSetting() : value); + }, + ); + this.setContent(menu); + } + + private showAutomaticMenu(): void { + this.mode = "automatic"; + const content = new Container(); + content.addChild(new Text(theme.bold(theme.fg("accent", "Automatic Theme")), 0, 0)); + content.addChild(new Spacer(1)); + content.addChild(new Text(theme.fg("muted", "Choose themes for terminal light and dark appearance."), 0, 0)); + content.addChild(new Text(theme.fg("muted", "Light/dark detection requires terminal support."), 0, 0)); + content.addChild(new Spacer(1)); + + const items: SettingItem[] = [ + { + id: "light-theme", + label: "Light theme", + description: "Theme to use in automatic mode when the terminal is light", + currentValue: this.lightTheme, + submenu: (currentValue, done) => + this.createThemeSelect( + "Light Theme", + "Select the theme to use for light terminal appearance", + currentValue, + done, + (value) => { + this.lightTheme = value; + this.callbacks.onThemePreview?.(this.getThemeSetting()); + done(value); + }, + ), + }, + { + id: "dark-theme", + label: "Dark theme", + description: "Theme to use in automatic mode when the terminal is dark", + currentValue: this.darkTheme, + submenu: (currentValue, done) => + this.createThemeSelect( + "Dark Theme", + "Select the theme to use for dark terminal appearance", + currentValue, + done, + (value) => { + this.darkTheme = value; + this.callbacks.onThemePreview?.(this.getThemeSetting()); + done(value); + }, + ), + }, + { + id: "apply", + label: "Apply", + description: "Save and go back", + currentValue: "save and go back", + values: ["save and go back"], + }, + { + id: "single-mode", + label: "Change mode", + description: "Switch to one theme for light and dark", + currentValue: "switch to single theme", + values: ["switch to single theme"], + }, + ]; + + const settingsList = new SettingsList( + items, + Math.min(items.length, 10), + getSettingsListTheme(), + (id) => { + switch (id) { + case "single-mode": + this.mode = "single"; + this.singleTheme = this.getActiveAutomaticTheme(); + this.callbacks.onThemePreview?.(this.singleTheme); + this.showSingleMenu(); + break; + case "apply": + this.apply(this.getAutomaticThemeSetting()); + break; + } + }, + () => this.cancel(), + ); + content.addChild(settingsList); + this.setContent(content, settingsList); + } + + private createThemeSelect( + title: string, + description: string, + currentValue: string, + done: (selectedValue?: string) => void, + onSelect: (value: string) => void, + ): SelectSubmenu { + return new SelectSubmenu( + title, + description, + themeItems(this.availableThemes), + currentValue, + onSelect, + () => { + this.callbacks.onThemePreview?.(this.getThemeSetting()); + done(); + }, + (value) => this.callbacks.onThemePreview?.(value), + ); + } + + private getThemeSetting(): string { + return this.mode === "automatic" ? this.getAutomaticThemeSetting() : this.singleTheme; + } + + private getActiveAutomaticTheme(): string { + return this.terminalTheme === "light" ? this.lightTheme : this.darkTheme; + } + + private getAutomaticThemeSetting(): string { + return `${this.lightTheme}/${this.darkTheme}`; + } + + private apply(themeSetting: string): void { + this.onDone(themeSetting); + } + + private cancel(): void { + this.callbacks.onThemePreview?.(this.originalThemeSetting); + this.onDone(); + } +} + +/** + * Main settings selector component. + */ +export class SettingsSelectorComponent extends Container { + private settingsList: SettingsList; + + constructor(config: SettingsConfig, callbacks: SettingsCallbacks) { + super(); + + const supportsImages = getCapabilities().images; + const followUpKey = keyDisplayText("app.message.followUp"); + let currentWarnings = { ...config.warnings }; + + const items: SettingItem[] = [ + { + id: "autocompact", + label: "Auto-compact", + description: "Automatically compact context when it gets too large", + currentValue: config.autoCompact ? "true" : "false", + values: ["true", "false"], + }, + { + id: "steering-mode", + label: "Steering mode", + description: + "Enter while streaming queues steering messages. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.", + currentValue: config.steeringMode, + values: ["one-at-a-time", "all"], + }, + { + id: "follow-up-mode", + label: "Follow-up mode", + description: `${followUpKey} queues follow-up messages until agent stops. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.`, + currentValue: config.followUpMode, + values: ["one-at-a-time", "all"], + }, + { + id: "transport", + label: "Transport", + description: "Preferred transport for providers that support multiple transports", + currentValue: config.transport, + values: ["sse", "websocket", "websocket-cached", "auto"], + }, + { + id: "http-idle-timeout", + label: "HTTP idle timeout", + description: + "Maximum idle gap while waiting for HTTP headers or body chunks. Disable for local models that pause longer than five minutes.", + currentValue: formatHttpIdleTimeoutMs(config.httpIdleTimeoutMs), + values: HTTP_IDLE_TIMEOUT_CHOICES.map((choice) => choice.label), + }, + { + id: "hide-thinking", + label: "Hide thinking", + description: "Hide thinking blocks in assistant responses", + currentValue: config.hideThinkingBlock ? "true" : "false", + values: ["true", "false"], + }, + { + id: "quiet-startup", + label: "Quiet startup", + description: "Disable verbose printing at startup", + currentValue: config.quietStartup ? "true" : "false", + values: ["true", "false"], + }, + { + id: "default-project-trust", + label: "Default project trust", + description: "Fallback behavior when no extension or saved trust decision decides project trust", + currentValue: DEFAULT_PROJECT_TRUST_LABELS[config.defaultProjectTrust], + values: Object.values(DEFAULT_PROJECT_TRUST_LABELS), + }, + { + id: "double-escape-action", + label: "Double-escape action", + description: "Action when pressing Escape twice with empty editor", + currentValue: config.doubleEscapeAction, + values: ["tree", "fork", "none"], + }, + { + id: "tree-filter-mode", + label: "Tree filter mode", + description: "Default filter when opening /tree", + currentValue: config.treeFilterMode, + values: ["default", "no-tools", "user-only", "labeled-only", "all"], + }, + { + id: "warnings", + label: "Warnings", + description: "Enable or disable individual warnings", + currentValue: "configure", + submenu: (_currentValue, done) => + new WarningSettingsSubmenu( + currentWarnings, + (warnings) => { + currentWarnings = warnings; + callbacks.onWarningsChange(warnings); + }, + () => done(), + ), + }, + { + id: "thinking", + label: "Thinking level", + description: "Reasoning depth for thinking-capable models", + currentValue: config.thinkingLevel, + submenu: (currentValue, done) => + new SelectSubmenu( + "Thinking Level", + "Select reasoning depth for thinking-capable models", + config.availableThinkingLevels.map((level) => ({ + value: level, + label: level, + description: THINKING_DESCRIPTIONS[level], + })), + currentValue, + (value) => { + callbacks.onThinkingLevelChange(value as ThinkingLevel); + done(value); + }, + () => done(), + ), + }, + { + id: "theme", + label: "Theme", + description: "Color theme for the interface", + currentValue: config.currentTheme, + submenu: (currentValue, done) => + new ThemeSubmenu(currentValue, config.terminalTheme, config.availableThemes, callbacks, done), + }, + ]; + + // Only show image toggle if terminal supports it + if (supportsImages) { + // Insert after autocompact + items.splice(1, 0, { + id: "show-images", + label: "Show images", + description: "Render images inline in terminal", + currentValue: config.showImages ? "true" : "false", + values: ["true", "false"], + }); + items.splice(2, 0, { + id: "image-width-cells", + label: "Image width", + description: "Preferred inline image width in terminal cells", + currentValue: String(config.imageWidthCells), + values: ["60", "80", "120"], + }); + } + + // Image auto-resize toggle (always available, affects both attached and read images) + items.splice(supportsImages ? 3 : 1, 0, { + id: "auto-resize-images", + label: "Auto-resize images", + description: "Resize large images to 2000x2000 max for better model compatibility", + currentValue: config.autoResizeImages ? "true" : "false", + values: ["true", "false"], + }); + + // Block images toggle (always available, insert after auto-resize-images) + const autoResizeIndex = items.findIndex((item) => item.id === "auto-resize-images"); + items.splice(autoResizeIndex + 1, 0, { + id: "block-images", + label: "Block images", + description: "Prevent images from being sent to LLM providers", + currentValue: config.blockImages ? "true" : "false", + values: ["true", "false"], + }); + + // Skill commands toggle (insert after block-images) + const blockImagesIndex = items.findIndex((item) => item.id === "block-images"); + items.splice(blockImagesIndex + 1, 0, { + id: "skill-commands", + label: "Skill commands", + description: "Register skills as /skill:name commands", + currentValue: config.enableSkillCommands ? "true" : "false", + values: ["true", "false"], + }); + + // Hardware cursor toggle (insert after skill-commands) + const skillCommandsIndex = items.findIndex((item) => item.id === "skill-commands"); + items.splice(skillCommandsIndex + 1, 0, { + id: "show-hardware-cursor", + label: "Show hardware cursor", + description: "Show the terminal cursor while still positioning it for IME support", + currentValue: config.showHardwareCursor ? "true" : "false", + values: ["true", "false"], + }); + + // Editor padding toggle (insert after show-hardware-cursor) + const hardwareCursorIndex = items.findIndex((item) => item.id === "show-hardware-cursor"); + items.splice(hardwareCursorIndex + 1, 0, { + id: "editor-padding", + label: "Editor padding", + description: "Horizontal padding for input editor (0-3)", + currentValue: String(config.editorPaddingX), + values: ["0", "1", "2", "3"], + }); + + // Autocomplete max visible toggle (insert after editor-padding) + const editorPaddingIndex = items.findIndex((item) => item.id === "editor-padding"); + items.splice(editorPaddingIndex + 1, 0, { + id: "autocomplete-max-visible", + label: "Autocomplete max items", + description: "Max visible items in autocomplete dropdown (3-20)", + currentValue: String(config.autocompleteMaxVisible), + values: ["3", "5", "7", "10", "15", "20"], + }); + + // Clear on shrink toggle (insert after autocomplete-max-visible) + const autocompleteIndex = items.findIndex((item) => item.id === "autocomplete-max-visible"); + items.splice(autocompleteIndex + 1, 0, { + id: "clear-on-shrink", + label: "Clear on shrink", + description: "Clear empty rows when content shrinks (may cause flicker)", + currentValue: config.clearOnShrink ? "true" : "false", + values: ["true", "false"], + }); + + // Terminal progress toggle (insert after clear-on-shrink) + const clearOnShrinkIndex = items.findIndex((item) => item.id === "clear-on-shrink"); + items.splice(clearOnShrinkIndex + 1, 0, { + id: "terminal-progress", + label: "Terminal progress", + description: "Show OSC 9;4 progress indicators in the terminal tab bar", + currentValue: config.showTerminalProgress ? "true" : "false", + values: ["true", "false"], + }); + + // Add borders + this.addChild(new DynamicBorder()); + + this.settingsList = new SettingsList( + items, + 10, + getSettingsListTheme(), + (id, newValue) => { + switch (id) { + case "autocompact": + callbacks.onAutoCompactChange(newValue === "true"); + break; + case "show-images": + callbacks.onShowImagesChange(newValue === "true"); + break; + case "image-width-cells": + callbacks.onImageWidthCellsChange(parseInt(newValue, 10)); + break; + case "auto-resize-images": + callbacks.onAutoResizeImagesChange(newValue === "true"); + break; + case "block-images": + callbacks.onBlockImagesChange(newValue === "true"); + break; + case "skill-commands": + callbacks.onEnableSkillCommandsChange(newValue === "true"); + break; + case "steering-mode": + callbacks.onSteeringModeChange(newValue as "all" | "one-at-a-time"); + break; + case "follow-up-mode": + callbacks.onFollowUpModeChange(newValue as "all" | "one-at-a-time"); + break; + case "transport": + callbacks.onTransportChange(newValue as Transport); + break; + case "http-idle-timeout": { + const choice = HTTP_IDLE_TIMEOUT_CHOICES.find((item) => item.label === newValue); + if (choice) { + callbacks.onHttpIdleTimeoutMsChange(choice.timeoutMs); + } + break; + } + case "hide-thinking": + callbacks.onHideThinkingBlockChange(newValue === "true"); + break; + case "quiet-startup": + callbacks.onQuietStartupChange(newValue === "true"); + break; + case "default-project-trust": { + const defaultProjectTrust = DEFAULT_PROJECT_TRUST_BY_LABEL.get(newValue); + if (defaultProjectTrust) { + callbacks.onDefaultProjectTrustChange(defaultProjectTrust); + } + break; + } + case "double-escape-action": + callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree"); + break; + case "tree-filter-mode": + callbacks.onTreeFilterModeChange( + newValue as "default" | "no-tools" | "user-only" | "labeled-only" | "all", + ); + break; + case "show-hardware-cursor": + callbacks.onShowHardwareCursorChange(newValue === "true"); + break; + case "editor-padding": + callbacks.onEditorPaddingXChange(parseInt(newValue, 10)); + break; + case "autocomplete-max-visible": + callbacks.onAutocompleteMaxVisibleChange(parseInt(newValue, 10)); + break; + case "clear-on-shrink": + callbacks.onClearOnShrinkChange(newValue === "true"); + break; + case "terminal-progress": + callbacks.onShowTerminalProgressChange(newValue === "true"); + break; + case "theme": + callbacks.onThemeChange(newValue); + break; + } + }, + callbacks.onCancel, + { enableSearch: true }, + ); + + this.addChild(this.settingsList); + this.addChild(new DynamicBorder()); + } + + getSettingsList(): SettingsList { + return this.settingsList; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts new file mode 100644 index 000000000..e09febc7c --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/skill-invocation-message.ts @@ -0,0 +1,55 @@ +import { Box, Markdown, type MarkdownTheme, Text } from "@earendil-works/pi-tui"; +import type { ParsedSkillBlock } from "../../../core/agent-session.ts"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; +import { keyText } from "./keybinding-hints.ts"; + +/** + * Component that renders a skill invocation message with collapsed/expanded state. + * Uses same background color as custom messages for visual consistency. + * Only renders the skill block itself - user message is rendered separately. + */ +export class SkillInvocationMessageComponent extends Box { + private expanded = false; + private skillBlock: ParsedSkillBlock; + private markdownTheme: MarkdownTheme; + + constructor(skillBlock: ParsedSkillBlock, markdownTheme: MarkdownTheme = getMarkdownTheme()) { + super(1, 1, (t) => theme.bg("customMessageBg", t)); + this.skillBlock = skillBlock; + this.markdownTheme = markdownTheme; + this.updateDisplay(); + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + this.updateDisplay(); + } + + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + + private updateDisplay(): void { + this.clear(); + + if (this.expanded) { + // Expanded: label + skill name header + full content + const label = theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m`); + this.addChild(new Text(label, 0, 0)); + const header = `**${this.skillBlock.name}**\n\n`; + this.addChild( + new Markdown(header + this.skillBlock.content, 0, 0, this.markdownTheme, { + color: (text: string) => theme.fg("customMessageText", text), + }), + ); + } else { + // Collapsed: single line - [skill] name (hint to expand) + const line = + theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) + + theme.fg("customMessageText", this.skillBlock.name) + + theme.fg("dim", ` (${keyText("app.tools.expand")} to expand)`); + this.addChild(new Text(line, 0, 0)); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/theme-selector.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/theme-selector.ts new file mode 100644 index 000000000..07b377423 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/theme-selector.ts @@ -0,0 +1,67 @@ +import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui"; +import { getAvailableThemes, getSelectListTheme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; + +const THEME_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + +/** + * Component that renders a theme selector + */ +export class ThemeSelectorComponent extends Container { + private selectList: SelectList; + private onPreview: (themeName: string) => void; + + constructor( + currentTheme: string, + onSelect: (themeName: string) => void, + onCancel: () => void, + onPreview: (themeName: string) => void, + ) { + super(); + this.onPreview = onPreview; + + // Get available themes and create select items + const themes = getAvailableThemes(); + const themeItems: SelectItem[] = themes.map((name) => ({ + value: name, + label: name, + description: name === currentTheme ? "(current)" : undefined, + })); + + // Add top border + this.addChild(new DynamicBorder()); + + // Create selector + this.selectList = new SelectList(themeItems, 10, getSelectListTheme(), THEME_SELECT_LIST_LAYOUT); + + // Preselect current theme + const currentIndex = themes.indexOf(currentTheme); + if (currentIndex !== -1) { + this.selectList.setSelectedIndex(currentIndex); + } + + this.selectList.onSelect = (item) => { + onSelect(item.value); + }; + + this.selectList.onCancel = () => { + onCancel(); + }; + + this.selectList.onSelectionChange = (item) => { + this.onPreview(item.value); + }; + + this.addChild(this.selectList); + + // Add bottom border + this.addChild(new DynamicBorder()); + } + + getSelectList(): SelectList { + return this.selectList; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts new file mode 100644 index 000000000..91a3a6266 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts @@ -0,0 +1,74 @@ +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { Container, type SelectItem, SelectList, type SelectListLayoutOptions } from "@earendil-works/pi-tui"; +import { getSelectListTheme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; + +const THINKING_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + +const LEVEL_DESCRIPTIONS: Record = { + off: "No reasoning", + minimal: "Very brief reasoning (~1k tokens)", + low: "Light reasoning (~2k tokens)", + medium: "Moderate reasoning (~8k tokens)", + high: "Deep reasoning (~16k tokens)", + xhigh: "Maximum reasoning (~32k tokens)", +}; + +/** + * Component that renders a thinking level selector with borders + */ +export class ThinkingSelectorComponent extends Container { + private selectList: SelectList; + + constructor( + currentLevel: ThinkingLevel, + availableLevels: ThinkingLevel[], + onSelect: (level: ThinkingLevel) => void, + onCancel: () => void, + ) { + super(); + + const thinkingLevels: SelectItem[] = availableLevels.map((level) => ({ + value: level, + label: level, + description: LEVEL_DESCRIPTIONS[level], + })); + + // Add top border + this.addChild(new DynamicBorder()); + + // Create selector + this.selectList = new SelectList( + thinkingLevels, + thinkingLevels.length, + getSelectListTheme(), + THINKING_SELECT_LIST_LAYOUT, + ); + + // Preselect current level + const currentIndex = thinkingLevels.findIndex((item) => item.value === currentLevel); + if (currentIndex !== -1) { + this.selectList.setSelectedIndex(currentIndex); + } + + this.selectList.onSelect = (item) => { + onSelect(item.value as ThinkingLevel); + }; + + this.selectList.onCancel = () => { + onCancel(); + }; + + this.addChild(this.selectList); + + // Add bottom border + this.addChild(new DynamicBorder()); + } + + getSelectList(): SelectList { + return this.selectList; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/tool-execution.ts new file mode 100644 index 000000000..ad84f4413 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -0,0 +1,377 @@ +import { Box, type Component, Container, getCapabilities, Image, Spacer, Text, type TUI } from "@earendil-works/pi-tui"; +import type { ToolDefinition, ToolRenderContext } from "../../../core/extensions/types.ts"; +import { createAllToolDefinitions, type ToolName } from "../../../core/tools/index.ts"; +import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.ts"; +import { convertToPng } from "../../../utils/image-convert.ts"; +import { theme } from "../theme/theme.ts"; + +export interface ToolExecutionOptions { + showImages?: boolean; + imageWidthCells?: number; +} + +export class ToolExecutionComponent extends Container { + private contentBox: Box; + private contentText: Text; + private selfRenderContainer: Container; + private callRendererComponent?: Component; + private resultRendererComponent?: Component; + private rendererState: any = {}; + private imageComponents: Image[] = []; + private imageSpacers: Spacer[] = []; + private toolName: string; + private toolCallId: string; + private args: any; + private expanded = false; + private showImages: boolean; + private imageWidthCells: number; + private isPartial = true; + private toolDefinition?: ToolDefinition; + private builtInToolDefinition?: ToolDefinition; + private ui: TUI; + private cwd: string; + private executionStarted = false; + private argsComplete = false; + private result?: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError: boolean; + details?: any; + }; + private convertedImages: Map = new Map(); + private hideComponent = false; + + constructor( + toolName: string, + toolCallId: string, + args: any, + options: ToolExecutionOptions = {}, + toolDefinition: ToolDefinition | undefined, + ui: TUI, + cwd: string, + ) { + super(); + this.toolName = toolName; + this.toolCallId = toolCallId; + this.args = args; + this.toolDefinition = toolDefinition; + this.builtInToolDefinition = createAllToolDefinitions(cwd)[toolName as ToolName]; + this.showImages = options.showImages ?? true; + this.imageWidthCells = options.imageWidthCells ?? 60; + this.ui = ui; + this.cwd = cwd; + + this.addChild(new Spacer(1)); + + // Always create all shell variants. contentBox is used for default renderer-based composition. + // selfRenderContainer is used when the tool renders its own framing. + // contentText is reserved for generic fallback rendering when no tool definition exists. + this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text)); + this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text)); + this.selfRenderContainer = new Container(); + + if (this.hasRendererDefinition()) { + this.addChild(this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox); + } else { + this.addChild(this.contentText); + } + + this.updateDisplay(); + } + + private getCallRenderer(): ToolDefinition["renderCall"] | undefined { + if (!this.builtInToolDefinition) { + return this.toolDefinition?.renderCall; + } + if (!this.toolDefinition) { + return this.builtInToolDefinition.renderCall; + } + return this.toolDefinition.renderCall ?? this.builtInToolDefinition.renderCall; + } + + private getResultRenderer(): ToolDefinition["renderResult"] | undefined { + if (!this.builtInToolDefinition) { + return this.toolDefinition?.renderResult; + } + if (!this.toolDefinition) { + return this.builtInToolDefinition.renderResult; + } + return this.toolDefinition.renderResult ?? this.builtInToolDefinition.renderResult; + } + + private hasRendererDefinition(): boolean { + return this.builtInToolDefinition !== undefined || this.toolDefinition !== undefined; + } + + private getRenderShell(): "default" | "self" { + if (!this.builtInToolDefinition) { + return this.toolDefinition?.renderShell ?? "default"; + } + if (!this.toolDefinition) { + return this.builtInToolDefinition.renderShell ?? "default"; + } + return this.toolDefinition.renderShell ?? this.builtInToolDefinition.renderShell ?? "default"; + } + + private getRenderContext(lastComponent: Component | undefined): ToolRenderContext { + return { + args: this.args, + toolCallId: this.toolCallId, + invalidate: () => { + this.invalidate(); + this.ui.requestRender(); + }, + lastComponent, + state: this.rendererState, + cwd: this.cwd, + executionStarted: this.executionStarted, + argsComplete: this.argsComplete, + isPartial: this.isPartial, + expanded: this.expanded, + showImages: this.showImages, + isError: this.result?.isError ?? false, + }; + } + + private createCallFallback(): Component { + return new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0); + } + + private createResultFallback(): Component | undefined { + const output = this.getTextOutput(); + if (!output) { + return undefined; + } + return new Text(theme.fg("toolOutput", output), 0, 0); + } + + updateArgs(args: any): void { + this.args = args; + this.updateDisplay(); + } + + markExecutionStarted(): void { + this.executionStarted = true; + this.updateDisplay(); + this.ui.requestRender(); + } + + setArgsComplete(): void { + this.argsComplete = true; + this.updateDisplay(); + this.ui.requestRender(); + } + + updateResult( + result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + details?: any; + isError: boolean; + }, + isPartial = false, + ): void { + this.result = result; + this.isPartial = isPartial; + this.updateDisplay(); + this.maybeConvertImagesForKitty(); + } + + private maybeConvertImagesForKitty(): void { + const caps = getCapabilities(); + if (caps.images !== "kitty") return; + if (!this.result) return; + + const imageBlocks = this.result.content.filter((c) => c.type === "image"); + for (let i = 0; i < imageBlocks.length; i++) { + const img = imageBlocks[i]; + if (!img.data || !img.mimeType) continue; + if (img.mimeType === "image/png") continue; + if (this.convertedImages.has(i)) continue; + + const index = i; + convertToPng(img.data, img.mimeType).then((converted) => { + if (converted) { + this.convertedImages.set(index, converted); + this.updateDisplay(); + this.ui.requestRender(); + } + }); + } + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + this.updateDisplay(); + } + + setShowImages(show: boolean): void { + this.showImages = show; + this.updateDisplay(); + } + + setImageWidthCells(width: number): void { + this.imageWidthCells = Math.max(1, Math.floor(width)); + this.updateDisplay(); + } + + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + + override render(width: number): string[] { + if (this.hideComponent) { + return []; + } + + if (this.hasRendererDefinition() && this.getRenderShell() === "self") { + const contentLines = this.selfRenderContainer.render(width); + if (contentLines.length === 0 && this.imageComponents.length === 0) { + return []; + } + + const lines: string[] = []; + if (contentLines.length > 0) { + lines.push(""); + lines.push(...contentLines); + } + for (let i = 0; i < this.imageComponents.length; i++) { + const spacer = this.imageSpacers[i]; + if (spacer) { + lines.push(...spacer.render(width)); + } + const imageComponent = this.imageComponents[i]; + if (imageComponent) { + lines.push(...imageComponent.render(width)); + } + } + return lines; + } + + return super.render(width); + } + + private updateDisplay(): void { + const bgFn = this.isPartial + ? (text: string) => theme.bg("toolPendingBg", text) + : this.result?.isError + ? (text: string) => theme.bg("toolErrorBg", text) + : (text: string) => theme.bg("toolSuccessBg", text); + + let hasContent = false; + this.hideComponent = false; + if (this.hasRendererDefinition()) { + const renderContainer = this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox; + if (renderContainer instanceof Box) { + renderContainer.setBgFn(bgFn); + } + renderContainer.clear(); + + const callRenderer = this.getCallRenderer(); + if (!callRenderer) { + renderContainer.addChild(this.createCallFallback()); + hasContent = true; + } else { + try { + const component = callRenderer(this.args, theme, this.getRenderContext(this.callRendererComponent)); + this.callRendererComponent = component; + renderContainer.addChild(component); + hasContent = true; + } catch { + this.callRendererComponent = undefined; + renderContainer.addChild(this.createCallFallback()); + hasContent = true; + } + } + + if (this.result) { + const resultRenderer = this.getResultRenderer(); + if (!resultRenderer) { + const component = this.createResultFallback(); + if (component) { + renderContainer.addChild(component); + hasContent = true; + } + } else { + try { + const component = resultRenderer( + { content: this.result.content as any, details: this.result.details }, + { expanded: this.expanded, isPartial: this.isPartial }, + theme, + this.getRenderContext(this.resultRendererComponent), + ); + this.resultRendererComponent = component; + renderContainer.addChild(component); + hasContent = true; + } catch { + this.resultRendererComponent = undefined; + const component = this.createResultFallback(); + if (component) { + renderContainer.addChild(component); + hasContent = true; + } + } + } + } + } else { + this.contentText.setCustomBgFn(bgFn); + this.contentText.setText(this.formatToolExecution()); + hasContent = true; + } + + for (const img of this.imageComponents) { + this.removeChild(img); + } + this.imageComponents = []; + for (const spacer of this.imageSpacers) { + this.removeChild(spacer); + } + this.imageSpacers = []; + + if (this.result) { + const imageBlocks = this.result.content.filter((c) => c.type === "image"); + const caps = getCapabilities(); + for (let i = 0; i < imageBlocks.length; i++) { + const img = imageBlocks[i]; + if (caps.images && this.showImages && img.data && img.mimeType) { + const converted = this.convertedImages.get(i); + const imageData = converted?.data ?? img.data; + const imageMimeType = converted?.mimeType ?? img.mimeType; + if (caps.images === "kitty" && imageMimeType !== "image/png") continue; + + const spacer = new Spacer(1); + this.addChild(spacer); + this.imageSpacers.push(spacer); + const imageComponent = new Image( + imageData, + imageMimeType, + { fallbackColor: (s: string) => theme.fg("toolOutput", s) }, + { maxWidthCells: this.imageWidthCells }, + ); + this.imageComponents.push(imageComponent); + this.addChild(imageComponent); + } + } + } + + if (this.hasRendererDefinition() && !hasContent && this.imageComponents.length === 0) { + this.hideComponent = true; + } + } + + private getTextOutput(): string { + return getRenderedTextOutput(this.result, this.showImages); + } + + private formatToolExecution(): string { + let text = theme.fg("toolTitle", theme.bold(this.toolName)); + const content = JSON.stringify(this.args, null, 2); + if (content) { + text += `\n\n${content}`; + } + const output = this.getTextOutput(); + if (output) { + text += `\n${output}`; + } + return text; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/tree-selector.ts new file mode 100644 index 000000000..8bcdc2fb8 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -0,0 +1,1386 @@ +import { + type Component, + Container, + type Focusable, + getKeybindings, + Input, + type Keybinding, + Spacer, + sliceByColumn, + Text, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@earendil-works/pi-tui"; +import type { SessionTreeNode } from "../../../core/session-manager.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { formatKeyText, keyHint } from "./keybinding-hints.ts"; + +/** Gutter info: position (displayIndent where connector was) and whether to show │ */ +interface GutterInfo { + position: number; // displayIndent level where the connector was shown + show: boolean; // true = show │, false = show spaces +} + +/** Flattened tree node for navigation */ +interface FlatNode { + node: SessionTreeNode; + /** Indentation level (each level = 3 chars) */ + indent: number; + /** Whether to show connector (├─ or └─) - true if parent has multiple children */ + showConnector: boolean; + /** If showConnector, true = last sibling (└─), false = not last (├─) */ + isLast: boolean; + /** Gutter info for each ancestor branch point */ + gutters: GutterInfo[]; + /** True if this node is a root under a virtual branching root (multiple roots) */ + isVirtualRootChild: boolean; +} + +interface HorizontalViewportRow { + gutter: string; + body: string; + anchorCol: number; + bodyWidth: number; + isSelected: boolean; +} + +const TREE_GUTTER_WIDTH = 2; +const MIN_VISIBLE_ANCHOR_CONTENT_WIDTH = 4; +const MAX_VISIBLE_ANCHOR_CONTENT_WIDTH = 20; +const MIN_ANCHOR_CONTEXT_WIDTH = 2; +const MAX_ANCHOR_CONTEXT_WIDTH = 12; + +/** + * Render tree rows into a horizontally clipped viewport. + * + * The tree gutter is always kept visible. The row bodies are shifted left only + * when the selected row's anchor (the start of its entry text after tree + * indentation/markers) would otherwise be too far right to see useful content. + */ +function renderHorizontalViewport(rows: HorizontalViewportRow[], width: number): string[] { + const viewportWidth = Math.max(0, width - TREE_GUTTER_WIDTH); + const maxBodyWidth = rows.reduce((max, row) => Math.max(max, row.bodyWidth), 0); + const maxHorizontalScroll = Math.max(0, maxBodyWidth - viewportWidth); + const selectedRow = rows.find((row) => row.isSelected); + + // Only pan horizontally when needed to keep enough selected-row content visible after its anchor. + let horizontalScroll = 0; + if (selectedRow && maxHorizontalScroll > 0) { + const minVisibleAnchorContentWidth = Math.min( + MAX_VISIBLE_ANCHOR_CONTENT_WIDTH, + Math.max(MIN_VISIBLE_ANCHOR_CONTENT_WIDTH, Math.floor(viewportWidth / 3)), + ); + if (selectedRow.anchorCol > viewportWidth - minVisibleAnchorContentWidth) { + const anchorContextWidth = Math.min( + MAX_ANCHOR_CONTEXT_WIDTH, + Math.max(MIN_ANCHOR_CONTEXT_WIDTH, Math.floor(viewportWidth / 4)), + ); + horizontalScroll = Math.min(maxHorizontalScroll, selectedRow.anchorCol - anchorContextWidth); + } + } + + // Clip only the body; the fixed-width gutter remains visible as navigation context. + return rows.map((row) => { + const line = + horizontalScroll > 0 + ? `${row.gutter}${sliceByColumn(row.body, horizontalScroll, viewportWidth, true)}\x1b[0m` + : row.gutter + row.body; + return truncateToWidth(line, width, ""); + }); +} + +/** Filter mode for tree display */ +export type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all"; + +/** + * Tree list component with selection and ASCII art visualization + */ +/** Tool call info for lookup */ +interface ToolCallInfo { + name: string; + arguments: Record; +} + +class TreeList implements Component { + private flatNodes: FlatNode[] = []; + private filteredNodes: FlatNode[] = []; + private selectedIndex = 0; + private currentLeafId: string | null; + private maxVisibleLines: number; + private filterMode: FilterMode = "default"; + private searchQuery = ""; + private toolCallMap: Map = new Map(); + private multipleRoots = false; + private showLabelTimestamps = false; + private activePathIds: Set = new Set(); + private visibleParentMap: Map = new Map(); + private visibleChildrenMap: Map = new Map(); + private lastSelectedId: string | null = null; + private foldedNodes: Set = new Set(); + + public onSelect?: (entryId: string) => void; + public onCancel?: () => void; + public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void; + + constructor( + tree: SessionTreeNode[], + currentLeafId: string | null, + maxVisibleLines: number, + initialSelectedId?: string, + initialFilterMode?: FilterMode, + ) { + this.currentLeafId = currentLeafId; + this.maxVisibleLines = maxVisibleLines; + this.filterMode = initialFilterMode ?? "default"; + this.multipleRoots = tree.length > 1; + this.flatNodes = this.flattenTree(tree); + this.buildActivePath(); + this.applyFilter(); + + // Start with initialSelectedId if provided, otherwise current leaf + const targetId = initialSelectedId ?? currentLeafId; + this.selectedIndex = this.findNearestVisibleIndex(targetId); + this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? null; + } + + /** + * Find the index of the nearest visible entry, walking up the parent chain if needed. + * Returns the index in filteredNodes, or the last index as fallback. + */ + private findNearestVisibleIndex(entryId: string | null): number { + if (this.filteredNodes.length === 0) return 0; + + // Build a map for parent lookup + const entryMap = new Map(); + for (const flatNode of this.flatNodes) { + entryMap.set(flatNode.node.entry.id, flatNode); + } + + // Build a map of visible entry IDs to their indices in filteredNodes + const visibleIdToIndex = new Map(this.filteredNodes.map((node, i) => [node.node.entry.id, i])); + + // Walk from entryId up to root, looking for a visible entry + let currentId = entryId; + while (currentId !== null) { + const index = visibleIdToIndex.get(currentId); + if (index !== undefined) return index; + const node = entryMap.get(currentId); + if (!node) break; + currentId = node.node.entry.parentId ?? null; + } + + // Fallback: last visible entry + return this.filteredNodes.length - 1; + } + + /** Build the set of entry IDs on the path from root to current leaf */ + private buildActivePath(): void { + this.activePathIds.clear(); + if (!this.currentLeafId) return; + + // Build a map of id -> entry for parent lookup + const entryMap = new Map(); + for (const flatNode of this.flatNodes) { + entryMap.set(flatNode.node.entry.id, flatNode); + } + + // Walk from leaf to root + let currentId: string | null = this.currentLeafId; + while (currentId) { + this.activePathIds.add(currentId); + const node = entryMap.get(currentId); + if (!node) break; + currentId = node.node.entry.parentId ?? null; + } + } + + private flattenTree(roots: SessionTreeNode[]): FlatNode[] { + const result: FlatNode[] = []; + this.toolCallMap.clear(); + + // Indentation rules: + // - At indent 0: stay at 0 unless parent has >1 children (then +1) + // - At indent 1: children always go to indent 2 (visual grouping of subtree) + // - At indent 2+: stay flat for single-child chains, +1 only if parent branches + + // Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] + type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean]; + const stack: StackItem[] = []; + + // Determine which subtrees contain the active leaf (to sort current branch first) + // Use iterative post-order traversal to avoid stack overflow + const containsActive = new Map(); + const leafId = this.currentLeafId; + { + // Build list in pre-order, then process in reverse for post-order effect + const allNodes: SessionTreeNode[] = []; + const preOrderStack: SessionTreeNode[] = [...roots]; + while (preOrderStack.length > 0) { + const node = preOrderStack.pop()!; + allNodes.push(node); + // Push children in reverse so they're processed left-to-right + for (let i = node.children.length - 1; i >= 0; i--) { + preOrderStack.push(node.children[i]); + } + } + // Process in reverse (post-order): children before parents + for (let i = allNodes.length - 1; i >= 0; i--) { + const node = allNodes[i]; + let has = leafId !== null && node.entry.id === leafId; + for (const child of node.children) { + if (containsActive.get(child)) { + has = true; + } + } + containsActive.set(node, has); + } + } + + // Add roots in reverse order, prioritizing the one containing the active leaf + // If multiple roots, treat them as children of a virtual root that branches + const multipleRoots = roots.length > 1; + const orderedRoots = [...roots].sort((a, b) => Number(containsActive.get(b)) - Number(containsActive.get(a))); + for (let i = orderedRoots.length - 1; i >= 0; i--) { + const isLast = i === orderedRoots.length - 1; + stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]); + } + + while (stack.length > 0) { + const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!; + + // Extract tool calls from assistant messages for later lookup + const entry = node.entry; + if (entry.type === "message" && entry.message.role === "assistant") { + const content = (entry.message as { content?: unknown }).content; + if (Array.isArray(content)) { + for (const block of content) { + if (typeof block === "object" && block !== null && "type" in block && block.type === "toolCall") { + const tc = block as { id: string; name: string; arguments: Record }; + this.toolCallMap.set(tc.id, { name: tc.name, arguments: tc.arguments }); + } + } + } + } + + result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild }); + + const children = node.children; + const multipleChildren = children.length > 1; + + // Order children so the branch containing the active leaf comes first + const orderedChildren = (() => { + const prioritized: SessionTreeNode[] = []; + const rest: SessionTreeNode[] = []; + for (const child of children) { + if (containsActive.get(child)) { + prioritized.push(child); + } else { + rest.push(child); + } + } + return [...prioritized, ...rest]; + })(); + + // Calculate child indent + let childIndent: number; + if (multipleChildren) { + // Parent branches: children get +1 + childIndent = indent + 1; + } else if (justBranched && indent > 0) { + // First generation after a branch: +1 for visual grouping + childIndent = indent + 1; + } else { + // Single-child chain: stay flat + childIndent = indent; + } + + // Build gutters for children + // If this node showed a connector, add a gutter entry for descendants + // Only add gutter if connector is actually displayed (not suppressed for virtual root children) + const connectorDisplayed = showConnector && !isVirtualRootChild; + // When connector is displayed, add a gutter entry at the connector's position + // Connector is at position (displayIndent - 1), so gutter should be there too + const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent; + const connectorPosition = Math.max(0, currentDisplayIndent - 1); + const childGutters: GutterInfo[] = connectorDisplayed + ? [...gutters, { position: connectorPosition, show: !isLast }] + : gutters; + + // Add children in reverse order + for (let i = orderedChildren.length - 1; i >= 0; i--) { + const childIsLast = i === orderedChildren.length - 1; + stack.push([ + orderedChildren[i], + childIndent, + multipleChildren, + multipleChildren, + childIsLast, + childGutters, + false, + ]); + } + } + + return result; + } + + private applyFilter(): void { + // Update lastSelectedId only when we have a valid selection (non-empty list) + // This preserves the selection when switching through empty filter results + if (this.filteredNodes.length > 0) { + this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId; + } + + const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean); + + this.filteredNodes = this.flatNodes.filter((flatNode) => { + const entry = flatNode.node.entry; + const isCurrentLeaf = entry.id === this.currentLeafId; + + // Skip assistant messages with only tool calls (no text) unless error/aborted + // Always show current leaf so active position is visible + if (entry.type === "message" && entry.message.role === "assistant" && !isCurrentLeaf) { + const msg = entry.message as { stopReason?: string; content?: unknown }; + const hasText = this.hasTextContent(msg.content); + const isErrorOrAborted = msg.stopReason && msg.stopReason !== "stop" && msg.stopReason !== "toolUse"; + // Only hide if no text AND not an error/aborted message + if (!hasText && !isErrorOrAborted) { + return false; + } + } + + // Apply filter mode + let passesFilter = true; + // Entry types hidden in default view (settings/bookkeeping) + const isSettingsEntry = + entry.type === "label" || + entry.type === "custom" || + entry.type === "model_change" || + entry.type === "thinking_level_change" || + entry.type === "session_info"; + + switch (this.filterMode) { + case "user-only": + // Just user messages + passesFilter = entry.type === "message" && entry.message.role === "user"; + break; + case "no-tools": + // Default minus tool results + passesFilter = !isSettingsEntry && !(entry.type === "message" && entry.message.role === "toolResult"); + break; + case "labeled-only": + // Just labeled entries + passesFilter = flatNode.node.label !== undefined; + break; + case "all": + // Show everything + passesFilter = true; + break; + default: + // Default mode: hide settings/bookkeeping entries + passesFilter = !isSettingsEntry; + break; + } + + if (!passesFilter) return false; + + // Apply search filter + if (searchTokens.length > 0) { + const nodeText = this.getSearchableText(flatNode.node).toLowerCase(); + return searchTokens.every((token) => nodeText.includes(token)); + } + + return true; + }); + + // Filter out descendants of folded nodes. + if (this.foldedNodes.size > 0) { + const skipSet = new Set(); + for (const flatNode of this.flatNodes) { + const { id, parentId } = flatNode.node.entry; + if (parentId != null && (this.foldedNodes.has(parentId) || skipSet.has(parentId))) { + skipSet.add(id); + } + } + this.filteredNodes = this.filteredNodes.filter((flatNode) => !skipSet.has(flatNode.node.entry.id)); + } + + // Recalculate visual structure (indent, connectors, gutters) based on visible tree + this.recalculateVisualStructure(); + + // Try to preserve cursor on the same node, or find nearest visible ancestor + if (this.lastSelectedId) { + this.selectedIndex = this.findNearestVisibleIndex(this.lastSelectedId); + } else if (this.selectedIndex >= this.filteredNodes.length) { + // Clamp index if out of bounds + this.selectedIndex = Math.max(0, this.filteredNodes.length - 1); + } + + // Update lastSelectedId to the actual selection (may have changed due to parent walk) + if (this.filteredNodes.length > 0) { + this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId; + } + } + + /** + * Recompute indentation/connectors for the filtered view + * + * Filtering can hide intermediate entries; descendants attach to the nearest visible ancestor. + * Keep indentation semantics aligned with flattenTree() so single-child chains don't drift right. + */ + private recalculateVisualStructure(): void { + if (this.filteredNodes.length === 0) return; + + const visibleIds = new Set(this.filteredNodes.map((n) => n.node.entry.id)); + + // Build entry map for efficient parent lookup (using full tree) + const entryMap = new Map(); + for (const flatNode of this.flatNodes) { + entryMap.set(flatNode.node.entry.id, flatNode); + } + + // Find nearest visible ancestor for a node + const findVisibleAncestor = (nodeId: string): string | null => { + let currentId = entryMap.get(nodeId)?.node.entry.parentId ?? null; + while (currentId !== null) { + if (visibleIds.has(currentId)) { + return currentId; + } + currentId = entryMap.get(currentId)?.node.entry.parentId ?? null; + } + return null; + }; + + // Build visible tree structure: + // - visibleParent: nodeId → nearest visible ancestor (or null for roots) + // - visibleChildren: parentId → list of visible children (in filteredNodes order) + const visibleParent = new Map(); + const visibleChildren = new Map(); + visibleChildren.set(null, []); // root-level nodes + + for (const flatNode of this.filteredNodes) { + const nodeId = flatNode.node.entry.id; + const ancestorId = findVisibleAncestor(nodeId); + visibleParent.set(nodeId, ancestorId); + + if (!visibleChildren.has(ancestorId)) { + visibleChildren.set(ancestorId, []); + } + visibleChildren.get(ancestorId)!.push(nodeId); + } + + // Update multipleRoots based on visible roots + const visibleRootIds = visibleChildren.get(null)!; + this.multipleRoots = visibleRootIds.length > 1; + + // Build a map for quick lookup: nodeId → FlatNode + const filteredNodeMap = new Map(); + for (const flatNode of this.filteredNodes) { + filteredNodeMap.set(flatNode.node.entry.id, flatNode); + } + + // DFS over the visible tree using flattenTree() indentation semantics + // Stack items: [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] + type StackItem = [string, number, boolean, boolean, boolean, GutterInfo[], boolean]; + const stack: StackItem[] = []; + + // Add visible roots in reverse order (to process in forward order via stack) + for (let i = visibleRootIds.length - 1; i >= 0; i--) { + const isLast = i === visibleRootIds.length - 1; + stack.push([ + visibleRootIds[i], + this.multipleRoots ? 1 : 0, + this.multipleRoots, + this.multipleRoots, + isLast, + [], + this.multipleRoots, + ]); + } + + while (stack.length > 0) { + const [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!; + + const flatNode = filteredNodeMap.get(nodeId); + if (!flatNode) continue; + + // Update this node's visual properties + flatNode.indent = indent; + flatNode.showConnector = showConnector; + flatNode.isLast = isLast; + flatNode.gutters = gutters; + flatNode.isVirtualRootChild = isVirtualRootChild; + + // Get visible children of this node + const children = visibleChildren.get(nodeId) || []; + const multipleChildren = children.length > 1; + + // Child indent follows flattenTree(): branch points (and first generation after a branch) shift +1 + let childIndent: number; + if (multipleChildren) { + childIndent = indent + 1; + } else if (justBranched && indent > 0) { + childIndent = indent + 1; + } else { + childIndent = indent; + } + + // Child gutters follow flattenTree() connector/gutter rules + const connectorDisplayed = showConnector && !isVirtualRootChild; + const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent; + const connectorPosition = Math.max(0, currentDisplayIndent - 1); + const childGutters: GutterInfo[] = connectorDisplayed + ? [...gutters, { position: connectorPosition, show: !isLast }] + : gutters; + + // Add children in reverse order (to process in forward order via stack) + for (let i = children.length - 1; i >= 0; i--) { + const childIsLast = i === children.length - 1; + stack.push([ + children[i], + childIndent, + multipleChildren, + multipleChildren, + childIsLast, + childGutters, + false, + ]); + } + } + + // Store visible tree maps for ancestor/descendant lookups in navigation + this.visibleParentMap = visibleParent; + this.visibleChildrenMap = visibleChildren; + } + + /** Get searchable text content from a node */ + private getSearchableText(node: SessionTreeNode): string { + const entry = node.entry; + const parts: string[] = []; + + if (node.label) { + parts.push(node.label); + } + + switch (entry.type) { + case "message": { + const msg = entry.message; + parts.push(msg.role); + if ("content" in msg && msg.content) { + parts.push(this.extractContent(msg.content)); + } + if (msg.role === "bashExecution") { + const bashMsg = msg as { command?: string }; + if (bashMsg.command) parts.push(bashMsg.command); + } + break; + } + case "custom_message": { + parts.push(entry.customType); + if (typeof entry.content === "string") { + parts.push(entry.content); + } else { + parts.push(this.extractContent(entry.content)); + } + break; + } + case "compaction": + parts.push("compaction"); + break; + case "branch_summary": + parts.push("branch summary", entry.summary); + break; + case "session_info": + parts.push("title"); + if (entry.name) parts.push(entry.name); + break; + case "model_change": + parts.push("model", entry.modelId); + break; + case "thinking_level_change": + parts.push("thinking", entry.thinkingLevel); + break; + case "custom": + parts.push("custom", entry.customType); + break; + case "label": + parts.push("label", entry.label ?? ""); + break; + } + + return parts.join(" "); + } + + invalidate(): void {} + + getSearchQuery(): string { + return this.searchQuery; + } + + getSelectedNode(): SessionTreeNode | undefined { + return this.filteredNodes[this.selectedIndex]?.node; + } + + updateNodeLabel(entryId: string, label: string | undefined, labelTimestamp?: string): void { + for (const flatNode of this.flatNodes) { + if (flatNode.node.entry.id === entryId) { + flatNode.node.label = label; + flatNode.node.labelTimestamp = label ? (labelTimestamp ?? new Date().toISOString()) : undefined; + break; + } + } + } + + private getStatusLabels(): string { + let labels = ""; + switch (this.filterMode) { + case "no-tools": + labels += " [no-tools]"; + break; + case "user-only": + labels += " [user]"; + break; + case "labeled-only": + labels += " [labeled]"; + break; + case "all": + labels += " [all]"; + break; + } + if (this.showLabelTimestamps) { + labels += " [+label time]"; + } + return labels; + } + + render(width: number): string[] { + const lines: string[] = []; + + if (this.filteredNodes.length === 0) { + lines.push(truncateToWidth(theme.fg("muted", " No entries found"), width)); + lines.push(truncateToWidth(theme.fg("muted", ` (0/0)${this.getStatusLabels()}`), width)); + return lines; + } + + const startIndex = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(this.maxVisibleLines / 2), + this.filteredNodes.length - this.maxVisibleLines, + ), + ); + const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length); + + const renderedRows: HorizontalViewportRow[] = []; + for (let i = startIndex; i < endIndex; i++) { + const flatNode = this.filteredNodes[i]; + const entry = flatNode.node.entry; + const isSelected = i === this.selectedIndex; + + // Build line: cursor + prefix + path marker + label + content + const cursor = isSelected ? theme.fg("accent", "› ") : " "; + + // If multiple roots, shift display (roots at 0, not 1) + const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent; + + // Build prefix with gutters at their correct positions + // Each gutter has a position (displayIndent where its connector was shown) + const connector = + flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─ " : "├─ ") : ""; + const connectorPosition = connector ? displayIndent - 1 : -1; + + // Build prefix char by char, placing gutters and connector at their positions + const totalChars = displayIndent * 3; + const prefixChars: string[] = []; + const isFolded = this.foldedNodes.has(entry.id); + for (let i = 0; i < totalChars; i++) { + const level = Math.floor(i / 3); + const posInLevel = i % 3; + + // Check if there's a gutter at this level + const gutter = flatNode.gutters.find((g) => g.position === level); + if (gutter) { + if (posInLevel === 0) { + prefixChars.push(gutter.show ? "│" : " "); + } else { + prefixChars.push(" "); + } + } else if (connector && level === connectorPosition) { + // Connector at this level, with fold indicator + if (posInLevel === 0) { + prefixChars.push(flatNode.isLast ? "└" : "├"); + } else if (posInLevel === 1) { + const foldable = this.isFoldable(entry.id); + prefixChars.push(isFolded ? "⊞" : foldable ? "⊟" : "─"); + } else { + prefixChars.push(" "); + } + } else { + prefixChars.push(" "); + } + } + const prefix = prefixChars.join(""); + + // Fold marker for nodes without connectors (roots) + const showsFoldInConnector = flatNode.showConnector && !flatNode.isVirtualRootChild; + const foldMarker = isFolded && !showsFoldInConnector ? theme.fg("accent", "⊞ ") : ""; + + // Active path marker - shown right before the entry text + const isOnActivePath = this.activePathIds.has(entry.id); + const pathMarker = isOnActivePath ? theme.fg("accent", "• ") : ""; + + const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; + const labelTimestamp = + this.showLabelTimestamps && flatNode.node.label && flatNode.node.labelTimestamp + ? theme.fg("muted", `${this.formatLabelTimestamp(flatNode.node.labelTimestamp)} `) + : ""; + const content = this.getEntryDisplayText(flatNode.node, isSelected); + const prefixPart = theme.fg("dim", prefix) + foldMarker + pathMarker; + const anchorCol = visibleWidth(prefixPart); + let gutter = cursor; + let body = prefixPart + label + labelTimestamp + content; + if (isSelected) { + gutter = theme.bg("selectedBg", gutter); + body = theme.bg("selectedBg", body); + } + renderedRows.push({ gutter, body, anchorCol, bodyWidth: visibleWidth(body), isSelected }); + } + + lines.push(...renderHorizontalViewport(renderedRows, width)); + lines.push( + truncateToWidth( + theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getStatusLabels()}`), + width, + ), + ); + + return lines; + } + + private getEntryDisplayText(node: SessionTreeNode, isSelected: boolean): string { + const entry = node.entry; + let result: string; + + const normalize = (s: string) => s.replace(/[\n\t]/g, " ").trim(); + + switch (entry.type) { + case "message": { + const msg = entry.message; + const role = msg.role; + if (role === "user") { + const msgWithContent = msg as { content?: unknown }; + const content = normalize(this.extractContent(msgWithContent.content)); + result = theme.fg("accent", "user: ") + content; + } else if (role === "assistant") { + const msgWithContent = msg as { content?: unknown; stopReason?: string; errorMessage?: string }; + const textContent = normalize(this.extractContent(msgWithContent.content)); + if (textContent) { + result = theme.fg("success", "assistant: ") + textContent; + } else if (msgWithContent.stopReason === "aborted") { + result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)"); + } else if (msgWithContent.errorMessage) { + const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80); + result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg); + } else { + result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)"); + } + } else if (role === "toolResult") { + const toolMsg = msg as { toolCallId?: string; toolName?: string }; + const toolCall = toolMsg.toolCallId ? this.toolCallMap.get(toolMsg.toolCallId) : undefined; + if (toolCall) { + result = theme.fg("muted", this.formatToolCall(toolCall.name, toolCall.arguments)); + } else { + result = theme.fg("muted", `[${toolMsg.toolName ?? "tool"}]`); + } + } else if (role === "bashExecution") { + const bashMsg = msg as { command?: string }; + result = theme.fg("dim", `[bash]: ${normalize(bashMsg.command ?? "")}`); + } else { + result = theme.fg("dim", `[${role}]`); + } + break; + } + case "custom_message": { + const content = + typeof entry.content === "string" + ? entry.content + : entry.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join(""); + result = theme.fg("customMessageLabel", `[${entry.customType}]: `) + normalize(content); + break; + } + case "compaction": { + const tokens = Math.round(entry.tokensBefore / 1000); + result = theme.fg("borderAccent", `[compaction: ${tokens}k tokens]`); + break; + } + case "branch_summary": + result = theme.fg("warning", `[branch summary]: `) + normalize(entry.summary); + break; + case "model_change": + result = theme.fg("dim", `[model: ${entry.modelId}]`); + break; + case "thinking_level_change": + result = theme.fg("dim", `[thinking: ${entry.thinkingLevel}]`); + break; + case "custom": + result = theme.fg("dim", `[custom: ${entry.customType}]`); + break; + case "label": + result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`); + break; + case "session_info": + result = entry.name + ? [theme.fg("dim", "[title: "), theme.fg("dim", entry.name), theme.fg("dim", "]")].join("") + : [theme.fg("dim", "[title: "), theme.italic(theme.fg("dim", "empty")), theme.fg("dim", "]")].join(""); + break; + default: + result = ""; + } + + return isSelected ? theme.bold(result) : result; + } + + private formatLabelTimestamp(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const hours = date.getHours().toString().padStart(2, "0"); + const minutes = date.getMinutes().toString().padStart(2, "0"); + const time = `${hours}:${minutes}`; + + if ( + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate() + ) { + return time; + } + + const month = date.getMonth() + 1; + const day = date.getDate(); + if (date.getFullYear() === now.getFullYear()) { + return `${month}/${day} ${time}`; + } + + const year = date.getFullYear().toString().slice(-2); + return `${year}/${month}/${day} ${time}`; + } + + private extractContent(content: unknown): string { + const maxLen = 200; + if (typeof content === "string") return content.slice(0, maxLen); + if (Array.isArray(content)) { + let result = ""; + for (const c of content) { + if (typeof c === "object" && c !== null && "type" in c && c.type === "text") { + result += (c as { text: string }).text; + if (result.length >= maxLen) return result.slice(0, maxLen); + } + } + return result; + } + return ""; + } + + private hasTextContent(content: unknown): boolean { + if (typeof content === "string") return content.trim().length > 0; + if (Array.isArray(content)) { + for (const c of content) { + if (typeof c === "object" && c !== null && "type" in c && c.type === "text") { + const text = (c as { text?: string }).text; + if (text && text.trim().length > 0) return true; + } + } + } + return false; + } + + private formatToolCall(name: string, args: Record): string { + const shortenPath = (p: string): string => { + const home = process.env.HOME || process.env.USERPROFILE || ""; + if (home && p.startsWith(home)) return `~${p.slice(home.length)}`; + return p; + }; + + switch (name) { + case "read": { + const path = shortenPath(String(args.path || args.file_path || "")); + const offset = args.offset as number | undefined; + const limit = args.limit as number | undefined; + let display = path; + if (offset !== undefined || limit !== undefined) { + const start = offset ?? 1; + const end = limit !== undefined ? start + limit - 1 : ""; + display += `:${start}${end ? `-${end}` : ""}`; + } + return `[read: ${display}]`; + } + case "write": { + const path = shortenPath(String(args.path || args.file_path || "")); + return `[write: ${path}]`; + } + case "edit": { + const path = shortenPath(String(args.path || args.file_path || "")); + return `[edit: ${path}]`; + } + case "bash": { + const rawCmd = String(args.command || ""); + const cmd = rawCmd + .replace(/[\n\t]/g, " ") + .trim() + .slice(0, 50); + return `[bash: ${cmd}${rawCmd.length > 50 ? "..." : ""}]`; + } + case "grep": { + const pattern = String(args.pattern || ""); + const path = shortenPath(String(args.path || ".")); + return `[grep: /${pattern}/ in ${path}]`; + } + case "find": { + const pattern = String(args.pattern || ""); + const path = shortenPath(String(args.path || ".")); + return `[find: ${pattern} in ${path}]`; + } + case "ls": { + const path = shortenPath(String(args.path || ".")); + return `[ls: ${path}]`; + } + default: { + // Custom tool - show name and truncated JSON args + const argsStr = JSON.stringify(args).slice(0, 40); + return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? "..." : ""}]`; + } + } + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up")) { + this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; + } else if (kb.matches(keyData, "tui.select.down")) { + this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; + } else if (kb.matches(keyData, "app.tree.foldOrUp")) { + const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id; + if (currentId && this.isFoldable(currentId) && !this.foldedNodes.has(currentId)) { + this.foldedNodes.add(currentId); + this.applyFilter(); + } else { + this.selectedIndex = this.findBranchSegmentStart("up"); + } + } else if (kb.matches(keyData, "app.tree.unfoldOrDown")) { + const currentId = this.filteredNodes[this.selectedIndex]?.node.entry.id; + if (currentId && this.foldedNodes.has(currentId)) { + this.foldedNodes.delete(currentId); + this.applyFilter(); + } else { + this.selectedIndex = this.findBranchSegmentStart("down"); + } + } else if (kb.matches(keyData, "tui.editor.cursorLeft") || kb.matches(keyData, "tui.select.pageUp")) { + // Page up + this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines); + } else if (kb.matches(keyData, "tui.editor.cursorRight") || kb.matches(keyData, "tui.select.pageDown")) { + // Page down + this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines); + } else if (kb.matches(keyData, "tui.select.confirm")) { + const selected = this.filteredNodes[this.selectedIndex]; + if (selected && this.onSelect) { + this.onSelect(selected.node.entry.id); + } + } else if (kb.matches(keyData, "tui.select.cancel")) { + if (this.searchQuery) { + this.searchQuery = ""; + this.foldedNodes.clear(); + this.applyFilter(); + } else { + this.onCancel?.(); + } + } else if (kb.matches(keyData, "app.tree.filter.default")) { + // Direct filter: default + this.filterMode = "default"; + this.foldedNodes.clear(); + this.applyFilter(); + } else if (kb.matches(keyData, "app.tree.filter.noTools")) { + // Toggle filter: no-tools ↔ default + this.filterMode = this.filterMode === "no-tools" ? "default" : "no-tools"; + this.foldedNodes.clear(); + this.applyFilter(); + } else if (kb.matches(keyData, "app.tree.filter.userOnly")) { + // Toggle filter: user-only ↔ default + this.filterMode = this.filterMode === "user-only" ? "default" : "user-only"; + this.foldedNodes.clear(); + this.applyFilter(); + } else if (kb.matches(keyData, "app.tree.filter.labeledOnly")) { + // Toggle filter: labeled-only ↔ default + this.filterMode = this.filterMode === "labeled-only" ? "default" : "labeled-only"; + this.foldedNodes.clear(); + this.applyFilter(); + } else if (kb.matches(keyData, "app.tree.filter.all")) { + // Toggle filter: all ↔ default + this.filterMode = this.filterMode === "all" ? "default" : "all"; + this.foldedNodes.clear(); + this.applyFilter(); + } else if (kb.matches(keyData, "app.tree.filter.cycleBackward")) { + // Cycle filter backwards + const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"]; + const currentIndex = modes.indexOf(this.filterMode); + this.filterMode = modes[(currentIndex - 1 + modes.length) % modes.length]; + this.foldedNodes.clear(); + this.applyFilter(); + } else if (kb.matches(keyData, "app.tree.filter.cycleForward")) { + // Cycle filter forwards: default → no-tools → user-only → labeled-only → all → default + const modes: FilterMode[] = ["default", "no-tools", "user-only", "labeled-only", "all"]; + const currentIndex = modes.indexOf(this.filterMode); + this.filterMode = modes[(currentIndex + 1) % modes.length]; + this.foldedNodes.clear(); + this.applyFilter(); + } else if (kb.matches(keyData, "tui.editor.deleteCharBackward")) { + if (this.searchQuery.length > 0) { + this.searchQuery = this.searchQuery.slice(0, -1); + this.foldedNodes.clear(); + this.applyFilter(); + } + } else if (kb.matches(keyData, "app.tree.editLabel")) { + const selected = this.filteredNodes[this.selectedIndex]; + if (selected && this.onLabelEdit) { + this.onLabelEdit(selected.node.entry.id, selected.node.label); + } + } else if (kb.matches(keyData, "app.tree.toggleLabelTimestamp")) { + this.showLabelTimestamps = !this.showLabelTimestamps; + } else { + const hasControlChars = [...keyData].some((ch) => { + const code = ch.charCodeAt(0); + return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); + }); + if (!hasControlChars && keyData.length > 0) { + this.searchQuery += keyData; + this.foldedNodes.clear(); + this.applyFilter(); + } + } + } + + /** + * Whether a node can be folded. A node is foldable if it has visible children + * and is either a root (no visible parent) or a segment start (visible parent + * has multiple visible children). + */ + private isFoldable(entryId: string): boolean { + const children = this.visibleChildrenMap.get(entryId); + if (!children || children.length === 0) return false; + const parentId = this.visibleParentMap.get(entryId); + if (parentId === null || parentId === undefined) return true; + const siblings = this.visibleChildrenMap.get(parentId); + return siblings !== undefined && siblings.length > 1; + } + + /** + * Find the index of the next branch segment start in the given direction. + * A segment start is the first child of a branch point. + * + * "up" walks the visible parent chain; "down" walks visible children + * (always following the first child). + */ + private findBranchSegmentStart(direction: "up" | "down"): number { + const selectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id; + if (!selectedId) return this.selectedIndex; + + const indexByEntryId = new Map(this.filteredNodes.map((node, i) => [node.node.entry.id, i])); + let currentId: string = selectedId; + if (direction === "down") { + while (true) { + const children: string[] = this.visibleChildrenMap.get(currentId) ?? []; + if (children.length === 0) return indexByEntryId.get(currentId)!; + if (children.length > 1) return indexByEntryId.get(children[0])!; + currentId = children[0]; + } + } + + // direction === "up" + while (true) { + const parentId: string | null = this.visibleParentMap.get(currentId) ?? null; + if (parentId === null) return indexByEntryId.get(currentId)!; + const children = this.visibleChildrenMap.get(parentId) ?? []; + if (children.length > 1) { + const segmentStart = indexByEntryId.get(currentId)!; + if (segmentStart < this.selectedIndex) { + return segmentStart; + } + } + currentId = parentId; + } + } +} + +/** Component that displays the current search query */ +class SearchLine implements Component { + private treeList: TreeList; + + constructor(treeList: TreeList) { + this.treeList = treeList; + } + + invalidate(): void {} + + render(width: number): string[] { + const query = this.treeList.getSearchQuery(); + if (query) { + return [truncateToWidth(` ${theme.fg("muted", "Type to search:")} ${theme.fg("accent", query)}`, width)]; + } + return [truncateToWidth(` ${theme.fg("muted", "Type to search:")}`, width)]; + } + + handleInput(_keyData: string): void {} +} + +/** Component that renders tree help as semantic rows with chunk-aware wrapping */ +class TreeHelp implements Component { + invalidate(): void {} + + render(width: number): string[] { + const items = TREE_HELP_ITEMS.map(({ keys, label, labelFirst }) => { + const text = formatHelpKeys(keys); + if (!text) return label; + return labelFirst ? `${label} ${text}` : `${text} ${label}`; + }); + + const availableWidth = Math.max(1, width); + const indent = " "; + const separator = " · "; + const lines: string[] = []; + let currentLine = ""; + + for (const item of items) { + const candidate = currentLine + ? `${currentLine}${separator}${item}` + : visibleWidth(`${indent}${item}`) <= availableWidth + ? `${indent}${item}` + : item; + if (!currentLine || visibleWidth(candidate) <= availableWidth) { + currentLine = candidate; + continue; + } + + lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth)); + currentLine = visibleWidth(`${indent}${item}`) <= availableWidth ? `${indent}${item}` : item; + } + + if (currentLine) { + lines.push(...wrapTextWithAnsi(currentLine.trimEnd(), availableWidth)); + } + + return lines.map((line) => theme.fg("muted", line)); + } +} + +const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: boolean }> = [ + { keys: ["tui.select.up", "tui.select.down"], label: "move" }, + { keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" }, + { keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" }, + { keys: ["app.tree.editLabel"], label: "label" }, + { keys: ["app.tree.toggleLabelTimestamp"], label: "label time" }, + { + keys: [ + "app.tree.filter.default", + "app.tree.filter.noTools", + "app.tree.filter.userOnly", + "app.tree.filter.labeledOnly", + "app.tree.filter.all", + ], + label: "filters", + labelFirst: true, + }, + { keys: ["app.tree.filter.cycleForward", "app.tree.filter.cycleBackward"], label: "cycle", labelFirst: true }, +]; + +function formatHelpKeys(keybindings: Keybinding[]): string { + const keys: string[] = []; + for (const keybinding of keybindings) { + const key = getKeybindings().getKeys(keybinding)[0]; + if (key !== undefined) keys.push(key); + } + if (keys.length === 0) return ""; + + return formatKeyText(compactRawKeys(keys)) + .replace(/\bpageUp\b/g, "pgup") + .replace(/\bpageDown\b/g, "pgdn") + .replace(/\bup\b/g, "↑") + .replace(/\bdown\b/g, "↓") + .replace(/\bleft\b/g, "←") + .replace(/\bright\b/g, "→"); +} + +function compactRawKeys(keys: string[]): string { + if (keys.length === 1) return keys[0]!; + + const parts = keys.map((key) => { + const separatorIndex = key.lastIndexOf("+"); + return separatorIndex === -1 + ? { prefix: "", suffix: key } + : { prefix: key.slice(0, separatorIndex + 1), suffix: key.slice(separatorIndex + 1) }; + }); + const prefix = parts[0]!.prefix; + return prefix && parts.every((part) => part.prefix === prefix) + ? `${prefix}${parts.map((part) => part.suffix).join("/")}` + : keys.join("/"); +} + +/** Label input component shown when editing a label */ +class LabelInput implements Component, Focusable { + private input: Input; + private entryId: string; + public onSubmit?: (entryId: string, label: string | undefined) => void; + public onCancel?: () => void; + + // Focusable implementation - propagate to input for IME cursor positioning + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.input.focused = value; + } + + constructor(entryId: string, currentLabel: string | undefined) { + this.entryId = entryId; + this.input = new Input(); + if (currentLabel) { + this.input.setValue(currentLabel); + } + } + + invalidate(): void {} + + render(width: number): string[] { + const lines: string[] = []; + const indent = " "; + const availableWidth = width - indent.length; + lines.push(truncateToWidth(`${indent}${theme.fg("muted", "Label (empty to remove):")}`, width)); + lines.push(...this.input.render(availableWidth).map((line) => truncateToWidth(`${indent}${line}`, width))); + lines.push( + truncateToWidth( + `${indent}${keyHint("tui.select.confirm", "save")} ${keyHint("tui.select.cancel", "cancel")}`, + width, + ), + ); + return lines; + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.confirm")) { + const value = this.input.getValue().trim(); + this.onSubmit?.(this.entryId, value || undefined); + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.onCancel?.(); + } else { + this.input.handleInput(keyData); + } + } +} + +/** + * Component that renders a session tree selector for navigation + */ +export class TreeSelectorComponent extends Container implements Focusable { + private treeList: TreeList; + private labelInput: LabelInput | null = null; + private labelInputContainer: Container; + private treeContainer: Container; + private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void; + + // Focusable implementation - propagate to labelInput when active for IME cursor positioning + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + // Propagate to labelInput when it's active + if (this.labelInput) { + this.labelInput.focused = value; + } + } + + constructor( + tree: SessionTreeNode[], + currentLeafId: string | null, + terminalHeight: number, + onSelect: (entryId: string) => void, + onCancel: () => void, + onLabelChange?: (entryId: string, label: string | undefined) => void, + initialSelectedId?: string, + initialFilterMode?: FilterMode, + ) { + super(); + + this.onLabelChangeCallback = onLabelChange; + const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2)); + + this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode); + this.treeList.onSelect = onSelect; + this.treeList.onCancel = onCancel; + this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel); + + this.treeContainer = new Container(); + this.treeContainer.addChild(this.treeList); + + this.labelInputContainer = new Container(); + + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); + this.addChild(new TreeHelp()); + this.addChild(new SearchLine(this.treeList)); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(this.treeContainer); + this.addChild(this.labelInputContainer); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + if (tree.length === 0) { + setTimeout(() => onCancel(), 100); + } + } + + private showLabelInput(entryId: string, currentLabel: string | undefined): void { + this.labelInput = new LabelInput(entryId, currentLabel); + this.labelInput.onSubmit = (id, label) => { + this.treeList.updateNodeLabel(id, label); + this.onLabelChangeCallback?.(id, label); + this.hideLabelInput(); + }; + this.labelInput.onCancel = () => this.hideLabelInput(); + + // Propagate current focused state to the new labelInput + this.labelInput.focused = this._focused; + + this.treeContainer.clear(); + this.labelInputContainer.clear(); + this.labelInputContainer.addChild(this.labelInput); + } + + private hideLabelInput(): void { + this.labelInput = null; + this.labelInputContainer.clear(); + this.treeContainer.clear(); + this.treeContainer.addChild(this.treeList); + } + + handleInput(keyData: string): void { + if (this.labelInput) { + this.labelInput.handleInput(keyData); + } else { + this.treeList.handleInput(keyData); + } + } + + getTreeList(): TreeList { + return this.treeList; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/trust-selector.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/trust-selector.ts new file mode 100644 index 000000000..92c232889 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/trust-selector.ts @@ -0,0 +1,134 @@ +import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui"; +import { + getProjectTrustOptions, + type ProjectTrustOption, + type ProjectTrustStoreEntry, +} from "../../../core/trust-manager.ts"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; +import { keyHint, rawKeyHint } from "./keybinding-hints.ts"; + +export type TrustSelection = Pick; + +export interface TrustSelectorOptions { + cwd: string; + savedDecision: ProjectTrustStoreEntry | null; + projectTrusted: boolean; + onSelect: (selection: TrustSelection) => void; + onCancel: () => void; +} + +function formatDecision(trustPath: string | undefined, decision: ProjectTrustStoreEntry | null): string { + if (decision === null) { + return "none"; + } + const label = decision.decision ? "trusted" : "untrusted"; + if (trustPath !== undefined && decision.path !== trustPath) { + return `${label} (inherited from ${decision.path})`; + } + return `${label} (${decision.path})`; +} + +export class TrustSelectorComponent extends Container { + private selectedIndex: number; + private readonly listContainer: Container; + private readonly trustOptions: ProjectTrustOption[]; + private readonly savedDecision: ProjectTrustStoreEntry | null; + private readonly onSelectCallback: (selection: TrustSelection) => void; + private readonly onCancelCallback: () => void; + + constructor(options: TrustSelectorOptions) { + super(); + + this.savedDecision = options.savedDecision; + this.trustOptions = getProjectTrustOptions(options.cwd); + this.selectedIndex = Math.max( + 0, + this.trustOptions.findIndex((option) => this.isSavedOption(option)), + ); + this.onSelectCallback = options.onSelect; + this.onCancelCallback = options.onCancel; + + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0)); + this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild( + new Text( + theme.fg( + "muted", + `Saved decision: ${formatDecision(this.trustOptions[0]?.savedPath, options.savedDecision)}`, + ), + 1, + 0, + ), + ); + this.addChild( + new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0), + ); + this.addChild(new Spacer(1)); + + this.listContainer = new Container(); + this.addChild(this.listContainer); + this.addChild(new Spacer(1)); + this.addChild( + new Text( + rawKeyHint("↑↓", "navigate") + + " " + + keyHint("tui.select.confirm", "save") + + " " + + keyHint("tui.select.cancel", "cancel"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + this.updateList(); + } + + private isSavedOption(option: ProjectTrustOption): boolean { + return ( + option.savedPath !== undefined && + this.savedDecision?.decision === option.trusted && + this.savedDecision.path === option.savedPath + ); + } + + private updateList(): void { + this.listContainer.clear(); + for (let i = 0; i < this.trustOptions.length; i++) { + const option = this.trustOptions[i]; + if (!option) { + continue; + } + + const isSelected = i === this.selectedIndex; + const isCurrent = this.isSavedOption(option); + const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; + const prefix = isSelected ? theme.fg("accent", "→ ") : " "; + const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label); + this.listContainer.addChild(new Text(`${prefix}${label}${checkmark}`, 1, 0)); + } + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + if (kb.matches(keyData, "tui.select.up") || keyData === "k") { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.updateList(); + } else if (kb.matches(keyData, "tui.select.down") || keyData === "j") { + this.selectedIndex = Math.min(this.trustOptions.length - 1, this.selectedIndex + 1); + this.updateList(); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + const selected = this.trustOptions[this.selectedIndex]; + if (selected) { + this.onSelectCallback({ trusted: selected.trusted, updates: selected.updates }); + } + } else if (kb.matches(keyData, "tui.select.cancel")) { + this.onCancelCallback(); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts new file mode 100644 index 000000000..69fbbc82a --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -0,0 +1,155 @@ +import { type Component, Container, getKeybindings, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui"; +import { theme } from "../theme/theme.ts"; +import { DynamicBorder } from "./dynamic-border.ts"; + +interface UserMessageItem { + id: string; // Entry ID in the session + text: string; // The message text + timestamp?: string; // Optional timestamp if available +} + +/** + * Custom user message list component with selection + */ +class UserMessageList implements Component { + private messages: UserMessageItem[] = []; + private selectedIndex: number = 0; + public onSelect?: (entryId: string) => void; + public onCancel?: () => void; + private maxVisible: number = 10; // Max messages visible + + constructor(messages: UserMessageItem[], initialSelectedId?: string) { + // Store messages in chronological order (oldest to newest) + this.messages = messages; + const initialIndex = initialSelectedId ? messages.findIndex((message) => message.id === initialSelectedId) : -1; + // Start with selected message if provided, else default to the most recent + this.selectedIndex = initialIndex >= 0 ? initialIndex : Math.max(0, messages.length - 1); + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(width: number): string[] { + const lines: string[] = []; + + if (this.messages.length === 0) { + lines.push(theme.fg("muted", " No user messages found")); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.messages.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.messages.length); + + // Render visible messages (2 lines per message + blank line) + for (let i = startIndex; i < endIndex; i++) { + const message = this.messages[i]; + const isSelected = i === this.selectedIndex; + + // Normalize message to single line + const normalizedMessage = message.text.replace(/\n/g, " ").trim(); + + // First line: cursor + message + const cursor = isSelected ? theme.fg("accent", "› ") : " "; + const maxMsgWidth = width - 2; // Account for cursor (2 chars) + const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth); + const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg); + + lines.push(messageLine); + + // Second line: metadata (position in history) + const position = i + 1; + const metadata = ` Message ${position} of ${this.messages.length}`; + const metadataLine = theme.fg("muted", metadata); + lines.push(metadataLine); + lines.push(""); // Blank line between messages + } + + // Add scroll indicator if needed + if (startIndex > 0 || endIndex < this.messages.length) { + const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.messages.length})`); + lines.push(scrollInfo); + } + + return lines; + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + // Up arrow - go to previous (older) message, wrap to bottom when at top + if (kb.matches(keyData, "tui.select.up")) { + this.selectedIndex = this.selectedIndex === 0 ? this.messages.length - 1 : this.selectedIndex - 1; + } + // Down arrow - go to next (newer) message, wrap to top when at bottom + else if (kb.matches(keyData, "tui.select.down")) { + this.selectedIndex = this.selectedIndex === this.messages.length - 1 ? 0 : this.selectedIndex + 1; + } + // Enter - select message and branch + else if (kb.matches(keyData, "tui.select.confirm")) { + const selected = this.messages[this.selectedIndex]; + if (selected && this.onSelect) { + this.onSelect(selected.id); + } + } + // Escape - cancel + else if (kb.matches(keyData, "tui.select.cancel")) { + if (this.onCancel) { + this.onCancel(); + } + } + } +} + +/** + * Component that renders a user message selector for branching + */ +export class UserMessageSelectorComponent extends Container { + private messageList: UserMessageList; + + constructor( + messages: UserMessageItem[], + onSelect: (entryId: string) => void, + onCancel: () => void, + initialSelectedId?: string, + ) { + super(); + + // Add header + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.bold("Fork from Message"), 1, 0)); + this.addChild( + new Text( + theme.fg("muted", "Select a user message to copy the active path up to that point into a new session"), + 1, + 0, + ), + ); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + // Create message list + this.messageList = new UserMessageList(messages, initialSelectedId); + this.messageList.onSelect = onSelect; + this.messageList.onCancel = onCancel; + + this.addChild(this.messageList); + + // Add bottom border + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + // Auto-cancel if no messages + if (messages.length === 0) { + setTimeout(() => onCancel(), 100); + } + } + + getMessageList(): UserMessageList { + return this.messageList; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/user-message.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/user-message.ts new file mode 100644 index 000000000..45d655776 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/user-message.ts @@ -0,0 +1,42 @@ +import { Box, Container, Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; +import { getMarkdownTheme, theme } from "../theme/theme.ts"; + +const OSC133_ZONE_START = "\x1b]133;A\x07"; +const OSC133_ZONE_END = "\x1b]133;B\x07"; +const OSC133_ZONE_FINAL = "\x1b]133;C\x07"; + +/** + * Component that renders a user message + */ +export class UserMessageComponent extends Container { + private contentBox: Box; + + constructor(text: string, markdownTheme: MarkdownTheme = getMarkdownTheme()) { + super(); + this.contentBox = new Box(1, 1, (content: string) => theme.bg("userMessageBg", content)); + this.contentBox.addChild( + new Markdown( + text, + 0, + 0, + markdownTheme, + { + color: (content: string) => theme.fg("userMessageText", content), + }, + { preserveOrderedListMarkers: true }, + ), + ); + this.addChild(this.contentBox); + } + + override render(width: number): string[] { + const lines = super.render(width); + if (lines.length === 0) { + return lines; + } + + lines[0] = OSC133_ZONE_START + lines[0]; + lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1]; + return lines; + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts b/cactus-code/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts new file mode 100644 index 000000000..827230416 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts @@ -0,0 +1,50 @@ +/** + * Shared utility for truncating text to visual lines (accounting for line wrapping). + * Used by both tool-execution.ts and bash-execution.ts for consistent behavior. + */ + +import { Text } from "@earendil-works/pi-tui"; + +export interface VisualTruncateResult { + /** The visual lines to display */ + visualLines: string[]; + /** Number of visual lines that were skipped (hidden) */ + skippedCount: number; +} + +/** + * Truncate text to a maximum number of visual lines (from the end). + * This accounts for line wrapping based on terminal width. + * + * @param text - The text content (may contain newlines) + * @param maxVisualLines - Maximum number of visual lines to show + * @param width - Terminal/render width + * @param paddingX - Horizontal padding for Text component (default 0). + * Use 0 when result will be placed in a Box (Box adds its own padding). + * Use 1 when result will be placed in a plain Container. + * @returns The truncated visual lines and count of skipped lines + */ +export function truncateToVisualLines( + text: string, + maxVisualLines: number, + width: number, + paddingX: number = 0, +): VisualTruncateResult { + if (!text) { + return { visualLines: [], skippedCount: 0 }; + } + + // Create a temporary Text component to render and get visual lines + const tempText = new Text(text, paddingX, 0); + const allVisualLines = tempText.render(width); + + if (allVisualLines.length <= maxVisualLines) { + return { visualLines: allVisualLines, skippedCount: 0 }; + } + + // Take the last N visual lines + const truncatedLines = allVisualLines.slice(-maxVisualLines); + const skippedCount = allVisualLines.length - maxVisualLines; + + return { visualLines: truncatedLines, skippedCount }; +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/cactus-code/packages/coding-agent/src/modes/interactive/interactive-mode.ts new file mode 100644 index 000000000..7df647d94 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -0,0 +1,4966 @@ +/** + * Interactive mode for the coding agent. + * Handles TUI rendering and user interaction, delegating business logic to AgentSession. + */ + +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { + type AssistantMessage, + getProviders, + type ImageContent, + type Message, + type Model, +} from "@earendil-works/pi-ai/compat"; +import type { + AutocompleteItem, + AutocompleteProvider, + EditorComponent, + Keybinding, + KeyId, + MarkdownTheme, + OverlayHandle, + OverlayOptions, + SlashCommand, +} from "@earendil-works/pi-tui"; +import { + CombinedAutocompleteProvider, + type Component, + Container, + fuzzyFilter, + Loader, + type LoaderIndicatorOptions, + Markdown, + matchesKey, + ProcessTerminal, + Spacer, + setKeybindings, + Text, + TruncatedText, + TUI, + visibleWidth, +} from "@earendil-works/pi-tui"; +import chalk from "chalk"; +import { spawn } from "child_process"; +import { + APP_NAME, + APP_TITLE, + CONFIG_DIR_NAME, + getDebugLogPath, + VERSION, +} from "../../config.ts"; +import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; +import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; +import type { + AutocompleteProviderFactory, + EditorFactory, + ExtensionCommandContext, + ExtensionContext, + ExtensionRunner, + ExtensionUIContext, + ExtensionUIDialogOptions, + ExtensionWidgetOptions, + ProjectTrustContext, +} from "../../core/extensions/index.ts"; +import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.ts"; +import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts"; +import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts"; +import { createCompactionSummaryMessage } from "../../core/messages.ts"; +import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts"; +import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts"; +import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; +import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; +import { type SessionContext, SessionManager } from "../../core/session-manager.ts"; +import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; +import type { SourceInfo } from "../../core/source-info.ts"; +import type { TruncationResult } from "../../core/tools/truncate.ts"; +import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; +import { copyToClipboard } from "../../utils/clipboard.ts"; +import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; +import { parseGitUrl } from "../../utils/git.ts"; +import { getCwdRelativePath } from "../../utils/paths.ts"; +import { killTrackedDetachedChildren } from "../../utils/shell.ts"; +import { ensureTool } from "../../utils/tools-manager.ts"; +import { AssistantMessageComponent } from "./components/assistant-message.ts"; +import { BashExecutionComponent } from "./components/bash-execution.ts"; +import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts"; +import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.ts"; +import { CountdownTimer } from "./components/countdown-timer.ts"; +import { CustomEditor } from "./components/custom-editor.ts"; +import { CustomMessageComponent } from "./components/custom-message.ts"; +import { DynamicBorder } from "./components/dynamic-border.ts"; +import { ExtensionEditorComponent } from "./components/extension-editor.ts"; +import { ExtensionInputComponent } from "./components/extension-input.ts"; +import { ExtensionSelectorComponent } from "./components/extension-selector.ts"; +import { FooterComponent } from "./components/footer.ts"; +import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.ts"; +import { ModelSelectorComponent } from "./components/model-selector.ts"; +import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.ts"; +import { SessionSelectorComponent } from "./components/session-selector.ts"; +import { SettingsSelectorComponent } from "./components/settings-selector.ts"; +import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.ts"; +import { ToolExecutionComponent } from "./components/tool-execution.ts"; +import { TreeSelectorComponent } from "./components/tree-selector.ts"; +import { TrustSelectorComponent } from "./components/trust-selector.ts"; +import { UserMessageComponent } from "./components/user-message.ts"; +import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; +import { getModelSearchText } from "./model-search.ts"; +import { + getAvailableThemes, + getAvailableThemesWithPaths, + getEditorTheme, + getMarkdownTheme, + getThemeByName, + onThemeChange, + setRegisteredThemes, + stopThemeWatcher, + Theme, + type ThemeColor, + theme, +} from "./theme/theme.ts"; +import { InteractiveThemeController } from "./theme/theme-controller.ts"; + +/** Interface for components that can be expanded/collapsed */ +interface Expandable { + setExpanded(expanded: boolean): void; +} + +function isExpandable(obj: unknown): obj is Expandable { + return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function"; +} + +class ExpandableText extends Text implements Expandable { + private readonly getCollapsedText: () => string; + private readonly getExpandedText: () => string; + + constructor( + getCollapsedText: () => string, + getExpandedText: () => string, + expanded = false, + paddingX = 0, + paddingY = 0, + ) { + super(expanded ? getExpandedText() : getCollapsedText(), paddingX, paddingY); + this.getCollapsedText = getCollapsedText; + this.getExpandedText = getExpandedText; + } + + setExpanded(expanded: boolean): void { + this.setText(expanded ? this.getExpandedText() : this.getCollapsedText()); + } +} + +type CompactionQueuedMessage = { + text: string; + mode: "steer" | "followUp"; +}; + +const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]); + +function isDeadTerminalError(error: unknown): boolean { + if (!error || typeof error !== "object" || !("code" in error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); +} + +const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = + "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage."; + +function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean { + return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat"); +} + +function isUnknownModel(model: Model | undefined): boolean { + return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown"; +} + +function quoteIfNeeded(value: string): string { + if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value)) { + return value; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export function formatResumeCommand(sessionManager: SessionManager): string | undefined { + if (!process.stdout.isTTY) return undefined; + if (!sessionManager.isPersisted()) return undefined; + + const sessionFile = sessionManager.getSessionFile(); + if (!sessionFile || !fs.existsSync(sessionFile)) return undefined; + + const args = [APP_NAME]; + if (!sessionManager.usesDefaultSessionDir()) { + args.push("--session-dir", quoteIfNeeded(sessionManager.getSessionDir())); + } + args.push("--session", sessionManager.getSessionId()); + return args.join(" "); +} + +function hasDefaultModelProvider(providerId: string): providerId is keyof typeof defaultModelPerProvider { + return providerId in defaultModelPerProvider; +} + +const BUILT_IN_MODEL_PROVIDERS = new Set(getProviders()); + +export function isApiKeyLoginProvider( + providerId: string, + oauthProviderIds: ReadonlySet, + builtInProviderIds: ReadonlySet = BUILT_IN_MODEL_PROVIDERS, +): boolean { + if (BUILT_IN_PROVIDER_DISPLAY_NAMES[providerId]) { + return true; + } + if (builtInProviderIds.has(providerId)) { + return false; + } + return !oauthProviderIds.has(providerId); +} + +/** + * Options for InteractiveMode initialization. + */ +export interface InteractiveModeOptions { + /** Providers that were migrated to auth.json (shows warning) */ + migratedProviders?: string[]; + /** Warning message if session model couldn't be restored */ + modelFallbackMessage?: string; + /** Cwd to trust after reload if it gained a .pi directory during this implicitly trusted session. */ + autoTrustOnReloadCwd?: string; + /** Initial message to send on startup (can include @file content) */ + initialMessage?: string; + /** Images to attach to the initial message */ + initialImages?: ImageContent[]; + /** Additional messages to send after the initial message */ + initialMessages?: string[]; + /** Force verbose startup (overrides quietStartup setting) */ + verbose?: boolean; +} + +export class InteractiveMode { + private runtimeHost: AgentSessionRuntime; + private ui: TUI; + private chatContainer: Container; + private pendingMessagesContainer: Container; + private statusContainer: Container; + private defaultEditor: CustomEditor; + private editor: EditorComponent; + private editorComponentFactory: EditorFactory | undefined; + private autocompleteProvider: AutocompleteProvider | undefined; + private autocompleteProviderWrappers: AutocompleteProviderFactory[] = []; + private fdPath: string | undefined; + private editorContainer: Container; + private footer: FooterComponent; + private footerDataProvider: FooterDataProvider; + // Stored so the same manager can be injected into custom editors, selectors, and extension UI. + private keybindings: KeybindingsManager; + private version: string; + private isInitialized = false; + private onInputCallback?: (text: string) => void; + private pendingUserInputs: string[] = []; + private loadingAnimation: Loader | undefined = undefined; + private workingMessage: string | undefined = undefined; + private workingVisible = true; + private workingIndicatorOptions: LoaderIndicatorOptions | undefined = undefined; + private readonly defaultWorkingMessage = "Working..."; + private readonly defaultHiddenThinkingLabel = "Thinking..."; + private hiddenThinkingLabel = this.defaultHiddenThinkingLabel; + + private lastSigintTime = 0; + private lastEscapeTime = 0; + private anthropicSubscriptionWarningShown = false; + + // Status line tracking (for mutating immediately-sequential status updates) + private lastStatusSpacer: Spacer | undefined = undefined; + private lastStatusText: Text | undefined = undefined; + + // Streaming message tracking + private streamingComponent: AssistantMessageComponent | undefined = undefined; + private streamingMessage: AssistantMessage | undefined = undefined; + + // Tool execution tracking: toolCallId -> component + private pendingTools = new Map(); + + // Tool output expansion state + private toolOutputExpanded = false; + + // Thinking block visibility state + private hideThinkingBlock = false; + + // Skill commands: command name -> skill file path + private skillCommands = new Map(); + + // Agent subscription unsubscribe function + private unsubscribe?: () => void; + private signalCleanupHandlers: Array<() => void> = []; + + // Track if editor is in bash mode (text starts with !) + private isBashMode = false; + + // Track current bash execution component + private bashComponent: BashExecutionComponent | undefined = undefined; + + // Track pending bash components (shown in pending area, moved to chat on submit) + private pendingBashComponents: BashExecutionComponent[] = []; + + // Auto-compaction state + private autoCompactionLoader: Loader | undefined = undefined; + private autoCompactionEscapeHandler?: () => void; + + // Auto-retry state + private retryLoader: Loader | undefined = undefined; + private retryCountdown: CountdownTimer | undefined = undefined; + private retryEscapeHandler?: () => void; + + // Messages queued while compaction is running + private compactionQueuedMessages: CompactionQueuedMessage[] = []; + + // Shutdown state + private shutdownRequested = false; + + // Extension UI state + private extensionSelector: ExtensionSelectorComponent | undefined = undefined; + private extensionInput: ExtensionInputComponent | undefined = undefined; + private extensionEditor: ExtensionEditorComponent | undefined = undefined; + private extensionTerminalInputUnsubscribers = new Set<() => void>(); + + // Extension widgets (components rendered above/below the editor) + private extensionWidgetsAbove = new Map(); + private extensionWidgetsBelow = new Map(); + private widgetContainerAbove!: Container; + private widgetContainerBelow!: Container; + + // Custom footer from extension (undefined = use built-in footer) + private customFooter: (Component & { dispose?(): void }) | undefined = undefined; + + // Header container that holds the built-in or custom header + private headerContainer: Container; + + // Built-in header (logo + keybinding hints) + private builtInHeader: Component | undefined = undefined; + + // Custom header from extension (undefined = use built-in header) + private customHeader: (Component & { dispose?(): void }) | undefined = undefined; + + private options: InteractiveModeOptions; + private autoTrustOnReloadCwd: string | undefined; + private themeController: InteractiveThemeController; + + // Convenience accessors + private get session(): AgentSession { + return this.runtimeHost.session; + } + private get agent() { + return this.session.agent; + } + private get sessionManager() { + return this.session.sessionManager; + } + private get settingsManager() { + return this.session.settingsManager; + } + + constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) { + this.runtimeHost = runtimeHost; + this.options = options; + this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd; + this.runtimeHost.setBeforeSessionInvalidate(() => { + this.resetExtensionUI(); + }); + this.runtimeHost.setRebindSession(async () => { + await this.rebindCurrentSession({ renderBeforeBind: true }); + }); + this.version = VERSION; + this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor()); + this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); + this.headerContainer = new Container(); + this.chatContainer = new Container(); + this.pendingMessagesContainer = new Container(); + this.statusContainer = new Container(); + this.widgetContainerAbove = new Container(); + this.widgetContainerBelow = new Container(); + this.keybindings = KeybindingsManager.create(); + setKeybindings(this.keybindings); + const editorPaddingX = this.settingsManager.getEditorPaddingX(); + const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); + this.defaultEditor = new CustomEditor(this.ui, getEditorTheme(), this.keybindings, { + paddingX: editorPaddingX, + autocompleteMaxVisible, + }); + this.editor = this.defaultEditor; + this.editorContainer = new Container(); + this.editorContainer.addChild(this.editor as Component); + this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd()); + this.footer = new FooterComponent(this.session, this.footerDataProvider); + this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); + + // Load hide thinking block setting + this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + + // Register themes from resource loader and initialize + setRegisteredThemes(this.session.resourceLoader.getThemes().themes); + this.themeController = new InteractiveThemeController( + this.ui, + this.settingsManager, + (message) => this.showError(message), + () => this.updateEditorBorderColor(), + ); + } + + private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { + if (!sourceInfo) { + return undefined; + } + + const scopePrefix = sourceInfo.scope === "user" ? "u" : sourceInfo.scope === "project" ? "p" : "t"; + const source = sourceInfo.source.trim(); + + if (source === "auto" || source === "local" || source === "cli") { + return scopePrefix; + } + + if (source.startsWith("npm:")) { + return `${scopePrefix}:${source}`; + } + + const gitSource = parseGitUrl(source); + if (gitSource) { + const ref = gitSource.ref ? `@${gitSource.ref}` : ""; + return `${scopePrefix}:git:${gitSource.host}/${gitSource.path}${ref}`; + } + + return scopePrefix; + } + + private prefixAutocompleteDescription(description: string | undefined, sourceInfo?: SourceInfo): string | undefined { + const sourceTag = this.getAutocompleteSourceTag(sourceInfo); + if (!sourceTag) { + return description; + } + return description ? `[${sourceTag}] ${description}` : `[${sourceTag}]`; + } + + private getBuiltInCommandConflictDiagnostics(extensionRunner: ExtensionRunner): ResourceDiagnostic[] { + const builtinNames = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name)); + return extensionRunner + .getRegisteredCommands() + .filter((command) => builtinNames.has(command.name)) + .map((command) => ({ + type: "warning" as const, + message: + command.invocationName === command.name + ? `Extension command '/${command.name}' conflicts with built-in interactive command. Skipping in autocomplete.` + : `Extension command '/${command.name}' conflicts with built-in interactive command. Available as '/${command.invocationName}'.`, + path: command.sourceInfo.path, + })); + } + + private createBaseAutocompleteProvider(): AutocompleteProvider { + // Define commands for autocomplete + const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map((command) => ({ + name: command.name, + description: command.description, + })); + + const modelCommand = slashCommands.find((command) => command.name === "model"); + if (modelCommand) { + modelCommand.getArgumentCompletions = (prefix: string): AutocompleteItem[] | null => { + // Get available models (scoped or from registry) + const models = + this.session.scopedModels.length > 0 + ? this.session.scopedModels.map((s) => s.model) + : this.session.modelRegistry.getAvailable(); + + if (models.length === 0) return null; + + // Create items with provider/id format + const items = models.map((m) => ({ + id: m.id, + provider: m.provider, + name: m.name, + label: `${m.provider}/${m.id}`, + })); + + // Fuzzy filter by model ID + provider in either order. + const filtered = fuzzyFilter(items, prefix, getModelSearchText); + + if (filtered.length === 0) return null; + + return filtered.map((item) => ({ + value: item.label, + label: item.id, + description: item.provider, + })); + }; + } + + // Convert prompt templates to SlashCommand format for autocomplete + const templateCommands: SlashCommand[] = this.session.promptTemplates.map((cmd) => ({ + name: cmd.name, + description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo), + ...(cmd.argumentHint && { argumentHint: cmd.argumentHint }), + })); + + // Convert extension commands to SlashCommand format + const builtinCommandNames = new Set(slashCommands.map((c) => c.name)); + const extensionCommands: SlashCommand[] = this.session.extensionRunner + .getRegisteredCommands() + .filter((cmd) => !builtinCommandNames.has(cmd.name)) + .map((cmd) => ({ + name: cmd.invocationName, + description: this.prefixAutocompleteDescription(cmd.description, cmd.sourceInfo), + getArgumentCompletions: cmd.getArgumentCompletions, + })); + + // Build skill commands from session.skills (if enabled) + this.skillCommands.clear(); + const skillCommandList: SlashCommand[] = []; + if (this.settingsManager.getEnableSkillCommands()) { + for (const skill of this.session.resourceLoader.getSkills().skills) { + const commandName = `skill:${skill.name}`; + this.skillCommands.set(commandName, skill.filePath); + skillCommandList.push({ + name: commandName, + description: this.prefixAutocompleteDescription(skill.description, skill.sourceInfo), + }); + } + } + + return new CombinedAutocompleteProvider( + [...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList], + this.sessionManager.getCwd(), + this.fdPath, + ); + } + + private setupAutocompleteProvider(): void { + let provider = this.createBaseAutocompleteProvider(); + const triggerCharacters: string[] = []; + for (const wrapProvider of this.autocompleteProviderWrappers) { + provider = wrapProvider(provider); + triggerCharacters.push(...(provider.triggerCharacters ?? [])); + } + if (triggerCharacters.length > 0) { + provider.triggerCharacters = [...new Set(triggerCharacters)]; + } + + this.autocompleteProvider = provider; + this.defaultEditor.setAutocompleteProvider(provider); + if (this.editor !== this.defaultEditor) { + this.editor.setAutocompleteProvider?.(provider); + } + } + + async init(): Promise { + if (this.isInitialized) return; + + this.registerSignalHandlers(); + + // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir) + // Both are needed: fd for autocomplete, rg for grep tool and bash commands + const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]); + this.fdPath = fdPath; + + if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) { + const modelList = this.session.scopedModels + .map((sm) => { + const thinkingStr = sm.thinkingLevel ? `:${sm.thinkingLevel}` : ""; + return `${sm.model.id}${thinkingStr}`; + }) + .join(", "); + const cycleKeys = this.keybindings.getKeys("app.model.cycleForward"); + const cycleHint = + cycleKeys.length > 0 + ? theme.fg("muted", ` (${formatKeyText(cycleKeys.join("/"), { capitalize: true })} to cycle)`) + : ""; + console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`)); + } + + // Add header container as first child. Populate it after detectThemeIfUnset. + this.ui.addChild(this.headerContainer); + + this.ui.addChild(this.chatContainer); + this.ui.addChild(this.pendingMessagesContainer); + this.ui.addChild(this.statusContainer); + this.renderWidgets(); // Initialize with default spacer + this.ui.addChild(this.widgetContainerAbove); + this.ui.addChild(this.editorContainer); + this.ui.addChild(this.widgetContainerBelow); + this.ui.addChild(this.footer); + this.ui.setFocus(this.editor); + + this.setupKeyHandlers(); + this.setupEditorSubmitHandler(); + + // Start the UI before initializing extensions so session_start handlers can use interactive dialogs + this.ui.start(); + this.isInitialized = true; + + await this.themeController.applyFromSettings(); + + // Add header with keybindings from config (unless silenced) + if (this.options.verbose || !this.settingsManager.getQuietStartup()) { + const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`); + + // Build startup instructions using keybinding hint helpers + const hint = (keybinding: AppKeybinding, description: string) => keyHint(keybinding, description); + + const expandedInstructions = [ + hint("app.interrupt", "to interrupt"), + hint("app.clear", "to clear"), + rawKeyHint(`${keyText("app.clear")} twice`, "to exit"), + hint("app.exit", "to exit (empty)"), + hint("app.suspend", "to suspend"), + keyHint("tui.editor.deleteToLineEnd", "to delete to end"), + hint("app.thinking.cycle", "to cycle thinking level"), + rawKeyHint(`${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`, "to cycle models"), + hint("app.model.select", "to select model"), + hint("app.tools.expand", "to expand tools"), + hint("app.thinking.toggle", "to expand thinking"), + hint("app.editor.external", "for external editor"), + rawKeyHint("/", "for commands"), + rawKeyHint("!", "to run bash"), + rawKeyHint("!!", "to run bash (no context)"), + hint("app.message.followUp", "to queue follow-up"), + hint("app.message.dequeue", "to edit all queued messages"), + hint("app.clipboard.pasteImage", "to paste image"), + rawKeyHint("drop files", "to attach"), + ].join("\n"); + const compactInstructions = [ + hint("app.interrupt", "interrupt"), + rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"), + rawKeyHint("/", "commands"), + rawKeyHint("!", "bash"), + hint("app.tools.expand", "more"), + ].join(theme.fg("muted", " · ")); + const compactOnboarding = theme.fg( + "dim", + `Press ${keyText("app.tools.expand")} to show full startup help and loaded resources.`, + ); + const onboarding = theme.fg( + "dim", + `Cactus reads, edits, and runs code in your project. Describe a task to get started.`, + ); + this.builtInHeader = new ExpandableText( + () => `${logo}\n${compactInstructions}\n${compactOnboarding}\n\n${onboarding}`, + () => `${logo}\n${expandedInstructions}\n\n${onboarding}`, + this.getStartupExpansionState(), + 1, + 0, + ); + + // Setup UI layout + this.headerContainer.addChild(new Spacer(1)); + this.headerContainer.addChild(this.builtInHeader); + this.headerContainer.addChild(new Spacer(1)); + } else { + // Minimal header when silenced + this.builtInHeader = new Text("", 0, 0); + this.headerContainer.addChild(this.builtInHeader); + } + this.ui.requestRender(); + + // Initialize extensions first so resources are shown before messages + await this.rebindCurrentSession(); + + // Render initial messages AFTER showing loaded resources + this.renderInitialMessages(); + + // Set up theme file watcher + onThemeChange(() => { + this.ui.invalidate(); + this.updateEditorBorderColor(); + this.ui.requestRender(); + }); + + // Set up git branch watcher (uses provider instead of footer) + this.footerDataProvider.onBranchChange(() => { + this.ui.requestRender(); + }); + + // Initialize available provider count for footer display + await this.updateAvailableProviderCount(); + } + + /** + * Update terminal title with session name and cwd. + */ + private updateTerminalTitle(): void { + const cwdBasename = path.basename(this.sessionManager.getCwd()); + const sessionName = this.sessionManager.getSessionName(); + if (sessionName) { + this.ui.terminal.setTitle(`${APP_TITLE} - ${sessionName} - ${cwdBasename}`); + } else { + this.ui.terminal.setTitle(`${APP_TITLE} - ${cwdBasename}`); + } + } + + /** + * Run the interactive mode. This is the main entry point. + * Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop. + */ + async run(): Promise { + await this.init(); + + // Check tmux keyboard setup asynchronously + this.checkTmuxKeyboardSetup().then((warning) => { + if (warning) { + this.showWarning(warning); + } + }); + + // Show startup warnings + const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options; + + if (migratedProviders && migratedProviders.length > 0) { + this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); + } + + const modelsJsonError = this.session.modelRegistry.getError(); + if (modelsJsonError) { + this.showError(`models.json error: ${modelsJsonError}`); + } + + if (modelFallbackMessage) { + this.showWarning(modelFallbackMessage); + } + + void this.maybeWarnAboutAnthropicSubscriptionAuth(); + + // Process initial messages + if (initialMessage) { + try { + await this.session.prompt(initialMessage, { images: initialImages }); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + this.showError(errorMessage); + } + } + + if (initialMessages) { + for (const message of initialMessages) { + try { + await this.session.prompt(message); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + this.showError(errorMessage); + } + } + } + + // Main interactive loop + while (true) { + const userInput = await this.getUserInput(); + try { + await this.session.prompt(userInput); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + this.showError(errorMessage); + } + } + } + + private async checkTmuxKeyboardSetup(): Promise { + if (!process.env.TMUX) return undefined; + + const runTmuxShow = (option: string): Promise => { + return new Promise((resolve) => { + const proc = spawn("tmux", ["show", "-gv", option], { + stdio: ["ignore", "pipe", "ignore"], + }); + let stdout = ""; + const timer = setTimeout(() => { + proc.kill(); + resolve(undefined); + }, 2000); + + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + proc.on("error", () => { + clearTimeout(timer); + resolve(undefined); + }); + proc.on("close", (code) => { + clearTimeout(timer); + resolve(code === 0 ? stdout.trim() : undefined); + }); + }); + }; + + const [extendedKeys, extendedKeysFormat] = await Promise.all([ + runTmuxShow("extended-keys"), + runTmuxShow("extended-keys-format"), + ]); + + // If we couldn't query tmux (timeout, sandbox, etc.), don't warn + if (extendedKeys === undefined) return undefined; + + if (extendedKeys !== "on" && extendedKeys !== "always") { + return "tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux."; + } + + if (extendedKeysFormat === "xterm") { + return "tmux extended-keys-format is xterm. Cactus works best with csi-u. Add `set -g extended-keys-format csi-u` to ~/.tmux.conf and restart tmux."; + } + + return undefined; + } + + private getMarkdownThemeWithSettings(): MarkdownTheme { + return { + ...getMarkdownTheme(), + codeBlockIndent: this.settingsManager.getCodeBlockIndent(), + }; + } + + // ========================================================================= + // Extension System + // ========================================================================= + + private formatDisplayPath(p: string): string { + const home = os.homedir(); + let result = p; + + // Replace home directory with ~ + if (result.startsWith(home)) { + result = `~${result.slice(home.length)}`; + } + + return result; + } + + private formatExtensionDisplayPath(path: string): string { + let result = this.formatDisplayPath(path); + result = result.replace(/\/index\.ts$/, "").replace(/\/index\.js$/, ""); + return result; + } + + private formatContextPath(p: string): string { + const cwd = path.resolve(this.sessionManager.getCwd()); + const absolutePath = path.isAbsolute(p) ? path.resolve(p) : path.resolve(cwd, p); + const relativePath = getCwdRelativePath(absolutePath, cwd); + if (relativePath !== undefined) { + return relativePath; + } + + return this.formatDisplayPath(absolutePath); + } + + private getStartupExpansionState(): boolean { + return this.options.verbose || this.toolOutputExpanded; + } + + /** + * Get a short path relative to the package root for display. + */ + private getShortPath(fullPath: string, sourceInfo?: SourceInfo): string { + const baseDir = sourceInfo?.baseDir; + if (baseDir && this.isPackageSource(sourceInfo)) { + const relativePath = path.relative(path.resolve(baseDir), path.resolve(fullPath)); + if ( + relativePath && + relativePath !== "." && + !relativePath.startsWith("..") && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ) { + return relativePath.replace(/\\/g, "/"); + } + } + + const source = sourceInfo?.source ?? ""; + const npmMatch = fullPath.match(/node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/); + if (npmMatch && source.startsWith("npm:")) { + return npmMatch[2]; + } + + const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/); + if (gitMatch && source.startsWith("git:")) { + return gitMatch[1]; + } + + return this.formatDisplayPath(fullPath); + } + + private getCompactPathLabel(resourcePath: string, sourceInfo?: SourceInfo): string { + const shortPath = this.getShortPath(resourcePath, sourceInfo); + const normalizedPath = shortPath.replace(/\\/g, "/"); + const segments = normalizedPath.split("/").filter((segment) => segment.length > 0 && segment !== "~"); + if (segments.length > 0) { + return segments[segments.length - 1]!; + } + return shortPath; + } + + private getCompactPackageSourceLabel(sourceInfo?: SourceInfo): string { + const source = sourceInfo?.source ?? ""; + if (source.startsWith("npm:")) { + return source.slice("npm:".length) || source; + } + + const gitSource = parseGitUrl(source); + if (gitSource) { + return gitSource.path || source; + } + + return source; + } + + private getCompactExtensionLabel(resourcePath: string, sourceInfo?: SourceInfo): string { + if (!this.isPackageSource(sourceInfo)) { + return this.getCompactPathLabel(resourcePath, sourceInfo); + } + + const sourceLabel = this.getCompactPackageSourceLabel(sourceInfo); + if (!sourceLabel) { + return this.getCompactPathLabel(resourcePath, sourceInfo); + } + + const shortPath = this.getShortPath(resourcePath, sourceInfo).replace(/\\/g, "/"); + const packagePath = shortPath.startsWith("extensions/") ? shortPath.slice("extensions/".length) : shortPath; + const parsedPath = path.posix.parse(packagePath); + + if (parsedPath.name === "index") { + return !parsedPath.dir || parsedPath.dir === "." ? sourceLabel : `${sourceLabel}:${parsedPath.dir}`; + } + + return `${sourceLabel}:${packagePath}`; + } + + private getCompactDisplayPathSegments(resourcePath: string): string[] { + return this.formatDisplayPath(resourcePath) + .replace(/\\/g, "/") + .split("/") + .filter((segment) => segment.length > 0 && segment !== "~"); + } + + private getCompactNonPackageExtensionLabel( + resourcePath: string, + index: number, + allPaths: Array<{ path: string; segments: string[] }>, + ): string { + const segments = allPaths[index]?.segments; + if (!segments || segments.length === 0) { + return this.getCompactPathLabel(resourcePath); + } + + for (let segmentCount = 1; segmentCount <= segments.length; segmentCount += 1) { + const candidate = segments.slice(-segmentCount).join("/"); + const isUnique = allPaths.every((item, itemIndex) => { + if (itemIndex === index) { + return true; + } + return item.segments.slice(-segmentCount).join("/") !== candidate; + }); + + if (isUnique) { + return candidate; + } + } + + return segments.join("/"); + } + + private getCompactExtensionLabels(extensions: Array<{ path: string; sourceInfo?: SourceInfo }>): string[] { + const nonPackageExtensions = extensions + .map((extension) => { + const segments = this.getCompactDisplayPathSegments(extension.path); + const lastSegment = segments[segments.length - 1]; + if (segments.length > 1 && (lastSegment === "index.ts" || lastSegment === "index.js")) { + segments.pop(); + } + return { + path: extension.path, + sourceInfo: extension.sourceInfo, + segments, + }; + }) + .filter((extension) => !this.isPackageSource(extension.sourceInfo)); + + return extensions.map((extension) => { + if (this.isPackageSource(extension.sourceInfo)) { + return this.getCompactExtensionLabel(extension.path, extension.sourceInfo); + } + + const nonPackageIndex = nonPackageExtensions.findIndex((item) => item.path === extension.path); + if (nonPackageIndex === -1) { + return this.getCompactPathLabel(extension.path, extension.sourceInfo); + } + + return this.getCompactNonPackageExtensionLabel(extension.path, nonPackageIndex, nonPackageExtensions); + }); + } + + private getDisplaySourceInfo(sourceInfo?: SourceInfo): { + label: string; + scopeLabel?: string; + color: "accent" | "muted"; + } { + const source = sourceInfo?.source ?? "local"; + const scope = sourceInfo?.scope ?? "project"; + if (source === "local") { + if (scope === "user") { + return { label: "user", color: "muted" }; + } + if (scope === "project") { + return { label: "project", color: "muted" }; + } + if (scope === "temporary") { + return { label: "path", scopeLabel: "temp", color: "muted" }; + } + return { label: "path", color: "muted" }; + } + + if (source === "cli") { + return { label: "path", scopeLabel: scope === "temporary" ? "temp" : undefined, color: "muted" }; + } + + const scopeLabel = + scope === "user" ? "user" : scope === "project" ? "project" : scope === "temporary" ? "temp" : undefined; + return { label: source, scopeLabel, color: "accent" }; + } + + private getScopeGroup(sourceInfo?: SourceInfo): "user" | "project" | "path" { + const source = sourceInfo?.source ?? "local"; + const scope = sourceInfo?.scope ?? "project"; + if (source === "cli" || scope === "temporary") return "path"; + if (scope === "user") return "user"; + if (scope === "project") return "project"; + return "path"; + } + + private isPackageSource(sourceInfo?: SourceInfo): boolean { + const source = sourceInfo?.source ?? ""; + return source.startsWith("npm:") || source.startsWith("git:"); + } + + private buildScopeGroups(items: Array<{ path: string; sourceInfo?: SourceInfo }>): Array<{ + scope: "user" | "project" | "path"; + paths: Array<{ path: string; sourceInfo?: SourceInfo }>; + packages: Map>; + }> { + const groups: Record< + "user" | "project" | "path", + { + scope: "user" | "project" | "path"; + paths: Array<{ path: string; sourceInfo?: SourceInfo }>; + packages: Map>; + } + > = { + user: { scope: "user", paths: [], packages: new Map() }, + project: { scope: "project", paths: [], packages: new Map() }, + path: { scope: "path", paths: [], packages: new Map() }, + }; + + for (const item of items) { + const groupKey = this.getScopeGroup(item.sourceInfo); + const group = groups[groupKey]; + const source = item.sourceInfo?.source ?? "local"; + + if (this.isPackageSource(item.sourceInfo)) { + const list = group.packages.get(source) ?? []; + list.push(item); + group.packages.set(source, list); + } else { + group.paths.push(item); + } + } + + return [groups.project, groups.user, groups.path].filter( + (group) => group.paths.length > 0 || group.packages.size > 0, + ); + } + + private formatScopeGroups( + groups: Array<{ + scope: "user" | "project" | "path"; + paths: Array<{ path: string; sourceInfo?: SourceInfo }>; + packages: Map>; + }>, + options: { + formatPath: (item: { path: string; sourceInfo?: SourceInfo }) => string; + formatPackagePath: (item: { path: string; sourceInfo?: SourceInfo }, source: string) => string; + }, + ): string { + const lines: string[] = []; + + for (const group of groups) { + lines.push(` ${theme.fg("accent", group.scope)}`); + + const sortedPaths = [...group.paths].sort((a, b) => a.path.localeCompare(b.path)); + for (const item of sortedPaths) { + lines.push(theme.fg("dim", ` ${options.formatPath(item)}`)); + } + + const sortedPackages = Array.from(group.packages.entries()).sort(([a], [b]) => a.localeCompare(b)); + for (const [source, items] of sortedPackages) { + lines.push(` ${theme.fg("mdLink", source)}`); + const sortedPackagePaths = [...items].sort((a, b) => a.path.localeCompare(b.path)); + for (const item of sortedPackagePaths) { + lines.push(theme.fg("dim", ` ${options.formatPackagePath(item, source)}`)); + } + } + } + + return lines.join("\n"); + } + + private findSourceInfoForPath(p: string, sourceInfos: Map): SourceInfo | undefined { + const exact = sourceInfos.get(p); + if (exact) return exact; + + let current = p; + while (current.includes("/")) { + current = current.substring(0, current.lastIndexOf("/")); + const parent = sourceInfos.get(current); + if (parent) return parent; + } + + return undefined; + } + + private formatPathWithSource(p: string, sourceInfo?: SourceInfo): string { + if (sourceInfo) { + const shortPath = this.getShortPath(p, sourceInfo); + const { label, scopeLabel } = this.getDisplaySourceInfo(sourceInfo); + const labelText = scopeLabel ? `${label} (${scopeLabel})` : label; + return `${labelText} ${shortPath}`; + } + return this.formatDisplayPath(p); + } + + private formatDiagnostics(diagnostics: readonly ResourceDiagnostic[], sourceInfos: Map): string { + const lines: string[] = []; + + // Group collision diagnostics by name + const collisions = new Map(); + const otherDiagnostics: ResourceDiagnostic[] = []; + + for (const d of diagnostics) { + if (d.type === "collision" && d.collision) { + const list = collisions.get(d.collision.name) ?? []; + list.push(d); + collisions.set(d.collision.name, list); + } else { + otherDiagnostics.push(d); + } + } + + // Format collision diagnostics grouped by name + for (const [name, collisionList] of collisions) { + const first = collisionList[0]?.collision; + if (!first) continue; + lines.push(theme.fg("warning", ` "${name}" collision:`)); + lines.push( + theme.fg( + "dim", + ` ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, this.findSourceInfoForPath(first.winnerPath, sourceInfos))}`, + ), + ); + for (const d of collisionList) { + if (d.collision) { + lines.push( + theme.fg( + "dim", + ` ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, this.findSourceInfoForPath(d.collision.loserPath, sourceInfos))} (skipped)`, + ), + ); + } + } + } + + for (const d of otherDiagnostics) { + if (d.path) { + const formattedPath = this.formatPathWithSource(d.path, this.findSourceInfoForPath(d.path, sourceInfos)); + lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${formattedPath}`)); + lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`)); + } else { + lines.push(theme.fg(d.type === "error" ? "error" : "warning", ` ${d.message}`)); + } + } + + return lines.join("\n"); + } + + private showLoadedResources(options?: { + extensions?: Array<{ path: string; sourceInfo?: SourceInfo }>; + force?: boolean; + showDiagnosticsWhenQuiet?: boolean; + }): void { + const showListing = options?.force || this.options.verbose || !this.settingsManager.getQuietStartup(); + const showDiagnostics = showListing || options?.showDiagnosticsWhenQuiet === true; + if (!showListing && !showDiagnostics) { + return; + } + + const sectionHeader = (name: string, color: ThemeColor = "mdHeading") => theme.fg(color, `[${name}]`); + const formatCompactList = (items: string[], options?: { sort?: boolean }): string => { + const labels = items.map((item) => item.trim()).filter((item) => item.length > 0); + if (options?.sort !== false) { + labels.sort((a, b) => a.localeCompare(b)); + } + return theme.fg("dim", ` ${labels.join(", ")}`); + }; + const addLoadedSection = ( + name: string, + collapsedBody: string, + expandedBody = collapsedBody, + color: ThemeColor = "mdHeading", + ): void => { + const section = new ExpandableText( + () => `${sectionHeader(name, color)}\n${collapsedBody}`, + () => `${sectionHeader(name, color)}\n${expandedBody}`, + this.getStartupExpansionState(), + 0, + 0, + ); + this.chatContainer.addChild(section); + this.chatContainer.addChild(new Spacer(1)); + }; + + const skillsResult = this.session.resourceLoader.getSkills(); + const promptsResult = this.session.resourceLoader.getPrompts(); + const themesResult = this.session.resourceLoader.getThemes(); + const extensions = + options?.extensions ?? + this.session.resourceLoader.getExtensions().extensions.map((extension) => ({ + path: extension.path, + sourceInfo: extension.sourceInfo, + })); + const sourceInfos = new Map(); + for (const extension of extensions) { + if (extension.sourceInfo) { + sourceInfos.set(extension.path, extension.sourceInfo); + } + } + for (const skill of skillsResult.skills) { + if (skill.sourceInfo) { + sourceInfos.set(skill.filePath, skill.sourceInfo); + } + } + for (const prompt of promptsResult.prompts) { + if (prompt.sourceInfo) { + sourceInfos.set(prompt.filePath, prompt.sourceInfo); + } + } + for (const loadedTheme of themesResult.themes) { + if (loadedTheme.sourcePath && loadedTheme.sourceInfo) { + sourceInfos.set(loadedTheme.sourcePath, loadedTheme.sourceInfo); + } + } + + if (showListing) { + const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles; + if (contextFiles.length > 0) { + this.chatContainer.addChild(new Spacer(1)); + const contextList = contextFiles + .map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)) + .join("\n"); + const contextCompactList = formatCompactList( + contextFiles.map((contextFile) => this.formatContextPath(contextFile.path)), + { sort: false }, + ); + addLoadedSection("Context", contextCompactList, contextList); + } + + const skills = skillsResult.skills; + if (skills.length > 0) { + const groups = this.buildScopeGroups( + skills.map((skill) => ({ path: skill.filePath, sourceInfo: skill.sourceInfo })), + ); + const skillList = this.formatScopeGroups(groups, { + formatPath: (item) => this.formatDisplayPath(item.path), + formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), + }); + const skillCompactList = formatCompactList(skills.map((skill) => skill.name)); + addLoadedSection("Skills", skillCompactList, skillList); + } + + const templates = this.session.promptTemplates; + if (templates.length > 0) { + const groups = this.buildScopeGroups( + templates.map((template) => ({ path: template.filePath, sourceInfo: template.sourceInfo })), + ); + const templateByPath = new Map(templates.map((t) => [t.filePath, t])); + const templateList = this.formatScopeGroups(groups, { + formatPath: (item) => { + const template = templateByPath.get(item.path); + return template ? `/${template.name}` : this.formatDisplayPath(item.path); + }, + formatPackagePath: (item) => { + const template = templateByPath.get(item.path); + return template ? `/${template.name}` : this.formatDisplayPath(item.path); + }, + }); + const promptCompactList = formatCompactList(templates.map((template) => `/${template.name}`)); + addLoadedSection("Prompts", promptCompactList, templateList); + } + + if (extensions.length > 0) { + const groups = this.buildScopeGroups(extensions); + const extList = this.formatScopeGroups(groups, { + formatPath: (item) => this.formatExtensionDisplayPath(item.path), + formatPackagePath: (item) => + this.formatExtensionDisplayPath(this.getShortPath(item.path, item.sourceInfo)), + }); + const extensionCompactList = formatCompactList(this.getCompactExtensionLabels(extensions)); + addLoadedSection("Extensions", extensionCompactList, extList, "mdHeading"); + } + + // Show loaded themes (excluding built-in) + const loadedThemes = themesResult.themes; + const customThemes = loadedThemes.filter((t) => t.sourcePath); + if (customThemes.length > 0) { + const groups = this.buildScopeGroups( + customThemes.map((loadedTheme) => ({ + path: loadedTheme.sourcePath!, + sourceInfo: loadedTheme.sourceInfo, + })), + ); + const themeList = this.formatScopeGroups(groups, { + formatPath: (item) => this.formatDisplayPath(item.path), + formatPackagePath: (item) => this.getShortPath(item.path, item.sourceInfo), + }); + const themeCompactList = formatCompactList( + customThemes.map( + (loadedTheme) => + loadedTheme.name ?? this.getCompactPathLabel(loadedTheme.sourcePath!, loadedTheme.sourceInfo), + ), + ); + addLoadedSection("Themes", themeCompactList, themeList); + } + } + + if (showDiagnostics) { + const skillDiagnostics = skillsResult.diagnostics; + if (skillDiagnostics.length > 0) { + const warningLines = this.formatDiagnostics(skillDiagnostics, sourceInfos); + this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`, 0, 0)); + this.chatContainer.addChild(new Spacer(1)); + } + + const promptDiagnostics = promptsResult.diagnostics; + if (promptDiagnostics.length > 0) { + const warningLines = this.formatDiagnostics(promptDiagnostics, sourceInfos); + this.chatContainer.addChild( + new Text(`${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines}`, 0, 0), + ); + this.chatContainer.addChild(new Spacer(1)); + } + + const extensionDiagnostics: ResourceDiagnostic[] = []; + const extensionErrors = this.session.resourceLoader.getExtensions().errors; + if (extensionErrors.length > 0) { + for (const error of extensionErrors) { + extensionDiagnostics.push({ type: "error", message: error.error, path: error.path }); + } + } + + const commandDiagnostics = this.session.extensionRunner.getCommandDiagnostics(); + extensionDiagnostics.push(...commandDiagnostics); + extensionDiagnostics.push(...this.getBuiltInCommandConflictDiagnostics(this.session.extensionRunner)); + + const shortcutDiagnostics = this.session.extensionRunner.getShortcutDiagnostics(); + extensionDiagnostics.push(...shortcutDiagnostics); + + if (extensionDiagnostics.length > 0) { + const warningLines = this.formatDiagnostics(extensionDiagnostics, sourceInfos); + this.chatContainer.addChild( + new Text(`${theme.fg("warning", "[Extension issues]")}\n${warningLines}`, 0, 0), + ); + this.chatContainer.addChild(new Spacer(1)); + } + + const themeDiagnostics = themesResult.diagnostics; + if (themeDiagnostics.length > 0) { + const warningLines = this.formatDiagnostics(themeDiagnostics, sourceInfos); + this.chatContainer.addChild(new Text(`${theme.fg("warning", "[Theme conflicts]")}\n${warningLines}`, 0, 0)); + this.chatContainer.addChild(new Spacer(1)); + } + } + } + + /** + * Initialize the extension system with TUI-based UI context. + */ + private async bindCurrentSessionExtensions(): Promise { + const uiContext = this.createExtensionUIContext(); + await this.session.bindExtensions({ + uiContext, + mode: "tui", + abortHandler: () => { + this.restoreQueuedMessagesToEditor({ abort: true }); + }, + commandContextActions: { + waitForIdle: () => this.session.agent.waitForIdle(), + newSession: async (options) => { + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + try { + return await this.runtimeHost.newSession(options); + } catch (error: unknown) { + return this.handleFatalRuntimeError("Failed to create session", error); + } + }, + fork: async (entryId, options) => { + try { + const result = await this.runtimeHost.fork(entryId, options); + if (!result.cancelled) { + this.editor.setText(result.selectedText ?? ""); + this.showStatus("Forked to new session"); + } + return { cancelled: result.cancelled }; + } catch (error: unknown) { + return this.handleFatalRuntimeError("Failed to fork session", error); + } + }, + navigateTree: async (targetId, options) => { + const result = await this.session.navigateTree(targetId, { + summarize: options?.summarize, + customInstructions: options?.customInstructions, + replaceInstructions: options?.replaceInstructions, + label: options?.label, + }); + if (result.cancelled) { + return { cancelled: true }; + } + + this.chatContainer.clear(); + this.renderInitialMessages(); + if (result.editorText && !this.editor.getText().trim()) { + this.editor.setText(result.editorText); + } + this.showStatus("Navigated to selected point"); + void this.flushCompactionQueue({ willRetry: false }); + return { cancelled: false }; + }, + switchSession: async (sessionPath, options) => { + return this.handleResumeSession(sessionPath, options); + }, + reload: async () => { + await this.handleReloadCommand(); + }, + }, + shutdownHandler: () => { + this.shutdownRequested = true; + if (!this.session.isStreaming) { + void this.shutdown(); + } + }, + onError: (error) => { + this.showExtensionError(error.extensionPath, error.error, error.stack); + }, + }); + + setRegisteredThemes(this.session.resourceLoader.getThemes().themes); + this.setupAutocompleteProvider(); + + const extensionRunner = this.session.extensionRunner; + this.setupExtensionShortcuts(extensionRunner); + this.showLoadedResources({ force: false, showDiagnosticsWhenQuiet: true }); + } + + private applyRuntimeSettings(): void { + configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); + this.footer.setSession(this.session); + this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); + this.footerDataProvider.setCwd(this.sessionManager.getCwd()); + this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); + this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); + const editorPaddingX = this.settingsManager.getEditorPaddingX(); + const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); + this.defaultEditor.setPaddingX(editorPaddingX); + this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible); + if (this.editor !== this.defaultEditor) { + this.editor.setPaddingX?.(editorPaddingX); + this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible); + } + } + + private async rebindCurrentSession(options: { renderBeforeBind?: boolean } = {}): Promise { + this.unsubscribe?.(); + this.unsubscribe = undefined; + this.applyRuntimeSettings(); + if (options.renderBeforeBind) { + this.renderCurrentSessionState(); + this.subscribeToAgent(); + await this.bindCurrentSessionExtensions(); + } else { + await this.bindCurrentSessionExtensions(); + this.subscribeToAgent(); + } + await this.updateAvailableProviderCount(); + this.updateEditorBorderColor(); + this.updateTerminalTitle(); + } + + private async handleFatalRuntimeError(prefix: string, error: unknown): Promise { + const message = error instanceof Error ? error.message : String(error); + this.showError(`${prefix}: ${message}`); + stopThemeWatcher(); + this.stop(); + process.exit(1); + } + + private renderCurrentSessionState(): void { + this.chatContainer.clear(); + this.pendingMessagesContainer.clear(); + this.compactionQueuedMessages = []; + this.streamingComponent = undefined; + this.streamingMessage = undefined; + this.pendingTools.clear(); + this.renderInitialMessages(); + } + + /** + * Get a registered tool definition by name (for custom rendering). + */ + private getRegisteredToolDefinition(toolName: string) { + return this.session.getToolDefinition(toolName); + } + + /** + * Set up keyboard shortcuts registered by extensions. + */ + private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void { + const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig()); + if (shortcuts.size === 0) return; + + // Create a context for shortcut handlers + const createContext = (): ExtensionContext => ({ + ui: this.createExtensionUIContext(), + mode: "tui", + hasUI: true, + cwd: this.sessionManager.getCwd(), + sessionManager: this.sessionManager, + modelRegistry: this.session.modelRegistry, + model: this.session.model, + isIdle: () => !this.session.isStreaming, + isProjectTrusted: () => this.settingsManager.isProjectTrusted(), + signal: this.session.agent.signal, + abort: () => { + this.restoreQueuedMessagesToEditor({ abort: true }); + }, + hasPendingMessages: () => this.session.pendingMessageCount > 0, + shutdown: () => { + this.shutdownRequested = true; + }, + getContextUsage: () => this.session.getContextUsage(), + compact: (options) => { + void (async () => { + try { + const result = await this.session.compact(options?.customInstructions); + options?.onComplete?.(result); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + options?.onError?.(err); + } + })(); + }, + getSystemPrompt: () => this.session.systemPrompt, + }); + + // Set up the extension shortcut handler on the default editor + this.defaultEditor.onExtensionShortcut = (data: string) => { + for (const [shortcutStr, shortcut] of shortcuts) { + // Cast to KeyId - extension shortcuts use the same format + if (matchesKey(data, shortcutStr as KeyId)) { + // Run handler async, don't block input + Promise.resolve(shortcut.handler(createContext())).catch((err) => { + this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`); + }); + return true; + } + } + return false; + }; + } + + /** + * Set extension status text in the footer. + */ + private setExtensionStatus(key: string, text: string | undefined): void { + this.footerDataProvider.setExtensionStatus(key, text); + this.ui.requestRender(); + } + + private getWorkingLoaderMessage(): string { + return this.workingMessage ?? this.defaultWorkingMessage; + } + + private createWorkingLoader(): Loader { + return new Loader( + this.ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + this.getWorkingLoaderMessage(), + this.workingIndicatorOptions, + ); + } + + private stopWorkingLoader(): void { + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + } + + private setWorkingVisible(visible: boolean): void { + this.workingVisible = visible; + if (!visible) { + this.stopWorkingLoader(); + this.ui.requestRender(); + return; + } + if (this.session.isStreaming && !this.loadingAnimation) { + this.statusContainer.clear(); + this.loadingAnimation = this.createWorkingLoader(); + this.statusContainer.addChild(this.loadingAnimation); + } + this.ui.requestRender(); + } + + private setWorkingIndicator(options?: LoaderIndicatorOptions): void { + this.workingIndicatorOptions = options; + this.loadingAnimation?.setIndicator(options); + this.ui.requestRender(); + } + + private setHiddenThinkingLabel(label?: string): void { + this.hiddenThinkingLabel = label ?? this.defaultHiddenThinkingLabel; + for (const child of this.chatContainer.children) { + if (child instanceof AssistantMessageComponent) { + child.setHiddenThinkingLabel(this.hiddenThinkingLabel); + } + } + if (this.streamingComponent) { + this.streamingComponent.setHiddenThinkingLabel(this.hiddenThinkingLabel); + } + this.ui.requestRender(); + } + + /** + * Set an extension widget (string array or custom component). + */ + private setExtensionWidget( + key: string, + content: string[] | ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined, + options?: ExtensionWidgetOptions, + ): void { + const placement = options?.placement ?? "aboveEditor"; + const removeExisting = (map: Map) => { + const existing = map.get(key); + if (existing?.dispose) existing.dispose(); + map.delete(key); + }; + + removeExisting(this.extensionWidgetsAbove); + removeExisting(this.extensionWidgetsBelow); + + if (content === undefined) { + this.renderWidgets(); + return; + } + + let component: Component & { dispose?(): void }; + + if (Array.isArray(content)) { + // Wrap string array in a Container with Text components + const container = new Container(); + for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) { + container.addChild(new Text(line, 1, 0)); + } + if (content.length > InteractiveMode.MAX_WIDGET_LINES) { + container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0)); + } + component = container; + } else { + // Factory function - create component + component = content(this.ui, theme); + } + + const targetMap = placement === "belowEditor" ? this.extensionWidgetsBelow : this.extensionWidgetsAbove; + targetMap.set(key, component); + this.renderWidgets(); + } + + private clearExtensionWidgets(): void { + for (const widget of this.extensionWidgetsAbove.values()) { + widget.dispose?.(); + } + for (const widget of this.extensionWidgetsBelow.values()) { + widget.dispose?.(); + } + this.extensionWidgetsAbove.clear(); + this.extensionWidgetsBelow.clear(); + this.renderWidgets(); + } + + private resetExtensionUI(): void { + if (this.extensionSelector) { + this.hideExtensionSelector(); + } + if (this.extensionInput) { + this.hideExtensionInput(); + } + if (this.extensionEditor) { + this.hideExtensionEditor(); + } + this.ui.hideOverlay(); + this.clearExtensionTerminalInputListeners(); + this.setExtensionFooter(undefined); + this.setExtensionHeader(undefined); + this.clearExtensionWidgets(); + this.footerDataProvider.clearExtensionStatuses(); + this.footer.invalidate(); + this.autocompleteProviderWrappers = []; + this.setCustomEditorComponent(undefined); + this.setupAutocompleteProvider(); + this.defaultEditor.onExtensionShortcut = undefined; + this.updateTerminalTitle(); + this.workingMessage = undefined; + this.workingVisible = true; + this.setWorkingIndicator(); + if (this.loadingAnimation) { + this.loadingAnimation.setMessage(`${this.defaultWorkingMessage} (${keyText("app.interrupt")} to interrupt)`); + } + this.setHiddenThinkingLabel(); + } + + // Maximum total widget lines to prevent viewport overflow + private static readonly MAX_WIDGET_LINES = 10; + + /** + * Render all extension widgets to the widget container. + */ + private renderWidgets(): void { + if (!this.widgetContainerAbove || !this.widgetContainerBelow) return; + this.renderWidgetContainer(this.widgetContainerAbove, this.extensionWidgetsAbove, true, true); + this.renderWidgetContainer(this.widgetContainerBelow, this.extensionWidgetsBelow, false, false); + this.ui.requestRender(); + } + + private renderWidgetContainer( + container: Container, + widgets: Map, + spacerWhenEmpty: boolean, + leadingSpacer: boolean, + ): void { + container.clear(); + + if (widgets.size === 0) { + if (spacerWhenEmpty) { + container.addChild(new Spacer(1)); + } + return; + } + + if (leadingSpacer) { + container.addChild(new Spacer(1)); + } + for (const component of widgets.values()) { + container.addChild(component); + } + } + + /** + * Set a custom footer component, or restore the built-in footer. + */ + private setExtensionFooter( + factory: + | ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void }) + | undefined, + ): void { + // Dispose existing custom footer + if (this.customFooter?.dispose) { + this.customFooter.dispose(); + } + + // Remove current footer from UI + if (this.customFooter) { + this.ui.removeChild(this.customFooter); + } else { + this.ui.removeChild(this.footer); + } + + if (factory) { + // Create and add custom footer, passing the data provider + this.customFooter = factory(this.ui, theme, this.footerDataProvider); + this.ui.addChild(this.customFooter); + } else { + // Restore built-in footer + this.customFooter = undefined; + this.ui.addChild(this.footer); + } + + this.ui.requestRender(); + } + + /** + * Set a custom header component, or restore the built-in header. + */ + private setExtensionHeader(factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined): void { + // Header may not be initialized yet if called during early initialization + if (!this.builtInHeader) { + return; + } + + // Dispose existing custom header + if (this.customHeader?.dispose) { + this.customHeader.dispose(); + } + + // Find the index of the current header in the header container + const currentHeader = this.customHeader || this.builtInHeader; + const index = this.headerContainer.children.indexOf(currentHeader); + + if (factory) { + // Create and add custom header + this.customHeader = factory(this.ui, theme); + if (isExpandable(this.customHeader)) { + this.customHeader.setExpanded(this.toolOutputExpanded); + } + if (index !== -1) { + this.headerContainer.children[index] = this.customHeader; + } else { + // If not found (e.g. builtInHeader was never added), add at the top + this.headerContainer.children.unshift(this.customHeader); + } + } else { + // Restore built-in header + this.customHeader = undefined; + if (isExpandable(this.builtInHeader)) { + this.builtInHeader.setExpanded(this.toolOutputExpanded); + } + if (index !== -1) { + this.headerContainer.children[index] = this.builtInHeader; + } + } + + this.ui.requestRender(); + } + + private addExtensionTerminalInputListener( + handler: (data: string) => { consume?: boolean; data?: string } | undefined, + ): () => void { + const unsubscribe = this.ui.addInputListener(handler); + this.extensionTerminalInputUnsubscribers.add(unsubscribe); + return () => { + unsubscribe(); + this.extensionTerminalInputUnsubscribers.delete(unsubscribe); + }; + } + + private clearExtensionTerminalInputListeners(): void { + for (const unsubscribe of this.extensionTerminalInputUnsubscribers) { + unsubscribe(); + } + this.extensionTerminalInputUnsubscribers.clear(); + } + + /** + * Create the ExtensionUIContext for extensions. + */ + private createProjectTrustContext(cwd: string): ProjectTrustContext { + const ui = this.createExtensionUIContext(); + return { + cwd, + mode: "tui", + hasUI: true, + ui: { + select: ui.select, + confirm: ui.confirm, + input: ui.input, + notify: ui.notify, + }, + }; + } + + private createExtensionUIContext(): ExtensionUIContext { + return { + select: (title, options, opts) => this.showExtensionSelector(title, options, opts), + confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts), + input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts), + notify: (message, type) => this.showExtensionNotify(message, type), + onTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler), + setStatus: (key, text) => this.setExtensionStatus(key, text), + setWorkingMessage: (message) => { + this.workingMessage = message; + if (this.loadingAnimation) { + this.loadingAnimation.setMessage(message ?? this.defaultWorkingMessage); + } + }, + setWorkingVisible: (visible) => this.setWorkingVisible(visible), + setWorkingIndicator: (options) => this.setWorkingIndicator(options), + setHiddenThinkingLabel: (label) => this.setHiddenThinkingLabel(label), + setWidget: (key, content, options) => this.setExtensionWidget(key, content, options), + setFooter: (factory) => this.setExtensionFooter(factory), + setHeader: (factory) => this.setExtensionHeader(factory), + setTitle: (title) => this.ui.terminal.setTitle(title), + custom: (factory, options) => this.showExtensionCustom(factory, options), + pasteToEditor: (text) => this.editor.handleInput(`\x1b[200~${text}\x1b[201~`), + setEditorText: (text) => this.editor.setText(text), + getEditorText: () => this.editor.getExpandedText?.() ?? this.editor.getText(), + editor: (title, prefill) => this.showExtensionEditor(title, prefill), + addAutocompleteProvider: (factory) => { + this.autocompleteProviderWrappers.push(factory); + this.setupAutocompleteProvider(); + }, + setEditorComponent: (factory) => this.setCustomEditorComponent(factory), + getEditorComponent: () => this.editorComponentFactory, + get theme() { + return theme; + }, + getAllThemes: () => getAvailableThemesWithPaths(), + getTheme: (name) => getThemeByName(name), + setTheme: (themeOrName) => { + if (themeOrName instanceof Theme) { + return this.themeController.setThemeInstance(themeOrName); + } + const result = this.themeController.setThemeName(themeOrName); + if (result.success) { + if (this.settingsManager.getTheme() !== themeOrName) { + this.settingsManager.setTheme(themeOrName); + } + } + return result; + }, + getToolsExpanded: () => this.toolOutputExpanded, + setToolsExpanded: (expanded) => this.setToolsExpanded(expanded), + }; + } + + /** + * Show a selector for extensions. + */ + private showExtensionSelector( + title: string, + options: string[], + opts?: ExtensionUIDialogOptions, + ): Promise { + return new Promise((resolve) => { + if (opts?.signal?.aborted) { + resolve(undefined); + return; + } + + const onAbort = () => { + this.hideExtensionSelector(); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + + this.extensionSelector = new ExtensionSelectorComponent( + title, + options, + (option) => { + opts?.signal?.removeEventListener("abort", onAbort); + this.hideExtensionSelector(); + resolve(option); + }, + () => { + opts?.signal?.removeEventListener("abort", onAbort); + this.hideExtensionSelector(); + resolve(undefined); + }, + { tui: this.ui, timeout: opts?.timeout, onToggleToolsExpanded: () => this.toggleToolOutputExpansion() }, + ); + + this.editorContainer.clear(); + this.editorContainer.addChild(this.extensionSelector); + this.ui.setFocus(this.extensionSelector); + this.ui.requestRender(); + }); + } + + /** + * Hide the extension selector. + */ + private hideExtensionSelector(): void { + this.extensionSelector?.dispose(); + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.extensionSelector = undefined; + this.ui.setFocus(this.editor); + this.ui.requestRender(); + } + + /** + * Show a confirmation dialog for extensions. + */ + private async showExtensionConfirm( + title: string, + message: string, + opts?: ExtensionUIDialogOptions, + ): Promise { + const result = await this.showExtensionSelector(`${title}\n${message}`, ["Yes", "No"], opts); + return result === "Yes"; + } + + private async promptForMissingSessionCwd(error: MissingSessionCwdError): Promise { + const confirmed = await this.showExtensionConfirm( + "Session cwd not found", + formatMissingSessionCwdPrompt(error.issue), + ); + return confirmed ? error.issue.fallbackCwd : undefined; + } + + /** + * Show a text input for extensions. + */ + private showExtensionInput( + title: string, + placeholder?: string, + opts?: ExtensionUIDialogOptions, + ): Promise { + return new Promise((resolve) => { + if (opts?.signal?.aborted) { + resolve(undefined); + return; + } + + const onAbort = () => { + this.hideExtensionInput(); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + + this.extensionInput = new ExtensionInputComponent( + title, + placeholder, + (value) => { + opts?.signal?.removeEventListener("abort", onAbort); + this.hideExtensionInput(); + resolve(value); + }, + () => { + opts?.signal?.removeEventListener("abort", onAbort); + this.hideExtensionInput(); + resolve(undefined); + }, + { tui: this.ui, timeout: opts?.timeout }, + ); + + this.editorContainer.clear(); + this.editorContainer.addChild(this.extensionInput); + this.ui.setFocus(this.extensionInput); + this.ui.requestRender(); + }); + } + + /** + * Hide the extension input. + */ + private hideExtensionInput(): void { + this.extensionInput?.dispose(); + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.extensionInput = undefined; + this.ui.setFocus(this.editor); + this.ui.requestRender(); + } + + /** + * Show a multi-line editor for extensions (with Ctrl+G support). + */ + private showExtensionEditor(title: string, prefill?: string): Promise { + return new Promise((resolve) => { + this.extensionEditor = new ExtensionEditorComponent( + this.ui, + this.keybindings, + title, + prefill, + (value) => { + this.hideExtensionEditor(); + resolve(value); + }, + () => { + this.hideExtensionEditor(); + resolve(undefined); + }, + ); + + this.editorContainer.clear(); + this.editorContainer.addChild(this.extensionEditor); + this.ui.setFocus(this.extensionEditor); + this.ui.requestRender(); + }); + } + + /** + * Hide the extension editor. + */ + private hideExtensionEditor(): void { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.extensionEditor = undefined; + this.ui.setFocus(this.editor); + this.ui.requestRender(); + } + + /** + * Set a custom editor component from an extension. + * Pass undefined to restore the default editor. + */ + private setCustomEditorComponent(factory: EditorFactory | undefined): void { + this.editorComponentFactory = factory; + + // Save text from current editor before switching + const currentText = this.editor.getText(); + + this.editorContainer.clear(); + + if (factory) { + // Create the custom editor with tui, theme, and keybindings + const newEditor = factory(this.ui, getEditorTheme(), this.keybindings); + + // Wire up callbacks from the default editor + newEditor.onSubmit = this.defaultEditor.onSubmit; + newEditor.onChange = this.defaultEditor.onChange; + + // Copy text from previous editor + newEditor.setText(currentText); + + // Copy appearance settings if supported + if (newEditor.borderColor !== undefined) { + newEditor.borderColor = this.defaultEditor.borderColor; + } + if (newEditor.setPaddingX !== undefined) { + newEditor.setPaddingX(this.defaultEditor.getPaddingX()); + } + + // Set autocomplete if supported + if (newEditor.setAutocompleteProvider && this.autocompleteProvider) { + newEditor.setAutocompleteProvider(this.autocompleteProvider); + } + + // If extending CustomEditor, copy app-level handlers + // Use duck typing since instanceof fails across jiti module boundaries + const customEditor = newEditor as unknown as Record; + if ("actionHandlers" in customEditor && customEditor.actionHandlers instanceof Map) { + if (!customEditor.onEscape) { + customEditor.onEscape = () => this.defaultEditor.onEscape?.(); + } + if (!customEditor.onCtrlD) { + customEditor.onCtrlD = () => this.defaultEditor.onCtrlD?.(); + } + if (!customEditor.onPasteImage) { + customEditor.onPasteImage = () => this.defaultEditor.onPasteImage?.(); + } + if (!customEditor.onExtensionShortcut) { + customEditor.onExtensionShortcut = (data: string) => this.defaultEditor.onExtensionShortcut?.(data); + } + // Copy action handlers (clear, suspend, model switching, etc.) + for (const [action, handler] of this.defaultEditor.actionHandlers) { + (customEditor.actionHandlers as Map void>).set(action, handler); + } + } + + this.editor = newEditor; + } else { + // Restore default editor with text from custom editor + this.defaultEditor.setText(currentText); + this.editor = this.defaultEditor; + } + + this.editorContainer.addChild(this.editor as Component); + this.ui.setFocus(this.editor as Component); + this.ui.requestRender(); + } + + /** + * Show a notification for extensions. + */ + private showExtensionNotify(message: string, type?: "info" | "warning" | "error"): void { + if (type === "error") { + this.showError(message); + } else if (type === "warning") { + this.showWarning(message); + } else { + this.showStatus(message); + } + } + + /** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */ + private async showExtensionCustom( + factory: ( + tui: TUI, + theme: Theme, + keybindings: KeybindingsManager, + done: (result: T) => void, + ) => (Component & { dispose?(): void }) | Promise, + options?: { + overlay?: boolean; + overlayOptions?: OverlayOptions | (() => OverlayOptions); + onHandle?: (handle: OverlayHandle) => void; + }, + ): Promise { + const savedText = this.editor.getText(); + const isOverlay = options?.overlay ?? false; + + const restoreEditor = () => { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.editor.setText(savedText); + this.ui.setFocus(this.editor); + this.ui.requestRender(); + }; + + return new Promise((resolve, reject) => { + let component: Component & { dispose?(): void }; + let closed = false; + + const close = (result: T) => { + if (closed) return; + closed = true; + if (isOverlay) this.ui.hideOverlay(); + else restoreEditor(); + // Note: both branches above already call requestRender + resolve(result); + try { + component?.dispose?.(); + } catch { + /* ignore dispose errors */ + } + }; + + Promise.resolve(factory(this.ui, theme, this.keybindings, close)) + .then((c) => { + if (closed) return; + component = c; + if (isOverlay) { + // Resolve overlay options - can be static or dynamic function + const resolveOptions = (): OverlayOptions | undefined => { + if (options?.overlayOptions) { + const opts = + typeof options.overlayOptions === "function" + ? options.overlayOptions() + : options.overlayOptions; + return opts; + } + // Fallback: use component's width property if available + const w = (component as { width?: number }).width; + return w ? { width: w } : undefined; + }; + const handle = this.ui.showOverlay(component, resolveOptions()); + // Expose handle to caller for visibility control + options?.onHandle?.(handle); + } else { + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.ui.setFocus(component); + this.ui.requestRender(); + } + }) + .catch((err) => { + if (closed) return; + if (!isOverlay) restoreEditor(); + reject(err); + }); + }); + } + + /** + * Show an extension error in the UI. + */ + private showExtensionError(extensionPath: string, error: string, stack?: string): void { + const errorMsg = `Extension "${extensionPath}" error: ${error}`; + const errorText = new Text(theme.fg("error", errorMsg), 1, 0); + this.chatContainer.addChild(errorText); + if (stack) { + // Show stack trace in dim color, indented + const stackLines = stack + .split("\n") + .slice(1) // Skip first line (duplicates error message) + .map((line) => theme.fg("dim", ` ${line.trim()}`)) + .join("\n"); + if (stackLines) { + this.chatContainer.addChild(new Text(stackLines, 1, 0)); + } + } + this.ui.requestRender(); + } + + // ========================================================================= + // Key Handlers + // ========================================================================= + + private setupKeyHandlers(): void { + // Set up handlers on defaultEditor - they use this.editor for text access + // so they work correctly regardless of which editor is active + this.defaultEditor.onEscape = () => { + if (this.session.isStreaming) { + this.restoreQueuedMessagesToEditor({ abort: true }); + } else if (this.session.isBashRunning) { + this.session.abortBash(); + } else if (this.isBashMode) { + this.editor.setText(""); + this.isBashMode = false; + this.updateEditorBorderColor(); + } else if (!this.editor.getText().trim()) { + // Double-escape with empty editor triggers /tree, /fork, or nothing based on setting + const action = this.settingsManager.getDoubleEscapeAction(); + if (action !== "none") { + const now = Date.now(); + if (now - this.lastEscapeTime < 500) { + if (action === "tree") { + this.showTreeSelector(); + } else { + this.showUserMessageSelector(); + } + this.lastEscapeTime = 0; + } else { + this.lastEscapeTime = now; + } + } + } + }; + + // Register app action handlers + this.defaultEditor.onAction("app.clear", () => this.handleCtrlC()); + this.defaultEditor.onCtrlD = () => this.handleCtrlD(); + this.defaultEditor.onAction("app.suspend", () => this.handleCtrlZ()); + this.defaultEditor.onAction("app.thinking.cycle", () => this.cycleThinkingLevel()); + this.defaultEditor.onAction("app.model.cycleForward", () => this.cycleModel("forward")); + this.defaultEditor.onAction("app.model.cycleBackward", () => this.cycleModel("backward")); + + // Global debug handler on TUI (works regardless of focus) + this.ui.onDebug = () => this.handleDebugCommand(); + this.defaultEditor.onAction("app.model.select", () => this.showModelSelector()); + this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion()); + this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility()); + this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor()); + this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp()); + this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue()); + this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand()); + this.defaultEditor.onAction("app.session.tree", () => this.showTreeSelector()); + this.defaultEditor.onAction("app.session.fork", () => this.showUserMessageSelector()); + this.defaultEditor.onAction("app.session.resume", () => this.showSessionSelector()); + + this.defaultEditor.onChange = (text: string) => { + const wasBashMode = this.isBashMode; + this.isBashMode = text.trimStart().startsWith("!"); + if (wasBashMode !== this.isBashMode) { + this.updateEditorBorderColor(); + } + }; + + // Handle clipboard image paste (triggered on Ctrl+V) + this.defaultEditor.onPasteImage = () => { + this.handleClipboardImagePaste(); + }; + } + + private async handleClipboardImagePaste(): Promise { + try { + const image = await readClipboardImage(); + if (!image) { + return; + } + + // Write to temp file + const tmpDir = os.tmpdir(); + const ext = extensionForImageMimeType(image.mimeType) ?? "png"; + const fileName = `cactus-clipboard-${crypto.randomUUID()}.${ext}`; + const filePath = path.join(tmpDir, fileName); + fs.writeFileSync(filePath, Buffer.from(image.bytes)); + + // Insert file path directly + this.editor.insertTextAtCursor?.(filePath); + this.ui.requestRender(); + } catch { + // Silently ignore clipboard errors (may not have permission, etc.) + } + } + + private setupEditorSubmitHandler(): void { + this.defaultEditor.onSubmit = async (text: string) => { + text = text.trim(); + if (!text) return; + + // Handle commands + if (text === "/settings") { + this.showSettingsSelector(); + this.editor.setText(""); + return; + } + if (text === "/scoped-models") { + this.editor.setText(""); + await this.showModelsSelector(); + return; + } + if (text === "/model" || text.startsWith("/model ")) { + const searchTerm = text.startsWith("/model ") ? text.slice(7).trim() : undefined; + this.editor.setText(""); + await this.handleModelCommand(searchTerm); + return; + } + if (text === "/export" || text.startsWith("/export ")) { + await this.handleExportCommand(text); + this.editor.setText(""); + return; + } + if (text === "/import" || text.startsWith("/import ")) { + await this.handleImportCommand(text); + this.editor.setText(""); + return; + } + if (text === "/copy") { + await this.handleCopyCommand(); + this.editor.setText(""); + return; + } + if (text === "/name" || text.startsWith("/name ")) { + this.handleNameCommand(text); + this.editor.setText(""); + return; + } + if (text === "/session") { + this.handleSessionCommand(); + this.editor.setText(""); + return; + } + if (text === "/hotkeys") { + this.handleHotkeysCommand(); + this.editor.setText(""); + return; + } + if (text === "/fork") { + this.showUserMessageSelector(); + this.editor.setText(""); + return; + } + if (text === "/clone") { + this.editor.setText(""); + await this.handleCloneCommand(); + return; + } + if (text === "/tree") { + this.showTreeSelector(); + this.editor.setText(""); + return; + } + if (text === "/trust") { + this.showTrustSelector(); + this.editor.setText(""); + return; + } + if (text === "/new") { + this.editor.setText(""); + await this.handleClearCommand(); + return; + } + if (text === "/compact" || text.startsWith("/compact ")) { + const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined; + this.editor.setText(""); + await this.handleCompactCommand(customInstructions); + return; + } + if (text === "/reload") { + this.editor.setText(""); + await this.handleReloadCommand(); + return; + } + if (text === "/debug") { + this.handleDebugCommand(); + this.editor.setText(""); + return; + } + if (text === "/resume") { + this.showSessionSelector(); + this.editor.setText(""); + return; + } + if (text === "/quit") { + this.editor.setText(""); + await this.shutdown(); + return; + } + + // Handle bash command (! for normal, !! for excluded from context) + if (text.startsWith("!")) { + const isExcluded = text.startsWith("!!"); + const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim(); + if (command) { + if (this.session.isBashRunning) { + this.showWarning("A bash command is already running. Press Esc to cancel it first."); + this.editor.setText(text); + return; + } + this.editor.addToHistory?.(text); + await this.handleBashCommand(command, isExcluded); + this.isBashMode = false; + this.updateEditorBorderColor(); + return; + } + } + + // Queue input during compaction (extension commands execute immediately) + if (this.session.isCompacting) { + if (this.isExtensionCommand(text)) { + this.editor.addToHistory?.(text); + this.editor.setText(""); + await this.session.prompt(text); + } else { + this.queueCompactionMessage(text, "steer"); + } + return; + } + + // If streaming, use prompt() with steer behavior + // This handles extension commands (execute immediately), prompt template expansion, and queueing + if (this.session.isStreaming) { + this.editor.addToHistory?.(text); + this.editor.setText(""); + await this.session.prompt(text, { streamingBehavior: "steer" }); + this.updatePendingMessagesDisplay(); + this.ui.requestRender(); + return; + } + + // Normal message submission + // First, move any pending bash components to chat + this.flushPendingBashComponents(); + + if (this.onInputCallback) { + this.onInputCallback(text); + } else { + this.pendingUserInputs.push(text); + } + this.editor.addToHistory?.(text); + }; + } + + private subscribeToAgent(): void { + this.unsubscribe = this.session.subscribe(async (event) => { + await this.handleEvent(event); + }); + } + + private async handleEvent(event: AgentSessionEvent): Promise { + if (!this.isInitialized) { + await this.init(); + } + + this.footer.invalidate(); + + switch (event.type) { + case "agent_start": + this.pendingTools.clear(); + if (this.settingsManager.getShowTerminalProgress()) { + this.ui.terminal.setProgress(true); + } + // Restore main escape handler if retry handler is still active + // (retry success event fires later, but we need main handler now) + if (this.retryEscapeHandler) { + this.defaultEditor.onEscape = this.retryEscapeHandler; + this.retryEscapeHandler = undefined; + } + if (this.retryCountdown) { + this.retryCountdown.dispose(); + this.retryCountdown = undefined; + } + if (this.retryLoader) { + this.retryLoader.stop(); + this.retryLoader = undefined; + } + this.stopWorkingLoader(); + if (this.workingVisible) { + this.loadingAnimation = this.createWorkingLoader(); + this.statusContainer.addChild(this.loadingAnimation); + } + this.ui.requestRender(); + break; + + case "queue_update": + this.updatePendingMessagesDisplay(); + this.ui.requestRender(); + break; + + case "session_info_changed": + this.updateTerminalTitle(); + this.footer.invalidate(); + this.ui.requestRender(); + break; + + case "thinking_level_changed": + this.footer.invalidate(); + this.updateEditorBorderColor(); + break; + + case "message_start": + if (event.message.role === "custom") { + this.addMessageToChat(event.message); + this.ui.requestRender(); + } else if (event.message.role === "user") { + this.addMessageToChat(event.message); + this.updatePendingMessagesDisplay(); + this.ui.requestRender(); + } else if (event.message.role === "assistant") { + this.streamingComponent = new AssistantMessageComponent( + undefined, + this.hideThinkingBlock, + this.getMarkdownThemeWithSettings(), + this.hiddenThinkingLabel, + ); + this.streamingMessage = event.message; + this.chatContainer.addChild(this.streamingComponent); + this.streamingComponent.updateContent(this.streamingMessage); + this.ui.requestRender(); + } + break; + + case "message_update": + if (this.streamingComponent && event.message.role === "assistant") { + this.streamingMessage = event.message; + this.streamingComponent.updateContent(this.streamingMessage); + + for (const content of this.streamingMessage.content) { + if (content.type === "toolCall") { + if (!this.pendingTools.has(content.id)) { + const component = new ToolExecutionComponent( + content.name, + content.id, + content.arguments, + { + showImages: this.settingsManager.getShowImages(), + imageWidthCells: this.settingsManager.getImageWidthCells(), + }, + this.getRegisteredToolDefinition(content.name), + this.ui, + this.sessionManager.getCwd(), + ); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + this.pendingTools.set(content.id, component); + } else { + const component = this.pendingTools.get(content.id); + if (component) { + component.updateArgs(content.arguments); + } + } + } + } + this.ui.requestRender(); + } + break; + + case "message_end": + if (event.message.role === "user") break; + if (this.streamingComponent && event.message.role === "assistant") { + this.streamingMessage = event.message; + let errorMessage: string | undefined; + if (this.streamingMessage.stopReason === "aborted") { + const retryAttempt = this.session.retryAttempt; + errorMessage = + retryAttempt > 0 + ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}` + : "Operation aborted"; + this.streamingMessage.errorMessage = errorMessage; + } + this.streamingComponent.updateContent(this.streamingMessage); + + if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") { + if (!errorMessage) { + errorMessage = this.streamingMessage.errorMessage || "Error"; + } + for (const [, component] of this.pendingTools.entries()) { + component.updateResult({ + content: [{ type: "text", text: errorMessage }], + isError: true, + }); + } + this.pendingTools.clear(); + } else { + // Args are now complete - trigger diff computation for edit tools + for (const [, component] of this.pendingTools.entries()) { + component.setArgsComplete(); + } + } + this.streamingComponent = undefined; + this.streamingMessage = undefined; + this.footer.invalidate(); + } + this.ui.requestRender(); + break; + + case "tool_execution_start": { + let component = this.pendingTools.get(event.toolCallId); + if (!component) { + component = new ToolExecutionComponent( + event.toolName, + event.toolCallId, + event.args, + { + showImages: this.settingsManager.getShowImages(), + imageWidthCells: this.settingsManager.getImageWidthCells(), + }, + this.getRegisteredToolDefinition(event.toolName), + this.ui, + this.sessionManager.getCwd(), + ); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + this.pendingTools.set(event.toolCallId, component); + } + component.markExecutionStarted(); + this.ui.requestRender(); + break; + } + + case "tool_execution_update": { + const component = this.pendingTools.get(event.toolCallId); + if (component) { + component.updateResult({ ...event.partialResult, isError: false }, true); + this.ui.requestRender(); + } + break; + } + + case "tool_execution_end": { + const component = this.pendingTools.get(event.toolCallId); + if (component) { + component.updateResult({ ...event.result, isError: event.isError }); + this.pendingTools.delete(event.toolCallId); + this.ui.requestRender(); + } + break; + } + + case "agent_end": + if (this.settingsManager.getShowTerminalProgress()) { + this.ui.terminal.setProgress(false); + } + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + this.statusContainer.clear(); + } + if (this.streamingComponent) { + this.chatContainer.removeChild(this.streamingComponent); + this.streamingComponent = undefined; + this.streamingMessage = undefined; + } + this.pendingTools.clear(); + + await this.checkShutdownRequested(); + + this.ui.requestRender(); + break; + + case "compaction_start": { + if (this.settingsManager.getShowTerminalProgress()) { + this.ui.terminal.setProgress(true); + } + // Keep editor active; submissions are queued during compaction. + this.autoCompactionEscapeHandler = this.defaultEditor.onEscape; + this.defaultEditor.onEscape = () => { + this.session.abortCompaction(); + }; + this.statusContainer.clear(); + const cancelHint = `(${keyText("app.interrupt")} to cancel)`; + const label = + event.reason === "manual" + ? `Compacting context... ${cancelHint}` + : `${event.reason === "overflow" ? "Context overflow detected, " : ""}Auto-compacting... ${cancelHint}`; + this.autoCompactionLoader = new Loader( + this.ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + label, + ); + this.statusContainer.addChild(this.autoCompactionLoader); + this.ui.requestRender(); + break; + } + + case "compaction_end": { + if (this.settingsManager.getShowTerminalProgress()) { + this.ui.terminal.setProgress(false); + } + if (this.autoCompactionEscapeHandler) { + this.defaultEditor.onEscape = this.autoCompactionEscapeHandler; + this.autoCompactionEscapeHandler = undefined; + } + if (this.autoCompactionLoader) { + this.autoCompactionLoader.stop(); + this.autoCompactionLoader = undefined; + this.statusContainer.clear(); + } + if (event.aborted) { + if (event.reason === "manual") { + this.showError("Compaction cancelled"); + } else { + this.showStatus("Auto-compaction cancelled"); + } + } else if (event.result) { + this.chatContainer.clear(); + this.rebuildChatFromMessages(); + this.addMessageToChat( + createCompactionSummaryMessage( + event.result.summary, + event.result.tokensBefore, + new Date().toISOString(), + ), + ); + this.footer.invalidate(); + } else if (event.errorMessage) { + if (event.reason === "manual") { + this.showError(event.errorMessage); + } else { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("error", event.errorMessage), 1, 0)); + } + } + void this.flushCompactionQueue({ willRetry: event.willRetry }); + this.ui.requestRender(); + break; + } + + case "auto_retry_start": { + // Set up escape to abort retry + this.retryEscapeHandler = this.defaultEditor.onEscape; + this.defaultEditor.onEscape = () => { + this.session.abortRetry(); + }; + // Show retry indicator + this.statusContainer.clear(); + this.retryCountdown?.dispose(); + const retryMessage = (seconds: number) => + `Retrying (${event.attempt}/${event.maxAttempts}) in ${seconds}s... (${keyText("app.interrupt")} to cancel)`; + this.retryLoader = new Loader( + this.ui, + (spinner) => theme.fg("warning", spinner), + (text) => theme.fg("muted", text), + retryMessage(Math.ceil(event.delayMs / 1000)), + ); + this.retryCountdown = new CountdownTimer( + event.delayMs, + this.ui, + (seconds) => { + this.retryLoader?.setMessage(retryMessage(seconds)); + }, + () => { + this.retryCountdown = undefined; + }, + ); + this.statusContainer.addChild(this.retryLoader); + this.ui.requestRender(); + break; + } + + case "auto_retry_end": { + // Restore escape handler + if (this.retryEscapeHandler) { + this.defaultEditor.onEscape = this.retryEscapeHandler; + this.retryEscapeHandler = undefined; + } + if (this.retryCountdown) { + this.retryCountdown.dispose(); + this.retryCountdown = undefined; + } + // Stop loader + if (this.retryLoader) { + this.retryLoader.stop(); + this.retryLoader = undefined; + this.statusContainer.clear(); + } + // Show error only on final failure (success shows normal response) + if (!event.success) { + this.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`); + } + this.ui.requestRender(); + break; + } + } + } + + /** Extract text content from a user message */ + private getUserMessageText(message: Message): string { + if (message.role !== "user") return ""; + const textBlocks = + typeof message.content === "string" + ? [{ type: "text", text: message.content }] + : message.content.filter((c: { type: string }) => c.type === "text"); + return textBlocks.map((c) => (c as { text: string }).text).join(""); + } + + /** + * Show a status message in the chat. + * + * If multiple status messages are emitted back-to-back (without anything else being added to the chat), + * we update the previous status line instead of appending new ones to avoid log spam. + */ + private showStatus(message: string): void { + const children = this.chatContainer.children; + const last = children.length > 0 ? children[children.length - 1] : undefined; + const secondLast = children.length > 1 ? children[children.length - 2] : undefined; + + if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) { + this.lastStatusText.setText(theme.fg("dim", message)); + this.ui.requestRender(); + return; + } + + const spacer = new Spacer(1); + const text = new Text(theme.fg("dim", message), 1, 0); + this.chatContainer.addChild(spacer); + this.chatContainer.addChild(text); + this.lastStatusSpacer = spacer; + this.lastStatusText = text; + this.ui.requestRender(); + } + + private addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void { + switch (message.role) { + case "bashExecution": { + const component = new BashExecutionComponent(message.command, this.ui, message.excludeFromContext); + if (message.output) { + component.appendOutput(message.output); + } + component.setComplete( + message.exitCode, + message.cancelled, + message.truncated ? ({ truncated: true } as TruncationResult) : undefined, + message.fullOutputPath, + ); + this.chatContainer.addChild(component); + break; + } + case "custom": { + if (message.display) { + const renderer = this.session.extensionRunner.getMessageRenderer(message.customType); + const component = new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings()); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + } + break; + } + case "compactionSummary": { + this.chatContainer.addChild(new Spacer(1)); + const component = new CompactionSummaryMessageComponent(message, this.getMarkdownThemeWithSettings()); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + break; + } + case "branchSummary": { + this.chatContainer.addChild(new Spacer(1)); + const component = new BranchSummaryMessageComponent(message, this.getMarkdownThemeWithSettings()); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + break; + } + case "user": { + const textContent = this.getUserMessageText(message); + if (textContent) { + if (this.chatContainer.children.length > 0) { + this.chatContainer.addChild(new Spacer(1)); + } + const skillBlock = parseSkillBlock(textContent); + if (skillBlock) { + // Render skill block (collapsible) + const component = new SkillInvocationMessageComponent( + skillBlock, + this.getMarkdownThemeWithSettings(), + ); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + // Render user message separately if present + if (skillBlock.userMessage) { + this.chatContainer.addChild(new Spacer(1)); + const userComponent = new UserMessageComponent( + skillBlock.userMessage, + this.getMarkdownThemeWithSettings(), + ); + this.chatContainer.addChild(userComponent); + } + } else { + const userComponent = new UserMessageComponent(textContent, this.getMarkdownThemeWithSettings()); + this.chatContainer.addChild(userComponent); + } + if (options?.populateHistory) { + this.editor.addToHistory?.(textContent); + } + } + break; + } + case "assistant": { + const assistantComponent = new AssistantMessageComponent( + message, + this.hideThinkingBlock, + this.getMarkdownThemeWithSettings(), + this.hiddenThinkingLabel, + ); + this.chatContainer.addChild(assistantComponent); + break; + } + case "toolResult": { + // Tool results are rendered inline with tool calls, handled separately + break; + } + default: { + const _exhaustive: never = message; + } + } + } + + /** + * Render session context to chat. Used for initial load and rebuild after compaction. + * @param sessionContext Session context to render + * @param options.updateFooter Update footer state + * @param options.populateHistory Add user messages to editor history + */ + private renderSessionContext( + sessionContext: SessionContext, + options: { updateFooter?: boolean; populateHistory?: boolean } = {}, + ): void { + this.pendingTools.clear(); + const renderedPendingTools = new Map(); + + if (options.updateFooter) { + this.footer.invalidate(); + this.updateEditorBorderColor(); + } + + for (const message of sessionContext.messages) { + // Assistant messages need special handling for tool calls + if (message.role === "assistant") { + this.addMessageToChat(message); + // Render tool call components + for (const content of message.content) { + if (content.type === "toolCall") { + const component = new ToolExecutionComponent( + content.name, + content.id, + content.arguments, + { + showImages: this.settingsManager.getShowImages(), + imageWidthCells: this.settingsManager.getImageWidthCells(), + }, + this.getRegisteredToolDefinition(content.name), + this.ui, + this.sessionManager.getCwd(), + ); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + + if (message.stopReason === "aborted" || message.stopReason === "error") { + let errorMessage: string; + if (message.stopReason === "aborted") { + const retryAttempt = this.session.retryAttempt; + errorMessage = + retryAttempt > 0 + ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}` + : "Operation aborted"; + } else { + errorMessage = message.errorMessage || "Error"; + } + component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); + } else { + renderedPendingTools.set(content.id, component); + } + } + } + } else if (message.role === "toolResult") { + // Match tool results to pending tool components + const component = renderedPendingTools.get(message.toolCallId); + if (component) { + component.updateResult(message); + renderedPendingTools.delete(message.toolCallId); + } + } else { + // All other messages use standard rendering + this.addMessageToChat(message, options); + } + } + + for (const [toolCallId, component] of renderedPendingTools) { + this.pendingTools.set(toolCallId, component); + } + this.ui.requestRender(); + } + + renderInitialMessages(): void { + // Get aligned messages and entries from session context + const context = this.sessionManager.buildSessionContext(); + this.renderSessionContext(context, { + updateFooter: true, + populateHistory: true, + }); + this.renderProjectTrustWarningIfNeeded(); + + // Show compaction info if session was compacted + const allEntries = this.sessionManager.getEntries(); + const compactionCount = allEntries.filter((e) => e.type === "compaction").length; + if (compactionCount > 0) { + const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`; + this.showStatus(`Session compacted ${times}`); + } + } + + private renderProjectTrustWarningIfNeeded(): void { + if (this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(this.sessionManager.getCwd())) { + return; + } + + if (this.chatContainer.children.length > 0) { + this.chatContainer.addChild(new Spacer(1)); + } + this.chatContainer.addChild( + new Text( + theme.fg( + "warning", + `This project is not trusted. Project ${CONFIG_DIR_NAME} resources are ignored. Use /trust to save a trust decision, then restart Cactus.`, + ), + 1, + 0, + ), + ); + } + + async getUserInput(): Promise { + const queuedInput = this.pendingUserInputs.shift(); + if (queuedInput !== undefined) { + return queuedInput; + } + + return new Promise((resolve) => { + this.onInputCallback = (text: string) => { + this.onInputCallback = undefined; + resolve(text); + }; + }); + } + + private rebuildChatFromMessages(): void { + this.chatContainer.clear(); + const context = this.sessionManager.buildSessionContext(); + this.renderSessionContext(context); + } + + // ========================================================================= + // Key handlers + // ========================================================================= + + private handleCtrlC(): void { + const now = Date.now(); + if (now - this.lastSigintTime < 500) { + void this.shutdown(); + } else { + this.clearEditor(); + this.lastSigintTime = now; + } + } + + private handleCtrlD(): void { + // Only called when editor is empty (enforced by CustomEditor) + void this.shutdown(); + } + + /** + * Gracefully shutdown the agent. + * Stops the TUI before emitting shutdown events so extension UI cleanup cannot + * repaint the final frame while the process is exiting. + */ + private isShuttingDown = false; + + private async shutdown(options?: { fromSignal?: boolean }): Promise { + if (this.isShuttingDown) return; + this.isShuttingDown = true; + // Keep signal handlers registered until terminal cleanup has completed. + // `signal-exit` checks the listener list during the same SIGTERM/SIGHUP + // dispatch and re-sends the signal if only its own listeners remain. + + if (options?.fromSignal) { + // Signal-triggered shutdown (SIGTERM/SIGHUP). Emit extension cleanup + // (session_shutdown) BEFORE touching the terminal. Extension teardown + // such as removing sockets does not write to the tty, so it must not be + // skipped if a later terminal-restore write fails on a dead or stalled + // terminal. If the terminal is gone, the restore writes below emit EIO, + // which the stdout/stderr error handler turns into emergencyTerminalExit; + // the render loop is already idle, so this cannot hot-spin (see #4144). + await this.runtimeHost.dispose(); + this.themeController.disableAutoSync(); + await this.ui.terminal.drainInput(1000); + this.stop(); + process.exit(0); + } + + // Interactive quit (Ctrl+D, Ctrl+C, /quit, extension shutdown()). Stop the + // TUI before emitting shutdown events so extension UI cleanup cannot repaint + // the final frame while the process is exiting. + // Drain any in-flight Kitty key release events before stopping. + // This prevents escape sequences from leaking to the parent shell over slow SSH. + this.themeController.disableAutoSync(); + await this.ui.terminal.drainInput(1000); + + this.stop(); + await this.runtimeHost.dispose(); + + const resumeCommand = formatResumeCommand(this.sessionManager); + if (resumeCommand) { + process.stdout.write(`${chalk.dim("To resume this session:")} ${resumeCommand}\n`); + } + + process.exit(0); + } + + private emergencyTerminalExit(): never { + this.isShuttingDown = true; + this.unregisterSignalHandlers(); + killTrackedDetachedChildren(); + // The terminal is gone. Do not run normal shutdown because TUI and + // extension cleanup can write restore sequences and re-trigger EIO. + process.exit(129); + } + + /** + * Last-resort handler for uncaught exceptions. The TUI puts stdin into raw + * mode and hides the cursor; without this handler, an uncaught throw from + * anywhere (e.g. an extension's async `ChildProcess.on("exit")` callback) + * tears down the process while leaving the terminal in raw mode with no + * cursor, requiring `stty sane && reset` to recover. + * + * Unlike emergencyTerminalExit, the terminal is still alive here, so we + * call ui.stop() to restore cooked mode, the cursor, and disable bracketed + * paste / Kitty / modifyOtherKeys sequences. + */ + private uncaughtCrash(error: Error): never { + if (this.isShuttingDown) { + process.exit(1); + } + this.isShuttingDown = true; + try { + this.unregisterSignalHandlers(); + } catch {} + try { + killTrackedDetachedChildren(); + } catch {} + try { + this.ui.stop(); + } catch {} + console.error("cactus exiting due to uncaughtException:"); + console.error(error); + process.exit(1); + } + + /** + * Check if shutdown was requested and perform shutdown if so. + */ + private async checkShutdownRequested(): Promise { + if (!this.shutdownRequested) return; + await this.shutdown(); + } + + private registerSignalHandlers(): void { + this.unregisterSignalHandlers(); + + const signals: NodeJS.Signals[] = ["SIGTERM"]; + if (process.platform !== "win32") { + signals.push("SIGHUP"); + } + + for (const signal of signals) { + const handler = () => { + // SIGHUP no longer hard-exits: graceful shutdown emits session_shutdown + // first, then attempts terminal restore. A genuinely dead terminal + // surfaces as an EIO on the restore writes, which the stdout/stderr + // error handler converts into emergencyTerminalExit (see #4144, #5080). + killTrackedDetachedChildren(); + void this.shutdown({ fromSignal: true }); + }; + process.prependListener(signal, handler); + this.signalCleanupHandlers.push(() => process.off(signal, handler)); + } + + const terminalErrorHandler = (error: Error) => { + if (isDeadTerminalError(error)) { + this.emergencyTerminalExit(); + } + throw error; + }; + process.stdout.on("error", terminalErrorHandler); + process.stderr.on("error", terminalErrorHandler); + this.signalCleanupHandlers.push(() => process.stdout.off("error", terminalErrorHandler)); + this.signalCleanupHandlers.push(() => process.stderr.off("error", terminalErrorHandler)); + + // Restore the terminal before the process dies on any uncaught throw. + // Without this, an unhandled exception from extension code (or anywhere + // in pi) leaves the terminal in raw mode with no cursor. + const uncaughtExceptionHandler = (error: Error) => this.uncaughtCrash(error); + process.prependListener("uncaughtException", uncaughtExceptionHandler); + this.signalCleanupHandlers.push(() => process.off("uncaughtException", uncaughtExceptionHandler)); + } + + private unregisterSignalHandlers(): void { + for (const cleanup of this.signalCleanupHandlers) { + cleanup(); + } + this.signalCleanupHandlers = []; + } + + private handleCtrlZ(): void { + if (process.platform === "win32") { + this.showStatus("Suspend to background is not supported on Windows"); + return; + } + + // Keep the event loop alive while suspended. Without this, stopping the TUI + // can leave Node with no ref'ed handles, causing the process to exit on fg + // before the SIGCONT handler gets a chance to restore the terminal. + const suspendKeepAlive = setInterval(() => {}, 2 ** 30); + + // Ignore SIGINT while suspended so Ctrl+C in the terminal does not + // kill the backgrounded process. The handler is removed on resume. + const ignoreSigint = () => {}; + process.on("SIGINT", ignoreSigint); + + // Set up handler to restore TUI when resumed + process.once("SIGCONT", () => { + clearInterval(suspendKeepAlive); + process.removeListener("SIGINT", ignoreSigint); + this.ui.start(); + this.ui.requestRender(true); + }); + + try { + // Stop the TUI (restore terminal to normal mode) + this.ui.stop(); + + // Send SIGTSTP to process group (pid=0 means all processes in group) + process.kill(0, "SIGTSTP"); + } catch (error) { + clearInterval(suspendKeepAlive); + process.removeListener("SIGINT", ignoreSigint); + throw error; + } + } + + private async handleFollowUp(): Promise { + const text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim(); + if (!text) return; + + // Queue input during compaction (extension commands execute immediately) + if (this.session.isCompacting) { + if (this.isExtensionCommand(text)) { + this.editor.addToHistory?.(text); + this.editor.setText(""); + await this.session.prompt(text); + } else { + this.queueCompactionMessage(text, "followUp"); + } + return; + } + + // Alt+Enter queues a follow-up message (waits until agent finishes) + // This handles extension commands (execute immediately), prompt template expansion, and queueing + if (this.session.isStreaming) { + this.editor.addToHistory?.(text); + this.editor.setText(""); + await this.session.prompt(text, { streamingBehavior: "followUp" }); + this.updatePendingMessagesDisplay(); + this.ui.requestRender(); + } + // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit) + else if (this.editor.onSubmit) { + this.editor.setText(""); + this.editor.onSubmit(text); + } + } + + private handleDequeue(): void { + const restored = this.restoreQueuedMessagesToEditor(); + if (restored === 0) { + this.showStatus("No queued messages to restore"); + } else { + this.showStatus(`Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`); + } + } + + private updateEditorBorderColor(): void { + if (this.isBashMode) { + this.editor.borderColor = theme.getBashModeBorderColor(); + } else { + const level = this.session.thinkingLevel || "off"; + this.editor.borderColor = theme.getThinkingBorderColor(level); + } + this.ui.requestRender(); + } + + private cycleThinkingLevel(): void { + const newLevel = this.session.cycleThinkingLevel(); + if (newLevel === undefined) { + this.showStatus("Current model does not support thinking"); + } else { + this.footer.invalidate(); + this.updateEditorBorderColor(); + this.showStatus(`Thinking level: ${newLevel}`); + } + } + + private async cycleModel(direction: "forward" | "backward"): Promise { + try { + const result = await this.session.cycleModel(direction); + if (result === undefined) { + const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available"; + this.showStatus(msg); + } else { + this.footer.invalidate(); + this.updateEditorBorderColor(); + const thinkingStr = + result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; + this.showStatus(`Switched to ${result.model.name || result.model.id}${thinkingStr}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(result.model); + } + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + } + + private toggleToolOutputExpansion(): void { + this.setToolsExpanded(!this.toolOutputExpanded); + } + + private setToolsExpanded(expanded: boolean): void { + this.toolOutputExpanded = expanded; + const activeHeader = this.customHeader ?? this.builtInHeader; + if (isExpandable(activeHeader)) { + activeHeader.setExpanded(expanded); + } + for (const child of this.chatContainer.children) { + if (isExpandable(child)) { + child.setExpanded(expanded); + } + } + this.ui.requestRender(); + } + + private toggleThinkingBlockVisibility(): void { + this.hideThinkingBlock = !this.hideThinkingBlock; + this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock); + + // Rebuild chat from session messages + this.chatContainer.clear(); + this.rebuildChatFromMessages(); + + // If streaming, re-add the streaming component with updated visibility and re-render + if (this.streamingComponent && this.streamingMessage) { + this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock); + this.streamingComponent.updateContent(this.streamingMessage); + this.chatContainer.addChild(this.streamingComponent); + } + + this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`); + } + + private async openExternalEditor(): Promise { + // Determine editor (respect $VISUAL, then $EDITOR) + const editorCmd = process.env.VISUAL || process.env.EDITOR; + if (!editorCmd) { + this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable."); + return; + } + + const currentText = this.editor.getExpandedText?.() ?? this.editor.getText(); + const tmpFile = path.join(os.tmpdir(), `cactus-editor-${Date.now()}.md`); + + try { + // Write current content to temp file + fs.writeFileSync(tmpFile, currentText, "utf-8"); + + // Stop TUI to release terminal + this.ui.stop(); + + // Split by space to support editor arguments (e.g., "code --wait") + const [editor, ...editorArgs] = editorCmd.split(" "); + + process.stdout.write(`Launching external editor: ${editorCmd}\nPi will resume when the editor exits.\n`); + + // Do not use spawnSync here. On Windows, synchronous child_process calls can keep + // Node/libuv's console input read active after ui.stop() pauses stdin, racing + // vim/nvim for the console input buffer until Ctrl+C cancels the pending read. + const status = await new Promise((resolve) => { + const child = spawn(editor, [...editorArgs, tmpFile], { + stdio: "inherit", + shell: process.platform === "win32", + }); + child.on("error", () => resolve(null)); + child.on("close", (code) => resolve(code)); + }); + + // On successful exit (status 0), replace editor content + if (status === 0) { + const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); + this.editor.setText(newContent); + } + // On non-zero exit, keep original text (no action needed) + } finally { + // Clean up temp file + try { + fs.unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + + // Restart TUI + this.ui.start(); + // Force full re-render since external editor uses alternate screen + this.ui.requestRender(true); + } + } + + // ========================================================================= + // UI helpers + // ========================================================================= + + clearEditor(): void { + this.editor.setText(""); + this.ui.requestRender(); + } + + showError(errorMessage: string): void { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0)); + this.ui.requestRender(); + } + + showWarning(warningMessage: string): void { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0)); + this.ui.requestRender(); + } + + /** + * Get all queued messages (read-only). + * Combines session queue and compaction queue. + */ + private getAllQueuedMessages(): { steering: string[]; followUp: string[] } { + return { + steering: [ + ...this.session.getSteeringMessages(), + ...this.compactionQueuedMessages.filter((msg) => msg.mode === "steer").map((msg) => msg.text), + ], + followUp: [ + ...this.session.getFollowUpMessages(), + ...this.compactionQueuedMessages.filter((msg) => msg.mode === "followUp").map((msg) => msg.text), + ], + }; + } + + /** + * Clear all queued messages and return their contents. + * Clears both session queue and compaction queue. + */ + private clearAllQueues(): { steering: string[]; followUp: string[] } { + const { steering, followUp } = this.session.clearQueue(); + const compactionSteering = this.compactionQueuedMessages + .filter((msg) => msg.mode === "steer") + .map((msg) => msg.text); + const compactionFollowUp = this.compactionQueuedMessages + .filter((msg) => msg.mode === "followUp") + .map((msg) => msg.text); + this.compactionQueuedMessages = []; + return { + steering: [...steering, ...compactionSteering], + followUp: [...followUp, ...compactionFollowUp], + }; + } + + private updatePendingMessagesDisplay(): void { + this.pendingMessagesContainer.clear(); + const { steering: steeringMessages, followUp: followUpMessages } = this.getAllQueuedMessages(); + if (steeringMessages.length > 0 || followUpMessages.length > 0) { + this.pendingMessagesContainer.addChild(new Spacer(1)); + for (const message of steeringMessages) { + const text = theme.fg("dim", `Steering: ${message}`); + this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); + } + for (const message of followUpMessages) { + const text = theme.fg("dim", `Follow-up: ${message}`); + this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); + } + const dequeueHint = this.getAppKeyDisplay("app.message.dequeue"); + const hintText = theme.fg("dim", `↳ ${dequeueHint} to edit all queued messages`); + this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0)); + } + } + + private restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number { + const { steering, followUp } = this.clearAllQueues(); + const allQueued = [...steering, ...followUp]; + if (allQueued.length === 0) { + this.updatePendingMessagesDisplay(); + if (options?.abort) { + this.agent.abort(); + } + return 0; + } + const queuedText = allQueued.join("\n\n"); + const currentText = options?.currentText ?? this.editor.getText(); + const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n"); + this.editor.setText(combinedText); + this.updatePendingMessagesDisplay(); + if (options?.abort) { + this.agent.abort(); + } + return allQueued.length; + } + + private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { + this.compactionQueuedMessages.push({ text, mode }); + this.editor.addToHistory?.(text); + this.editor.setText(""); + this.updatePendingMessagesDisplay(); + this.showStatus("Queued message for after compaction"); + } + + private isExtensionCommand(text: string): boolean { + if (!text.startsWith("/")) return false; + + const extensionRunner = this.session.extensionRunner; + + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + return !!extensionRunner.getCommand(commandName); + } + + private async flushCompactionQueue(options?: { willRetry?: boolean }): Promise { + if (this.compactionQueuedMessages.length === 0) { + return; + } + + const queuedMessages = [...this.compactionQueuedMessages]; + this.compactionQueuedMessages = []; + this.updatePendingMessagesDisplay(); + + const restoreQueue = (error: unknown) => { + this.session.clearQueue(); + this.compactionQueuedMessages = queuedMessages; + this.updatePendingMessagesDisplay(); + this.showError( + `Failed to send queued message${queuedMessages.length > 1 ? "s" : ""}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }; + + try { + if (options?.willRetry) { + // When retry is pending, queue messages for the retry turn + for (const message of queuedMessages) { + if (this.isExtensionCommand(message.text)) { + await this.session.prompt(message.text); + } else if (message.mode === "followUp") { + await this.session.followUp(message.text); + } else { + await this.session.steer(message.text); + } + } + this.updatePendingMessagesDisplay(); + return; + } + + // Find first non-extension-command message to use as prompt + const firstPromptIndex = queuedMessages.findIndex((message) => !this.isExtensionCommand(message.text)); + if (firstPromptIndex === -1) { + // All extension commands - execute them all + for (const message of queuedMessages) { + await this.session.prompt(message.text); + } + return; + } + + // Execute any extension commands before the first prompt + const preCommands = queuedMessages.slice(0, firstPromptIndex); + const firstPrompt = queuedMessages[firstPromptIndex]; + const rest = queuedMessages.slice(firstPromptIndex + 1); + + for (const message of preCommands) { + await this.session.prompt(message.text); + } + + // Send first prompt (starts streaming) + const promptPromise = this.session.prompt(firstPrompt.text).catch((error) => { + restoreQueue(error); + }); + + // Queue remaining messages + for (const message of rest) { + if (this.isExtensionCommand(message.text)) { + await this.session.prompt(message.text); + } else if (message.mode === "followUp") { + await this.session.followUp(message.text); + } else { + await this.session.steer(message.text); + } + } + this.updatePendingMessagesDisplay(); + void promptPromise; + } catch (error) { + restoreQueue(error); + } + } + + /** Move pending bash components from pending area to chat */ + private flushPendingBashComponents(): void { + for (const component of this.pendingBashComponents) { + this.pendingMessagesContainer.removeChild(component); + this.chatContainer.addChild(component); + } + this.pendingBashComponents = []; + } + + // ========================================================================= + // Selectors + // ========================================================================= + + /** + * Shows a selector component in place of the editor. + * @param create Factory that receives a `done` callback and returns the component and focus target + */ + private showSelector(create: (done: () => void) => { component: Component; focus: Component }): void { + const done = () => { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + }; + const { component, focus } = create(done); + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.ui.setFocus(focus); + this.ui.requestRender(); + } + + private showSettingsSelector(): void { + this.showSelector((done) => { + const selector = new SettingsSelectorComponent( + { + autoCompact: this.session.autoCompactionEnabled, + showImages: this.settingsManager.getShowImages(), + imageWidthCells: this.settingsManager.getImageWidthCells(), + autoResizeImages: this.settingsManager.getImageAutoResize(), + blockImages: this.settingsManager.getBlockImages(), + enableSkillCommands: this.settingsManager.getEnableSkillCommands(), + steeringMode: this.session.steeringMode, + followUpMode: this.session.followUpMode, + transport: this.settingsManager.getTransport(), + httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(), + thinkingLevel: this.session.thinkingLevel, + availableThinkingLevels: this.session.getAvailableThinkingLevels(), + currentTheme: this.settingsManager.getThemeSetting() || "dark", + terminalTheme: this.themeController.getTerminalTheme(), + availableThemes: getAvailableThemes(), + hideThinkingBlock: this.hideThinkingBlock, + doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(), + treeFilterMode: this.settingsManager.getTreeFilterMode(), + showHardwareCursor: this.settingsManager.getShowHardwareCursor(), + defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(), + editorPaddingX: this.settingsManager.getEditorPaddingX(), + autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(), + quietStartup: this.settingsManager.getQuietStartup(), + clearOnShrink: this.settingsManager.getClearOnShrink(), + showTerminalProgress: this.settingsManager.getShowTerminalProgress(), + warnings: this.settingsManager.getWarnings(), + }, + { + onAutoCompactChange: (enabled) => { + this.session.setAutoCompactionEnabled(enabled); + this.footer.setAutoCompactEnabled(enabled); + }, + onShowImagesChange: (enabled) => { + this.settingsManager.setShowImages(enabled); + for (const child of this.chatContainer.children) { + if (child instanceof ToolExecutionComponent) { + child.setShowImages(enabled); + } + } + }, + onImageWidthCellsChange: (width) => { + this.settingsManager.setImageWidthCells(width); + for (const child of this.chatContainer.children) { + if (child instanceof ToolExecutionComponent) { + child.setImageWidthCells(width); + } + } + }, + onAutoResizeImagesChange: (enabled) => { + this.settingsManager.setImageAutoResize(enabled); + }, + onBlockImagesChange: (blocked) => { + this.settingsManager.setBlockImages(blocked); + }, + onEnableSkillCommandsChange: (enabled) => { + this.settingsManager.setEnableSkillCommands(enabled); + this.setupAutocompleteProvider(); + }, + onSteeringModeChange: (mode) => { + this.session.setSteeringMode(mode); + }, + onFollowUpModeChange: (mode) => { + this.session.setFollowUpMode(mode); + }, + onTransportChange: (transport) => { + this.settingsManager.setTransport(transport); + this.session.agent.transport = transport; + }, + onHttpIdleTimeoutMsChange: (timeoutMs) => { + this.settingsManager.setHttpIdleTimeoutMs(timeoutMs); + configureHttpDispatcher(timeoutMs); + this.showStatus(`HTTP idle timeout: ${formatHttpIdleTimeoutMs(timeoutMs)}`); + }, + onThinkingLevelChange: (level) => { + this.session.setThinkingLevel(level); + this.footer.invalidate(); + this.updateEditorBorderColor(); + }, + onThemeChange: (themeSetting) => { + this.settingsManager.setTheme(themeSetting); + void this.themeController.applyFromSettings(); + }, + onThemePreview: (themeName) => this.themeController.preview(themeName), + onHideThinkingBlockChange: (hidden) => { + this.hideThinkingBlock = hidden; + this.settingsManager.setHideThinkingBlock(hidden); + for (const child of this.chatContainer.children) { + if (child instanceof AssistantMessageComponent) { + child.setHideThinkingBlock(hidden); + } + } + this.chatContainer.clear(); + this.rebuildChatFromMessages(); + }, + onQuietStartupChange: (enabled) => { + this.settingsManager.setQuietStartup(enabled); + }, + onDefaultProjectTrustChange: (defaultProjectTrust) => { + this.settingsManager.setDefaultProjectTrust(defaultProjectTrust); + }, + onDoubleEscapeActionChange: (action) => { + this.settingsManager.setDoubleEscapeAction(action); + }, + onTreeFilterModeChange: (mode) => { + this.settingsManager.setTreeFilterMode(mode); + }, + onShowHardwareCursorChange: (enabled) => { + this.settingsManager.setShowHardwareCursor(enabled); + this.ui.setShowHardwareCursor(enabled); + }, + onEditorPaddingXChange: (padding) => { + this.settingsManager.setEditorPaddingX(padding); + this.defaultEditor.setPaddingX(padding); + if (this.editor !== this.defaultEditor && this.editor.setPaddingX !== undefined) { + this.editor.setPaddingX(padding); + } + }, + onAutocompleteMaxVisibleChange: (maxVisible) => { + this.settingsManager.setAutocompleteMaxVisible(maxVisible); + this.defaultEditor.setAutocompleteMaxVisible(maxVisible); + if (this.editor !== this.defaultEditor && this.editor.setAutocompleteMaxVisible !== undefined) { + this.editor.setAutocompleteMaxVisible(maxVisible); + } + }, + onClearOnShrinkChange: (enabled) => { + this.settingsManager.setClearOnShrink(enabled); + this.ui.setClearOnShrink(enabled); + }, + onShowTerminalProgressChange: (enabled) => { + this.settingsManager.setShowTerminalProgress(enabled); + }, + onWarningsChange: (warnings) => { + this.settingsManager.setWarnings(warnings); + }, + onCancel: () => { + done(); + this.ui.requestRender(); + }, + }, + ); + return { component: selector, focus: selector.getSettingsList() }; + }); + } + + private async handleModelCommand(searchTerm?: string): Promise { + if (!searchTerm) { + this.showModelSelector(); + return; + } + + const model = await this.findExactModelMatch(searchTerm); + if (model) { + try { + await this.session.setModel(model); + this.footer.invalidate(); + this.updateEditorBorderColor(); + this.showStatus(`Model: ${model.id}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + return; + } + + this.showModelSelector(searchTerm); + } + + private async findExactModelMatch(searchTerm: string): Promise | undefined> { + const models = await this.getModelCandidates(); + return findExactModelReferenceMatch(searchTerm, models); + } + + private async getModelCandidates(): Promise[]> { + if (this.session.scopedModels.length > 0) { + return this.session.scopedModels.map((scoped) => scoped.model); + } + + this.session.modelRegistry.refresh(); + try { + return await this.session.modelRegistry.getAvailable(); + } catch { + return []; + } + } + + /** Update the footer's available provider count from current model candidates */ + private async updateAvailableProviderCount(): Promise { + const models = await this.getModelCandidates(); + const uniqueProviders = new Set(models.map((m) => m.provider)); + this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size); + } + + private async maybeWarnAboutAnthropicSubscriptionAuth( + model: Model | undefined = this.session.model, + ): Promise { + if (this.settingsManager.getWarnings().anthropicExtraUsage === false) { + return; + } + if (this.anthropicSubscriptionWarningShown) { + return; + } + if (!model || model.provider !== "anthropic") { + return; + } + + const storedCredential = this.session.modelRegistry.authStorage.get("anthropic"); + if (storedCredential?.type === "oauth") { + this.anthropicSubscriptionWarningShown = true; + this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING); + return; + } + + try { + const apiKey = await this.session.modelRegistry.getApiKeyForProvider(model.provider); + if (!isAnthropicSubscriptionAuthKey(apiKey)) { + return; + } + this.anthropicSubscriptionWarningShown = true; + this.showWarning(ANTHROPIC_SUBSCRIPTION_AUTH_WARNING); + } catch { + // Ignore auth lookup failures for warning-only checks. + } + } + + private maybeSaveImplicitProjectTrustAfterReload(): boolean { + const cwd = this.sessionManager.getCwd(); + if (this.autoTrustOnReloadCwd !== cwd) { + return false; + } + if (!this.settingsManager.isProjectTrusted() || !hasTrustRequiringProjectResources(cwd)) { + return false; + } + + const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); + try { + if (trustStore.get(cwd) !== null) { + this.autoTrustOnReloadCwd = undefined; + return false; + } + trustStore.set(cwd, true); + this.autoTrustOnReloadCwd = undefined; + return true; + } catch (error) { + this.showWarning( + `Could not save project trust after reload: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } + } + + private showTrustSelector(): void { + const cwd = this.sessionManager.getCwd(); + const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir); + const savedDecision = trustStore.getEntry(cwd); + this.showSelector((done) => { + const selector = new TrustSelectorComponent({ + cwd, + savedDecision, + projectTrusted: this.settingsManager.isProjectTrusted(), + onSelect: (selection) => { + trustStore.setMany(selection.updates); + done(); + this.showStatus( + `Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart Cactus for this to take effect.`, + ); + }, + onCancel: () => { + done(); + this.ui.requestRender(); + }, + }); + return { component: selector, focus: selector }; + }); + } + + private showModelSelector(initialSearchInput?: string): void { + this.showSelector((done) => { + const selector = new ModelSelectorComponent( + this.ui, + this.session.model, + this.settingsManager, + this.session.modelRegistry, + this.session.scopedModels, + async (model) => { + try { + await this.session.setModel(model); + this.footer.invalidate(); + this.updateEditorBorderColor(); + done(); + this.showStatus(`Model: ${model.id}`); + void this.maybeWarnAboutAnthropicSubscriptionAuth(model); + } catch (error) { + done(); + this.showError(error instanceof Error ? error.message : String(error)); + } + }, + () => { + done(); + this.ui.requestRender(); + }, + initialSearchInput, + ); + return { component: selector, focus: selector }; + }); + } + + private async showModelsSelector(): Promise { + // Get all available models + this.session.modelRegistry.refresh(); + const allModels = this.session.modelRegistry.getAvailable(); + + if (allModels.length === 0) { + this.showStatus("No models available"); + return; + } + + // Check if session has scoped models (from previous session-only changes or CLI --models) + const sessionScopedModels = this.session.scopedModels; + const hasSessionScope = sessionScopedModels.length > 0; + + // Build enabled model IDs from session state or settings + let currentEnabledIds: string[] | null = null; + + if (hasSessionScope) { + // Use current session's scoped models + currentEnabledIds = sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`); + } else { + // Fall back to settings + const patterns = this.settingsManager.getEnabledModels(); + if (patterns !== undefined && patterns.length > 0) { + const scopedModels = await resolveModelScope(patterns, this.session.modelRegistry); + currentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`); + } + } + + // Helper to update session's scoped models (session-only, no persist) + const updateSessionModels = async (enabledIds: string[] | null) => { + currentEnabledIds = enabledIds === null ? null : [...enabledIds]; + if (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) { + const newScopedModels = await resolveModelScope(enabledIds, this.session.modelRegistry); + this.session.setScopedModels( + newScopedModels.map((sm) => ({ + model: sm.model, + thinkingLevel: sm.thinkingLevel, + })), + ); + } else { + // All enabled or none enabled = no filter + this.session.setScopedModels([]); + } + await this.updateAvailableProviderCount(); + this.ui.requestRender(); + }; + + this.showSelector((done) => { + const selector = new ScopedModelsSelectorComponent( + { + allModels, + enabledModelIds: currentEnabledIds, + }, + { + onChange: async (enabledIds) => { + await updateSessionModels(enabledIds); + }, + onPersist: (enabledIds) => { + // Persist to settings + const newPatterns = + enabledIds === null || enabledIds.length === allModels.length + ? undefined // All enabled = clear filter + : enabledIds; + this.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined); + this.showStatus("Model selection saved to settings"); + }, + onCancel: () => { + done(); + this.ui.requestRender(); + }, + }, + ); + return { component: selector, focus: selector }; + }); + } + + private showUserMessageSelector(): void { + const userMessages = this.session.getUserMessagesForForking(); + + if (userMessages.length === 0) { + this.showStatus("No messages to fork from"); + return; + } + + const initialSelectedId = userMessages[userMessages.length - 1]?.entryId; + + this.showSelector((done) => { + const selector = new UserMessageSelectorComponent( + userMessages.map((m) => ({ id: m.entryId, text: m.text })), + async (entryId) => { + try { + const result = await this.runtimeHost.fork(entryId); + if (result.cancelled) { + done(); + this.ui.requestRender(); + return; + } + + this.editor.setText(result.selectedText ?? ""); + done(); + this.showStatus("Forked to new session"); + } catch (error: unknown) { + done(); + this.showError(error instanceof Error ? error.message : String(error)); + } + }, + () => { + done(); + this.ui.requestRender(); + }, + initialSelectedId, + ); + return { component: selector, focus: selector.getMessageList() }; + }); + } + + private async handleCloneCommand(): Promise { + const leafId = this.sessionManager.getLeafId(); + if (!leafId) { + this.showStatus("Nothing to clone yet"); + return; + } + + try { + const result = await this.runtimeHost.fork(leafId, { position: "at" }); + if (result.cancelled) { + this.ui.requestRender(); + return; + } + + this.editor.setText(""); + this.showStatus("Cloned to new session"); + } catch (error: unknown) { + this.showError(error instanceof Error ? error.message : String(error)); + } + } + + private showTreeSelector(initialSelectedId?: string): void { + const tree = this.sessionManager.getTree(); + const realLeafId = this.sessionManager.getLeafId(); + const initialFilterMode = this.settingsManager.getTreeFilterMode(); + + if (tree.length === 0) { + this.showStatus("No entries in session"); + return; + } + + this.showSelector((done) => { + const selector = new TreeSelectorComponent( + tree, + realLeafId, + this.ui.terminal.rows, + async (entryId) => { + // Selecting the current leaf is a no-op (already there) + if (entryId === realLeafId) { + done(); + this.showStatus("Already at this point"); + return; + } + + // Ask about summarization + done(); // Close selector first + + // Loop until user makes a complete choice or cancels to tree + let wantsSummary = false; + let customInstructions: string | undefined; + + // Check if we should skip the prompt (user preference to always default to no summary) + if (!this.settingsManager.getBranchSummarySkipPrompt()) { + while (true) { + const summaryChoice = await this.showExtensionSelector("Summarize branch?", [ + "No summary", + "Summarize", + "Summarize with custom prompt", + ]); + + if (summaryChoice === undefined) { + // User pressed escape - re-show tree selector with same selection + this.showTreeSelector(entryId); + return; + } + + wantsSummary = summaryChoice !== "No summary"; + + if (summaryChoice === "Summarize with custom prompt") { + customInstructions = await this.showExtensionEditor("Custom summarization instructions"); + if (customInstructions === undefined) { + // User cancelled - loop back to summary selector + continue; + } + } + + // User made a complete choice + break; + } + } + + // Set up escape handler and loader if summarizing + let summaryLoader: Loader | undefined; + const originalOnEscape = this.defaultEditor.onEscape; + + if (wantsSummary) { + this.defaultEditor.onEscape = () => { + this.session.abortBranchSummary(); + }; + this.chatContainer.addChild(new Spacer(1)); + summaryLoader = new Loader( + this.ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + `Summarizing branch... (${keyText("app.interrupt")} to cancel)`, + ); + this.statusContainer.addChild(summaryLoader); + this.ui.requestRender(); + } + + try { + const result = await this.session.navigateTree(entryId, { + summarize: wantsSummary, + customInstructions, + }); + + if (result.aborted) { + // Summarization aborted - re-show tree selector with same selection + this.showStatus("Branch summarization cancelled"); + this.showTreeSelector(entryId); + return; + } + if (result.cancelled) { + this.showStatus("Navigation cancelled"); + return; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + if (result.editorText && !this.editor.getText().trim()) { + this.editor.setText(result.editorText); + } + this.showStatus("Navigated to selected point"); + void this.flushCompactionQueue({ willRetry: false }); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } finally { + if (summaryLoader) { + summaryLoader.stop(); + this.statusContainer.clear(); + } + this.defaultEditor.onEscape = originalOnEscape; + } + }, + () => { + done(); + this.ui.requestRender(); + }, + (entryId, label) => { + this.sessionManager.appendLabelChange(entryId, label); + this.ui.requestRender(); + }, + initialSelectedId, + initialFilterMode, + ); + return { component: selector, focus: selector }; + }); + } + + private showSessionSelector(): void { + this.showSelector((done) => { + const selector = new SessionSelectorComponent( + (onProgress) => + SessionManager.list(this.sessionManager.getCwd(), this.sessionManager.getSessionDir(), onProgress), + (onProgress) => + this.sessionManager.usesDefaultSessionDir() + ? SessionManager.listAll(onProgress) + : SessionManager.listAll(this.sessionManager.getSessionDir(), onProgress), + async (sessionPath) => { + done(); + await this.handleResumeSession(sessionPath); + }, + () => { + done(); + this.ui.requestRender(); + }, + () => { + void this.shutdown(); + }, + () => this.ui.requestRender(), + { + renameSession: async (sessionFilePath: string, nextName: string | undefined) => { + const next = (nextName ?? "").trim(); + if (!next) return; + const mgr = SessionManager.open(sessionFilePath); + mgr.appendSessionInfo(next); + }, + showRenameHint: true, + keybindings: this.keybindings, + }, + + this.sessionManager.getSessionFile(), + ); + return { component: selector, focus: selector }; + }); + } + + private async handleResumeSession( + sessionPath: string, + options?: Parameters[1], + ): Promise<{ cancelled: boolean }> { + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + try { + const result = await this.runtimeHost.switchSession(sessionPath, { + withSession: options?.withSession, + projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd), + }); + if (result.cancelled) { + return result; + } + this.showStatus("Resumed session"); + return result; + } catch (error: unknown) { + if (error instanceof MissingSessionCwdError) { + const selectedCwd = await this.promptForMissingSessionCwd(error); + if (!selectedCwd) { + this.showStatus("Resume cancelled"); + return { cancelled: true }; + } + const result = await this.runtimeHost.switchSession(sessionPath, { + cwdOverride: selectedCwd, + withSession: options?.withSession, + projectTrustContextFactory: (cwd) => this.createProjectTrustContext(cwd), + }); + if (result.cancelled) { + return result; + } + this.showStatus("Resumed session in current cwd"); + return result; + } + return this.handleFatalRuntimeError("Failed to resume session", error); + } + } + + // ========================================================================= + // Command handlers + // ========================================================================= + + private async handleReloadCommand(): Promise { + if (this.session.isStreaming) { + this.showWarning("Wait for the current response to finish before reloading."); + return; + } + if (this.session.isCompacting) { + this.showWarning("Wait for compaction to finish before reloading."); + return; + } + + this.resetExtensionUI(); + + const reloadBox = new Container(); + const borderColor = (s: string) => theme.fg("border", s); + reloadBox.addChild(new DynamicBorder(borderColor)); + reloadBox.addChild(new Spacer(1)); + reloadBox.addChild( + new Text(theme.fg("muted", "Reloading keybindings, extensions, skills, prompts, themes..."), 1, 0), + ); + reloadBox.addChild(new Spacer(1)); + reloadBox.addChild(new DynamicBorder(borderColor)); + + const previousEditor = this.editor; + this.editorContainer.clear(); + this.editorContainer.addChild(reloadBox); + this.ui.setFocus(reloadBox); + this.ui.requestRender(true); + await new Promise((resolve) => process.nextTick(resolve)); + + const dismissReloadBox = (editor: Component) => { + this.editorContainer.clear(); + this.editorContainer.addChild(editor); + this.ui.setFocus(editor); + this.ui.requestRender(); + }; + + let chatRestoredBeforeSessionStart = false; + let reloadBoxDismissed = false; + const restoreChatBeforeSessionStart = () => { + if (chatRestoredBeforeSessionStart) { + return; + } + this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock(); + this.rebuildChatFromMessages(); + chatRestoredBeforeSessionStart = true; + }; + + try { + await this.session.reload({ beforeSessionStart: restoreChatBeforeSessionStart }); + restoreChatBeforeSessionStart(); + configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs()); + this.keybindings.reload(); + const activeHeader = this.customHeader ?? this.builtInHeader; + if (isExpandable(activeHeader)) { + activeHeader.setExpanded(this.toolOutputExpanded); + } + setRegisteredThemes(this.session.resourceLoader.getThemes().themes); + await this.themeController.applyFromSettings(); + const editorPaddingX = this.settingsManager.getEditorPaddingX(); + const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible(); + this.defaultEditor.setPaddingX(editorPaddingX); + this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible); + if (this.editor !== this.defaultEditor) { + this.editor.setPaddingX?.(editorPaddingX); + this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible); + } + this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor()); + this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); + this.setupAutocompleteProvider(); + const runner = this.session.extensionRunner; + this.setupExtensionShortcuts(runner); + this.showLoadedResources({ + force: false, + showDiagnosticsWhenQuiet: true, + }); + const savedImplicitProjectTrust = this.maybeSaveImplicitProjectTrustAfterReload(); + const modelsJsonError = this.session.modelRegistry.getError(); + if (modelsJsonError) { + this.showError(`models.json error: ${modelsJsonError}`); + } + this.showStatus( + savedImplicitProjectTrust + ? "Reloaded keybindings, extensions, skills, prompts, themes; saved project trust" + : "Reloaded keybindings, extensions, skills, prompts, themes", + ); + dismissReloadBox(this.editor as Component); + reloadBoxDismissed = true; + } catch (error) { + if (!reloadBoxDismissed) { + dismissReloadBox(previousEditor as Component); + } + this.showError(`Reload failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async handleExportCommand(text: string): Promise { + const outputPath = this.getPathCommandArgument(text, "/export"); + + try { + const filePath = this.session.exportToJsonl(outputPath); + this.showStatus(`Session exported to: ${filePath}`); + } catch (error: unknown) { + this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } + + private getPathCommandArgument(text: string, command: "/export" | "/import"): string | undefined { + if (text === command) { + return undefined; + } + if (!text.startsWith(`${command} `)) { + return undefined; + } + + const argsString = text.slice(command.length + 1).trimStart(); + if (!argsString) { + return undefined; + } + + const firstChar = argsString[0]; + if (firstChar === '"' || firstChar === "'") { + const closingQuoteIndex = argsString.indexOf(firstChar, 1); + if (closingQuoteIndex < 0) { + return undefined; + } + return argsString.slice(1, closingQuoteIndex); + } + + const firstWhitespaceIndex = argsString.search(/\s/); + if (firstWhitespaceIndex < 0) { + return argsString; + } + return argsString.slice(0, firstWhitespaceIndex); + } + + private async handleImportCommand(text: string): Promise { + const inputPath = this.getPathCommandArgument(text, "/import"); + if (!inputPath) { + this.showError("Usage: /import "); + return; + } + + const confirmed = await this.showExtensionConfirm("Import session", `Replace current session with ${inputPath}?`); + if (!confirmed) { + this.showStatus("Import cancelled"); + return; + } + + try { + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + const result = await this.runtimeHost.importFromJsonl(inputPath); + if (result.cancelled) { + this.showStatus("Import cancelled"); + return; + } + this.showStatus(`Session imported from: ${inputPath}`); + } catch (error: unknown) { + if (error instanceof MissingSessionCwdError) { + const selectedCwd = await this.promptForMissingSessionCwd(error); + if (!selectedCwd) { + this.showStatus("Import cancelled"); + return; + } + const result = await this.runtimeHost.importFromJsonl(inputPath, selectedCwd); + if (result.cancelled) { + this.showStatus("Import cancelled"); + return; + } + this.showStatus(`Session imported from: ${inputPath}`); + return; + } + if (error instanceof SessionImportFileNotFoundError) { + this.showError(`Failed to import session: ${error.message}`); + return; + } + await this.handleFatalRuntimeError("Failed to import session", error); + } + } + + private async handleCopyCommand(): Promise { + const text = this.session.getLastAssistantText(); + if (!text) { + this.showError("No agent messages to copy yet."); + return; + } + + try { + await copyToClipboard(text); + this.showStatus("Copied last agent message to clipboard"); + } catch (error) { + this.showError(error instanceof Error ? error.message : String(error)); + } + } + + private handleNameCommand(text: string): void { + const name = text.replace(/^\/name\s*/, "").trim(); + if (!name) { + const currentName = this.sessionManager.getSessionName(); + if (currentName) { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0)); + } else { + this.showWarning("Usage: /name "); + } + this.ui.requestRender(); + return; + } + + this.session.setSessionName(name); + const sessionName = this.sessionManager.getSessionName(); + if (sessionName !== name) { + this.showWarning(`Session name was normalized from ${JSON.stringify(name)} to ${JSON.stringify(sessionName)}`); + } + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("dim", `Session name set: ${sessionName ?? name}`), 1, 0)); + this.ui.requestRender(); + } + + private handleSessionCommand(): void { + const stats = this.session.getSessionStats(); + const sessionName = this.sessionManager.getSessionName(); + + let info = `${theme.bold("Session Info")}\n\n`; + if (sessionName) { + info += `${theme.fg("dim", "Name:")} ${sessionName}\n`; + } + info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`; + info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`; + info += `${theme.bold("Messages")}\n`; + info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`; + info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`; + info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`; + info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`; + info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`; + info += `${theme.bold("Tokens")}\n`; + info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`; + info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`; + if (stats.tokens.cacheRead > 0) { + info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`; + } + if (stats.tokens.cacheWrite > 0) { + info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`; + } + info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`; + + if (stats.cost > 0) { + info += `\n${theme.bold("Cost")}\n`; + info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`; + } + + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(info, 1, 0)); + this.ui.requestRender(); + } + + /** + * Get capitalized display string for an app keybinding action. + */ + private getAppKeyDisplay(action: AppKeybinding): string { + return keyDisplayText(action); + } + + /** + * Get capitalized display string for an editor keybinding action. + */ + private getEditorKeyDisplay(action: Keybinding): string { + return keyDisplayText(action); + } + + private handleHotkeysCommand(): void { + // Navigation keybindings + const cursorUp = this.getEditorKeyDisplay("tui.editor.cursorUp"); + const cursorDown = this.getEditorKeyDisplay("tui.editor.cursorDown"); + const cursorLeft = this.getEditorKeyDisplay("tui.editor.cursorLeft"); + const cursorRight = this.getEditorKeyDisplay("tui.editor.cursorRight"); + const cursorWordLeft = this.getEditorKeyDisplay("tui.editor.cursorWordLeft"); + const cursorWordRight = this.getEditorKeyDisplay("tui.editor.cursorWordRight"); + const cursorLineStart = this.getEditorKeyDisplay("tui.editor.cursorLineStart"); + const cursorLineEnd = this.getEditorKeyDisplay("tui.editor.cursorLineEnd"); + const jumpForward = this.getEditorKeyDisplay("tui.editor.jumpForward"); + const jumpBackward = this.getEditorKeyDisplay("tui.editor.jumpBackward"); + const pageUp = this.getEditorKeyDisplay("tui.editor.pageUp"); + const pageDown = this.getEditorKeyDisplay("tui.editor.pageDown"); + + // Editing keybindings + const submit = this.getEditorKeyDisplay("tui.input.submit"); + const newLine = this.getEditorKeyDisplay("tui.input.newLine"); + const deleteWordBackward = this.getEditorKeyDisplay("tui.editor.deleteWordBackward"); + const deleteWordForward = this.getEditorKeyDisplay("tui.editor.deleteWordForward"); + const deleteToLineStart = this.getEditorKeyDisplay("tui.editor.deleteToLineStart"); + const deleteToLineEnd = this.getEditorKeyDisplay("tui.editor.deleteToLineEnd"); + const yank = this.getEditorKeyDisplay("tui.editor.yank"); + const yankPop = this.getEditorKeyDisplay("tui.editor.yankPop"); + const undo = this.getEditorKeyDisplay("tui.editor.undo"); + const tab = this.getEditorKeyDisplay("tui.input.tab"); + + // App keybindings + const interrupt = this.getAppKeyDisplay("app.interrupt"); + const clear = this.getAppKeyDisplay("app.clear"); + const exit = this.getAppKeyDisplay("app.exit"); + const suspend = this.getAppKeyDisplay("app.suspend"); + const cycleThinkingLevel = this.getAppKeyDisplay("app.thinking.cycle"); + const cycleModelForward = this.getAppKeyDisplay("app.model.cycleForward"); + const selectModel = this.getAppKeyDisplay("app.model.select"); + const expandTools = this.getAppKeyDisplay("app.tools.expand"); + const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle"); + const externalEditor = this.getAppKeyDisplay("app.editor.external"); + const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward"); + const followUp = this.getAppKeyDisplay("app.message.followUp"); + const dequeue = this.getAppKeyDisplay("app.message.dequeue"); + const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage"); + + let hotkeys = ` +**Navigation** +| Key | Action | +|-----|--------| +| \`${cursorUp}\` / \`${cursorDown}\` / \`${cursorLeft}\` / \`${cursorRight}\` | Move cursor / browse history | +| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word | +| \`${cursorLineStart}\` | Start of line | +| \`${cursorLineEnd}\` | End of line | +| \`${jumpForward}\` | Jump forward to character | +| \`${jumpBackward}\` | Jump backward to character | +| \`${pageUp}\` / \`${pageDown}\` | Scroll by page | + +**Editing** +| Key | Action | +|-----|--------| +| \`${submit}\` | Send message | +| \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} | +| \`${deleteWordBackward}\` | Delete word backwards | +| \`${deleteWordForward}\` | Delete word forwards | +| \`${deleteToLineStart}\` | Delete to start of line | +| \`${deleteToLineEnd}\` | Delete to end of line | +| \`${yank}\` | Paste the most-recently-deleted text | +| \`${yankPop}\` | Cycle through the deleted text after pasting | +| \`${undo}\` | Undo | + +**Other** +| Key | Action | +|-----|--------| +| \`${tab}\` | Path completion / accept autocomplete | +| \`${interrupt}\` | Cancel autocomplete / abort streaming | +| \`${clear}\` | Clear editor (first) / exit (second) | +| \`${exit}\` | Exit (when editor is empty) | +| \`${suspend}\` | Suspend to background | +| \`${cycleThinkingLevel}\` | Cycle thinking level | +| \`${cycleModelForward}\` / \`${cycleModelBackward}\` | Cycle models | +| \`${selectModel}\` | Open model selector | +| \`${expandTools}\` | Toggle tool output expansion | +| \`${toggleThinking}\` | Toggle thinking block visibility | +| \`${externalEditor}\` | Edit message in external editor | +| \`${followUp}\` | Queue follow-up message | +| \`${dequeue}\` | Restore queued messages | +| \`${pasteImage}\` | Paste image from clipboard | +| \`/\` | Slash commands | +| \`!\` | Run bash command | +| \`!!\` | Run bash command (excluded from context) | +`; + + // Add extension-registered shortcuts + const extensionRunner = this.session.extensionRunner; + const shortcuts = extensionRunner.getShortcuts(this.keybindings.getEffectiveConfig()); + if (shortcuts.size > 0) { + hotkeys += ` +**Extensions** +| Key | Action | +|-----|--------| +`; + for (const [key, shortcut] of shortcuts) { + const description = shortcut.description ?? shortcut.extensionPath; + const keyDisplay = formatKeyText(key, { capitalize: true }); + hotkeys += `| \`${keyDisplay}\` | ${description} |\n`; + } + } + + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new DynamicBorder()); + this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0)); + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings())); + this.chatContainer.addChild(new DynamicBorder()); + this.ui.requestRender(); + } + + private async handleClearCommand(): Promise { + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + try { + const result = await this.runtimeHost.newSession(); + if (result.cancelled) { + return; + } + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); + this.ui.requestRender(); + } catch (error: unknown) { + await this.handleFatalRuntimeError("Failed to create session", error); + } + } + + private handleDebugCommand(): void { + const width = this.ui.terminal.columns; + const height = this.ui.terminal.rows; + const allLines = this.ui.render(width); + + const debugLogPath = getDebugLogPath(); + const debugData = [ + `Debug output at ${new Date().toISOString()}`, + `Terminal: ${width}x${height}`, + `Total lines: ${allLines.length}`, + "", + "=== All rendered lines with visible widths ===", + ...allLines.map((line, idx) => { + const vw = visibleWidth(line); + const escaped = JSON.stringify(line); + return `[${idx}] (w=${vw}) ${escaped}`; + }), + "", + "=== Agent messages (JSONL) ===", + ...this.session.messages.map((msg) => JSON.stringify(msg)), + "", + ].join("\n"); + + fs.mkdirSync(path.dirname(debugLogPath), { recursive: true }); + fs.writeFileSync(debugLogPath, debugData); + + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Text(`${theme.fg("accent", "✓ Debug log written")}\n${theme.fg("muted", debugLogPath)}`, 1, 1), + ); + this.ui.requestRender(); + } + + private async handleBashCommand(command: string, excludeFromContext = false): Promise { + const extensionRunner = this.session.extensionRunner; + + // Emit user_bash event to let extensions intercept + const eventResult = await extensionRunner.emitUserBash({ + type: "user_bash", + command, + excludeFromContext, + cwd: this.sessionManager.getCwd(), + }); + + // If extension returned a full result, use it directly + if (eventResult?.result) { + const result = eventResult.result; + + // Create UI component for display + this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext); + if (this.session.isStreaming) { + this.pendingMessagesContainer.addChild(this.bashComponent); + this.pendingBashComponents.push(this.bashComponent); + } else { + this.chatContainer.addChild(this.bashComponent); + } + + // Show output and complete + if (result.output) { + this.bashComponent.appendOutput(result.output); + } + this.bashComponent.setComplete( + result.exitCode, + result.cancelled, + result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined, + result.fullOutputPath, + ); + + // Record the result in session + this.session.recordBashResult(command, result, { excludeFromContext }); + this.bashComponent = undefined; + this.ui.requestRender(); + return; + } + + // Normal execution path (possibly with custom operations) + const isDeferred = this.session.isStreaming; + this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext); + + if (isDeferred) { + // Show in pending area when agent is streaming + this.pendingMessagesContainer.addChild(this.bashComponent); + this.pendingBashComponents.push(this.bashComponent); + } else { + // Show in chat immediately when agent is idle + this.chatContainer.addChild(this.bashComponent); + } + this.ui.requestRender(); + + try { + const result = await this.session.executeBash( + command, + (chunk) => { + if (this.bashComponent) { + this.bashComponent.appendOutput(chunk); + this.ui.requestRender(); + } + }, + { excludeFromContext, operations: eventResult?.operations }, + ); + + if (this.bashComponent) { + this.bashComponent.setComplete( + result.exitCode, + result.cancelled, + result.truncated ? ({ truncated: true, content: result.output } as TruncationResult) : undefined, + result.fullOutputPath, + ); + } + } catch (error) { + if (this.bashComponent) { + this.bashComponent.setComplete(undefined, false); + } + this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`); + } + + this.bashComponent = undefined; + this.ui.requestRender(); + } + + private async handleCompactCommand(customInstructions?: string): Promise { + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + + try { + await this.session.compact(customInstructions); + } catch { + // Ignore, will be emitted as an event + } + } + + stop(): void { + if (this.settingsManager.getShowTerminalProgress()) { + this.ui.terminal.setProgress(false); + } + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.themeController.disableAutoSync(); + this.clearExtensionTerminalInputListeners(); + this.footer.dispose(); + this.footerDataProvider.dispose(); + if (this.unsubscribe) { + this.unsubscribe(); + } + if (this.isInitialized) { + this.ui.stop(); + this.isInitialized = false; + } + this.unregisterSignalHandlers(); + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/model-search.ts b/cactus-code/packages/coding-agent/src/modes/interactive/model-search.ts new file mode 100644 index 000000000..bab9c5a5b --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/model-search.ts @@ -0,0 +1,21 @@ +export interface ModelSearchItem { + id: string; + provider: string; + name?: string; +} + +export function getModelSearchText(item: ModelSearchItem): string { + const { id, provider } = item; + const name = item.name ? ` ${item.name}` : ""; + return `${id} ${provider} ${provider}/${id} ${provider} ${id}${name}`; +} + +/** + * The /model selector search should rank exact provider-prefixed queries before proxy-provider IDs + * like openrouter/openai/gpt-5, so keep the bare model ID out of the leading position. + */ +export function getModelSelectorSearchText(item: ModelSearchItem): string { + const { id, provider } = item; + const name = item.name ? ` ${item.name}` : ""; + return `${provider} ${provider}/${id} ${provider} ${id}${name}`; +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/theme/dark.json b/cactus-code/packages/coding-agent/src/modes/interactive/theme/dark.json new file mode 100644 index 000000000..c133e232f --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "dark", + "vars": { + "cyan": "#00d7ff", + "blue": "#5f87ff", + "green": "#b5bd68", + "red": "#cc6666", + "yellow": "#ffff00", + "text": "#d4d4d4", + "gray": "#808080", + "dimGray": "#666666", + "darkGray": "#505050", + "accent": "#8abeb7", + "selectedBg": "#3a3a4a", + "userMsgBg": "#343541", + "toolPendingBg": "#282832", + "toolSuccessBg": "#283228", + "toolErrorBg": "#3c2828", + "customMsgBg": "#2d2838" + }, + "colors": { + "accent": "accent", + "border": "blue", + "borderAccent": "cyan", + "borderMuted": "darkGray", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "gray", + "dim": "dimGray", + "text": "text", + "thinkingText": "gray", + + "selectedBg": "selectedBg", + "userMessageBg": "userMsgBg", + "userMessageText": "text", + "customMessageBg": "customMsgBg", + "customMessageText": "text", + "customMessageLabel": "#9575cd", + "toolPendingBg": "toolPendingBg", + "toolSuccessBg": "toolSuccessBg", + "toolErrorBg": "toolErrorBg", + "toolTitle": "text", + "toolOutput": "gray", + + "mdHeading": "#f0c674", + "mdLink": "#81a2be", + "mdLinkUrl": "dimGray", + "mdCode": "accent", + "mdCodeBlock": "green", + "mdCodeBlockBorder": "gray", + "mdQuote": "gray", + "mdQuoteBorder": "gray", + "mdHr": "gray", + "mdListBullet": "accent", + + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "gray", + + "syntaxComment": "#6A9955", + "syntaxKeyword": "#569CD6", + "syntaxFunction": "#DCDCAA", + "syntaxVariable": "#9CDCFE", + "syntaxString": "#CE9178", + "syntaxNumber": "#B5CEA8", + "syntaxType": "#4EC9B0", + "syntaxOperator": "#D4D4D4", + "syntaxPunctuation": "#D4D4D4", + + "thinkingOff": "darkGray", + "thinkingMinimal": "#6e6e6e", + "thinkingLow": "#5f87af", + "thinkingMedium": "#81a2be", + "thinkingHigh": "#b294bb", + "thinkingXhigh": "#d183e8", + + "bashMode": "green" + }, + "export": { + "pageBg": "#18181e", + "cardBg": "#1e1e24", + "infoBg": "#3c3728" + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/theme/light.json b/cactus-code/packages/coding-agent/src/modes/interactive/theme/light.json new file mode 100644 index 000000000..0a417b566 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/theme/light.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "light", + "vars": { + "teal": "#5a8080", + "blue": "#547da7", + "green": "#588458", + "red": "#aa5555", + "yellow": "#9a7326", + "text": "#1f2328", + "mediumGray": "#6c6c6c", + "dimGray": "#767676", + "lightGray": "#b0b0b0", + "selectedBg": "#d0d0e0", + "userMsgBg": "#e8e8e8", + "toolPendingBg": "#e8e8f0", + "toolSuccessBg": "#e8f0e8", + "toolErrorBg": "#f0e8e8", + "customMsgBg": "#ede7f6" + }, + "colors": { + "accent": "teal", + "border": "blue", + "borderAccent": "teal", + "borderMuted": "lightGray", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "mediumGray", + "dim": "dimGray", + "text": "text", + "thinkingText": "mediumGray", + + "selectedBg": "selectedBg", + "userMessageBg": "userMsgBg", + "userMessageText": "text", + "customMessageBg": "customMsgBg", + "customMessageText": "text", + "customMessageLabel": "#7e57c2", + "toolPendingBg": "toolPendingBg", + "toolSuccessBg": "toolSuccessBg", + "toolErrorBg": "toolErrorBg", + "toolTitle": "text", + "toolOutput": "mediumGray", + + "mdHeading": "yellow", + "mdLink": "blue", + "mdLinkUrl": "dimGray", + "mdCode": "teal", + "mdCodeBlock": "green", + "mdCodeBlockBorder": "mediumGray", + "mdQuote": "mediumGray", + "mdQuoteBorder": "mediumGray", + "mdHr": "mediumGray", + "mdListBullet": "green", + + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "mediumGray", + + "syntaxComment": "#008000", + "syntaxKeyword": "#0000FF", + "syntaxFunction": "#795E26", + "syntaxVariable": "#001080", + "syntaxString": "#A31515", + "syntaxNumber": "#098658", + "syntaxType": "#267F99", + "syntaxOperator": "#000000", + "syntaxPunctuation": "#000000", + + "thinkingOff": "lightGray", + "thinkingMinimal": "#767676", + "thinkingLow": "blue", + "thinkingMedium": "teal", + "thinkingHigh": "#875f87", + "thinkingXhigh": "#8b008b", + + "bashMode": "green" + }, + "export": { + "pageBg": "#f8f8f8", + "cardBg": "#ffffff", + "infoBg": "#fffae6" + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts b/cactus-code/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts new file mode 100644 index 000000000..43ad620d3 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts @@ -0,0 +1,126 @@ +import type { TUI } from "@earendil-works/pi-tui"; +import type { SettingsManager } from "../../../core/settings-manager.ts"; +import { + detectTerminalBackgroundFromEnv, + detectTerminalBackgroundTheme, + detectTerminalThemeForAuto, + initTheme, + parseAutoThemeSetting, + resolveThemeSetting, + setTheme, + setThemeInstance, + type TerminalTheme, + type Theme, +} from "./theme.ts"; + +type ThemeResult = { success: boolean; error?: string }; + +export class InteractiveThemeController { + private readonly ui: TUI; + private readonly settingsManager: SettingsManager; + private readonly showError: (message: string) => void; + private readonly onChanged: () => void; + private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme; + private activeThemeName: string | undefined; + private autoSyncEnabled = false; + + constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) { + this.ui = ui; + this.settingsManager = settingsManager; + this.showError = showError; + this.onChanged = onChanged; + this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme); + initTheme(this.activeThemeName, true); + this.ui.onTerminalColorSchemeChange((terminalTheme) => this.applyTerminalTheme(terminalTheme)); + } + + async applyFromSettings(): Promise { + const themeSetting = this.settingsManager.getThemeSetting(); + const autoTheme = parseAutoThemeSetting(themeSetting); + if (autoTheme) { + this.terminalTheme = await detectTerminalThemeForAuto({ ui: this.ui, timeoutMs: 100 }); + this.setAutoSync(true); + this.applyThemeName(this.terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme, true); + return; + } + + this.setAutoSync(false); + if (themeSetting !== undefined) { + this.applyThemeName(themeSetting, true); + return; + } + + const detection = await detectTerminalBackgroundTheme({ ui: this.ui, timeoutMs: 100 }); + this.terminalTheme = detection.theme; + if (!this.applyThemeName(detection.theme).success) return; + if (detection.confidence === "high") { + this.settingsManager.setTheme(detection.theme); + await this.settingsManager.flush(); + } + } + + setThemeName(themeName: string, showError = false): ThemeResult { + this.setAutoSync(false); + return this.applyThemeName(themeName, showError); + } + + setThemeInstance(themeInstance: Theme): ThemeResult { + this.setAutoSync(false); + setThemeInstance(themeInstance); + this.activeThemeName = ""; + this.notifyChanged(); + return { success: true }; + } + + preview(themeSettingOrName: string): void { + const themeName = resolveThemeSetting(themeSettingOrName, this.terminalTheme) ?? this.activeThemeName; + if (!themeName) return; + if (setTheme(themeName, true).success) { + this.ui.invalidate(); + this.ui.requestRender(); + } + } + + disableAutoSync(): void { + this.setAutoSync(false); + } + + getTerminalTheme(): TerminalTheme { + return this.terminalTheme; + } + + private applyThemeName(themeName: string, showError = false): ThemeResult { + const result = setTheme(themeName, true); + this.activeThemeName = result.success ? themeName : "dark"; + this.notifyChanged(); + if (!result.success && showError) { + this.showError(`Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`); + } + return result; + } + + private notifyChanged(): void { + this.ui.invalidate(); + this.onChanged(); + } + + private setAutoSync(enabled: boolean): void { + if (this.autoSyncEnabled === enabled) return; + this.autoSyncEnabled = enabled; + this.ui.setTerminalColorSchemeNotifications(enabled); + } + + private applyTerminalTheme(terminalTheme: TerminalTheme): void { + if (!this.autoSyncEnabled) return; + this.terminalTheme = terminalTheme; + const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting()); + if (!autoTheme) { + this.setAutoSync(false); + return; + } + const themeName = terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme; + if (themeName !== this.activeThemeName) { + this.applyThemeName(themeName); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/cactus-code/packages/coding-agent/src/modes/interactive/theme/theme-schema.json new file mode 100644 index 000000000..9d94a12a7 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -0,0 +1,336 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Pi Coding Agent Theme", + "description": "Theme schema for Pi coding agent", + "type": "object", + "required": ["name", "colors"], + "properties": { + "$schema": { + "type": "string", + "description": "JSON schema reference" + }, + "name": { + "type": "string", + "pattern": "^[^/]+$", + "description": "Theme name. Must not contain '/' because it is reserved for automatic light/dark theme settings." + }, + "vars": { + "type": "object", + "description": "Reusable color variables", + "additionalProperties": { + "oneOf": [ + { + "type": "string", + "description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default" + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "256-color palette index (0-255)" + } + ] + } + }, + "colors": { + "type": "object", + "description": "Theme color definitions (all required)", + "required": [ + "accent", + "border", + "borderAccent", + "borderMuted", + "success", + "error", + "warning", + "muted", + "dim", + "text", + "thinkingText", + "selectedBg", + "userMessageBg", + "userMessageText", + "customMessageBg", + "customMessageText", + "customMessageLabel", + "toolPendingBg", + "toolSuccessBg", + "toolErrorBg", + "toolTitle", + "toolOutput", + "mdHeading", + "mdLink", + "mdLinkUrl", + "mdCode", + "mdCodeBlock", + "mdCodeBlockBorder", + "mdQuote", + "mdQuoteBorder", + "mdHr", + "mdListBullet", + "toolDiffAdded", + "toolDiffRemoved", + "toolDiffContext", + "syntaxComment", + "syntaxKeyword", + "syntaxFunction", + "syntaxVariable", + "syntaxString", + "syntaxNumber", + "syntaxType", + "syntaxOperator", + "syntaxPunctuation", + "thinkingOff", + "thinkingMinimal", + "thinkingLow", + "thinkingMedium", + "thinkingHigh", + "thinkingXhigh", + "bashMode" + ], + "properties": { + "accent": { + "$ref": "#/$defs/colorValue", + "description": "Primary accent color (logo, selected items, cursor)" + }, + "border": { + "$ref": "#/$defs/colorValue", + "description": "Normal borders" + }, + "borderAccent": { + "$ref": "#/$defs/colorValue", + "description": "Highlighted borders" + }, + "borderMuted": { + "$ref": "#/$defs/colorValue", + "description": "Subtle borders" + }, + "success": { + "$ref": "#/$defs/colorValue", + "description": "Success states" + }, + "error": { + "$ref": "#/$defs/colorValue", + "description": "Error states" + }, + "warning": { + "$ref": "#/$defs/colorValue", + "description": "Warning states" + }, + "muted": { + "$ref": "#/$defs/colorValue", + "description": "Secondary/dimmed text" + }, + "dim": { + "$ref": "#/$defs/colorValue", + "description": "Very dimmed text (more subtle than muted)" + }, + "text": { + "$ref": "#/$defs/colorValue", + "description": "Default text color (usually empty string)" + }, + "thinkingText": { + "$ref": "#/$defs/colorValue", + "description": "Thinking block text color" + }, + "selectedBg": { + "$ref": "#/$defs/colorValue", + "description": "Selected item background" + }, + "userMessageBg": { + "$ref": "#/$defs/colorValue", + "description": "User message background" + }, + "userMessageText": { + "$ref": "#/$defs/colorValue", + "description": "User message text color" + }, + "customMessageBg": { + "$ref": "#/$defs/colorValue", + "description": "Custom message background (hook-injected messages)" + }, + "customMessageText": { + "$ref": "#/$defs/colorValue", + "description": "Custom message text color" + }, + "customMessageLabel": { + "$ref": "#/$defs/colorValue", + "description": "Custom message type label color" + }, + "toolPendingBg": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box (pending state)" + }, + "toolSuccessBg": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box (success state)" + }, + "toolErrorBg": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box (error state)" + }, + "toolTitle": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box title color" + }, + "toolOutput": { + "$ref": "#/$defs/colorValue", + "description": "Tool execution box output text color" + }, + "mdHeading": { + "$ref": "#/$defs/colorValue", + "description": "Markdown heading text" + }, + "mdLink": { + "$ref": "#/$defs/colorValue", + "description": "Markdown link text" + }, + "mdLinkUrl": { + "$ref": "#/$defs/colorValue", + "description": "Markdown link URL" + }, + "mdCode": { + "$ref": "#/$defs/colorValue", + "description": "Markdown inline code" + }, + "mdCodeBlock": { + "$ref": "#/$defs/colorValue", + "description": "Markdown code block content" + }, + "mdCodeBlockBorder": { + "$ref": "#/$defs/colorValue", + "description": "Markdown code block fences" + }, + "mdQuote": { + "$ref": "#/$defs/colorValue", + "description": "Markdown blockquote text" + }, + "mdQuoteBorder": { + "$ref": "#/$defs/colorValue", + "description": "Markdown blockquote border" + }, + "mdHr": { + "$ref": "#/$defs/colorValue", + "description": "Markdown horizontal rule" + }, + "mdListBullet": { + "$ref": "#/$defs/colorValue", + "description": "Markdown list bullets/numbers" + }, + "toolDiffAdded": { + "$ref": "#/$defs/colorValue", + "description": "Added lines in tool diffs" + }, + "toolDiffRemoved": { + "$ref": "#/$defs/colorValue", + "description": "Removed lines in tool diffs" + }, + "toolDiffContext": { + "$ref": "#/$defs/colorValue", + "description": "Context lines in tool diffs" + }, + "syntaxComment": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: comments" + }, + "syntaxKeyword": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: keywords" + }, + "syntaxFunction": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: function names" + }, + "syntaxVariable": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: variable names" + }, + "syntaxString": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: string literals" + }, + "syntaxNumber": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: number literals" + }, + "syntaxType": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: type names" + }, + "syntaxOperator": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: operators" + }, + "syntaxPunctuation": { + "$ref": "#/$defs/colorValue", + "description": "Syntax highlighting: punctuation" + }, + "thinkingOff": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: off" + }, + "thinkingMinimal": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: minimal" + }, + "thinkingLow": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: low" + }, + "thinkingMedium": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: medium" + }, + "thinkingHigh": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: high" + }, + "thinkingXhigh": { + "$ref": "#/$defs/colorValue", + "description": "Thinking level border: xhigh (OpenAI codex-max only)" + }, + "bashMode": { + "$ref": "#/$defs/colorValue", + "description": "Editor border color in bash mode" + } + }, + "additionalProperties": false + }, + "export": { + "type": "object", + "description": "Optional colors for HTML export (defaults derived from userMessageBg if not specified)", + "properties": { + "pageBg": { + "$ref": "#/$defs/colorValue", + "description": "Page background color" + }, + "cardBg": { + "$ref": "#/$defs/colorValue", + "description": "Card/container background color" + }, + "infoBg": { + "$ref": "#/$defs/colorValue", + "description": "Info sections background (system prompt, notices)" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$defs": { + "colorValue": { + "oneOf": [ + { + "type": "string", + "description": "Hex color (#RRGGBB), variable reference, or empty string for terminal default" + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255, + "description": "256-color palette index (0-255)" + } + ] + } + } +} diff --git a/cactus-code/packages/coding-agent/src/modes/interactive/theme/theme.ts b/cactus-code/packages/coding-agent/src/modes/interactive/theme/theme.ts new file mode 100644 index 000000000..676bc5299 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -0,0 +1,1284 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + type EditorTheme, + getCapabilities, + type MarkdownTheme, + type RgbColor, + type SelectListTheme, + type SettingsListTheme, +} from "@earendil-works/pi-tui"; +import chalk from "chalk"; +import { type Static, Type } from "typebox"; +import { Compile } from "typebox/compile"; +import { getCustomThemesDir, getThemesDir } from "../../../config.ts"; +import type { SourceInfo } from "../../../core/source-info.ts"; +import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts"; +import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.ts"; + +// ============================================================================ +// Types & Schema +// ============================================================================ + +const ColorValueSchema = Type.Union([ + Type.String(), // hex "#ff0000", var ref "primary", or empty "" + Type.Integer({ minimum: 0, maximum: 255 }), // 256-color index +]); + +type ColorValue = Static; + +const ThemeJsonSchema = Type.Object({ + $schema: Type.Optional(Type.String()), + name: Type.String(), + vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)), + colors: Type.Object({ + // Core UI (10 colors) + accent: ColorValueSchema, + border: ColorValueSchema, + borderAccent: ColorValueSchema, + borderMuted: ColorValueSchema, + success: ColorValueSchema, + error: ColorValueSchema, + warning: ColorValueSchema, + muted: ColorValueSchema, + dim: ColorValueSchema, + text: ColorValueSchema, + thinkingText: ColorValueSchema, + // Backgrounds & Content Text (11 colors) + selectedBg: ColorValueSchema, + userMessageBg: ColorValueSchema, + userMessageText: ColorValueSchema, + customMessageBg: ColorValueSchema, + customMessageText: ColorValueSchema, + customMessageLabel: ColorValueSchema, + toolPendingBg: ColorValueSchema, + toolSuccessBg: ColorValueSchema, + toolErrorBg: ColorValueSchema, + toolTitle: ColorValueSchema, + toolOutput: ColorValueSchema, + // Markdown (10 colors) + mdHeading: ColorValueSchema, + mdLink: ColorValueSchema, + mdLinkUrl: ColorValueSchema, + mdCode: ColorValueSchema, + mdCodeBlock: ColorValueSchema, + mdCodeBlockBorder: ColorValueSchema, + mdQuote: ColorValueSchema, + mdQuoteBorder: ColorValueSchema, + mdHr: ColorValueSchema, + mdListBullet: ColorValueSchema, + // Tool Diffs (3 colors) + toolDiffAdded: ColorValueSchema, + toolDiffRemoved: ColorValueSchema, + toolDiffContext: ColorValueSchema, + // Syntax Highlighting (9 colors) + syntaxComment: ColorValueSchema, + syntaxKeyword: ColorValueSchema, + syntaxFunction: ColorValueSchema, + syntaxVariable: ColorValueSchema, + syntaxString: ColorValueSchema, + syntaxNumber: ColorValueSchema, + syntaxType: ColorValueSchema, + syntaxOperator: ColorValueSchema, + syntaxPunctuation: ColorValueSchema, + // Thinking Level Borders (6 colors) + thinkingOff: ColorValueSchema, + thinkingMinimal: ColorValueSchema, + thinkingLow: ColorValueSchema, + thinkingMedium: ColorValueSchema, + thinkingHigh: ColorValueSchema, + thinkingXhigh: ColorValueSchema, + // Bash Mode (1 color) + bashMode: ColorValueSchema, + }), + export: Type.Optional( + Type.Object({ + pageBg: Type.Optional(ColorValueSchema), + cardBg: Type.Optional(ColorValueSchema), + infoBg: Type.Optional(ColorValueSchema), + }), + ), +}); + +type ThemeJson = Static; + +const validateThemeJson = Compile(ThemeJsonSchema); + +export type ThemeColor = + | "accent" + | "border" + | "borderAccent" + | "borderMuted" + | "success" + | "error" + | "warning" + | "muted" + | "dim" + | "text" + | "thinkingText" + | "userMessageText" + | "customMessageText" + | "customMessageLabel" + | "toolTitle" + | "toolOutput" + | "mdHeading" + | "mdLink" + | "mdLinkUrl" + | "mdCode" + | "mdCodeBlock" + | "mdCodeBlockBorder" + | "mdQuote" + | "mdQuoteBorder" + | "mdHr" + | "mdListBullet" + | "toolDiffAdded" + | "toolDiffRemoved" + | "toolDiffContext" + | "syntaxComment" + | "syntaxKeyword" + | "syntaxFunction" + | "syntaxVariable" + | "syntaxString" + | "syntaxNumber" + | "syntaxType" + | "syntaxOperator" + | "syntaxPunctuation" + | "thinkingOff" + | "thinkingMinimal" + | "thinkingLow" + | "thinkingMedium" + | "thinkingHigh" + | "thinkingXhigh" + | "bashMode"; + +export type ThemeBg = + | "selectedBg" + | "userMessageBg" + | "customMessageBg" + | "toolPendingBg" + | "toolSuccessBg" + | "toolErrorBg"; + +type ColorMode = "truecolor" | "256color"; + +// ============================================================================ +// Color Utilities +// ============================================================================ + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const cleaned = hex.replace("#", ""); + if (cleaned.length !== 6) { + throw new Error(`Invalid hex color: ${hex}`); + } + const r = parseInt(cleaned.substring(0, 2), 16); + const g = parseInt(cleaned.substring(2, 4), 16); + const b = parseInt(cleaned.substring(4, 6), 16); + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) { + throw new Error(`Invalid hex color: ${hex}`); + } + return { r, g, b }; +} + +// The 6x6x6 color cube channel values (indices 0-5) +const CUBE_VALUES = [0, 95, 135, 175, 215, 255]; + +// Grayscale ramp values (indices 232-255, 24 grays from 8 to 238) +const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10); + +function findClosestCubeIndex(value: number): number { + let minDist = Infinity; + let minIdx = 0; + for (let i = 0; i < CUBE_VALUES.length; i++) { + const dist = Math.abs(value - CUBE_VALUES[i]); + if (dist < minDist) { + minDist = dist; + minIdx = i; + } + } + return minIdx; +} + +function findClosestGrayIndex(gray: number): number { + let minDist = Infinity; + let minIdx = 0; + for (let i = 0; i < GRAY_VALUES.length; i++) { + const dist = Math.abs(gray - GRAY_VALUES[i]); + if (dist < minDist) { + minDist = dist; + minIdx = i; + } + } + return minIdx; +} + +function colorDistance(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number { + // Weighted Euclidean distance (human eye is more sensitive to green) + const dr = r1 - r2; + const dg = g1 - g2; + const db = b1 - b2; + return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114; +} + +function rgbTo256(r: number, g: number, b: number): number { + // Find closest color in the 6x6x6 cube + const rIdx = findClosestCubeIndex(r); + const gIdx = findClosestCubeIndex(g); + const bIdx = findClosestCubeIndex(b); + const cubeR = CUBE_VALUES[rIdx]; + const cubeG = CUBE_VALUES[gIdx]; + const cubeB = CUBE_VALUES[bIdx]; + const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx; + const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB); + + // Find closest grayscale + const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + const grayIdx = findClosestGrayIndex(gray); + const grayValue = GRAY_VALUES[grayIdx]; + const grayIndex = 232 + grayIdx; + const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue); + + // Check if color has noticeable saturation (hue matters) + // If max-min spread is significant, prefer cube to preserve tint + const maxC = Math.max(r, g, b); + const minC = Math.min(r, g, b); + const spread = maxC - minC; + + // Only consider grayscale if color is nearly neutral (spread < 10) + // AND grayscale is actually closer + if (spread < 10 && grayDist < cubeDist) { + return grayIndex; + } + + return cubeIndex; +} + +function hexTo256(hex: string): number { + const { r, g, b } = hexToRgb(hex); + return rgbTo256(r, g, b); +} + +function fgAnsi(color: string | number, mode: ColorMode): string { + if (color === "") return "\x1b[39m"; + if (typeof color === "number") return `\x1b[38;5;${color}m`; + if (color.startsWith("#")) { + if (mode === "truecolor") { + const { r, g, b } = hexToRgb(color); + return `\x1b[38;2;${r};${g};${b}m`; + } else { + const index = hexTo256(color); + return `\x1b[38;5;${index}m`; + } + } + throw new Error(`Invalid color value: ${color}`); +} + +function bgAnsi(color: string | number, mode: ColorMode): string { + if (color === "") return "\x1b[49m"; + if (typeof color === "number") return `\x1b[48;5;${color}m`; + if (color.startsWith("#")) { + if (mode === "truecolor") { + const { r, g, b } = hexToRgb(color); + return `\x1b[48;2;${r};${g};${b}m`; + } else { + const index = hexTo256(color); + return `\x1b[48;5;${index}m`; + } + } + throw new Error(`Invalid color value: ${color}`); +} + +function resolveVarRefs( + value: ColorValue, + vars: Record, + visited = new Set(), +): string | number { + if (typeof value === "number" || value === "" || value.startsWith("#")) { + return value; + } + if (visited.has(value)) { + throw new Error(`Circular variable reference detected: ${value}`); + } + if (!(value in vars)) { + throw new Error(`Variable reference not found: ${value}`); + } + visited.add(value); + return resolveVarRefs(vars[value], vars, visited); +} + +function resolveThemeColors>( + colors: T, + vars: Record = {}, +): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(colors)) { + resolved[key] = resolveVarRefs(value, vars); + } + return resolved as Record; +} + +// ============================================================================ +// Theme Class +// ============================================================================ + +export class Theme { + readonly name?: string; + readonly sourcePath?: string; + sourceInfo?: SourceInfo; + private fgColors: Map; + private bgColors: Map; + private mode: ColorMode; + + constructor( + fgColors: Record, + bgColors: Record, + mode: ColorMode, + options: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {}, + ) { + this.name = options.name; + this.sourcePath = options.sourcePath; + this.sourceInfo = options.sourceInfo; + this.mode = mode; + this.fgColors = new Map(); + for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) { + this.fgColors.set(key, fgAnsi(value, mode)); + } + this.bgColors = new Map(); + for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) { + this.bgColors.set(key, bgAnsi(value, mode)); + } + } + + fg(color: ThemeColor, text: string): string { + const ansi = this.fgColors.get(color); + if (!ansi) throw new Error(`Unknown theme color: ${color}`); + return `${ansi}${text}\x1b[39m`; // Reset only foreground color + } + + bg(color: ThemeBg, text: string): string { + const ansi = this.bgColors.get(color); + if (!ansi) throw new Error(`Unknown theme background color: ${color}`); + return `${ansi}${text}\x1b[49m`; // Reset only background color + } + + bold(text: string): string { + return chalk.bold(text); + } + + italic(text: string): string { + return chalk.italic(text); + } + + underline(text: string): string { + return chalk.underline(text); + } + + inverse(text: string): string { + return chalk.inverse(text); + } + + strikethrough(text: string): string { + return chalk.strikethrough(text); + } + + getFgAnsi(color: ThemeColor): string { + const ansi = this.fgColors.get(color); + if (!ansi) throw new Error(`Unknown theme color: ${color}`); + return ansi; + } + + getBgAnsi(color: ThemeBg): string { + const ansi = this.bgColors.get(color); + if (!ansi) throw new Error(`Unknown theme background color: ${color}`); + return ansi; + } + + getColorMode(): ColorMode { + return this.mode; + } + + getThinkingBorderColor(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): (str: string) => string { + // Map thinking levels to dedicated theme colors + switch (level) { + case "off": + return (str: string) => this.fg("thinkingOff", str); + case "minimal": + return (str: string) => this.fg("thinkingMinimal", str); + case "low": + return (str: string) => this.fg("thinkingLow", str); + case "medium": + return (str: string) => this.fg("thinkingMedium", str); + case "high": + return (str: string) => this.fg("thinkingHigh", str); + case "xhigh": + return (str: string) => this.fg("thinkingXhigh", str); + default: + return (str: string) => this.fg("thinkingOff", str); + } + } + + getBashModeBorderColor(): (str: string) => string { + return (str: string) => this.fg("bashMode", str); + } +} + +// ============================================================================ +// Theme Loading +// ============================================================================ + +let BUILTIN_THEMES: Record | undefined; + +function getBuiltinThemes(): Record { + if (!BUILTIN_THEMES) { + const themesDir = getThemesDir(); + const darkPath = path.join(themesDir, "dark.json"); + const lightPath = path.join(themesDir, "light.json"); + BUILTIN_THEMES = { + dark: JSON.parse(fs.readFileSync(darkPath, "utf-8")) as ThemeJson, + light: JSON.parse(fs.readFileSync(lightPath, "utf-8")) as ThemeJson, + }; + } + return BUILTIN_THEMES; +} + +export function getAvailableThemes(): string[] { + return getAvailableThemesWithPaths().map(({ name }) => name); +} + +export interface ThemeInfo { + name: string; + path: string | undefined; +} + +export function getAvailableThemesWithPaths(): ThemeInfo[] { + const themesDir = getThemesDir(); + const result: ThemeInfo[] = []; + const seen = new Set(); + const addTheme = (themeInfo: ThemeInfo) => { + if (seen.has(themeInfo.name)) { + return; + } + seen.add(themeInfo.name); + result.push(themeInfo); + }; + + // Built-in themes + for (const name of Object.keys(getBuiltinThemes())) { + addTheme({ name, path: path.join(themesDir, `${name}.json`) }); + } + + // Custom themes + for (const themeInfo of getCustomThemeInfos()) { + addTheme(themeInfo); + } + + for (const [name, theme] of registeredThemes.entries()) { + addTheme({ name, path: theme.sourcePath }); + } + + return result.sort((a, b) => a.name.localeCompare(b.name)); +} + +function getCustomThemeInfos(): ThemeInfo[] { + const customThemesDir = getCustomThemesDir(); + const result: ThemeInfo[] = []; + if (!fs.existsSync(customThemesDir)) { + return result; + } + + for (const file of fs.readdirSync(customThemesDir)) { + if (!file.endsWith(".json")) { + continue; + } + const themePath = path.join(customThemesDir, file); + try { + const customTheme = loadThemeFromPath(themePath); + if (customTheme.name) { + result.push({ name: customTheme.name, path: themePath }); + } + } catch { + // Invalid themes are ignored here; the resource loader reports them + // during normal startup/reload. + } + } + return result; +} + +function assertThemeNameIsValid(name: string): void { + if (name.includes("/")) { + throw new Error( + `Invalid theme name "${name}": theme names cannot contain "/" because it is reserved for automatic light/dark theme settings.`, + ); + } +} + +function parseThemeJson(label: string, json: unknown): ThemeJson { + if (!validateThemeJson.Check(json)) { + const errors = Array.from(validateThemeJson.Errors(json)); + const missingColors = new Set(); + const otherErrors: string[] = []; + + for (const error of errors) { + if (error.keyword === "required" && error.instancePath === "/colors") { + const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties; + for (const requiredProperty of requiredProperties ?? []) { + missingColors.add(requiredProperty); + } + continue; + } + + const path = error.instancePath || "/"; + otherErrors.push(` - ${path}: ${error.message}`); + } + + let errorMessage = `Invalid theme "${label}":\n`; + if (missingColors.size > 0) { + errorMessage += "\nMissing required color tokens:\n"; + errorMessage += Array.from(missingColors) + .sort() + .map((color) => ` - ${color}`) + .join("\n"); + errorMessage += '\n\nPlease add these colors to your theme\'s "colors" object.'; + errorMessage += "\nSee the built-in themes (dark.json, light.json) for reference values."; + } + if (otherErrors.length > 0) { + errorMessage += `\n\nOther errors:\n${otherErrors.join("\n")}`; + } + + throw new Error(errorMessage); + } + + const themeJson = json as ThemeJson; + assertThemeNameIsValid(themeJson.name); + return themeJson; +} + +function parseThemeJsonContent(label: string, content: string): ThemeJson { + let json: unknown; + try { + json = JSON.parse(content); + } catch (error) { + throw new Error(`Failed to parse theme ${label}: ${error}`); + } + return parseThemeJson(label, json); +} + +function loadThemeJson(name: string): ThemeJson { + const builtinThemes = getBuiltinThemes(); + if (name in builtinThemes) { + return builtinThemes[name]; + } + const registeredTheme = registeredThemes.get(name); + if (registeredTheme?.sourcePath) { + const content = fs.readFileSync(registeredTheme.sourcePath, "utf-8"); + return parseThemeJsonContent(registeredTheme.sourcePath, content); + } + if (registeredTheme) { + throw new Error(`Theme "${name}" does not have a source path for export`); + } + const customThemesDir = getCustomThemesDir(); + const themePath = path.join(customThemesDir, `${name}.json`); + if (!fs.existsSync(themePath)) { + throw new Error(`Theme not found: ${name}`); + } + const content = fs.readFileSync(themePath, "utf-8"); + return parseThemeJsonContent(name, content); +} + +function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string): Theme { + const colorMode = mode ?? (getCapabilities().trueColor ? "truecolor" : "256color"); + const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars); + const fgColors: Record = {} as Record; + const bgColors: Record = {} as Record; + const bgColorKeys: Set = new Set([ + "selectedBg", + "userMessageBg", + "customMessageBg", + "toolPendingBg", + "toolSuccessBg", + "toolErrorBg", + ]); + for (const [key, value] of Object.entries(resolvedColors)) { + if (bgColorKeys.has(key)) { + bgColors[key as ThemeBg] = value; + } else { + fgColors[key as ThemeColor] = value; + } + } + return new Theme(fgColors, bgColors, colorMode, { + name: themeJson.name, + sourcePath, + }); +} + +export function loadThemeFromPath(themePath: string, mode?: ColorMode): Theme { + const content = fs.readFileSync(themePath, "utf-8"); + const themeJson = parseThemeJsonContent(themePath, content); + return createTheme(themeJson, mode, themePath); +} + +function loadTheme(name: string, mode?: ColorMode): Theme { + const registeredTheme = registeredThemes.get(name); + if (registeredTheme) { + return registeredTheme; + } + const themeJson = loadThemeJson(name); + return createTheme(themeJson, mode); +} + +export function getThemeByName(name: string): Theme | undefined { + try { + return loadTheme(name); + } catch { + return undefined; + } +} + +export type TerminalTheme = "dark" | "light"; + +export function parseAutoThemeSetting( + themeSetting: string | undefined, +): { lightTheme: string; darkTheme: string } | undefined { + if (!themeSetting) return undefined; + const slashIndex = themeSetting.indexOf("/"); + if (slashIndex === -1 || themeSetting.indexOf("/", slashIndex + 1) !== -1) { + return undefined; + } + + const lightTheme = themeSetting.slice(0, slashIndex).trim(); + const darkTheme = themeSetting.slice(slashIndex + 1).trim(); + if (!lightTheme || !darkTheme) { + return undefined; + } + return { lightTheme, darkTheme }; +} + +export function resolveThemeSetting( + themeSetting: string | undefined, + terminalTheme: TerminalTheme, +): string | undefined { + const autoTheme = parseAutoThemeSetting(themeSetting); + if (autoTheme) { + return terminalTheme === "light" ? autoTheme.lightTheme : autoTheme.darkTheme; + } + if (themeSetting?.includes("/")) return undefined; + if (typeof themeSetting === "string") return themeSetting; + return undefined; +} + +export interface TerminalThemeDetection { + theme: TerminalTheme; + source: "terminal background" | "COLORFGBG" | "fallback"; + detail: string; + confidence: "high" | "low"; +} + +export interface TerminalThemeDetectionOptions { + env?: NodeJS.ProcessEnv; +} + +export interface TerminalBackgroundThemeDetector { + queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise; +} + +export interface TerminalAutoThemeDetector extends TerminalBackgroundThemeDetector { + queryTerminalColorScheme?({ timeoutMs }: { timeoutMs: number }): Promise; +} + +export interface TerminalBackgroundThemeDetectionOptions extends TerminalThemeDetectionOptions { + ui: TerminalBackgroundThemeDetector; + timeoutMs: number; +} + +export interface TerminalAutoThemeDetectionOptions extends TerminalThemeDetectionOptions { + ui: TerminalAutoThemeDetector; + timeoutMs: number; +} + +function getColorFgBgBackgroundIndex(colorfgbg: string): number | undefined { + const parts = colorfgbg.split(";"); + for (let i = parts.length - 1; i >= 0; i--) { + const bg = parseInt(parts[i].trim(), 10); + if (Number.isInteger(bg) && bg >= 0 && bg <= 255) { + return bg; + } + } + return undefined; +} + +function getRgbColorLuminance({ r, g, b }: RgbColor): number { + const toLinear = (channel: number) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); +} + +function getAnsiColorLuminance(index: number): number { + return getRgbColorLuminance(hexToRgb(ansi256ToHex(index))); +} + +export function getThemeForRgbColor(rgb: RgbColor): TerminalTheme { + return getRgbColorLuminance(rgb) >= 0.5 ? "light" : "dark"; +} + +export function detectTerminalBackgroundFromEnv(options: TerminalThemeDetectionOptions = {}): TerminalThemeDetection { + const env = options.env ?? process.env; + const colorfgbg = env.COLORFGBG || ""; + const bg = getColorFgBgBackgroundIndex(colorfgbg); + if (bg !== undefined) { + return { + theme: getAnsiColorLuminance(bg) >= 0.5 ? "light" : "dark", + source: "COLORFGBG", + detail: `background color index ${bg}`, + confidence: "high", + }; + } + + return { + theme: "dark", + source: "fallback", + detail: "no terminal background hint found", + confidence: "low", + }; +} + +export async function detectTerminalBackgroundTheme({ + ui, + timeoutMs, + env, +}: TerminalBackgroundThemeDetectionOptions): Promise { + try { + const rgb = await ui.queryTerminalBackgroundColor({ timeoutMs }); + if (rgb) { + return { + theme: getThemeForRgbColor(rgb), + source: "terminal background", + detail: `OSC 11 background rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`, + confidence: "high", + }; + } + } catch { + // Fall back to environment-based detection when the terminal query fails. + } + + return detectTerminalBackgroundFromEnv({ env }); +} + +export async function detectTerminalThemeForAuto({ + ui, + timeoutMs, + env, +}: TerminalAutoThemeDetectionOptions): Promise { + try { + const colorScheme = await ui.queryTerminalColorScheme?.({ timeoutMs }); + if (colorScheme) return colorScheme; + } catch { + // Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported. + } + return (await detectTerminalBackgroundTheme({ ui, timeoutMs, env })).theme; +} + +export function getDefaultTheme(): string { + return detectTerminalBackgroundFromEnv().theme; +} + +// ============================================================================ +// Global Theme Instance +// ============================================================================ + +// Use globalThis to share theme across module loaders (tsx + jiti in dev mode) +const THEME_KEY = Symbol.for("@earendil-works/pi-coding-agent:theme"); +const THEME_KEY_OLD = Symbol.for("@mariozechner/pi-coding-agent:theme"); + +// Export theme as a getter that reads from globalThis +// This ensures all module instances (tsx, jiti) see the same theme +export const theme: Theme = new Proxy({} as Theme, { + get(_target, prop) { + const t = (globalThis as Record)[THEME_KEY]; + if (!t) throw new Error("Theme not initialized. Call initTheme() first."); + return (t as unknown as Record)[prop]; + }, +}); + +function setGlobalTheme(t: Theme): void { + (globalThis as Record)[THEME_KEY] = t; + (globalThis as Record)[THEME_KEY_OLD] = t; +} + +let currentThemeName: string | undefined; +let themeWatcher: fs.FSWatcher | undefined; +let themeReloadTimer: NodeJS.Timeout | undefined; +let onThemeChangeCallback: (() => void) | undefined; +const registeredThemes = new Map(); + +export function setRegisteredThemes(themes: Theme[]): void { + registeredThemes.clear(); + for (const theme of themes) { + if (theme.name) { + assertThemeNameIsValid(theme.name); + registeredThemes.set(theme.name, theme); + } + } +} + +export function initTheme(themeName?: string, enableWatcher: boolean = false): void { + const name = themeName ?? getDefaultTheme(); + currentThemeName = name; + try { + setGlobalTheme(loadTheme(name)); + if (enableWatcher) { + startThemeWatcher(); + } + } catch (_error) { + // Theme is invalid - fall back to dark theme silently + currentThemeName = "dark"; + setGlobalTheme(loadTheme("dark")); + // Don't start watcher for fallback theme + } +} + +export function setTheme(name: string, enableWatcher: boolean = false): { success: boolean; error?: string } { + currentThemeName = name; + try { + setGlobalTheme(loadTheme(name)); + if (enableWatcher) { + startThemeWatcher(); + } + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } + return { success: true }; + } catch (error) { + // Theme is invalid - fall back to dark theme + currentThemeName = "dark"; + setGlobalTheme(loadTheme("dark")); + // Don't start watcher for fallback theme + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function setThemeInstance(themeInstance: Theme): void { + setGlobalTheme(themeInstance); + currentThemeName = ""; + stopThemeWatcher(); // Can't watch a direct instance + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } +} + +export function onThemeChange(callback: () => void): void { + onThemeChangeCallback = callback; +} + +function startThemeWatcher(): void { + stopThemeWatcher(); + + // Only watch if it's a custom theme (not built-in) + if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") { + return; + } + + const customThemesDir = getCustomThemesDir(); + const watchedThemeName = currentThemeName; + const watchedFileName = `${watchedThemeName}.json`; + const themeFile = path.join(customThemesDir, watchedFileName); + + // Only watch if the file exists + if (!fs.existsSync(themeFile)) { + return; + } + + const scheduleReload = () => { + if (themeReloadTimer) { + clearTimeout(themeReloadTimer); + } + themeReloadTimer = setTimeout(() => { + themeReloadTimer = undefined; + + // Ignore stale timers after switching themes or stopping the watcher + if (currentThemeName !== watchedThemeName) { + return; + } + + // Keep the last successfully loaded theme active if the file is temporarily missing + if (!fs.existsSync(themeFile)) { + return; + } + + try { + // Reload the theme from disk and refresh the registry cache + const reloadedTheme = loadThemeFromPath(themeFile); + registeredThemes.set(watchedThemeName, reloadedTheme); + setGlobalTheme(reloadedTheme); + // Notify callback (to invalidate UI) + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } + } catch (_error) { + // Ignore errors (file might be in invalid state while being edited) + } + }, 100); + }; + + themeWatcher = + watchWithErrorHandler( + customThemesDir, + (_eventType, filename) => { + if (currentThemeName !== watchedThemeName) { + return; + } + if (!filename) { + scheduleReload(); + return; + } + if (filename !== watchedFileName) { + return; + } + scheduleReload(); + }, + () => { + closeWatcher(themeWatcher); + themeWatcher = undefined; + }, + ) ?? undefined; +} + +export function stopThemeWatcher(): void { + if (themeReloadTimer) { + clearTimeout(themeReloadTimer); + themeReloadTimer = undefined; + } + closeWatcher(themeWatcher); + themeWatcher = undefined; +} + +// ============================================================================ +// HTML Export Helpers +// ============================================================================ + +/** + * Convert a 256-color index to hex string. + * Indices 0-15: basic colors (approximate) + * Indices 16-231: 6x6x6 color cube + * Indices 232-255: grayscale ramp + */ +function ansi256ToHex(index: number): string { + // Basic colors (0-15) - approximate common terminal values + const basicColors = [ + "#000000", + "#800000", + "#008000", + "#808000", + "#000080", + "#800080", + "#008080", + "#c0c0c0", + "#808080", + "#ff0000", + "#00ff00", + "#ffff00", + "#0000ff", + "#ff00ff", + "#00ffff", + "#ffffff", + ]; + if (index < 16) { + return basicColors[index]; + } + + // Color cube (16-231): 6x6x6 = 216 colors + if (index < 232) { + const cubeIndex = index - 16; + const r = Math.floor(cubeIndex / 36); + const g = Math.floor((cubeIndex % 36) / 6); + const b = cubeIndex % 6; + const toHex = (n: number) => (n === 0 ? 0 : 55 + n * 40).toString(16).padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + + // Grayscale (232-255): 24 shades + const gray = 8 + (index - 232) * 10; + const grayHex = gray.toString(16).padStart(2, "0"); + return `#${grayHex}${grayHex}${grayHex}`; +} + +/** + * Get resolved theme colors as CSS-compatible hex strings. + * Used by HTML export to generate CSS custom properties. + */ +export function getResolvedThemeColors(themeName?: string): Record { + const name = themeName ?? currentThemeName ?? getDefaultTheme(); + const isLight = name === "light"; + const themeJson = loadThemeJson(name); + const resolved = resolveThemeColors(themeJson.colors, themeJson.vars); + + // Default text color for empty values (terminal uses default fg color) + const defaultText = isLight ? "#000000" : "#e5e5e7"; + + const cssColors: Record = {}; + for (const [key, value] of Object.entries(resolved)) { + if (typeof value === "number") { + cssColors[key] = ansi256ToHex(value); + } else if (value === "") { + // Empty means default terminal color - use sensible fallback for HTML + cssColors[key] = defaultText; + } else { + cssColors[key] = value; + } + } + return cssColors; +} + +/** + * Check if a theme is a "light" theme (for CSS that needs light/dark variants). + */ +export function isLightTheme(themeName?: string): boolean { + // Currently just check the name - could be extended to analyze colors + return themeName === "light"; +} + +/** + * Get explicit export colors from theme JSON, if specified. + * Returns undefined for each color that isn't explicitly set. + */ +export function getThemeExportColors(themeName?: string): { + pageBg?: string; + cardBg?: string; + infoBg?: string; +} { + const name = themeName ?? currentThemeName ?? getDefaultTheme(); + try { + const themeJson = loadThemeJson(name); + const exportSection = themeJson.export; + if (!exportSection) return {}; + + const vars = themeJson.vars ?? {}; + const resolve = (value: ColorValue | undefined): string | undefined => { + if (value === undefined) return undefined; + const resolved = resolveVarRefs(value, vars); + if (typeof resolved === "number") return ansi256ToHex(resolved); + if (resolved === "") return undefined; + return resolved; + }; + + return { + pageBg: resolve(exportSection.pageBg), + cardBg: resolve(exportSection.cardBg), + infoBg: resolve(exportSection.infoBg), + }; + } catch { + return {}; + } +} + +// ============================================================================ +// TUI Helpers +// ============================================================================ + +type CliHighlightTheme = Record string>; + +let cachedHighlightThemeFor: Theme | undefined; +let cachedCliHighlightTheme: CliHighlightTheme | undefined; + +function buildCliHighlightTheme(t: Theme): CliHighlightTheme { + return { + keyword: (s: string) => t.fg("syntaxKeyword", s), + built_in: (s: string) => t.fg("syntaxType", s), + literal: (s: string) => t.fg("syntaxNumber", s), + number: (s: string) => t.fg("syntaxNumber", s), + regexp: (s: string) => t.fg("syntaxString", s), + string: (s: string) => t.fg("syntaxString", s), + comment: (s: string) => t.fg("syntaxComment", s), + doctag: (s: string) => t.fg("syntaxComment", s), + meta: (s: string) => t.fg("muted", s), + function: (s: string) => t.fg("syntaxFunction", s), + title: (s: string) => t.fg("syntaxFunction", s), + class: (s: string) => t.fg("syntaxType", s), + type: (s: string) => t.fg("syntaxType", s), + tag: (s: string) => t.fg("syntaxPunctuation", s), + name: (s: string) => t.fg("syntaxKeyword", s), + attr: (s: string) => t.fg("syntaxVariable", s), + variable: (s: string) => t.fg("syntaxVariable", s), + params: (s: string) => t.fg("syntaxVariable", s), + operator: (s: string) => t.fg("syntaxOperator", s), + punctuation: (s: string) => t.fg("syntaxPunctuation", s), + emphasis: (s: string) => t.italic(s), + strong: (s: string) => t.bold(s), + link: (s: string) => t.underline(s), + addition: (s: string) => t.fg("toolDiffAdded", s), + deletion: (s: string) => t.fg("toolDiffRemoved", s), + }; +} + +function getCliHighlightTheme(t: Theme): CliHighlightTheme { + if (cachedHighlightThemeFor !== t || !cachedCliHighlightTheme) { + cachedHighlightThemeFor = t; + cachedCliHighlightTheme = buildCliHighlightTheme(t); + } + return cachedCliHighlightTheme; +} + +/** + * Highlight code with syntax coloring based on file extension or language. + * Returns array of highlighted lines. + */ +export function highlightCode(code: string, lang?: string): string[] { + // Validate language before highlighting to avoid stderr spam from cli-highlight + const validLang = lang && supportsLanguage(lang) ? lang : undefined; + // Skip highlighting when no valid language is specified. cli-highlight's + // auto-detection is unreliable and can misidentify prose as AppleScript, + // LiveCodeServer, etc., coloring random English words as keywords. + if (!validLang) { + return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); + } + const opts = { + language: validLang, + ignoreIllegals: true, + theme: getCliHighlightTheme(theme), + }; + try { + return highlight(code, opts).split("\n"); + } catch { + return code.split("\n"); + } +} + +/** + * Get language identifier from file path extension. + */ +export function getLanguageFromPath(filePath: string): string | undefined { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return undefined; + + const extToLang: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + py: "python", + rb: "ruby", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + swift: "swift", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + cs: "csharp", + php: "php", + sh: "bash", + bash: "bash", + zsh: "bash", + fish: "fish", + ps1: "powershell", + sql: "sql", + html: "html", + htm: "html", + css: "css", + scss: "scss", + sass: "sass", + less: "less", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + xml: "xml", + md: "markdown", + markdown: "markdown", + dockerfile: "dockerfile", + makefile: "makefile", + cmake: "cmake", + lua: "lua", + perl: "perl", + r: "r", + scala: "scala", + clj: "clojure", + ex: "elixir", + exs: "elixir", + erl: "erlang", + hs: "haskell", + ml: "ocaml", + vim: "vim", + graphql: "graphql", + proto: "protobuf", + tf: "hcl", + hcl: "hcl", + }; + + return extToLang[ext]; +} + +export function getMarkdownTheme(): MarkdownTheme { + return { + heading: (text: string) => theme.fg("mdHeading", text), + link: (text: string) => theme.fg("mdLink", text), + linkUrl: (text: string) => theme.fg("mdLinkUrl", text), + code: (text: string) => theme.fg("mdCode", text), + codeBlock: (text: string) => theme.fg("mdCodeBlock", text), + codeBlockBorder: (text: string) => theme.fg("mdCodeBlockBorder", text), + quote: (text: string) => theme.fg("mdQuote", text), + quoteBorder: (text: string) => theme.fg("mdQuoteBorder", text), + hr: (text: string) => theme.fg("mdHr", text), + listBullet: (text: string) => theme.fg("mdListBullet", text), + bold: (text: string) => theme.bold(text), + italic: (text: string) => theme.italic(text), + underline: (text: string) => theme.underline(text), + strikethrough: (text: string) => chalk.strikethrough(text), + highlightCode: (code: string, lang?: string): string[] => { + // Validate language before highlighting to avoid stderr spam from cli-highlight + const validLang = lang && supportsLanguage(lang) ? lang : undefined; + // Skip highlighting when no valid language is specified. cli-highlight's + // auto-detection is unreliable and can misidentify prose as AppleScript, + // LiveCodeServer, etc., coloring random English words as keywords. + if (!validLang) { + return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); + } + const opts = { + language: validLang, + ignoreIllegals: true, + theme: getCliHighlightTheme(theme), + }; + try { + return highlight(code, opts).split("\n"); + } catch { + return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); + } + }, + }; +} + +export function getSelectListTheme(): SelectListTheme { + return { + selectedPrefix: (text: string) => theme.fg("accent", text), + selectedText: (text: string) => theme.fg("accent", text), + description: (text: string) => theme.fg("muted", text), + scrollInfo: (text: string) => theme.fg("muted", text), + noMatch: (text: string) => theme.fg("muted", text), + }; +} + +export function getEditorTheme(): EditorTheme { + return { + borderColor: (text: string) => theme.fg("borderMuted", text), + selectList: getSelectListTheme(), + }; +} + +export function getSettingsListTheme(): SettingsListTheme { + return { + label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text), + value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)), + description: (text: string) => theme.fg("dim", text), + cursor: theme.fg("accent", "→ "), + hint: (text: string) => theme.fg("dim", text), + }; +} diff --git a/cactus-code/packages/coding-agent/src/modes/print-mode.ts b/cactus-code/packages/coding-agent/src/modes/print-mode.ts new file mode 100644 index 000000000..a5ee02355 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/modes/print-mode.ts @@ -0,0 +1,159 @@ +/** + * Print mode (single-shot): Send prompts, output result, exit. + * + * Used for: + * - `pi -p "prompt"` - text output + * - `pi --mode json "prompt"` - JSON event stream + */ + +import type { AssistantMessage, ImageContent } from "@earendil-works/pi-ai"; +import type { AgentSessionRuntime } from "../core/agent-session-runtime.ts"; +import { flushRawStdout, writeRawStdout } from "../core/output-guard.ts"; +import { killTrackedDetachedChildren } from "../utils/shell.ts"; + +/** + * Options for print mode. + */ +export interface PrintModeOptions { + /** Output mode: "text" for final response only, "json" for all events */ + mode: "text" | "json"; + /** Array of additional prompts to send after initialMessage */ + messages?: string[]; + /** First message to send (may contain @file content) */ + initialMessage?: string; + /** Images to attach to the initial message */ + initialImages?: ImageContent[]; +} + +/** + * Run in print (single-shot) mode. + * Sends prompts to the agent and outputs the result. + */ +export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: PrintModeOptions): Promise { + const { mode, messages = [], initialMessage, initialImages } = options; + let exitCode = 0; + let session = runtimeHost.session; + let unsubscribe: (() => void) | undefined; + let disposed = false; + const signalCleanupHandlers: Array<() => void> = []; + + const disposeRuntime = async (): Promise => { + if (disposed) return; + disposed = true; + unsubscribe?.(); + await runtimeHost.dispose(); + }; + + const registerSignalHandlers = (): void => { + const signals: NodeJS.Signals[] = ["SIGTERM"]; + if (process.platform !== "win32") { + signals.push("SIGHUP"); + } + + for (const signal of signals) { + const handler = () => { + killTrackedDetachedChildren(); + void disposeRuntime().finally(() => { + process.exit(signal === "SIGHUP" ? 129 : 143); + }); + }; + process.on(signal, handler); + signalCleanupHandlers.push(() => process.off(signal, handler)); + } + }; + + registerSignalHandlers(); + + runtimeHost.setRebindSession(async () => { + await rebindSession(); + }); + + const rebindSession = async (): Promise => { + session = runtimeHost.session; + await session.bindExtensions({ + mode: mode === "json" ? "json" : "print", + commandContextActions: { + waitForIdle: () => session.agent.waitForIdle(), + newSession: async (newSessionOptions) => runtimeHost.newSession(newSessionOptions), + fork: async (entryId, forkOptions) => { + const result = await runtimeHost.fork(entryId, forkOptions); + return { cancelled: result.cancelled }; + }, + navigateTree: async (targetId, navigateOptions) => { + const result = await session.navigateTree(targetId, { + summarize: navigateOptions?.summarize, + customInstructions: navigateOptions?.customInstructions, + replaceInstructions: navigateOptions?.replaceInstructions, + label: navigateOptions?.label, + }); + return { cancelled: result.cancelled }; + }, + switchSession: async (sessionPath, switchOptions) => { + return runtimeHost.switchSession(sessionPath, switchOptions); + }, + reload: async () => { + await session.reload(); + }, + }, + onError: (err) => { + console.error(`Extension error (${err.extensionPath}): ${err.error}`); + }, + }); + + unsubscribe?.(); + unsubscribe = session.subscribe((event) => { + if (mode === "json") { + writeRawStdout(`${JSON.stringify(event)}\n`); + } + }); + }; + + try { + if (mode === "json") { + const header = session.sessionManager.getHeader(); + if (header) { + writeRawStdout(`${JSON.stringify(header)}\n`); + } + } + + await rebindSession(); + + if (initialMessage) { + await session.prompt(initialMessage, { images: initialImages }); + } + + for (const message of messages) { + await session.prompt(message); + } + + if (mode === "text") { + const state = session.state; + const lastMessage = state.messages[state.messages.length - 1]; + + if (lastMessage?.role === "assistant") { + const assistantMsg = lastMessage as AssistantMessage; + if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { + console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`); + exitCode = 1; + } else { + for (const content of assistantMsg.content) { + if (content.type === "text") { + writeRawStdout(`${content.text}\n`); + } + } + } + } + } + + return exitCode; + } catch (error: unknown) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } finally { + for (const cleanup of signalCleanupHandlers) { + cleanup(); + } + await disposeRuntime(); + await flushRawStdout(); + } +} diff --git a/cactus-code/packages/coding-agent/src/utils/ansi.ts b/cactus-code/packages/coding-agent/src/utils/ansi.ts new file mode 100644 index 000000000..a95ded68f --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/ansi.ts @@ -0,0 +1,60 @@ +/* + * Portions of this file are derived from: + * - ansi-regex (https://github.com/chalk/ansi-regex) + * - strip-ansi (https://github.com/chalk/strip-ansi) + * + * MIT License + * + * Copyright (c) Sindre Sorhus (https://sindresorhus.com) + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +function ansiRegex({ onlyFirst = false }: { onlyFirst?: boolean } = {}): RegExp { + // Valid string terminator sequences are BEL, ESC\, and 0x9c + const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"; + + // OSC sequences only: ESC ] ... ST (non-greedy until the first ST) + const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`; + + // CSI and related: ESC/C1, optional intermediates, optional params (supports ; and :) then final byte + const csi = "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]"; + + const pattern = `${osc}|${csi}`; + + return new RegExp(pattern, onlyFirst ? undefined : "g"); +} + +const regex = ansiRegex(); + +export function stripAnsi(value: string): string { + if (typeof value !== "string") { + throw new TypeError(`Expected a \`string\`, got \`${typeof value}\``); + } + + // Fast path: ANSI codes require ESC (7-bit) or CSI (8-bit) introducer + if (!value.includes("\u001B") && !value.includes("\u009B")) { + return value; + } + + // Even though the regex is global, we don't need to reset the `.lastIndex` + // because unlike `.exec()` and `.test()`, `.replace()` does it automatically + // and doing it manually has a performance penalty. + return value.replace(regex, ""); +} diff --git a/cactus-code/packages/coding-agent/src/utils/child-process.ts b/cactus-code/packages/coding-agent/src/utils/child-process.ts new file mode 100644 index 000000000..b152444d6 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/child-process.ts @@ -0,0 +1,137 @@ +import { + type ChildProcess, + type ChildProcessByStdio, + spawn as nodeSpawn, + spawnSync as nodeSpawnSync, + type SpawnOptions, + type SpawnOptionsWithStdioTuple, + type SpawnSyncOptionsWithStringEncoding, + type SpawnSyncReturns, + type StdioNull, + type StdioPipe, +} from "node:child_process"; +import type { Readable } from "node:stream"; +import crossSpawn from "cross-spawn"; + +const EXIT_STDIO_GRACE_MS = 100; + +export function spawnProcess( + command: string, + args: string[], + options: SpawnOptionsWithStdioTuple, +): ChildProcessByStdio; +export function spawnProcess(command: string, args: string[], options: SpawnOptions): ChildProcess; +export function spawnProcess(command: string, args: string[], options: SpawnOptions): ChildProcess { + return process.platform === "win32" ? crossSpawn(command, args, options) : nodeSpawn(command, args, options); +} + +export function spawnProcessSync( + command: string, + args: string[], + options: SpawnSyncOptionsWithStringEncoding, +): SpawnSyncReturns { + return process.platform === "win32" + ? crossSpawn.sync(command, args, options) + : nodeSpawnSync(command, args, options); +} + +/** + * Wait for a child process to terminate without hanging on inherited stdio handles. + * + * A short-lived child can `exit` while a detached descendant keeps its stdout/stderr + * pipe open. We must not resolve and destroy the streams on a fixed deadline measured + * from `exit`, or output still being written past that deadline is silently lost + * (earendil-works/pi#5303). Instead, after `exit` we wait for the pipes to fall idle: + * the grace timer is re-armed on every chunk, so an actively writing descendant keeps + * us reading, while a quiet inherited handle (e.g. a Windows daemonized descendant + * that never lets `close` fire) still releases us after the grace elapses. + */ +export function waitForChildProcess(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let exited = false; + let exitCode: number | null = null; + let postExitTimer: NodeJS.Timeout | undefined; + let stdoutEnded = child.stdout === null; + let stderrEnded = child.stderr === null; + + const cleanup = () => { + if (postExitTimer) { + clearTimeout(postExitTimer); + postExitTimer = undefined; + } + child.removeListener("error", onError); + child.removeListener("exit", onExit); + child.removeListener("close", onClose); + child.stdout?.removeListener("end", onStdoutEnd); + child.stderr?.removeListener("end", onStderrEnd); + child.stdout?.removeListener("data", onData); + child.stderr?.removeListener("data", onData); + }; + + const finalize = (code: number | null) => { + if (settled) return; + settled = true; + cleanup(); + child.stdout?.destroy(); + child.stderr?.destroy(); + resolve(code); + }; + + const maybeFinalizeAfterExit = () => { + if (!exited || settled) return; + if (stdoutEnded && stderrEnded) { + finalize(exitCode); + } + }; + + const armIdleTimer = () => { + if (postExitTimer) clearTimeout(postExitTimer); + postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS); + }; + + const onData = () => { + // Output is still arriving after exit; defer finalizing so we don't + // destroy the stream mid-write and truncate the tail. + if (exited && !settled) armIdleTimer(); + }; + + const onStdoutEnd = () => { + stdoutEnded = true; + maybeFinalizeAfterExit(); + }; + + const onStderrEnd = () => { + stderrEnded = true; + maybeFinalizeAfterExit(); + }; + + const onError = (err: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(err); + }; + + const onExit = (code: number | null) => { + exited = true; + exitCode = code; + maybeFinalizeAfterExit(); + if (!settled) { + armIdleTimer(); + } + }; + + const onClose = (code: number | null) => { + finalize(code); + }; + + child.stdout?.once("end", onStdoutEnd); + child.stderr?.once("end", onStderrEnd); + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + child.once("error", onError); + child.once("exit", onExit); + child.once("close", onClose); + }); +} diff --git a/cactus-code/packages/coding-agent/src/utils/clipboard-image.ts b/cactus-code/packages/coding-agent/src/utils/clipboard-image.ts new file mode 100644 index 000000000..b98d0e9bb --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/clipboard-image.ts @@ -0,0 +1,300 @@ +import { spawnSync } from "child_process"; +import { randomUUID } from "crypto"; +import { readFileSync, unlinkSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +import { clipboard } from "./clipboard-native.ts"; +import { loadPhoton } from "./photon.ts"; + +export type ClipboardImage = { + bytes: Uint8Array; + mimeType: string; +}; + +const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const; + +const DEFAULT_LIST_TIMEOUT_MS = 1000; +const DEFAULT_READ_TIMEOUT_MS = 3000; +const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000; +const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; + +export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland"; +} + +function baseMimeType(mimeType: string): string { + return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase(); +} + +export function extensionForImageMimeType(mimeType: string): string | null { + switch (baseMimeType(mimeType)) { + case "image/png": + return "png"; + case "image/jpeg": + return "jpg"; + case "image/webp": + return "webp"; + case "image/gif": + return "gif"; + default: + return null; + } +} + +function selectPreferredImageMimeType(mimeTypes: string[]): string | null { + const normalized = mimeTypes + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => ({ raw: t, base: baseMimeType(t) })); + + for (const preferred of SUPPORTED_IMAGE_MIME_TYPES) { + const match = normalized.find((t) => t.base === preferred); + if (match) { + return match.raw; + } + } + + const anyImage = normalized.find((t) => t.base.startsWith("image/")); + return anyImage?.raw ?? null; +} + +function isSupportedImageMimeType(mimeType: string): boolean { + const base = baseMimeType(mimeType); + return SUPPORTED_IMAGE_MIME_TYPES.some((t) => t === base); +} + +/** + * Convert unsupported image formats to PNG using Photon. + * Returns null if conversion is unavailable or fails. + */ +async function convertToPng(bytes: Uint8Array): Promise { + const photon = await loadPhoton(); + if (!photon) { + return null; + } + + try { + const image = photon.PhotonImage.new_from_byteslice(bytes); + try { + return image.get_bytes(); + } finally { + image.free(); + } + } catch { + return null; + } +} + +function runCommand( + command: string, + args: string[], + options?: { timeoutMs?: number; maxBufferBytes?: number; env?: NodeJS.ProcessEnv }, +): { stdout: Buffer; ok: boolean } { + const timeoutMs = options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS; + const maxBufferBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; + + const result = spawnSync(command, args, { + timeout: timeoutMs, + maxBuffer: maxBufferBytes, + env: options?.env, + }); + + if (result.error) { + return { ok: false, stdout: Buffer.alloc(0) }; + } + + if (result.status !== 0) { + return { ok: false, stdout: Buffer.alloc(0) }; + } + + const stdout = Buffer.isBuffer(result.stdout) + ? result.stdout + : Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf-8" : undefined); + + return { ok: true, stdout }; +} + +function readClipboardImageViaWlPaste(): ClipboardImage | null { + const list = runCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS }); + if (!list.ok) { + return null; + } + + const types = list.stdout + .toString("utf-8") + .split(/\r?\n/) + .map((t) => t.trim()) + .filter(Boolean); + + const selectedType = selectPreferredImageMimeType(types); + if (!selectedType) { + return null; + } + + const data = runCommand("wl-paste", ["--type", selectedType, "--no-newline"]); + if (!data.ok || data.stdout.length === 0) { + return null; + } + + return { bytes: data.stdout, mimeType: baseMimeType(selectedType) }; +} + +function isWSL(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.WSL_DISTRO_NAME || env.WSLENV) { + return true; + } + + try { + const release = readFileSync("/proc/version", "utf-8"); + return /microsoft|wsl/i.test(release); + } catch { + return false; + } +} + +/** + * On WSL, the Linux clipboard (Wayland/X11) does not receive image data from + * Windows screenshots (Win+Shift+S). PowerShell can access the Windows clipboard + * directly, so we use it as a fallback. + */ +function readClipboardImageViaPowerShell(): ClipboardImage | null { + const tmpFile = join(tmpdir(), `cactus-wsl-clip-${randomUUID()}.png`); + + try { + const winPathResult = runCommand("wslpath", ["-w", tmpFile], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS }); + if (!winPathResult.ok) { + return null; + } + + const winPath = winPathResult.stdout.toString("utf-8").trim(); + if (!winPath) { + return null; + } + + const psQuotedWinPath = winPath.replaceAll("'", "''"); + const psScript = [ + "Add-Type -AssemblyName System.Windows.Forms", + "Add-Type -AssemblyName System.Drawing", + `$path = '${psQuotedWinPath}'`, + "$img = [System.Windows.Forms.Clipboard]::GetImage()", + "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }", + ].join("; "); + + const result = runCommand("powershell.exe", ["-NoProfile", "-Command", psScript], { + timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS, + }); + if (!result.ok) { + return null; + } + + const output = result.stdout.toString("utf-8").trim(); + if (output !== "ok") { + return null; + } + + const bytes = readFileSync(tmpFile); + if (bytes.length === 0) { + return null; + } + + return { bytes: new Uint8Array(bytes), mimeType: "image/png" }; + } catch { + return null; + } finally { + try { + unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors. + } + } +} + +function readClipboardImageViaXclip(): ClipboardImage | null { + const targets = runCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], { + timeoutMs: DEFAULT_LIST_TIMEOUT_MS, + }); + + let candidateTypes: string[] = []; + if (targets.ok) { + candidateTypes = targets.stdout + .toString("utf-8") + .split(/\r?\n/) + .map((t) => t.trim()) + .filter(Boolean); + } + + const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null; + const tryTypes = preferred ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] : [...SUPPORTED_IMAGE_MIME_TYPES]; + + for (const mimeType of tryTypes) { + const data = runCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]); + if (data.ok && data.stdout.length > 0) { + return { bytes: data.stdout, mimeType: baseMimeType(mimeType) }; + } + } + + return null; +} + +async function readClipboardImageViaNativeClipboard(): Promise { + if (!clipboard || !clipboard.hasImage()) { + return null; + } + + const imageData = await clipboard.getImageBinary(); + if (!imageData || imageData.length === 0) { + return null; + } + + const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData); + return { bytes, mimeType: "image/png" }; +} + +export async function readClipboardImage(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +}): Promise { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + + if (env.TERMUX_VERSION) { + return null; + } + + let image: ClipboardImage | null = null; + + if (platform === "linux") { + const wsl = isWSL(env); + const wayland = isWaylandSession(env); + + if (wayland || wsl) { + image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip(); + } + + if (!image && wsl) { + image = readClipboardImageViaPowerShell(); + } + + if (!image && !wayland) { + image = await readClipboardImageViaNativeClipboard(); + } + } else { + image = await readClipboardImageViaNativeClipboard(); + } + + if (!image) { + return null; + } + + // Convert unsupported formats (e.g., BMP from WSLg) to PNG + if (!isSupportedImageMimeType(image.mimeType)) { + const pngBytes = await convertToPng(image.bytes); + if (!pngBytes) { + return null; + } + return { bytes: pngBytes, mimeType: "image/png" }; + } + + return image; +} diff --git a/cactus-code/packages/coding-agent/src/utils/clipboard-native.ts b/cactus-code/packages/coding-agent/src/utils/clipboard-native.ts new file mode 100644 index 000000000..f8aeb5472 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/clipboard-native.ts @@ -0,0 +1,32 @@ +import { createRequire } from "module"; +import { dirname, join } from "path"; +import { pathToFileURL } from "url"; + +export type ClipboardModule = { + setText: (text: string) => Promise; + hasImage: () => boolean; + getImageBinary: () => Promise>; +}; + +type ClipboardRequire = (id: string) => unknown; + +const moduleRequire = createRequire(import.meta.url); +const executableDirRequire = createRequire(pathToFileURL(join(dirname(process.execPath), "package.json")).href); +const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY); + +export function loadClipboardNative( + requires: readonly ClipboardRequire[] = [moduleRequire, executableDirRequire], +): ClipboardModule | null { + for (const requireClipboard of requires) { + try { + return requireClipboard("@mariozechner/clipboard") as ClipboardModule; + } catch { + // Try the next resolution root. + } + } + return null; +} + +const clipboard = !process.env.TERMUX_VERSION && hasDisplay ? loadClipboardNative() : null; + +export { clipboard }; diff --git a/cactus-code/packages/coding-agent/src/utils/clipboard.ts b/cactus-code/packages/coding-agent/src/utils/clipboard.ts new file mode 100644 index 000000000..26e1d018d --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/clipboard.ts @@ -0,0 +1,127 @@ +import { execSync, spawn } from "child_process"; +import { platform } from "os"; +import { isWaylandSession } from "./clipboard-image.ts"; +import { clipboard } from "./clipboard-native.ts"; + +type NativeClipboardExecOptions = { + input: string; + timeout: number; + stdio: ["pipe", "ignore", "ignore"]; +}; + +function copyToX11Clipboard(options: NativeClipboardExecOptions): void { + try { + execSync("xclip -selection clipboard", options); + } catch { + execSync("xsel --clipboard --input", options); + } +} + +const MAX_OSC52_ENCODED_LENGTH = 100_000; + +function isRemoteSession(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION); +} + +function emitOsc52(text: string): boolean { + const encoded = Buffer.from(text).toString("base64"); + if (encoded.length > MAX_OSC52_ENCODED_LENGTH) { + return false; + } + process.stdout.write(`\x1b]52;c;${encoded}\x07`); + return true; +} + +export async function copyToClipboard(text: string): Promise { + let copied = false; + + const p = platform(); + + // Prefer direct clipboard writes. Emitting OSC 52 first can make terminals + // write the same native clipboard concurrently with the addon, and very large + // OSC 52 payloads can desynchronize terminal rendering. + // + // On Linux, skip the native addon. The underlying `clipboard-rs` crate is + // X11-only and does not retain selection ownership after `set_text` + // resolves, so on Wayland-only compositors (Hyprland, Niri, ...) and even + // some X11 sessions the call resolves successfully without populating the + // clipboard. The platform tools below (wl-copy, xclip, xsel) properly + // daemonize and keep ownership. + try { + if (clipboard && p !== "linux") { + await clipboard.setText(text); + copied = true; + } + } catch { + // Fall through to platform-specific clipboard tools. + } + + const remote = isRemoteSession(); + if (copied && !remote) { + return; + } + + const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] }; + + if (!copied) { + try { + if (p === "darwin") { + execSync("pbcopy", options); + copied = true; + } else if (p === "win32") { + execSync("clip", options); + copied = true; + } else { + // Linux. Try Termux, Wayland, or X11 clipboard tools. + if (process.env.TERMUX_VERSION) { + try { + execSync("termux-clipboard-set", options); + copied = true; + } catch { + // Fall back to Wayland or X11 tools. + } + } + + if (!copied) { + const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY); + const hasX11Display = Boolean(process.env.DISPLAY); + const isWayland = isWaylandSession(); + if (isWayland && hasWaylandDisplay) { + try { + // Verify wl-copy exists (spawn errors are async and won't be caught) + execSync("which wl-copy", { stdio: "ignore" }); + // wl-copy with execSync hangs due to fork behavior; use spawn instead + const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] }); + proc.stdin.on("error", () => { + // Ignore EPIPE errors if wl-copy exits early + }); + proc.stdin.write(text); + proc.stdin.end(); + proc.unref(); + copied = true; + } catch { + if (hasX11Display) { + copyToX11Clipboard(options); + copied = true; + } + } + } else if (hasX11Display) { + copyToX11Clipboard(options); + copied = true; + } + } + } + } catch { + // Fall through to OSC 52 fallback. + } + } + + if (remote || !copied) { + const osc52Copied = emitOsc52(text); + copied = copied || osc52Copied; + } + + if (!copied) { + throw new Error("Failed to copy to clipboard"); + } +} diff --git a/cactus-code/packages/coding-agent/src/utils/deprecation.ts b/cactus-code/packages/coding-agent/src/utils/deprecation.ts new file mode 100644 index 000000000..78a2f1469 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/deprecation.ts @@ -0,0 +1,14 @@ +import chalk from "chalk"; + +const emittedDeprecationWarnings = new Set(); + +export function warnDeprecation(message: string): void { + if (emittedDeprecationWarnings.has(message)) return; + emittedDeprecationWarnings.add(message); + console.warn(chalk.yellow(`Deprecation warning: ${message}`)); +} + +/** Clear deprecation warning state. Exported for tests. */ +export function clearDeprecationWarningsForTests(): void { + emittedDeprecationWarnings.clear(); +} diff --git a/cactus-code/packages/coding-agent/src/utils/exif-orientation.ts b/cactus-code/packages/coding-agent/src/utils/exif-orientation.ts new file mode 100644 index 000000000..4b454afaa --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/exif-orientation.ts @@ -0,0 +1,183 @@ +import type { PhotonImageType } from "./photon.ts"; + +type Photon = typeof import("@silvia-odwyer/photon-node"); + +function readOrientationFromTiff(bytes: Uint8Array, tiffStart: number): number { + if (tiffStart + 8 > bytes.length) return 1; + + const byteOrder = (bytes[tiffStart] << 8) | bytes[tiffStart + 1]; + const le = byteOrder === 0x4949; + + const read16 = (pos: number): number => { + if (le) return bytes[pos] | (bytes[pos + 1] << 8); + return (bytes[pos] << 8) | bytes[pos + 1]; + }; + + const read32 = (pos: number): number => { + if (le) return bytes[pos] | (bytes[pos + 1] << 8) | (bytes[pos + 2] << 16) | (bytes[pos + 3] << 24); + return ((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0; + }; + + const ifdOffset = read32(tiffStart + 4); + const ifdStart = tiffStart + ifdOffset; + if (ifdStart + 2 > bytes.length) return 1; + + const entryCount = read16(ifdStart); + for (let i = 0; i < entryCount; i++) { + const entryPos = ifdStart + 2 + i * 12; + if (entryPos + 12 > bytes.length) return 1; + + if (read16(entryPos) === 0x0112) { + const value = read16(entryPos + 8); + return value >= 1 && value <= 8 ? value : 1; + } + } + + return 1; +} + +function findJpegTiffOffset(bytes: Uint8Array): number { + let offset = 2; + while (offset < bytes.length - 1) { + if (bytes[offset] !== 0xff) return -1; + const marker = bytes[offset + 1]; + if (marker === 0xff) { + offset++; + continue; + } + + if (marker === 0xe1) { + if (offset + 4 >= bytes.length) return -1; + const segmentStart = offset + 4; + if (segmentStart + 6 > bytes.length) return -1; + if (!hasExifHeader(bytes, segmentStart)) return -1; + return segmentStart + 6; + } + + if (offset + 4 > bytes.length) return -1; + const length = (bytes[offset + 2] << 8) | bytes[offset + 3]; + offset += 2 + length; + } + + return -1; +} + +function findWebpTiffOffset(bytes: Uint8Array): number { + let offset = 12; + while (offset + 8 <= bytes.length) { + const chunkId = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); + const chunkSize = + bytes[offset + 4] | (bytes[offset + 5] << 8) | (bytes[offset + 6] << 16) | (bytes[offset + 7] << 24); + const dataStart = offset + 8; + + if (chunkId === "EXIF") { + if (dataStart + chunkSize > bytes.length) return -1; + // Some WebP files have "Exif\0\0" prefix before the TIFF header + const tiffStart = chunkSize >= 6 && hasExifHeader(bytes, dataStart) ? dataStart + 6 : dataStart; + return tiffStart; + } + + // RIFF chunks are padded to even size + offset = dataStart + chunkSize + (chunkSize % 2); + } + + return -1; +} + +function hasExifHeader(bytes: Uint8Array, offset: number): boolean { + return ( + bytes[offset] === 0x45 && + bytes[offset + 1] === 0x78 && + bytes[offset + 2] === 0x69 && + bytes[offset + 3] === 0x66 && + bytes[offset + 4] === 0x00 && + bytes[offset + 5] === 0x00 + ); +} + +function getExifOrientation(bytes: Uint8Array): number { + let tiffOffset = -1; + + // JPEG: starts with FF D8 + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) { + tiffOffset = findJpegTiffOffset(bytes); + } + // WebP: starts with RIFF....WEBP + else if ( + bytes.length >= 12 && + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + tiffOffset = findWebpTiffOffset(bytes); + } + + if (tiffOffset === -1) return 1; + return readOrientationFromTiff(bytes, tiffOffset); +} + +type DstIndexFn = (x: number, y: number, w: number, h: number) => number; + +function rotate90(photon: Photon, image: PhotonImageType, dstIndex: DstIndexFn): PhotonImageType { + const w = image.get_width(); + const h = image.get_height(); + const src = image.get_raw_pixels(); + const dst = new Uint8Array(src.length); + + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const srcIdx = (y * w + x) * 4; + const dstIdx = dstIndex(x, y, w, h) * 4; + dst[dstIdx] = src[srcIdx]; + dst[dstIdx + 1] = src[srcIdx + 1]; + dst[dstIdx + 2] = src[srcIdx + 2]; + dst[dstIdx + 3] = src[srcIdx + 3]; + } + } + + return new photon.PhotonImage(dst, h, w); +} + +// Flip orientations mutate in-place. Rotations return a new image (caller must free the old one if different). +export function applyExifOrientation( + photon: Photon, + image: PhotonImageType, + originalBytes: Uint8Array, +): PhotonImageType { + const orientation = getExifOrientation(originalBytes); + if (orientation === 1) return image; + + switch (orientation) { + case 2: + photon.fliph(image); + return image; + case 3: + photon.fliph(image); + photon.flipv(image); + return image; + case 4: + photon.flipv(image); + return image; + case 5: { + const rotated = rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y)); + photon.fliph(rotated); + return rotated; + } + case 6: + return rotate90(photon, image, (x, y, _w, h) => x * h + (h - 1 - y)); + case 7: { + const rotated = rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y); + photon.fliph(rotated); + return rotated; + } + case 8: + return rotate90(photon, image, (x, y, w, h) => (w - 1 - x) * h + y); + default: + return image; + } +} diff --git a/cactus-code/packages/coding-agent/src/utils/frontmatter.ts b/cactus-code/packages/coding-agent/src/utils/frontmatter.ts new file mode 100644 index 000000000..847e2e539 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/frontmatter.ts @@ -0,0 +1,39 @@ +import { parse } from "yaml"; + +type ParsedFrontmatter> = { + frontmatter: T; + body: string; +}; + +const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + +const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => { + const normalized = normalizeNewlines(content); + + if (!normalized.startsWith("---")) { + return { yamlString: null, body: normalized }; + } + + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { yamlString: null, body: normalized }; + } + + return { + yamlString: normalized.slice(4, endIndex), + body: normalized.slice(endIndex + 4).trim(), + }; +}; + +export const parseFrontmatter = = Record>( + content: string, +): ParsedFrontmatter => { + const { yamlString, body } = extractFrontmatter(content); + if (!yamlString) { + return { frontmatter: {} as T, body }; + } + const parsed = parse(yamlString); + return { frontmatter: (parsed ?? {}) as T, body }; +}; + +export const stripFrontmatter = (content: string): string => parseFrontmatter(content).body; diff --git a/cactus-code/packages/coding-agent/src/utils/fs-watch.ts b/cactus-code/packages/coding-agent/src/utils/fs-watch.ts new file mode 100644 index 000000000..daaf80900 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/fs-watch.ts @@ -0,0 +1,30 @@ +import { type FSWatcher, type WatchListener, watch } from "node:fs"; + +export const FS_WATCH_RETRY_DELAY_MS = 5000; + +export function closeWatcher(watcher: FSWatcher | null | undefined): void { + if (!watcher) { + return; + } + + try { + watcher.close(); + } catch { + // Ignore watcher close errors + } +} + +export function watchWithErrorHandler( + path: string, + listener: WatchListener, + onError: () => void, +): FSWatcher | null { + try { + const watcher = watch(path, listener); + watcher.on("error", onError); + return watcher; + } catch { + onError(); + return null; + } +} diff --git a/cactus-code/packages/coding-agent/src/utils/git.ts b/cactus-code/packages/coding-agent/src/utils/git.ts new file mode 100644 index 000000000..1314edea7 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/git.ts @@ -0,0 +1,226 @@ +import hostedGitInfo from "hosted-git-info"; + +/** + * Parsed git URL information. + */ +export type GitSource = { + /** Always "git" for git sources */ + type: "git"; + /** Clone URL (always valid for git clone, without ref suffix) */ + repo: string; + /** Git host domain (e.g., "github.com") */ + host: string; + /** Repository path (e.g., "user/repo") */ + path: string; + /** Git ref (branch, tag, commit) if specified */ + ref?: string; + /** True if ref was specified (package won't be auto-updated) */ + pinned: boolean; +}; + +function splitRef(url: string): { repo: string; ref?: string } { + const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/); + if (scpLikeMatch) { + const pathWithMaybeRef = scpLikeMatch[2] ?? ""; + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) return { repo: url }; + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) return { repo: url }; + return { + repo: `git@${scpLikeMatch[1] ?? ""}:${repoPath}`, + ref, + }; + } + + if (url.includes("://")) { + try { + const parsed = new URL(url); + const pathWithMaybeRef = parsed.pathname.replace(/^\/+/, ""); + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) return { repo: url }; + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) return { repo: url }; + parsed.pathname = `/${repoPath}`; + return { + repo: parsed.toString().replace(/\/$/, ""), + ref, + }; + } catch { + return { repo: url }; + } + } + + const slashIndex = url.indexOf("/"); + if (slashIndex < 0) { + return { repo: url }; + } + const host = url.slice(0, slashIndex); + const pathWithMaybeRef = url.slice(slashIndex + 1); + const refSeparator = pathWithMaybeRef.indexOf("@"); + if (refSeparator < 0) { + return { repo: url }; + } + const repoPath = pathWithMaybeRef.slice(0, refSeparator); + const ref = pathWithMaybeRef.slice(refSeparator + 1); + if (!repoPath || !ref) { + return { repo: url }; + } + return { + repo: `${host}/${repoPath}`, + ref, + }; +} + +function decodeForValidation(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} + +function hasUnsafeGitInstallPart(value: string, allowSlash: boolean): boolean { + const decoded = decodeForValidation(value); + if (decoded === null) { + return true; + } + const candidates = [value, decoded]; + for (const candidate of candidates) { + if (candidate.includes("\0") || candidate.includes("\\") || candidate.startsWith("/")) { + return true; + } + if (!allowSlash && candidate.includes("/")) { + return true; + } + if (candidate.split("/").includes("..")) { + return true; + } + } + return false; +} + +function buildGitSource(args: { repo: string; host: string; path: string; ref?: string }): GitSource | null { + if (args.path.startsWith("/")) { + return null; + } + const normalizedPath = args.path.replace(/\.git$/, "").replace(/^\/+/, ""); + if (!args.host || !normalizedPath || normalizedPath.split("/").length < 2) { + return null; + } + if (hasUnsafeGitInstallPart(args.host, false) || hasUnsafeGitInstallPart(normalizedPath, true)) { + return null; + } + + return { + type: "git", + repo: args.repo, + host: args.host, + path: normalizedPath, + ref: args.ref, + pinned: Boolean(args.ref), + }; +} + +function parseGenericGitUrl(url: string): GitSource | null { + const { repo: repoWithoutRef, ref } = splitRef(url); + let repo = repoWithoutRef; + let host = ""; + let path = ""; + + const scpLikeMatch = repoWithoutRef.match(/^git@([^:]+):(.+)$/); + if (scpLikeMatch) { + host = scpLikeMatch[1] ?? ""; + path = scpLikeMatch[2] ?? ""; + } else if ( + repoWithoutRef.startsWith("https://") || + repoWithoutRef.startsWith("http://") || + repoWithoutRef.startsWith("ssh://") || + repoWithoutRef.startsWith("git://") + ) { + try { + const parsed = new URL(repoWithoutRef); + host = parsed.hostname; + path = parsed.pathname.replace(/^\/+/, ""); + } catch { + return null; + } + } else { + const slashIndex = repoWithoutRef.indexOf("/"); + if (slashIndex < 0) { + return null; + } + host = repoWithoutRef.slice(0, slashIndex); + path = repoWithoutRef.slice(slashIndex + 1); + if (!host.includes(".") && host !== "localhost") { + return null; + } + repo = `https://${repoWithoutRef}`; + } + + return buildGitSource({ repo, host, path, ref }); +} + +/** + * Parse git source into a GitSource. + * + * Rules: + * - With git: prefix, accept all historical shorthand forms. + * - Without git: prefix, only accept explicit protocol URLs. + */ +export function parseGitUrl(source: string): GitSource | null { + const trimmed = source.trim(); + const hasGitPrefix = trimmed.startsWith("git:"); + const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed; + + if (!hasGitPrefix && !/^(https?|ssh|git):\/\//i.test(url)) { + return null; + } + + const split = splitRef(url); + + const hostedCandidates = [split.ref ? `${split.repo}#${split.ref}` : undefined, url].filter( + (value): value is string => Boolean(value), + ); + for (const candidate of hostedCandidates) { + const info = hostedGitInfo.fromUrl(candidate); + if (info) { + if (split.ref && info.project?.includes("@")) { + continue; + } + const useHttpsPrefix = + !split.repo.startsWith("http://") && + !split.repo.startsWith("https://") && + !split.repo.startsWith("ssh://") && + !split.repo.startsWith("git://") && + !split.repo.startsWith("git@"); + return buildGitSource({ + repo: useHttpsPrefix ? `https://${split.repo}` : split.repo, + host: info.domain || "", + path: `${info.user}/${info.project}`, + ref: info.committish || split.ref || undefined, + }); + } + } + + const httpsCandidates = [split.ref ? `https://${split.repo}#${split.ref}` : undefined, `https://${url}`].filter( + (value): value is string => Boolean(value), + ); + for (const candidate of httpsCandidates) { + const info = hostedGitInfo.fromUrl(candidate); + if (info) { + if (split.ref && info.project?.includes("@")) { + continue; + } + return buildGitSource({ + repo: `https://${split.repo}`, + host: info.domain || "", + path: `${info.user}/${info.project}`, + ref: info.committish || split.ref || undefined, + }); + } + } + + return parseGenericGitUrl(url); +} diff --git a/cactus-code/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts b/cactus-code/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts new file mode 100644 index 000000000..75e31da28 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts @@ -0,0 +1,19 @@ +declare module "highlight.js/lib/index.js" { + interface HighlightResult { + value: string; + } + + interface HighlightOptions { + language: string; + ignoreIllegals?: boolean; + } + + interface HighlightJs { + highlight(code: string, options: HighlightOptions): HighlightResult; + highlightAuto(code: string, languageSubset?: string[]): HighlightResult; + getLanguage(name: string): unknown; + } + + const hljs: HighlightJs; + export default hljs; +} diff --git a/cactus-code/packages/coding-agent/src/utils/html.ts b/cactus-code/packages/coding-agent/src/utils/html.ts new file mode 100644 index 000000000..a13ad46f4 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/html.ts @@ -0,0 +1,51 @@ +export interface DecodedHtmlEntity { + text: string; + length: number; +} + +function decodeCodePoint(codePoint: number): string | undefined { + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return undefined; + } + return String.fromCodePoint(codePoint); +} + +export function decodeHtmlEntity(entity: string): string | undefined { + switch (entity) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return '"'; + case "apos": + return "'"; + } + + if (entity.startsWith("#x") || entity.startsWith("#X")) { + return decodeCodePoint(Number.parseInt(entity.slice(2), 16)); + } + + if (entity.startsWith("#")) { + return decodeCodePoint(Number.parseInt(entity.slice(1), 10)); + } + + return undefined; +} + +export function decodeHtmlEntityAt(html: string, index: number): DecodedHtmlEntity | undefined { + const semicolonIndex = html.indexOf(";", index + 1); + if (semicolonIndex === -1 || semicolonIndex - index > 16) { + return undefined; + } + + const entity = html.slice(index + 1, semicolonIndex); + const decoded = decodeHtmlEntity(entity); + if (decoded === undefined) { + return undefined; + } + + return { text: decoded, length: semicolonIndex - index + 1 }; +} diff --git a/cactus-code/packages/coding-agent/src/utils/image-convert.ts b/cactus-code/packages/coding-agent/src/utils/image-convert.ts new file mode 100644 index 000000000..4d5f0204a --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/image-convert.ts @@ -0,0 +1,41 @@ +import { applyExifOrientation } from "./exif-orientation.ts"; +import { loadPhoton } from "./photon.ts"; + +/** + * Convert image to PNG format for terminal display. + * Kitty graphics protocol requires PNG format (f=100). + */ +export async function convertToPng( + base64Data: string, + mimeType: string, +): Promise<{ data: string; mimeType: string } | null> { + // Already PNG, no conversion needed + if (mimeType === "image/png") { + return { data: base64Data, mimeType }; + } + + const photon = await loadPhoton(); + if (!photon) { + // Photon not available, can't convert + return null; + } + + try { + const bytes = new Uint8Array(Buffer.from(base64Data, "base64")); + const rawImage = photon.PhotonImage.new_from_byteslice(bytes); + const image = applyExifOrientation(photon, rawImage, bytes); + if (image !== rawImage) rawImage.free(); + try { + const pngBuffer = image.get_bytes(); + return { + data: Buffer.from(pngBuffer).toString("base64"), + mimeType: "image/png", + }; + } finally { + image.free(); + } + } catch { + // Conversion failed + return null; + } +} diff --git a/cactus-code/packages/coding-agent/src/utils/image-resize-core.ts b/cactus-code/packages/coding-agent/src/utils/image-resize-core.ts new file mode 100644 index 000000000..e60820c7b --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/image-resize-core.ts @@ -0,0 +1,164 @@ +import { applyExifOrientation } from "./exif-orientation.ts"; +import { loadPhoton } from "./photon.ts"; + +export interface ImageResizeOptions { + maxWidth?: number; // Default: 2000 + maxHeight?: number; // Default: 2000 + maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit) + jpegQuality?: number; // Default: 80 +} + +export interface ResizedImage { + data: string; // base64 + mimeType: string; + originalWidth: number; + originalHeight: number; + width: number; + height: number; + wasResized: boolean; +} + +// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit. +const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024; + +const DEFAULT_OPTIONS: Required = { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: DEFAULT_MAX_BYTES, + jpegQuality: 80, +}; + +interface EncodedCandidate { + data: string; + encodedSize: number; + mimeType: string; +} + +function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate { + const data = Buffer.from(buffer).toString("base64"); + return { + data, + encodedSize: Buffer.byteLength(data, "utf-8"), + mimeType, + }; +} + +/** + * Resize an image to fit within the specified max dimensions and encoded file size. + * Returns null if the image cannot be resized below maxBytes. + * + * Uses Photon (Rust/WASM) for image processing. If Photon is not available, + * returns null. + * + * Strategy for staying under maxBytes: + * 1. First resize to maxWidth/maxHeight + * 2. Try both PNG and JPEG formats, pick the smaller one + * 3. If still too large, try JPEG with decreasing quality + * 4. If still too large, progressively reduce dimensions until 1x1 + */ +export async function resizeImageInProcess( + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4; + + const photon = await loadPhoton(); + if (!photon) { + return null; + } + + let image: ReturnType | undefined; + try { + const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes); + image = applyExifOrientation(photon, rawImage, inputBytes); + if (image !== rawImage) rawImage.free(); + + const originalWidth = image.get_width(); + const originalHeight = image.get_height(); + const format = mimeType.split("/")[1] ?? "png"; + + // Check if already within all limits (dimensions AND encoded size) + if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) { + return { + data: Buffer.from(inputBytes).toString("base64"), + mimeType: mimeType || `image/${format}`, + originalWidth, + originalHeight, + width: originalWidth, + height: originalHeight, + wasResized: false, + }; + } + + // Calculate initial dimensions respecting max limits + let targetWidth = originalWidth; + let targetHeight = originalHeight; + + if (targetWidth > opts.maxWidth) { + targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth); + targetWidth = opts.maxWidth; + } + if (targetHeight > opts.maxHeight) { + targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight); + targetHeight = opts.maxHeight; + } + + function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] { + const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3); + + try { + const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")]; + for (const quality of jpegQualities) { + candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg")); + } + return candidates; + } finally { + resized.free(); + } + } + + const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40])); + let currentWidth = targetWidth; + let currentHeight = targetHeight; + + while (true) { + const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps); + for (const candidate of candidates) { + if (candidate.encodedSize < opts.maxBytes) { + return { + data: candidate.data, + mimeType: candidate.mimeType, + originalWidth, + originalHeight, + width: currentWidth, + height: currentHeight, + wasResized: true, + }; + } + } + + if (currentWidth === 1 && currentHeight === 1) { + break; + } + + const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75)); + const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75)); + if (nextWidth === currentWidth && nextHeight === currentHeight) { + break; + } + + currentWidth = nextWidth; + currentHeight = nextHeight; + } + + return null; + } catch { + return null; + } finally { + if (image) { + image.free(); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/utils/image-resize-worker.ts b/cactus-code/packages/coding-agent/src/utils/image-resize-worker.ts new file mode 100644 index 000000000..ee881b3d9 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/image-resize-worker.ts @@ -0,0 +1,42 @@ +import { parentPort } from "node:worker_threads"; +import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts"; + +interface ResizeImageWorkerRequest { + inputBytes: Uint8Array; + mimeType: string; + options?: ImageResizeOptions; +} + +interface ResizeImageWorkerResponse { + result?: ResizedImage | null; + error?: string; +} + +function isResizeImageWorkerRequest(value: unknown): value is ResizeImageWorkerRequest { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string"; +} + +const port = parentPort; +if (!port) { + throw new Error("image resize worker requires parentPort"); +} + +port.once("message", (message: unknown) => { + void (async () => { + try { + if (!isResizeImageWorkerRequest(message)) { + throw new Error("Invalid image resize worker request"); + } + const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options); + const response: ResizeImageWorkerResponse = { result }; + port.postMessage(response); + } catch (error) { + const response: ResizeImageWorkerResponse = { + error: error instanceof Error ? error.message : String(error), + }; + port.postMessage(response); + } + })(); +}); diff --git a/cactus-code/packages/coding-agent/src/utils/image-resize.ts b/cactus-code/packages/coding-agent/src/utils/image-resize.ts new file mode 100644 index 000000000..516a1e57a --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/image-resize.ts @@ -0,0 +1,123 @@ +import { Worker } from "node:worker_threads"; +import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts"; + +export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts"; + +interface ResizeImageWorkerResponse { + result?: ResizedImage | null; + error?: string; +} + +function toTransferableBytes(input: Uint8Array): Uint8Array { + // Transfer detaches the buffer, so transfer a worker-owned copy and leave the + // caller's bytes intact. + return new Uint8Array(input); +} + +function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse { + return value !== null && typeof value === "object"; +} + +function createResizeWorker(workerSpecifier: string | URL): Worker { + return new Worker(workerSpecifier); +} + +async function resizeImageInWorker( + workerSpecifier: string | URL, + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + const worker = createResizeWorker(workerSpecifier); + try { + const inputBytesForWorker = toTransferableBytes(inputBytes); + return await new Promise((resolve, reject) => { + let settled = false; + const settle = (result: ResizedImage | null): void => { + if (settled) return; + settled = true; + resolve(result); + }; + const fail = (error: Error): void => { + if (settled) return; + settled = true; + reject(error); + }; + + worker.once("message", (message: unknown) => { + if (!isResizeImageWorkerResponse(message)) { + fail(new Error("Invalid image resize worker response")); + return; + } + if (message.error) { + fail(new Error(message.error)); + return; + } + settle(message.result ?? null); + }); + worker.once("error", fail); + worker.once("exit", (code) => { + if (!settled) { + fail(new Error(`Image resize worker exited with code ${code}`)); + } + }); + worker.postMessage( + { + inputBytes: inputBytesForWorker, + mimeType, + options, + }, + [inputBytesForWorker.buffer], + ); + }); + } finally { + void worker.terminate().catch(() => undefined); + } +} + +/** + * Resize an image to fit within the specified max dimensions and encoded file size. + * Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not + * block the TUI event loop. If the worker cannot be loaded (for example in some + * Bun compiled executable layouts), fall back to in-process resizing so image + * reads still work. + */ +export async function resizeImage( + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + const isTypeScriptRuntime = import.meta.url.endsWith(".ts"); + const workerUrl = new URL( + isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js", + import.meta.url, + ); + + // Bun compiled executables resolve worker entrypoints by string path, not via + // new URL(..., import.meta.url). Try the string path first under Bun so the + // release binary uses the embedded worker instead of falling back in-process. + if (typeof process.versions.bun === "string") { + try { + return await resizeImageInWorker("./src/utils/image-resize-worker.ts", inputBytes, mimeType, options); + } catch {} + } + + try { + return await resizeImageInWorker(workerUrl, inputBytes, mimeType, options); + } catch { + return resizeImageInProcess(inputBytes, mimeType, options); + } +} + +/** + * Format a dimension note for resized images. + * This helps the model understand the coordinate mapping. + */ +export function formatDimensionNote(result: ResizedImage): string | undefined { + if (!result.wasResized) { + return undefined; + } + + const scale = result.originalWidth / result.width; + return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`; +} diff --git a/cactus-code/packages/coding-agent/src/utils/json.ts b/cactus-code/packages/coding-agent/src/utils/json.ts new file mode 100644 index 000000000..9ee7b7b1f --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/json.ts @@ -0,0 +1,6 @@ +/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */ +export function stripJsonComments(input: string): string { + return input + .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : "")) + .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : "")); +} diff --git a/cactus-code/packages/coding-agent/src/utils/mime.ts b/cactus-code/packages/coding-agent/src/utils/mime.ts new file mode 100644 index 000000000..7381d3703 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/mime.ts @@ -0,0 +1,74 @@ +import { open } from "node:fs/promises"; + +const IMAGE_TYPE_SNIFF_BYTES = 4100; +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +export function detectSupportedImageMimeType(buffer: Uint8Array): string | null { + if (startsWith(buffer, [0xff, 0xd8, 0xff])) { + return buffer[3] === 0xf7 ? null : "image/jpeg"; + } + if (startsWith(buffer, PNG_SIGNATURE)) { + return isPng(buffer) && !isAnimatedPng(buffer) ? "image/png" : null; + } + if (startsWithAscii(buffer, 0, "GIF")) { + return "image/gif"; + } + if (startsWithAscii(buffer, 0, "RIFF") && startsWithAscii(buffer, 8, "WEBP")) { + return "image/webp"; + } + return null; +} + +export async function detectSupportedImageMimeTypeFromFile(filePath: string): Promise { + const fileHandle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(IMAGE_TYPE_SNIFF_BYTES); + const { bytesRead } = await fileHandle.read(buffer, 0, IMAGE_TYPE_SNIFF_BYTES, 0); + return detectSupportedImageMimeType(buffer.subarray(0, bytesRead)); + } finally { + await fileHandle.close(); + } +} + +function isPng(buffer: Uint8Array): boolean { + return ( + buffer.length >= 16 && readUint32BE(buffer, PNG_SIGNATURE.length) === 13 && startsWithAscii(buffer, 12, "IHDR") + ); +} + +function isAnimatedPng(buffer: Uint8Array): boolean { + let offset = PNG_SIGNATURE.length; + while (offset + 8 <= buffer.length) { + const chunkLength = readUint32BE(buffer, offset); + const chunkTypeOffset = offset + 4; + if (startsWithAscii(buffer, chunkTypeOffset, "acTL")) return true; + if (startsWithAscii(buffer, chunkTypeOffset, "IDAT")) return false; + + const nextOffset = offset + 8 + chunkLength + 4; + if (nextOffset <= offset || nextOffset > buffer.length) return false; + offset = nextOffset; + } + return false; +} + +function readUint32BE(buffer: Uint8Array, offset: number): number { + return ( + (buffer[offset] ?? 0) * 0x1000000 + + ((buffer[offset + 1] ?? 0) << 16) + + ((buffer[offset + 2] ?? 0) << 8) + + (buffer[offset + 3] ?? 0) + ); +} + +function startsWith(buffer: Uint8Array, bytes: number[]): boolean { + if (buffer.length < bytes.length) return false; + return bytes.every((byte, index) => buffer[index] === byte); +} + +function startsWithAscii(buffer: Uint8Array, offset: number, text: string): boolean { + if (buffer.length < offset + text.length) return false; + for (let index = 0; index < text.length; index++) { + if (buffer[offset + index] !== text.charCodeAt(index)) return false; + } + return true; +} diff --git a/cactus-code/packages/coding-agent/src/utils/open-browser.ts b/cactus-code/packages/coding-agent/src/utils/open-browser.ts new file mode 100644 index 000000000..435e23f97 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/open-browser.ts @@ -0,0 +1,24 @@ +import { spawn } from "node:child_process"; + +/** + * Open a URL or file in the platform browser/default handler. + * + * This intentionally never invokes a shell. On Windows, do not use + * `cmd /c start`: cmd.exe re-parses metacharacters (&, |, ^, ...) before + * `start` runs, which would make attacker-controlled URLs injectable. + */ +export function openBrowser(target: string): void { + const [cmd, args]: [string, string[]] = + process.platform === "darwin" + ? ["open", [target]] + : process.platform === "win32" + ? ["rundll32", ["url.dll,FileProtocolHandler", target]] + : ["xdg-open", [target]]; + + // spawn reports launcher failures (for example, missing xdg-open) via an + // error event. Browser launch is best-effort: callers still present the target + // to the user, so keep the launcher failure from becoming a process crash. + spawn(cmd, args, { stdio: "ignore", detached: true }) + .on("error", () => {}) + .unref(); +} diff --git a/cactus-code/packages/coding-agent/src/utils/paths.ts b/cactus-code/packages/coding-agent/src/utils/paths.ts new file mode 100644 index 000000000..7f1013446 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/paths.ts @@ -0,0 +1,118 @@ +import { realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve as nodeResolvePath, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnProcessSync } from "./child-process.ts"; + +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; + +export interface PathInputOptions { + /** Trim leading/trailing whitespace before normalization. */ + trim?: boolean; + /** Expand leading `~` to a home directory. Defaults to true. */ + expandTilde?: boolean; + /** Home directory used for `~` expansion. Defaults to `os.homedir()`. */ + homeDir?: string; + /** Strip a leading `@`, used for CLI @file paths. */ + stripAtPrefix?: boolean; + /** Normalize unicode space variants to regular spaces. */ + normalizeUnicodeSpaces?: boolean; +} + +/** + * Resolve a path to its canonical (real) form, following symlinks. + * Falls back to the raw path if resolution fails (e.g. the target does + * not exist yet), so that callers never crash on missing filesystem + * entries. + */ +export function canonicalizePath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +/** + * Returns true if the value is NOT a package source (npm:, git:, etc.) + * or a remote URL protocol. Bare names, relative paths, and file: URLs + * are considered local. + */ +export function isLocalPath(value: string): boolean { + const trimmed = value.trim(); + // Known non-local prefixes. file: URLs are local paths and are intentionally resolved by resolvePath(). + if ( + trimmed.startsWith("npm:") || + trimmed.startsWith("git:") || + trimmed.startsWith("github:") || + trimmed.startsWith("http:") || + trimmed.startsWith("https:") || + trimmed.startsWith("ssh:") + ) { + return false; + } + return true; +} + +export function normalizePath(input: string, options: PathInputOptions = {}): string { + let normalized = options.trim ? input.trim() : input; + if (options.normalizeUnicodeSpaces) { + normalized = normalized.replace(UNICODE_SPACES, " "); + } + if (options.stripAtPrefix && normalized.startsWith("@")) { + normalized = normalized.slice(1); + } + + if (options.expandTilde ?? true) { + const home = options.homeDir ?? homedir(); + if (normalized === "~") return home; + if (normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))) { + return join(home, normalized.slice(2)); + } + } + + if (/^file:\/\//.test(normalized)) { + return fileURLToPath(normalized); + } + + return normalized; +} + +export function resolvePath(input: string, baseDir: string = process.cwd(), options: PathInputOptions = {}): string { + const normalized = normalizePath(input, options); + const normalizedBaseDir = normalizePath(baseDir); + return isAbsolute(normalized) ? nodeResolvePath(normalized) : nodeResolvePath(normalizedBaseDir, normalized); +} + +export function getCwdRelativePath(filePath: string, cwd: string): string | undefined { + const resolvedCwd = resolvePath(cwd); + const resolvedPath = resolvePath(filePath, resolvedCwd); + const relativePath = relative(resolvedCwd, resolvedPath); + const isInsideCwd = + relativePath === "" || + (relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)); + + return isInsideCwd ? relativePath || "." : undefined; +} + +export function formatPathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string { + const absolutePath = resolvePath(filePath, cwd); + return (getCwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join("/"); +} + +export function markPathIgnoredByCloudSync(path: string): void { + const attrs = + process.platform === "darwin" + ? ["com.dropbox.ignored", "com.apple.fileprovider.ignore#P"] + : process.platform === "linux" + ? ["user.com.dropbox.ignored"] + : []; + + for (const attr of attrs) { + if (process.platform === "darwin") { + spawnProcessSync("xattr", ["-w", attr, "1", path], { encoding: "utf-8", stdio: "ignore" }); + } else { + spawnProcessSync("setfattr", ["-n", attr, "-v", "1", path], { encoding: "utf-8", stdio: "ignore" }); + } + } +} diff --git a/cactus-code/packages/coding-agent/src/utils/photon.ts b/cactus-code/packages/coding-agent/src/utils/photon.ts new file mode 100644 index 000000000..6c320705e --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/photon.ts @@ -0,0 +1,139 @@ +/** + * Photon image processing wrapper. + * + * This module provides a unified interface to @silvia-odwyer/photon-node that works in: + * 1. Node.js (development, npm run build) + * 2. Bun compiled binaries (standalone distribution) + * + * The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm') + * which bakes the build machine's absolute path into Bun compiled binaries. + * + * Solution: + * 1. Patch fs.readFileSync to redirect missing photon_rs_bg.wasm reads + * 2. Copy photon_rs_bg.wasm next to the executable in build:binary + */ + +import type { PathOrFileDescriptor } from "fs"; +import { createRequire } from "module"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +const require = createRequire(import.meta.url); +const fs = require("fs") as typeof import("fs"); + +// Re-export types from the main package +export type { PhotonImage as PhotonImageType } from "@silvia-odwyer/photon-node"; + +type ReadFileSync = typeof fs.readFileSync; + +const WASM_FILENAME = "photon_rs_bg.wasm"; + +// Lazy-loaded photon module +let photonModule: typeof import("@silvia-odwyer/photon-node") | null = null; +let loadPromise: Promise | null = null; + +function pathOrNull(file: PathOrFileDescriptor): string | null { + if (typeof file === "string") { + return file; + } + if (file instanceof URL) { + return fileURLToPath(file); + } + return null; +} + +function getFallbackWasmPaths(): string[] { + const execDir = path.dirname(process.execPath); + return [ + path.join(execDir, WASM_FILENAME), + path.join(execDir, "photon", WASM_FILENAME), + path.join(process.cwd(), WASM_FILENAME), + ]; +} + +function patchPhotonWasmRead(): () => void { + const originalReadFileSync: ReadFileSync = fs.readFileSync.bind(fs); + const fallbackPaths = getFallbackWasmPaths(); + const mutableFs = fs as { readFileSync: ReadFileSync }; + + const patchedReadFileSync: ReadFileSync = ((...args: Parameters) => { + const [file, options] = args; + const resolvedPath = pathOrNull(file); + + if (resolvedPath?.endsWith(WASM_FILENAME)) { + try { + return originalReadFileSync(...args); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err?.code && err.code !== "ENOENT") { + throw error; + } + + for (const fallbackPath of fallbackPaths) { + if (!fs.existsSync(fallbackPath)) { + continue; + } + if (options === undefined) { + return originalReadFileSync(fallbackPath); + } + return originalReadFileSync(fallbackPath, options); + } + + throw error; + } + } + + return originalReadFileSync(...args); + }) as ReadFileSync; + + try { + mutableFs.readFileSync = patchedReadFileSync; + } catch { + Object.defineProperty(fs, "readFileSync", { + value: patchedReadFileSync, + writable: true, + configurable: true, + }); + } + + return () => { + try { + mutableFs.readFileSync = originalReadFileSync; + } catch { + Object.defineProperty(fs, "readFileSync", { + value: originalReadFileSync, + writable: true, + configurable: true, + }); + } + }; +} + +/** + * Load the photon module asynchronously. + * Returns cached module on subsequent calls. + */ +export async function loadPhoton(): Promise { + if (photonModule) { + return photonModule; + } + + if (loadPromise) { + return loadPromise; + } + + loadPromise = (async () => { + const restoreReadFileSync = patchPhotonWasmRead(); + try { + photonModule = await import("@silvia-odwyer/photon-node"); + return photonModule; + } catch { + photonModule = null; + return photonModule; + } finally { + restoreReadFileSync(); + } + })(); + + return loadPromise; +} diff --git a/cactus-code/packages/coding-agent/src/utils/shell.ts b/cactus-code/packages/coding-agent/src/utils/shell.ts new file mode 100644 index 000000000..2cafa595e --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/shell.ts @@ -0,0 +1,225 @@ +import { existsSync } from "node:fs"; +import { delimiter } from "node:path"; +import { spawn, spawnSync } from "child_process"; +import { getBinDir } from "../config.ts"; + +export interface ShellConfig { + shell: string; + args: string[]; + commandTransport?: "argv" | "stdin"; +} + +/** + * Find bash executable on PATH (cross-platform) + */ +function isLegacyWslBashPath(path: string): boolean { + const normalized = path.replace(/\//g, "\\").toLowerCase(); + return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized); +} + +function getBashShellConfig(shell: string): ShellConfig { + return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] }; +} + +function findBashOnPath(): string | null { + if (process.platform === "win32") { + // Windows: Use 'where' and verify file exists (where can return non-existent paths) + try { + const result = spawnSync("where", ["bash.exe"], { + encoding: "utf-8", + timeout: 5000, + windowsHide: true, + }); + if (result.status === 0 && result.stdout) { + const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; + if (firstMatch && existsSync(firstMatch)) { + return firstMatch; + } + } + } catch { + // Ignore errors + } + return null; + } + + // Unix: Use 'which' and trust its output (handles Termux and special filesystems) + try { + const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); + if (result.status === 0 && result.stdout) { + const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; + if (firstMatch) { + return firstMatch; + } + } + } catch { + // Ignore errors + } + return null; +} + +/** + * Resolve shell configuration based on platform and an optional explicit shell path. + * Resolution order: + * 1. User-specified shellPath + * 2. On Windows: Git Bash in known locations, then bash on PATH + * 3. On Unix: /bin/bash, then bash on PATH, then fallback to sh + */ +export function getShellConfig(customShellPath?: string): ShellConfig { + // 1. Check user-specified shell path + if (customShellPath) { + if (existsSync(customShellPath)) { + return getBashShellConfig(customShellPath); + } + throw new Error(`Custom shell path not found: ${customShellPath}`); + } + + if (process.platform === "win32") { + // 2. Try Git Bash in known locations + const paths: string[] = []; + const programFiles = process.env.ProgramFiles; + if (programFiles) { + paths.push(`${programFiles}\\Git\\bin\\bash.exe`); + } + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) { + paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`); + } + + for (const path of paths) { + if (existsSync(path)) { + return getBashShellConfig(path); + } + } + + // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.) + const bashOnPath = findBashOnPath(); + if (bashOnPath) { + return getBashShellConfig(bashOnPath); + } + + throw new Error( + `No bash shell found. Options:\n` + + ` 1. Install Git for Windows: https://git-scm.com/download/win\n` + + ` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` + + " 3. Set shellPath in settings.json\n\n" + + `Searched Git Bash in:\n${paths.map((p) => ` ${p}`).join("\n")}`, + ); + } + + // Unix: try /bin/bash, then bash on PATH, then fallback to sh + if (existsSync("/bin/bash")) { + return getBashShellConfig("/bin/bash"); + } + + const bashOnPath = findBashOnPath(); + if (bashOnPath) { + return getBashShellConfig(bashOnPath); + } + + return { shell: "sh", args: ["-c"] }; +} + +export function getShellEnv(): NodeJS.ProcessEnv { + const binDir = getBinDir(); + const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH"; + const currentPath = process.env[pathKey] ?? ""; + const pathEntries = currentPath.split(delimiter).filter(Boolean); + const hasBinDir = pathEntries.includes(binDir); + const updatedPath = hasBinDir ? currentPath : [binDir, currentPath].filter(Boolean).join(delimiter); + + return { + ...process.env, + [pathKey]: updatedPath, + }; +} + +/** + * Sanitize binary output for display/storage. + * Removes characters that crash string-width or cause display issues: + * - Control characters (except tab, newline, carriage return) + * - Lone surrogates + * - Unicode Format characters (crash string-width due to a bug) + * - Characters with undefined code points + */ +export function sanitizeBinaryOutput(str: string): string { + // Use Array.from to properly iterate over code points (not code units) + // This handles surrogate pairs correctly and catches edge cases where + // codePointAt() might return undefined + return Array.from(str) + .filter((char) => { + // Filter out characters that cause string-width to crash + // This includes: + // - Unicode format characters + // - Lone surrogates (already filtered by Array.from) + // - Control chars except \t \n \r + // - Characters with undefined code points + + const code = char.codePointAt(0); + + // Skip if code point is undefined (edge case with invalid strings) + if (code === undefined) return false; + + // Allow tab, newline, carriage return + if (code === 0x09 || code === 0x0a || code === 0x0d) return true; + + // Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d) + if (code <= 0x1f) return false; + + // Filter out Unicode format characters + if (code >= 0xfff9 && code <= 0xfffb) return false; + + return true; + }) + .join(""); +} + +/** + * Detached child processes must be tracked so they can be killed on parent + * shutdown signals (SIGHUP/SIGTERM). + */ +const trackedDetachedChildPids = new Set(); + +export function trackDetachedChildPid(pid: number): void { + trackedDetachedChildPids.add(pid); +} + +export function untrackDetachedChildPid(pid: number): void { + trackedDetachedChildPids.delete(pid); +} + +export function killTrackedDetachedChildren(): void { + for (const pid of trackedDetachedChildPids) { + killProcessTree(pid); + } + trackedDetachedChildPids.clear(); +} + +/** + * Kill a process and all its children (cross-platform) + */ +export function killProcessTree(pid: number): void { + if (process.platform === "win32") { + // Use taskkill on Windows to kill process tree + try { + spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { + stdio: "ignore", + detached: true, + windowsHide: true, + }); + } catch { + // Ignore errors if taskkill fails + } + } else { + // Use SIGKILL on Unix/Linux/Mac + try { + process.kill(-pid, "SIGKILL"); + } catch { + // Fallback to killing just the child if process group kill fails + try { + process.kill(pid, "SIGKILL"); + } catch { + // Process already dead + } + } + } +} diff --git a/cactus-code/packages/coding-agent/src/utils/sleep.ts b/cactus-code/packages/coding-agent/src/utils/sleep.ts new file mode 100644 index 000000000..948f93c47 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/sleep.ts @@ -0,0 +1,18 @@ +/** + * Sleep helper that respects abort signal. + */ +export function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Aborted")); + return; + } + + const timeout = setTimeout(resolve, ms); + + signal?.addEventListener("abort", () => { + clearTimeout(timeout); + reject(new Error("Aborted")); + }); + }); +} diff --git a/cactus-code/packages/coding-agent/src/utils/syntax-highlight.ts b/cactus-code/packages/coding-agent/src/utils/syntax-highlight.ts new file mode 100644 index 000000000..bcc4add1c --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/syntax-highlight.ts @@ -0,0 +1,146 @@ +import hljs from "highlight.js/lib/index.js"; +import { decodeHtmlEntityAt } from "./html.ts"; + +export type HighlightFormatter = (text: string) => string; +export type HighlightTheme = Partial>; + +export interface HighlightOptions { + language?: string; + ignoreIllegals?: boolean; + languageSubset?: string[]; + theme?: HighlightTheme; +} + +const SPAN_CLOSE = ""; +const HIGHLIGHT_CLASS_PREFIX = "hljs-"; + +function getScopeFromSpanTag(tag: string): string | undefined { + const match = /\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(tag); + const classValue = match?.[1] ?? match?.[2]; + if (!classValue) { + return undefined; + } + + for (const className of classValue.split(/\s+/)) { + if (className.startsWith(HIGHLIGHT_CLASS_PREFIX)) { + return className.slice(HIGHLIGHT_CLASS_PREFIX.length); + } + } + + return undefined; +} + +function getScopeFormatter(scope: string, theme: HighlightTheme): HighlightFormatter | undefined { + const exact = theme[scope]; + if (exact) { + return exact; + } + + const dotIndex = scope.indexOf("."); + if (dotIndex !== -1) { + const prefixFormatter = theme[scope.slice(0, dotIndex)]; + if (prefixFormatter) { + return prefixFormatter; + } + } + + const dashIndex = scope.indexOf("-"); + if (dashIndex !== -1) { + const prefixFormatter = theme[scope.slice(0, dashIndex)]; + if (prefixFormatter) { + return prefixFormatter; + } + } + + return undefined; +} + +function getActiveFormatter(scopes: Array, theme: HighlightTheme): HighlightFormatter | undefined { + for (let i = scopes.length - 1; i >= 0; i--) { + const scope = scopes[i]; + if (!scope) { + continue; + } + const formatter = getScopeFormatter(scope, theme); + if (formatter) { + return formatter; + } + } + return theme.default; +} + +function isSpanOpenTagStart(html: string, index: number): boolean { + if (!html.startsWith("" || nextChar === " " || nextChar === "\t" || nextChar === "\n" || nextChar === "\r"; +} + +export function renderHighlightedHtml(html: string, theme: HighlightTheme = {}): string { + let output = ""; + let textBuffer = ""; + const scopes: Array = []; + + const flushText = () => { + if (!textBuffer) { + return; + } + const formatter = getActiveFormatter(scopes, theme); + output += formatter ? formatter(textBuffer) : textBuffer; + textBuffer = ""; + }; + + let index = 0; + while (index < html.length) { + if (isSpanOpenTagStart(html, index)) { + const tagEndIndex = html.indexOf(">", index + 5); + if (tagEndIndex !== -1) { + flushText(); + const tag = html.slice(index, tagEndIndex + 1); + const scope = getScopeFromSpanTag(tag); + scopes.push(scope); + index = tagEndIndex + 1; + continue; + } + } + + if (html.startsWith(SPAN_CLOSE, index)) { + flushText(); + if (scopes.length > 0) { + scopes.pop(); + } + index += SPAN_CLOSE.length; + continue; + } + + if (html[index] === "&") { + const decoded = decodeHtmlEntityAt(html, index); + if (decoded) { + textBuffer += decoded.text; + index += decoded.length; + continue; + } + } + + textBuffer += html[index]; + index++; + } + + flushText(); + return output; +} + +export function highlight(code: string, options: HighlightOptions = {}): string { + const html = options.language + ? hljs.highlight(code, { + language: options.language, + ignoreIllegals: options.ignoreIllegals, + }).value + : hljs.highlightAuto(code, options.languageSubset).value; + return renderHighlightedHtml(html, options.theme); +} + +export function supportsLanguage(name: string): boolean { + return hljs.getLanguage(name) !== undefined; +} diff --git a/cactus-code/packages/coding-agent/src/utils/tools-manager.ts b/cactus-code/packages/coding-agent/src/utils/tools-manager.ts new file mode 100644 index 000000000..09d9a1195 --- /dev/null +++ b/cactus-code/packages/coding-agent/src/utils/tools-manager.ts @@ -0,0 +1,369 @@ +import chalk from "chalk"; +import { type SpawnSyncReturns, spawnSync } from "child_process"; +import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs"; +import { arch, platform } from "os"; +import { join } from "path"; +import { Readable } from "stream"; +import { pipeline } from "stream/promises"; +import { APP_NAME, getBinDir } from "../config.ts"; + +const TOOLS_DIR = getBinDir(); +const NETWORK_TIMEOUT_MS = 10_000; +const DOWNLOAD_TIMEOUT_MS = 120_000; + +function isOfflineModeEnabled(): boolean { + const value = process.env.PI_OFFLINE; + if (!value) return false; + return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; +} + +interface ToolConfig { + name: string; + repo: string; // GitHub repo (e.g., "sharkdp/fd") + binaryName: string; // Name of the binary inside the archive + systemBinaryNames?: string[]; // Alternative system command names to try before downloading + tagPrefix: string; // Prefix for tags (e.g., "v" for v1.0.0, "" for 1.0.0) + getAssetName: (version: string, plat: string, architecture: string) => string | null; +} + +const TOOLS: Record = { + fd: { + name: "fd", + repo: "sharkdp/fd", + binaryName: "fd", + systemBinaryNames: ["fd", "fdfind"], + tagPrefix: "v", + getAssetName: (version, plat, architecture) => { + if (plat === "darwin") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `fd-v${version}-${archStr}-apple-darwin.tar.gz`; + } else if (plat === "linux") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`; + } else if (plat === "win32") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `fd-v${version}-${archStr}-pc-windows-msvc.zip`; + } + return null; + }, + }, + rg: { + name: "ripgrep", + repo: "BurntSushi/ripgrep", + binaryName: "rg", + tagPrefix: "", + getAssetName: (version, plat, architecture) => { + if (plat === "darwin") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`; + } else if (plat === "linux") { + if (architecture === "arm64") { + return `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`; + } + return `ripgrep-${version}-x86_64-unknown-linux-musl.tar.gz`; + } else if (plat === "win32") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `ripgrep-${version}-${archStr}-pc-windows-msvc.zip`; + } + return null; + }, + }, +}; + +// Check if a command exists in PATH by trying to run it +function commandExists(cmd: string): boolean { + try { + const result = spawnSync(cmd, ["--version"], { stdio: "pipe" }); + // Check for ENOENT error (command not found) + return result.error === undefined || result.error === null; + } catch { + return false; + } +} + +// Get the path to a tool (system-wide or in our tools dir) +export function getToolPath(tool: "fd" | "rg"): string | null { + const config = TOOLS[tool]; + if (!config) return null; + + // Check our tools directory first + const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : "")); + if (existsSync(localPath)) { + return localPath; + } + + // Check system PATH - if found, just return the command name (it's in PATH) + const systemBinaryNames = config.systemBinaryNames ?? [config.binaryName]; + for (const systemBinaryName of systemBinaryNames) { + if (commandExists(systemBinaryName)) { + return systemBinaryName; + } + } + + return null; +} + +// Fetch latest release version from GitHub +async function getLatestVersion(repo: string): Promise { + const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { + headers: { "User-Agent": `${APP_NAME}-coding-agent` }, + signal: AbortSignal.timeout(NETWORK_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status}`); + } + + const data = (await response.json()) as { tag_name: string }; + return data.tag_name.replace(/^v/, ""); +} + +// Download a file from URL +async function downloadFile(url: string, dest: string): Promise { + const response = await fetch(url, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`Failed to download: ${response.status}`); + } + + if (!response.body) { + throw new Error("No response body"); + } + + const fileStream = createWriteStream(dest); + await pipeline(Readable.fromWeb(response.body as any), fileStream); +} + +function findBinaryRecursively(rootDir: string, binaryFileName: string): string | null { + const stack: string[] = [rootDir]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + if (!currentDir) continue; + + const entries = readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + if (entry.isFile() && entry.name === binaryFileName) { + return fullPath; + } + if (entry.isDirectory()) { + stack.push(fullPath); + } + } + } + + return null; +} + +function formatSpawnFailure(result: SpawnSyncReturns): string { + if (result.error?.message) { + return result.error.message; + } + const stderr = result.stderr?.toString().trim(); + if (stderr) { + return stderr; + } + const stdout = result.stdout?.toString().trim(); + if (stdout) { + return stdout; + } + return `exit status ${result.status ?? "unknown"}`; +} + +function runExtractionCommand(command: string, args: string[]): string | null { + const result = spawnSync(command, args, { stdio: "pipe" }); + if (!result.error && result.status === 0) { + return null; + } + return `${command}: ${formatSpawnFailure(result)}`; +} + +function extractTarGzArchive(archivePath: string, extractDir: string, assetName: string): void { + const failure = runExtractionCommand("tar", ["xzf", archivePath, "-C", extractDir]); + if (failure) { + throw new Error(`Failed to extract ${assetName}: ${failure}`); + } +} + +function getWindowsTarCommand(): string { + const systemRoot = process.env.SystemRoot ?? process.env.WINDIR; + if (systemRoot) { + const systemTar = join(systemRoot, "System32", "tar.exe"); + if (existsSync(systemTar)) { + return systemTar; + } + } + return "tar.exe"; +} + +function extractZipArchive(archivePath: string, extractDir: string, assetName: string): void { + const failures: string[] = []; + + if (platform() === "win32") { + // Windows ships bsdtar as tar.exe, which supports zip files. Prefer the + // System32 binary over Git Bash's GNU tar, which does not handle zip archives. + const tarFailure = runExtractionCommand(getWindowsTarCommand(), ["xf", archivePath, "-C", extractDir]); + if (!tarFailure) return; + failures.push(tarFailure); + + const script = + "& { param($archive, $destination) $ErrorActionPreference = 'Stop'; Expand-Archive -LiteralPath $archive -DestinationPath $destination -Force }"; + const powershellFailure = runExtractionCommand("powershell.exe", [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + archivePath, + extractDir, + ]); + if (!powershellFailure) return; + failures.push(powershellFailure); + } else { + const unzipFailure = runExtractionCommand("unzip", ["-q", archivePath, "-d", extractDir]); + if (!unzipFailure) return; + failures.push(unzipFailure); + + const tarFailure = runExtractionCommand("tar", ["xf", archivePath, "-C", extractDir]); + if (!tarFailure) return; + failures.push(tarFailure); + } + + throw new Error(`Failed to extract ${assetName}: ${failures.join("; ")}`); +} + +// Download and install a tool +async function downloadTool(tool: "fd" | "rg"): Promise { + const config = TOOLS[tool]; + if (!config) throw new Error(`Unknown tool: ${tool}`); + + const plat = platform(); + const architecture = arch(); + + // Get latest version + let version = await getLatestVersion(config.repo); + if (tool === "fd" && plat === "darwin" && architecture === "x64") { + version = "10.3.0"; + } + + // Get asset name for this platform + const assetName = config.getAssetName(version, plat, architecture); + if (!assetName) { + throw new Error(`Unsupported platform: ${plat}/${architecture}`); + } + + // Create tools directory + mkdirSync(TOOLS_DIR, { recursive: true }); + + const downloadUrl = `https://github.com/${config.repo}/releases/download/${config.tagPrefix}${version}/${assetName}`; + const archivePath = join(TOOLS_DIR, assetName); + const binaryExt = plat === "win32" ? ".exe" : ""; + const binaryPath = join(TOOLS_DIR, config.binaryName + binaryExt); + + // Download + await downloadFile(downloadUrl, archivePath); + + // Extract into a unique temp directory. fd and rg downloads can run concurrently + // during startup, so sharing a fixed directory causes races. + const extractDir = join( + TOOLS_DIR, + `extract_tmp_${config.binaryName}_${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, + ); + mkdirSync(extractDir, { recursive: true }); + + try { + if (assetName.endsWith(".tar.gz")) { + extractTarGzArchive(archivePath, extractDir, assetName); + } else if (assetName.endsWith(".zip")) { + extractZipArchive(archivePath, extractDir, assetName); + } else { + throw new Error(`Unsupported archive format: ${assetName}`); + } + + // Find the binary in extracted files. Some archives contain files directly + // at root, others nest under a versioned subdirectory. + const binaryFileName = config.binaryName + binaryExt; + const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, "")); + const extractedBinaryCandidates = [join(extractedDir, binaryFileName), join(extractDir, binaryFileName)]; + let extractedBinary = extractedBinaryCandidates.find((candidate) => existsSync(candidate)); + + if (!extractedBinary) { + extractedBinary = findBinaryRecursively(extractDir, binaryFileName) ?? undefined; + } + + if (extractedBinary) { + renameSync(extractedBinary, binaryPath); + } else { + throw new Error(`Binary not found in archive: expected ${binaryFileName} under ${extractDir}`); + } + + // Make executable (Unix only) + if (plat !== "win32") { + chmodSync(binaryPath, 0o755); + } + } finally { + // Cleanup + rmSync(archivePath, { force: true }); + rmSync(extractDir, { recursive: true, force: true }); + } + + return binaryPath; +} + +// Termux package names for tools +const TERMUX_PACKAGES: Record = { + fd: "fd", + rg: "ripgrep", +}; + +// Ensure a tool is available, downloading if necessary +// Returns the path to the tool, or null if unavailable +export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise { + const existingPath = getToolPath(tool); + if (existingPath) { + return existingPath; + } + + const config = TOOLS[tool]; + if (!config) return undefined; + + if (isOfflineModeEnabled()) { + if (!silent) { + console.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`)); + } + return undefined; + } + + // On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility. + // Users must install via pkg. + if (platform() === "android") { + const pkgName = TERMUX_PACKAGES[tool] ?? tool; + if (!silent) { + console.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`)); + } + return undefined; + } + + // Tool not found - download it + if (!silent) { + console.log(chalk.dim(`${config.name} not found. Downloading...`)); + } + + try { + const path = await downloadTool(tool); + if (!silent) { + console.log(chalk.dim(`${config.name} installed to ${path}`)); + } + return path; + } catch (e) { + if (!silent) { + console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`)); + } + return undefined; + } +} diff --git a/cactus-code/packages/coding-agent/tsconfig.build.json b/cactus-code/packages/coding-agent/tsconfig.build.json new file mode 100644 index 000000000..15a2436ce --- /dev/null +++ b/cactus-code/packages/coding-agent/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "paths": { + "@earendil-works/pi-agent-core": ["../agent/dist/index.d.ts"], + "@earendil-works/pi-agent-core/*": ["../agent/dist/*.d.ts"], + "@earendil-works/pi-ai": ["../ai/dist/index.d.ts"], + "@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"], + "@earendil-works/pi-tui": ["../tui/dist/index.d.ts"], + "@earendil-works/pi-tui/*": ["../tui/dist/*.d.ts", "../tui/dist/components/*.d.ts"] + }, + "rootDir": "./src" + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/cactus-code/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node b/cactus-code/packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node new file mode 100755 index 0000000000000000000000000000000000000000..4614842699ca81944f15dff61a999878f841be28 GIT binary patch literal 50200 zcmeI5U2I%e5y$85`YW~Lrr05F6BOg3$_c@Cp%q4ImFqZm95rc@_0pK9llAVkeertN zxpz0Io1(6iwj!wl-YR@l6|jAXh^;s(bts?;a8(h3M1Y1IenPzvrxs?-Ul8)TN5 z`{Aw~S%?RuBK}93oilUh&YAh0d++X({f8S@{#iw&5swO%TUqv&57*HJNwS~KgKN^@!ix^Yx-W9AJTYNlPr`&xq ztQ*-DKGO5^Y)h?sUv}?zhLx}G~e^F{X6vq)|t-nI0O#eImbm2773;=RIic|HeNB$rcD=>#8*PR-pTcFGuP z-evY$5o@Hm(~1lkhm+Qz-Q1qEj2%{Fc)*O>O?|$<`60uy&1AxEZd($;d1c5xtt|dN zM7y|cjhfT8)f_i_Mcgf$wl&2@*pjP=?qZR8PfxiVHhH%2mGK!CS-;Ah8l{2%a1?^t z#rgf(*!4Yuv4tbSv4vMCdo8GqUk}naGaUV_-jJ=Q%+oodlk>jsr0aW@eT8z$zZswp zOX~C+dVs=z^JA_zWch4Iqb;{*-@cQwGr`*N0O#S?*z{Cj>!lf@d*3S8^IY=iHjb6` z%fXuQoUE~4IMU8FZ5x~4-!{fIW~YMcQsEWt;zugTz7k$5<$BA;7LID!d9L>w@5}e! zXoaTdMP}Dg-l_k-JDvK6xz@k?wPjr2Ao~{Uec$!NuB%ej8>&gb=!uUDT4ZN6~E+Nj+!_|57} zs9x)OL=RGUmgCI#PfU+ZOw4Wi{e`(>RWoxNwN>xs_)Pq@-<>!+xAOyDTkWp#4Ig&$ zeyQ!}@2>t<=y=nFleh88iO_FX=Pb$H_K!k%t(FUwaSdOkT!>#2wQB>p5T9G^ci86F zL#T$CZKSpDUM~O99nwSXjlxgaKRvrn&-48{x{1$2WUE&3-escj!jVoszYadf?PDLQ z_Ax$J=X^Q$$-NxECZOjjuJFDshg;?DPR%-y||y}IMqV=WT@aZFvlP*^9A0AE`- z&-0Dm{2I@{%=1KK*i=!=h@=eFmrg`e{K&da+eoPdKjbkrY$f?iIyFM3y*n8*`%J^y zYZ1aU6=o(+2-&knd!bPW82Z9NU{r#*IjV>;bbcMbYHYP$Us! zok&^9I3(100@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=9 z00@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p z2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?xfB*=900@8p2!H?x zfB*=900@8p2!H?xfB*=900@ANBZsVR3v3^gjKODVPC|I zi+>IKV{vfXEwc|Svx97xu*mkZ zMLA2cubx)yXGL5~$UC5xc-*t_sNHlL%Cv=<*=I7mVau>>BSuO&al@8*NY0~Z-1uYF z6t~;JE}uy)`?&u=H3hh{fWsoe>pk9OtBGE|S|6Y)&;Bg=XO zGG@@^PhL1U^1<%s>!1A1`InA%9NVz_;Pm~Iubo=;mjh*6-*{kV;;}O?^d!%}(>GP} z+Qo~5fjvE?g-6dl`p6S+{PL-;V_SawzrKF7Q2&F;@4ocRT@~lQ@~bPa>aUNS&7VGX z_Cm?@{!{c+$H0HPzxl}@zB)NwHF3Q4*%wj8~?(zN$ D%;!fI literal 0 HcmV?d00001 diff --git a/cactus-code/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node b/cactus-code/packages/tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node new file mode 100755 index 0000000000000000000000000000000000000000..0c82b391329a43f4d241183f915217b51b8380f5 GIT binary patch literal 12776 zcmeHOZ)jUp6u;S8ZQa(SW0TEoEDWZbNV9B;Q$Lt&%l3lFoTU3By4}4bFYRM#UibcV z77>aygGUHdurLHc{M0W(aZ@)GNw-O>A9Mr7;=oVpDs_SkCL6N&JMT}IWb}*QlzZU3 zbI*C_o_p@^+?N;HlW*_bxceYufu)SG4saJZ*3MWNl4vb>HozF?y@?~ej;Z+)YF3%2 zBLwrAJUBO%*UWpqt?|al8FXxkD@kv2hwinFjWnv98>W>t7EG=2{=lY)R=R@ux~H-Z zFWv*5!HviJmExeH!`2$l0SET&3jrAOQgto&<(D%|T`l!_{;l!06WAcz70lN?wK+D7 zoLi++YAMgvQbBX{=6ZbY#XIf=AiXI+&+ZGV>lrZTd*Z!uzWc@f8MnHAJr|L5PKa?N zoKkb%r!|yy(RhA1nnd$`BK#Y5%4-hn=H+`qhP|;Ls73el^7qVnQRc?*KvpaAK~rBe z-Wo3~#c#}mj*oXF9S_d)vMKw{_vg0;^NGANFwz_2IX4?Fv`-1lc`CX8g~Xnu|7>mZ zocw)y`WyicBH;}keG_9Njtefdb_b+4#ks!|3avvZKYlxOw*35oj}jZcIQQ`CPI`XAU}q1(hY@La z+4eZ@g}^>7$Dkifv7DURKSXI_ERq>EOl2UFQnR|O4@dUu@_=$m(@z+Y-I}iK)8)Z_ zHD~k`e0$`Oq8qAKG9vLN4{sq_XJ^AYjHR&c0E<|L9x19>Vt2`&T|N0>Xzs*EAt>Jo z56`WhPWb*CIw;w{#1F?0#d|Z-=GE4r2QqVR8oIeMaMeb;(G|zbooYb{Gzv&tF)EZ5%qoS zbb^i%evqu>2k-2M^bRTN9Irb%tUg(qyWdICFt&i*Uf8Tr)eO*X9l4^G3f~*pZkFewscv z`ls1BWp0%!n0+Cq4GhSoJd>W8F*lG}GIgy;sbQ^_zS7cB*^b$V-Z%Pj1?=bP2Eq9T_V!EQ2w3~M@{8|x`w zP@}}5V)XqX21)F{`)S-r^x3u?#aIO<0=|LnWo?10N80IJ2@(hh1Ox&C0fB%(Kp-Fx z5C{ka1Ofs9fq+0jARrJB2nYlO0s;Ynz<(Bj5dLLAwBD0jJn34+RUjY`5C{ka1Ofs9 zfq+0jARrJB2nYlO0s?{m0|MQfScdkoGRCO|Ue2B1x&9Np&yw{#d+8(_i=o*y?=;)< zEc(yU96v_>I_r+{-TM+lO3BP&~! zFNpj8?+MmHhw9A5kUi<@Zo?nO)Cr553;N3~`tcT>dIsrt3A8-*MN^-6qb +#include +#include +#include +#include + +#define NAPI_AUTO_LENGTH ((size_t)-1) + +typedef void* napi_env; +typedef void* napi_value; +typedef void* napi_callback_info; +typedef napi_value (*napi_callback)(napi_env, napi_callback_info); +typedef int (*napi_create_function_fn)(napi_env, const char*, size_t, napi_callback, void*, napi_value*); +typedef int (*napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); +typedef int (*napi_get_boolean_fn)(napi_env, bool, napi_value*); +typedef int (*napi_get_cb_info_fn)(napi_env, napi_callback_info, size_t*, napi_value*, napi_value*, void**); +typedef int (*napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*); + +static void* node_symbol(const char* name) { + return dlsym(RTLD_DEFAULT, name); +} + +static CGEventFlags modifier_mask_for_name(const char* name) { + if (strcmp(name, "shift") == 0) return kCGEventFlagMaskShift; + if (strcmp(name, "command") == 0) return kCGEventFlagMaskCommand; + if (strcmp(name, "control") == 0) return kCGEventFlagMaskControl; + if (strcmp(name, "option") == 0) return kCGEventFlagMaskAlternate; + return 0; +} + +static napi_value is_modifier_pressed(napi_env env, napi_callback_info info) { + napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info"); + napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8"); + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + + bool pressed = false; + if (napi_get_cb_info && napi_get_value_string_utf8) { + size_t argc = 1; + napi_value args[1] = {0}; + if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) { + char name[16] = {0}; + size_t copied = 0; + if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) { + CGEventFlags mask = modifier_mask_for_name(name); + if (mask != 0) { + CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState); + pressed = (flags & mask) != 0; + } + } + } + } + + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, pressed, &result); + return result; +} + +__attribute__((visibility("default"))) napi_value napi_register_module_v1(napi_env env, napi_value exports) { + napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); + napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); + + napi_value fn = 0; + if (napi_create_function && + napi_set_named_property && + napi_create_function(env, "isModifierPressed", NAPI_AUTO_LENGTH, is_modifier_pressed, 0, &fn) == 0) { + napi_set_named_property(env, exports, "isModifierPressed", fn); + } + + return exports; +} diff --git a/cactus-code/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node b/cactus-code/packages/tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node new file mode 100644 index 0000000000000000000000000000000000000000..42b2c77ccadb03f7ad745fda39583b2e6711d10f GIT binary patch literal 3072 zcmeHIU1%It6#ni^o1_T|tzE22(MeoE#4a^IJ_(&zgB4p1+2BKc*v#%+lhNJTb#}Ja z1Z2UN(t>0}TJ*(MeCRF+5hJ7pCAA8^6x4@2`LOW^^58@5whar?^_)99c2lV$_CX<+ z+&%Yv=V!iq?#;P}4$ngeu!Ux`2`rK1VXEK1{`rQ?eGi=P!{W9}{Y&EDrT)nor)U(s z{IqA~jEv>FdEZD&!z;Ol;~GyrpEPoLTPAvYyW_3YpWOS?SL;WQpN!_4jT5J?D*eRC zD|eWq04pk?k@Dj2^A;QD}s8L@3GaYF!pA zcun>W<#bc3LU3`Ed5T=aph)~#50Pbqcu>q*KkzUIM`JH=Pqc269~!{!HvMmxXUVeH`m&mI^?zp1DHy9w&h#ES*{HM%BQ_X1ex@gD1?5BJAat{yJ@x!Q@OSQ zo6G-v1?tJqD>r)3Xzs_iUBE)u4zp3$Q8h&QE{J_AO&#a1lKy@NKCTH&DxYm;V^v4M zc2i(@O*hZg^jKAhU4zQrYc|+61q=_PJV93_79+_v2oa0`@2pYIW+xgz`Ma-y#UCC- zu&T!%sp&hb99!!&nNx71hxjDFIJ>N;R=Ak;y$_3wM1}T7uEYI z_q7PSAcFfyXa2O0#)yW+7)|n@*9C%|#K#)Yf}d$GTZo5-^4m3iSM{{EZ)F)6`B66; zX|k{CcUKuN6JDb=^ID&Kl6Z_(>dA|W8*{18p~76cL|8-K8}Eg3@i!N`wsPIV{hb#0 zRPnxmAd0!2FV~NA)#ps!a}d>n>+x^L_&}J!ZpkApd8{SPmW;mRa{soo**|ov;H0Ld zpGxQRS!uZ&@Qf!dU#4Cvxf$QdE1S#yMe=v8oU~K)RurV?zYghI>8yOg@%)mNos?eA zajon#ZlT0Yj7HSYdujb8%{cYdcqd!<-Qt%ExMBd@?xMLy`Y$vu(_Ek)*iA9OYmPfK zxF?f$i$r*jdXIQhP|Q3z?G%0Kr3f!&W$MUd9KwU*xtFTG8qbCMG{PR7aJVn|L5GZ1u0dMjw~g2kW83LG_L6M*4Kv1P zG;e$uxOSgxIwz!{D7L#lWoGzxsr*tnO^ zjM}y*i$yv)M)rqCK+Y8R#Mb03$g^V;&y5`%8f?F98@X<3cc21vuRp>Eg8x6>#Y5VG R9l)zNa|aS{ruaWp;CE*fda3{b literal 0 HcmV?d00001 diff --git a/cactus-code/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node b/cactus-code/packages/tui/native/win32/prebuilds/win32-x64/win32-console-mode.node new file mode 100644 index 0000000000000000000000000000000000000000..2c6d86d8725cb88e742a168f0fd9db2e200e4766 GIT binary patch literal 3072 zcmeHIQD_`h6urCK#&qNErmzc93Qp>L1ZkIbH;RHtstJA7t%fA1h`62XjLFFC>@quB zV+4sV5tCshh_w9```-`!sQn7GVhd>vh#J%ng?isadnBG@Sa3 zqt%qEX4`dFnKl%sWh{5L8_C0mjNq4V1aXo(9~{R>Vsy!f4DAALoB#R> zh@}_QUa{n={m+W_6-9VgKi={oxdbjv=^No)L%@h=U-RAjL}v=7cizu^nIT$Sbq72N ze3m)$!!AE}0bu8~*y3s{?)@e@sqf)$ZlD5uh7gF~UG9vk!uv{im;80%G3vSML&7`D zPC>NKDV4KpYau5~9bjS#6Dxq2@V>^bAMmbk@~)lKDcHi>+-$P@DYoWvgR)rs(LKlq zJ|_6UL+$gvSQ=G_%AM4m$W3%|9bSnQO0eRR{{?7g%U)R(<<8E!-1l>)39gnp9x_Do zuKFBk7MNzhGc4Qn(giE{A$6)V=ox_-g0}9+A!cTeqwmzSb}+GlpB4uGcL?vv0zyj-k0m<)xNgb + +#ifndef ENABLE_VIRTUAL_TERMINAL_INPUT +#define ENABLE_VIRTUAL_TERMINAL_INPUT 0x0200 +#endif + +#define NAPI_AUTO_LENGTH ((unsigned long long)-1) + +typedef void* napi_env; +typedef void* napi_value; +typedef void* napi_callback_info; +typedef napi_value (__cdecl *napi_callback)(napi_env, napi_callback_info); +typedef int (__cdecl *napi_create_function_fn)(napi_env, const char*, unsigned long long, napi_callback, void*, napi_value*); +typedef int (__cdecl *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); +typedef int (__cdecl *napi_get_boolean_fn)(napi_env, int, napi_value*); + +static void* node_symbol(const char* name) { + HMODULE module = GetModuleHandleA(0); + void* proc = module ? (void*)GetProcAddress(module, name) : 0; + if (proc) return proc; + + module = GetModuleHandleA("node.dll"); + return module ? (void*)GetProcAddress(module, name) : 0; +} + +static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) { + (void)info; + + HANDLE handle = GetStdHandle(STD_INPUT_HANDLE); + DWORD mode = 0; + int enabled = handle != INVALID_HANDLE_VALUE && + GetConsoleMode(handle, &mode) && + SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_INPUT); + + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, enabled, &result); + return result; +} + +__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) { + napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); + napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); + + napi_value fn = 0; + if (napi_create_function && + napi_set_named_property && + napi_create_function(env, "enableVirtualTerminalInput", NAPI_AUTO_LENGTH, enable_virtual_terminal_input, 0, &fn) == 0) { + napi_set_named_property(env, exports, "enableVirtualTerminalInput", fn); + } + + return exports; +} diff --git a/cactus-code/packages/tui/package.json b/cactus-code/packages/tui/package.json new file mode 100644 index 000000000..25786e929 --- /dev/null +++ b/cactus-code/packages/tui/package.json @@ -0,0 +1,45 @@ +{ + "name": "@earendil-works/pi-tui", + "version": "0.80.2", + "description": "Terminal User Interface library with differential rendering for efficient text-based applications", + "type": "module", + "main": "dist/index.js", + "scripts": { + "clean": "shx rm -rf dist", + "build": "tsgo -p tsconfig.build.json", + "prepublishOnly": "npm run clean && npm run build" + }, + "files": [ + "dist/**/*", + "native/win32/prebuilds/**/*.node", + "native/darwin/prebuilds/**/*.node", + "README.md" + ], + "keywords": [ + "tui", + "terminal", + "ui", + "text-editor", + "differential-rendering", + "typescript", + "cli" + ], + "author": "Mario Zechner", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/earendil-works/pi.git", + "directory": "packages/tui" + }, + "engines": { + "node": ">=22.19.0" + }, + "types": "./dist/index.d.ts", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "devDependencies": { + "@types/node": "24.12.4" + } +} diff --git a/cactus-code/packages/tui/src/autocomplete.ts b/cactus-code/packages/tui/src/autocomplete.ts new file mode 100644 index 000000000..205748d88 --- /dev/null +++ b/cactus-code/packages/tui/src/autocomplete.ts @@ -0,0 +1,786 @@ +import { spawn } from "child_process"; +import { readdirSync, statSync } from "fs"; +import { homedir } from "os"; +import { basename, dirname, join } from "path"; +import { fuzzyFilter } from "./fuzzy.ts"; + +const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]); + +function toDisplayPath(value: string): string { + return value.replace(/\\/g, "/"); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildFdPathQuery(query: string): string { + const normalized = toDisplayPath(query); + if (!normalized.includes("/")) { + return normalized; + } + + const hasTrailingSeparator = normalized.endsWith("/"); + const trimmed = normalized.replace(/^\/+|\/+$/g, ""); + if (!trimmed) { + return normalized; + } + + const separatorPattern = "[\\\\/]"; + const segments = trimmed + .split("/") + .filter(Boolean) + .map((segment) => escapeRegex(segment)); + if (segments.length === 0) { + return normalized; + } + + let pattern = segments.join(separatorPattern); + if (hasTrailingSeparator) { + pattern += separatorPattern; + } + return pattern; +} + +function findLastDelimiter(text: string): number { + for (let i = text.length - 1; i >= 0; i -= 1) { + if (PATH_DELIMITERS.has(text[i] ?? "")) { + return i; + } + } + return -1; +} + +function findUnclosedQuoteStart(text: string): number | null { + let inQuotes = false; + let quoteStart = -1; + + for (let i = 0; i < text.length; i += 1) { + if (text[i] === '"') { + inQuotes = !inQuotes; + if (inQuotes) { + quoteStart = i; + } + } + } + + return inQuotes ? quoteStart : null; +} + +function isTokenStart(text: string, index: number): boolean { + return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? ""); +} + +function extractQuotedPrefix(text: string): string | null { + const quoteStart = findUnclosedQuoteStart(text); + if (quoteStart === null) { + return null; + } + + if (quoteStart > 0 && text[quoteStart - 1] === "@") { + if (!isTokenStart(text, quoteStart - 1)) { + return null; + } + return text.slice(quoteStart - 1); + } + + if (!isTokenStart(text, quoteStart)) { + return null; + } + + return text.slice(quoteStart); +} + +function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } { + if (prefix.startsWith('@"')) { + return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true }; + } + if (prefix.startsWith('"')) { + return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true }; + } + if (prefix.startsWith("@")) { + return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false }; + } + return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false }; +} + +function buildCompletionValue( + path: string, + options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean }, +): string { + const needsQuotes = options.isQuotedPrefix || path.includes(" "); + const prefix = options.isAtPrefix ? "@" : ""; + + if (!needsQuotes) { + return `${prefix}${path}`; + } + + const openQuote = `${prefix}"`; + const closeQuote = '"'; + return `${openQuote}${path}${closeQuote}`; +} + +// Use fd to walk directory tree (fast, respects .gitignore) +async function walkDirectoryWithFd( + baseDir: string, + fdPath: string, + query: string, + maxResults: number, + signal: AbortSignal, +): Promise> { + const args = [ + "--base-directory", + baseDir, + "--max-results", + String(maxResults), + "--type", + "f", + "--type", + "d", + "--follow", + "--hidden", + "--exclude", + ".git", + "--exclude", + ".git/*", + "--exclude", + ".git/**", + ]; + + if (toDisplayPath(query).includes("/")) { + args.push("--full-path"); + } + + if (query) { + args.push(buildFdPathQuery(query)); + } + + return await new Promise((resolve) => { + if (signal.aborted) { + resolve([]); + return; + } + + const child = spawn(fdPath, args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let resolved = false; + + const finish = (results: Array<{ path: string; isDirectory: boolean }>) => { + if (resolved) return; + resolved = true; + signal.removeEventListener("abort", onAbort); + resolve(results); + }; + + const onAbort = () => { + if (child.exitCode === null) { + child.kill("SIGKILL"); + } + }; + + signal.addEventListener("abort", onAbort, { once: true }); + child.stdout.setEncoding("utf-8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.on("error", () => { + finish([]); + }); + child.on("close", (code) => { + if (signal.aborted || code !== 0 || !stdout) { + finish([]); + return; + } + + const lines = stdout.trim().split("\n").filter(Boolean); + const results: Array<{ path: string; isDirectory: boolean }> = []; + + for (const line of lines) { + const displayLine = toDisplayPath(line); + const hasTrailingSeparator = displayLine.endsWith("/"); + const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine; + if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) { + continue; + } + + results.push({ + path: displayLine, + isDirectory: hasTrailingSeparator, + }); + } + + finish(results); + }); + }); +} + +export interface AutocompleteItem { + value: string; + label: string; + description?: string; +} + +type Awaitable = T | Promise; + +export interface SlashCommand { + name: string; + description?: string; + argumentHint?: string; + // Function to get argument completions for this command + // Returns null if no argument completion is available + getArgumentCompletions?(argumentPrefix: string): Awaitable; +} + +export interface AutocompleteSuggestions { + items: AutocompleteItem[]; + prefix: string; // What we're matching against (e.g., "/" or "src/") +} + +export interface AutocompleteProvider { + /** Characters that should naturally trigger this provider at token boundaries. */ + triggerCharacters?: string[]; + + // Get autocomplete suggestions for current text/cursor position + // Returns null if no suggestions available + getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise; + + // Apply the selected item + // Returns the new text and cursor position + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { + lines: string[]; + cursorLine: number; + cursorCol: number; + }; + + // Check if file completion should trigger for explicit Tab completion + shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean; +} + +// Combined provider that handles both slash commands and file paths +export class CombinedAutocompleteProvider implements AutocompleteProvider { + private commands: (SlashCommand | AutocompleteItem)[]; + private basePath: string; + private fdPath: string | null; + + constructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string, fdPath: string | null = null) { + this.commands = commands; + this.basePath = basePath; + this.fdPath = fdPath; + } + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ): Promise { + const currentLine = lines[cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, cursorCol); + + const atPrefix = this.extractAtPrefix(textBeforeCursor); + if (atPrefix) { + const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix); + const suggestions = await this.getFuzzyFileSuggestions(rawPrefix, { + isQuotedPrefix, + signal: options.signal, + }); + if (suggestions.length === 0) return null; + + return { + items: suggestions, + prefix: atPrefix, + }; + } + + if (!options.force && textBeforeCursor.startsWith("/")) { + const spaceIndex = textBeforeCursor.indexOf(" "); + + if (spaceIndex === -1) { + const prefix = textBeforeCursor.slice(1); + const commandItems = this.commands.map((cmd) => { + const name = "name" in cmd ? cmd.name : cmd.value; + const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined; + const desc = cmd.description ?? ""; + const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc; + return { + name, + label: name, + description: fullDesc || undefined, + }; + }); + + const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({ + value: item.name, + label: item.label, + ...(item.description && { description: item.description }), + })); + + if (filtered.length === 0) return null; + + return { + items: filtered, + prefix: textBeforeCursor, + }; + } + + const commandName = textBeforeCursor.slice(1, spaceIndex); + const argumentText = textBeforeCursor.slice(spaceIndex + 1); + + const command = this.commands.find((cmd) => { + const name = "name" in cmd ? cmd.name : cmd.value; + return name === commandName; + }); + if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) { + return null; + } + + const argumentSuggestions = await command.getArgumentCompletions(argumentText); + if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) { + return null; + } + + return { + items: argumentSuggestions, + prefix: argumentText, + }; + } + + const pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false); + if (pathMatch === null) { + return null; + } + + const suggestions = this.getFileSuggestions(pathMatch); + if (suggestions.length === 0) return null; + + return { + items: suggestions, + prefix: pathMatch, + }; + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ): { lines: string[]; cursorLine: number; cursorCol: number } { + const currentLine = lines[cursorLine] || ""; + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const afterCursor = currentLine.slice(cursorCol); + const isQuotedPrefix = prefix.startsWith('"') || prefix.startsWith('@"'); + const hasLeadingQuoteAfterCursor = afterCursor.startsWith('"'); + const hasTrailingQuoteInItem = item.value.endsWith('"'); + const adjustedAfterCursor = + isQuotedPrefix && hasTrailingQuoteInItem && hasLeadingQuoteAfterCursor ? afterCursor.slice(1) : afterCursor; + + // Check if we're completing a slash command (prefix starts with "/" but NOT a file path) + // Slash commands are at the start of the line and don't contain path separators after the first / + const isSlashCommand = prefix.startsWith("/") && beforePrefix.trim() === "" && !prefix.slice(1).includes("/"); + if (isSlashCommand) { + // This is a command name completion + const newLine = `${beforePrefix}/${item.value} ${adjustedAfterCursor}`; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space + }; + } + + // Check if we're completing a file attachment (prefix starts with "@") + if (prefix.startsWith("@")) { + // This is a file attachment completion + // Don't add space after directories so user can continue autocompleting + const isDirectory = item.label.endsWith("/"); + const suffix = isDirectory ? "" : " "; + const newLine = `${beforePrefix + item.value}${suffix}${adjustedAfterCursor}`; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset + suffix.length, + }; + } + + // Check if we're in a slash command context (beforePrefix contains "/command ") + const textBeforeCursor = currentLine.slice(0, cursorCol); + if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) { + // This is likely a command argument completion + const newLine = beforePrefix + item.value + adjustedAfterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + + const isDirectory = item.label.endsWith("/"); + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset, + }; + } + + // For file paths, complete the path + const newLine = beforePrefix + item.value + adjustedAfterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + + const isDirectory = item.label.endsWith("/"); + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset, + }; + } + + // Extract @ prefix for fuzzy file suggestions + private extractAtPrefix(text: string): string | null { + const quotedPrefix = extractQuotedPrefix(text); + if (quotedPrefix?.startsWith('@"')) { + return quotedPrefix; + } + + const lastDelimiterIndex = findLastDelimiter(text); + const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1; + + if (text[tokenStart] === "@") { + return text.slice(tokenStart); + } + + return null; + } + + // Extract a path-like prefix from the text before cursor + private extractPathPrefix(text: string, forceExtract: boolean = false): string | null { + const quotedPrefix = extractQuotedPrefix(text); + if (quotedPrefix) { + return quotedPrefix; + } + + const lastDelimiterIndex = findLastDelimiter(text); + const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1); + + // For forced extraction (Tab key), always return something + if (forceExtract) { + return pathPrefix; + } + + // For natural triggers, return if it looks like a path, ends with /, starts with ~/, . + // Only return empty string if the text looks like it's starting a path context + if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) { + return pathPrefix; + } + + // Return empty string only after a space (not for completely empty text) + // Empty text should not trigger file suggestions - that's for forced Tab completion + if (pathPrefix === "" && text.endsWith(" ")) { + return pathPrefix; + } + + return null; + } + + // Expand home directory (~/) to actual home path + private expandHomePath(path: string): string { + if (path.startsWith("~/")) { + const expandedPath = join(homedir(), path.slice(2)); + // Preserve trailing slash if original path had one + return path.endsWith("/") && !expandedPath.endsWith("/") ? `${expandedPath}/` : expandedPath; + } else if (path === "~") { + return homedir(); + } + return path; + } + + private resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null { + const normalizedQuery = toDisplayPath(rawQuery); + const slashIndex = normalizedQuery.lastIndexOf("/"); + if (slashIndex === -1) { + return null; + } + + const displayBase = normalizedQuery.slice(0, slashIndex + 1); + const query = normalizedQuery.slice(slashIndex + 1); + + let baseDir: string; + if (displayBase.startsWith("~/")) { + baseDir = this.expandHomePath(displayBase); + } else if (displayBase.startsWith("/")) { + baseDir = displayBase; + } else { + baseDir = join(this.basePath, displayBase); + } + + try { + if (!statSync(baseDir).isDirectory()) { + return null; + } + } catch { + return null; + } + + return { baseDir, query, displayBase }; + } + + private scopedPathForDisplay(displayBase: string, relativePath: string): string { + const normalizedRelativePath = toDisplayPath(relativePath); + if (displayBase === "/") { + return `/${normalizedRelativePath}`; + } + return `${toDisplayPath(displayBase)}${normalizedRelativePath}`; + } + + // Get file/directory suggestions for a given path prefix + private getFileSuggestions(prefix: string): AutocompleteItem[] { + try { + let searchDir: string; + let searchPrefix: string; + const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix); + let expandedPrefix = rawPrefix; + + // Handle home directory expansion + if (expandedPrefix.startsWith("~")) { + expandedPrefix = this.expandHomePath(expandedPrefix); + } + + const isRootPrefix = + rawPrefix === "" || + rawPrefix === "./" || + rawPrefix === "../" || + rawPrefix === "~" || + rawPrefix === "~/" || + rawPrefix === "/" || + (isAtPrefix && rawPrefix === ""); + + if (isRootPrefix) { + // Complete from specified position + if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { + searchDir = expandedPrefix; + } else { + searchDir = join(this.basePath, expandedPrefix); + } + searchPrefix = ""; + } else if (rawPrefix.endsWith("/")) { + // If prefix ends with /, show contents of that directory + if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { + searchDir = expandedPrefix; + } else { + searchDir = join(this.basePath, expandedPrefix); + } + searchPrefix = ""; + } else { + // Split into directory and file prefix + const dir = dirname(expandedPrefix); + const file = basename(expandedPrefix); + if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) { + searchDir = dir; + } else { + searchDir = join(this.basePath, dir); + } + searchPrefix = file; + } + + const entries = readdirSync(searchDir, { withFileTypes: true }); + const suggestions: AutocompleteItem[] = []; + + for (const entry of entries) { + if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) { + continue; + } + + // Check if entry is a directory (or a symlink pointing to a directory) + let isDirectory = entry.isDirectory(); + if (!isDirectory && entry.isSymbolicLink()) { + try { + const fullPath = join(searchDir, entry.name); + isDirectory = statSync(fullPath).isDirectory(); + } catch { + // Broken symlink or permission error - treat as file + } + } + + let relativePath: string; + const name = entry.name; + const displayPrefix = rawPrefix; + + if (displayPrefix.endsWith("/")) { + // If prefix ends with /, append entry to the prefix + relativePath = displayPrefix + name; + } else if (displayPrefix.includes("/") || displayPrefix.includes("\\")) { + // Preserve ~/ format for home directory paths + if (displayPrefix.startsWith("~/")) { + const homeRelativeDir = displayPrefix.slice(2); // Remove ~/ + const dir = dirname(homeRelativeDir); + relativePath = `~/${dir === "." ? name : join(dir, name)}`; + } else if (displayPrefix.startsWith("/")) { + // Absolute path - construct properly + const dir = dirname(displayPrefix); + if (dir === "/") { + relativePath = `/${name}`; + } else { + relativePath = `${dir}/${name}`; + } + } else { + relativePath = join(dirname(displayPrefix), name); + // path.join normalizes away ./ prefix, preserve it + if (displayPrefix.startsWith("./") && !relativePath.startsWith("./")) { + relativePath = `./${relativePath}`; + } + } + } else { + // For standalone entries, preserve ~/ if original prefix was ~/ + if (displayPrefix.startsWith("~")) { + relativePath = `~/${name}`; + } else { + relativePath = name; + } + } + + relativePath = toDisplayPath(relativePath); + const pathValue = isDirectory ? `${relativePath}/` : relativePath; + const value = buildCompletionValue(pathValue, { + isDirectory, + isAtPrefix, + isQuotedPrefix, + }); + + suggestions.push({ + value, + label: name + (isDirectory ? "/" : ""), + }); + } + + // Sort directories first, then alphabetically + suggestions.sort((a, b) => { + const aIsDir = a.value.endsWith("/"); + const bIsDir = b.value.endsWith("/"); + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + return a.label.localeCompare(b.label); + }); + + return suggestions; + } catch (_e) { + // Directory doesn't exist or not accessible + return []; + } + } + + // Score an entry against the query (higher = better match) + // isDirectory adds bonus to prioritize folders + private scoreEntry(filePath: string, query: string, isDirectory: boolean): number { + const fileName = basename(filePath); + const lowerFileName = fileName.toLowerCase(); + const lowerQuery = query.toLowerCase(); + + let score = 0; + + // Exact filename match (highest) + if (lowerFileName === lowerQuery) score = 100; + // Filename starts with query + else if (lowerFileName.startsWith(lowerQuery)) score = 80; + // Substring match in filename + else if (lowerFileName.includes(lowerQuery)) score = 50; + // Substring match in full path + else if (filePath.toLowerCase().includes(lowerQuery)) score = 30; + + // Directories get a bonus to appear first + if (isDirectory && score > 0) score += 10; + + return score; + } + + // Fuzzy file search using fd (fast, respects .gitignore) + private async getFuzzyFileSuggestions( + query: string, + options: { isQuotedPrefix: boolean; signal: AbortSignal }, + ): Promise { + if (!this.fdPath || options.signal.aborted) { + return []; + } + + try { + const scopedQuery = this.resolveScopedFuzzyQuery(query); + const fdBaseDir = scopedQuery?.baseDir ?? this.basePath; + const fdQuery = scopedQuery?.query ?? query; + const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal); + if (options.signal.aborted) { + return []; + } + + const scoredEntries = entries + .map((entry) => ({ + ...entry, + score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1, + })) + .filter((entry) => entry.score > 0); + + scoredEntries.sort((a, b) => b.score - a.score); + const topEntries = scoredEntries.slice(0, 20); + + const suggestions: AutocompleteItem[] = []; + for (const { path: entryPath, isDirectory } of topEntries) { + const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath; + const displayPath = scopedQuery + ? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash) + : pathWithoutSlash; + const entryName = basename(pathWithoutSlash); + const completionPath = isDirectory ? `${displayPath}/` : displayPath; + const value = buildCompletionValue(completionPath, { + isDirectory, + isAtPrefix: true, + isQuotedPrefix: options.isQuotedPrefix, + }); + + suggestions.push({ + value, + label: entryName + (isDirectory ? "/" : ""), + description: displayPath, + }); + } + + return suggestions; + } catch { + return []; + } + } + + // Check if we should trigger file completion (called on Tab key) + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean { + const currentLine = lines[cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, cursorCol); + + // Don't trigger if we're typing a slash command at the start of the line + if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) { + return false; + } + + return true; + } +} diff --git a/cactus-code/packages/tui/src/components/box.ts b/cactus-code/packages/tui/src/components/box.ts new file mode 100644 index 000000000..3573ab092 --- /dev/null +++ b/cactus-code/packages/tui/src/components/box.ts @@ -0,0 +1,137 @@ +import type { Component } from "../tui.ts"; +import { applyBackgroundToLine, visibleWidth } from "../utils.ts"; + +type RenderCache = { + childLines: string[]; + width: number; + bgSample: string | undefined; + lines: string[]; +}; + +/** + * Box component - a container that applies padding and background to all children + */ +export class Box implements Component { + children: Component[] = []; + private paddingX: number; + private paddingY: number; + private bgFn?: (text: string) => string; + + // Cache for rendered output + private cache?: RenderCache; + + constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) { + this.paddingX = paddingX; + this.paddingY = paddingY; + this.bgFn = bgFn; + } + + addChild(component: Component): void { + this.children.push(component); + this.invalidateCache(); + } + + removeChild(component: Component): void { + const index = this.children.indexOf(component); + if (index !== -1) { + this.children.splice(index, 1); + this.invalidateCache(); + } + } + + clear(): void { + this.children = []; + this.invalidateCache(); + } + + setBgFn(bgFn?: (text: string) => string): void { + this.bgFn = bgFn; + // Don't invalidate here - we'll detect bgFn changes by sampling output + } + + private invalidateCache(): void { + this.cache = undefined; + } + + private matchCache(width: number, childLines: string[], bgSample: string | undefined): boolean { + const cache = this.cache; + return ( + !!cache && + cache.width === width && + cache.bgSample === bgSample && + cache.childLines.length === childLines.length && + cache.childLines.every((line, i) => line === childLines[i]) + ); + } + + invalidate(): void { + this.invalidateCache(); + for (const child of this.children) { + child.invalidate?.(); + } + } + + render(width: number): string[] { + if (this.children.length === 0) { + return []; + } + + const contentWidth = Math.max(1, width - this.paddingX * 2); + const leftPad = " ".repeat(this.paddingX); + + // Render all children + const childLines: string[] = []; + for (const child of this.children) { + const lines = child.render(contentWidth); + for (const line of lines) { + childLines.push(leftPad + line); + } + } + + if (childLines.length === 0) { + return []; + } + + // Check if bgFn output changed by sampling + const bgSample = this.bgFn ? this.bgFn("test") : undefined; + + // Check cache validity + if (this.matchCache(width, childLines, bgSample)) { + return this.cache!.lines; + } + + // Apply background and padding + const result: string[] = []; + + // Top padding + for (let i = 0; i < this.paddingY; i++) { + result.push(this.applyBg("", width)); + } + + // Content + for (const line of childLines) { + result.push(this.applyBg(line, width)); + } + + // Bottom padding + for (let i = 0; i < this.paddingY; i++) { + result.push(this.applyBg("", width)); + } + + // Update cache + this.cache = { childLines, width, bgSample, lines: result }; + + return result; + } + + private applyBg(line: string, width: number): string { + const visLen = visibleWidth(line); + const padNeeded = Math.max(0, width - visLen); + const padded = line + " ".repeat(padNeeded); + + if (this.bgFn) { + return applyBackgroundToLine(padded, width, this.bgFn); + } + return padded; + } +} diff --git a/cactus-code/packages/tui/src/components/cancellable-loader.ts b/cactus-code/packages/tui/src/components/cancellable-loader.ts new file mode 100644 index 000000000..7822cb042 --- /dev/null +++ b/cactus-code/packages/tui/src/components/cancellable-loader.ts @@ -0,0 +1,40 @@ +import { getKeybindings } from "../keybindings.ts"; +import { Loader } from "./loader.ts"; + +/** + * Loader that can be cancelled with Escape. + * Extends Loader with an AbortSignal for cancelling async operations. + * + * @example + * const loader = new CancellableLoader(tui, cyan, dim, "Working..."); + * loader.onAbort = () => done(null); + * doWork(loader.signal).then(done); + */ +export class CancellableLoader extends Loader { + private abortController = new AbortController(); + + /** Called when user presses Escape */ + onAbort?: () => void; + + /** AbortSignal that is aborted when user presses Escape */ + get signal(): AbortSignal { + return this.abortController.signal; + } + + /** Whether the loader was aborted */ + get aborted(): boolean { + return this.abortController.signal.aborted; + } + + handleInput(data: string): void { + const kb = getKeybindings(); + if (kb.matches(data, "tui.select.cancel")) { + this.abortController.abort(); + this.onAbort?.(); + } + } + + dispose(): void { + this.stop(); + } +} diff --git a/cactus-code/packages/tui/src/components/editor.ts b/cactus-code/packages/tui/src/components/editor.ts new file mode 100644 index 000000000..bedd01057 --- /dev/null +++ b/cactus-code/packages/tui/src/components/editor.ts @@ -0,0 +1,2307 @@ +import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocomplete.ts"; +import { getKeybindings } from "../keybindings.ts"; +import { decodePrintableKey, matchesKey } from "../keys.ts"; +import { KillRing } from "../kill-ring.ts"; +import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.ts"; +import { UndoStack } from "../undo-stack.ts"; +import { + cjkBreakRegex, + getGraphemeSegmenter, + getWordSegmenter, + isWhitespaceChar, + truncateToWidth, + visibleWidth, +} from "../utils.ts"; +import { findWordBackward, findWordForward } from "../word-navigation.ts"; +import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list.ts"; + +const graphemeSegmenter = getGraphemeSegmenter(); +const wordSegmenter = getWordSegmenter(); + +/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */ +const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g; + +/** Non-global version for single-segment testing. */ +const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/; + +/** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */ +function isPasteMarker(segment: string): boolean { + return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment); +} + +/** + * A segmenter that wraps Intl.Segmenter and merges graphemes that fall + * within paste markers into single atomic segments. This makes cursor + * movement, deletion, word-wrap, etc. treat paste markers as single units. + * + * Only markers whose numeric ID exists in `validIds` are merged. + */ +function segmentWithMarkers( + text: string, + baseSegmenter: Intl.Segmenter, + validIds: Set, +): Iterable { + // Fast path: no paste markers in the text or no valid IDs. + if (validIds.size === 0 || !text.includes("[paste #")) { + return baseSegmenter.segment(text); + } + + // Find all marker spans with valid IDs. + const markers: Array<{ start: number; end: number }> = []; + for (const m of text.matchAll(PASTE_MARKER_REGEX)) { + const id = Number.parseInt(m[1]!, 10); + if (!validIds.has(id)) continue; + markers.push({ start: m.index, end: m.index + m[0].length }); + } + if (markers.length === 0) { + return baseSegmenter.segment(text); + } + + // Build merged segment list. + const baseSegments = baseSegmenter.segment(text); + const result: Intl.SegmentData[] = []; + let markerIdx = 0; + + for (const seg of baseSegments) { + // Skip past markers that are entirely before this segment. + while (markerIdx < markers.length && markers[markerIdx]!.end <= seg.index) { + markerIdx++; + } + + const marker = markerIdx < markers.length ? markers[markerIdx]! : null; + + if (marker && seg.index >= marker.start && seg.index < marker.end) { + // This segment falls inside a marker. + // If this is the first segment of the marker, emit a merged segment. + if (seg.index === marker.start) { + const markerText = text.slice(marker.start, marker.end); + result.push({ + segment: markerText, + index: marker.start, + input: text, + }); + } + // Otherwise skip (already merged into the first segment). + } else { + result.push(seg); + } + } + + return result; +} + +/** + * Represents a chunk of text for word-wrap layout. + * Tracks both the text content and its position in the original line. + */ +export interface TextChunk { + text: string; + startIndex: number; + endIndex: number; +} + +/** + * Split a line into word-wrapped chunks. + * Wraps at word boundaries when possible, falling back to character-level + * wrapping for words longer than the available width. + * + * @param line - The text line to wrap + * @param maxWidth - Maximum visible width per chunk + * @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness). + * When omitted the default Intl.Segmenter is used. + * @returns Array of chunks with text and position information + */ +export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[] { + if (!line || maxWidth <= 0) { + return [{ text: "", startIndex: 0, endIndex: 0 }]; + } + + const lineWidth = visibleWidth(line); + if (lineWidth <= maxWidth) { + return [{ text: line, startIndex: 0, endIndex: line.length }]; + } + + const chunks: TextChunk[] = []; + const segments = preSegmented ?? [...graphemeSegmenter.segment(line)]; + + let currentWidth = 0; + let chunkStart = 0; + + // Wrap opportunity: the position after the last whitespace before a non-whitespace + // grapheme, i.e. where a line break is allowed. + let wrapOppIndex = -1; + let wrapOppWidth = 0; + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + const grapheme = seg.segment; + const gWidth = visibleWidth(grapheme); + const charIndex = seg.index; + const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme); + + // Overflow check before advancing. + if (currentWidth + gWidth > maxWidth) { + if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) { + // Backtrack to last wrap opportunity (the remaining content + // plus the current grapheme still fits within maxWidth). + chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex }); + chunkStart = wrapOppIndex; + currentWidth -= wrapOppWidth; + } else if (chunkStart < charIndex) { + // No viable wrap opportunity: force-break at current position. + // This also handles the case where backtracking to a word + // boundary wouldn't help because the remaining content plus + // the current grapheme (e.g. a wide character) still exceeds + // maxWidth. + chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex }); + chunkStart = charIndex; + currentWidth = 0; + } + wrapOppIndex = -1; + } + + if (gWidth > maxWidth) { + // Single atomic segment wider than maxWidth (e.g. paste marker + // in a narrow terminal). Re-wrap it at grapheme granularity. + + // The segment remains logically atomic for cursor + // movement / editing — the split is purely visual for word-wrap layout. + const subChunks = wordWrapLine(grapheme, maxWidth); + for (let j = 0; j < subChunks.length - 1; j++) { + const sc = subChunks[j]!; + chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex }); + } + const last = subChunks[subChunks.length - 1]!; + chunkStart = charIndex + last.startIndex; + currentWidth = visibleWidth(last.text); + wrapOppIndex = -1; + continue; + } + + // Advance. + currentWidth += gWidth; + + // Record wrap opportunity: whitespace followed by non-whitespace + // (multiple spaces join; the break point is after the last space), + // or at a boundary where either side is CJK (CJK allows breaking + // between any adjacent characters). + const next = segments[i + 1]; + if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) { + wrapOppIndex = next.index; + wrapOppWidth = currentWidth; + } else if (!isWs && next && !isWhitespaceChar(next.segment)) { + const isCjk = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme); + const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment); + if (isCjk || nextIsCjk) { + wrapOppIndex = next.index; + wrapOppWidth = currentWidth; + } + } + } + + // Push final chunk. + chunks.push({ text: line.slice(chunkStart), startIndex: chunkStart, endIndex: line.length }); + + return chunks; +} + +// Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints. +interface EditorState { + lines: string[]; + cursorLine: number; + cursorCol: number; +} + +interface LayoutLine { + text: string; + hasCursor: boolean; + cursorPos?: number; +} + +export interface EditorTheme { + borderColor: (str: string) => string; + selectList: SelectListTheme; +} + +export interface EditorOptions { + paddingX?: number; + autocompleteMaxVisible?: number; +} + +const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { + minPrimaryColumnWidth: 12, + maxPrimaryColumnWidth: 32, +}; + +const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20; +const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"]; + +function escapeCharacterClass(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&"); +} + +function buildTriggerPattern(triggerCharacters: string[]): RegExp { + return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`); +} + +function buildDebouncePattern(triggerCharacters: string[]): RegExp { + const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass); + return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); +} + +export class Editor implements Component, Focusable { + private state: EditorState = { + lines: [""], + cursorLine: 0, + cursorCol: 0, + }; + + /** Focusable interface - set by TUI when focus changes */ + focused: boolean = false; + + protected tui: TUI; + private theme: EditorTheme; + private paddingX: number = 0; + + // Store last render width for cursor navigation + private lastWidth: number = 80; + + // Vertical scrolling support + private scrollOffset: number = 0; + + // Border color (can be changed dynamically) + public borderColor: (str: string) => string; + + // Autocomplete support + private autocompleteProvider?: AutocompleteProvider; + private autocompleteTriggerCharacters = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS]; + private autocompleteTriggerPattern = buildTriggerPattern(this.autocompleteTriggerCharacters); + private autocompleteDebouncePattern = buildDebouncePattern(this.autocompleteTriggerCharacters); + private autocompleteList?: SelectList; + private autocompleteState: "regular" | "force" | null = null; + private autocompletePrefix: string = ""; + private autocompleteMaxVisible: number = 5; + private autocompleteAbort?: AbortController; + private autocompleteDebounceTimer?: ReturnType; + private autocompleteRequestTask: Promise = Promise.resolve(); + private autocompleteStartToken: number = 0; + private autocompleteRequestId: number = 0; + + // Paste tracking for large pastes + private pastes: Map = new Map(); + private pasteCounter: number = 0; + + // Bracketed paste mode buffering + private pasteBuffer: string = ""; + private isInPaste: boolean = false; + + // Prompt history for up/down navigation + private history: string[] = []; + private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. + private historyDraft: EditorState | null = null; + + // Kill ring for Emacs-style kill/yank operations + private killRing = new KillRing(); + private lastAction: "kill" | "yank" | "type-word" | null = null; + + // Character jump mode + private jumpMode: "forward" | "backward" | null = null; + + // Preferred visual column for vertical cursor movement (sticky column) + private preferredVisualCol: number | null = null; + + // When the cursor is snapped to the start of an atomic segment, e.g. a + // paste marker, cursorCol no longer reflects where the cursor would have + // landed. This field stores the pre-snap cursorCol so that the next + // vertical move can resolve it to a visual column on whatever VL it belongs + // to. + private snappedFromCursorCol: number | null = null; + + // Undo support + private undoStack = new UndoStack(); + + public onSubmit?: (text: string) => void; + public onChange?: (text: string) => void; + public disableSubmit: boolean = false; + + constructor(tui: TUI, theme: EditorTheme, options: EditorOptions = {}) { + this.tui = tui; + this.theme = theme; + this.borderColor = theme.borderColor; + const paddingX = options.paddingX ?? 0; + this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0; + const maxVisible = options.autocompleteMaxVisible ?? 5; + this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; + } + + /** Set of currently valid paste IDs, for marker-aware segmentation. */ + private validPasteIds(): Set { + return new Set(this.pastes.keys()); + } + + /** Segment text with paste-marker awareness, only merging markers with valid IDs. */ + private segment(text: string, mode: "word" | "grapheme"): Iterable { + return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.validPasteIds()); + } + + getPaddingX(): number { + return this.paddingX; + } + + setPaddingX(padding: number): void { + const newPadding = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) : 0; + if (this.paddingX !== newPadding) { + this.paddingX = newPadding; + this.tui.requestRender(); + } + } + + getAutocompleteMaxVisible(): number { + return this.autocompleteMaxVisible; + } + + setAutocompleteMaxVisible(maxVisible: number): void { + const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5; + if (this.autocompleteMaxVisible !== newMaxVisible) { + this.autocompleteMaxVisible = newMaxVisible; + this.tui.requestRender(); + } + } + + setAutocompleteProvider(provider: AutocompleteProvider): void { + this.cancelAutocomplete(); + this.autocompleteProvider = provider; + this.setAutocompleteTriggerCharacters(provider.triggerCharacters ?? []); + } + + /** + * Add a prompt to history for up/down arrow navigation. + * Called after successful submission. + */ + addToHistory(text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + // Don't add consecutive duplicates + if (this.history.length > 0 && this.history[0] === trimmed) return; + this.history.unshift(trimmed); + // Limit history size + if (this.history.length > 100) { + this.history.pop(); + } + } + + private isEditorEmpty(): boolean { + return this.state.lines.length === 1 && this.state.lines[0] === ""; + } + + private isOnFirstVisualLine(): boolean { + const visualLines = this.buildVisualLineMap(this.lastWidth); + const currentVisualLine = this.findCurrentVisualLine(visualLines); + return currentVisualLine === 0; + } + + private isOnLastVisualLine(): boolean { + const visualLines = this.buildVisualLineMap(this.lastWidth); + const currentVisualLine = this.findCurrentVisualLine(visualLines); + return currentVisualLine === visualLines.length - 1; + } + + private navigateHistory(direction: 1 | -1): void { + this.lastAction = null; + if (this.history.length === 0) return; + + const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases + if (newIndex < -1 || newIndex >= this.history.length) return; + + // Capture state when first entering history browsing mode + if (this.historyIndex === -1 && newIndex >= 0) { + this.pushUndoSnapshot(); + this.historyDraft = structuredClone(this.state); + } + + this.historyIndex = newIndex; + + if (this.historyIndex === -1) { + const draft = this.historyDraft; + this.historyDraft = null; + if (draft) { + this.state = draft; + this.preferredVisualCol = null; + this.snappedFromCursorCol = null; + this.scrollOffset = 0; + if (this.onChange) this.onChange(this.getText()); + } else { + this.setTextInternal(""); + } + } else { + this.setTextInternal(this.history[this.historyIndex] || "", direction === -1 ? "start" : "end"); + } + } + + private exitHistoryBrowsing(): void { + this.historyIndex = -1; + this.historyDraft = null; + } + + /** Internal setText that doesn't reset history state - used by navigateHistory */ + private setTextInternal(text: string, cursorPlacement: "start" | "end" = "end"): void { + const lines = text.split("\n"); + this.state.lines = lines.length === 0 ? [""] : lines; + this.state.cursorLine = cursorPlacement === "start" ? 0 : this.state.lines.length - 1; + this.setCursorCol(cursorPlacement === "start" ? 0 : this.state.lines[this.state.cursorLine]?.length || 0); + // Reset scroll - render() will adjust to show cursor + this.scrollOffset = 0; + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(width: number): string[] { + const maxPadding = Math.max(0, Math.floor((width - 1) / 2)); + const paddingX = Math.min(this.paddingX, maxPadding); + const contentWidth = Math.max(1, width - paddingX * 2); + + // Layout width: with padding the cursor can overflow into it, + // without padding we reserve 1 column for the cursor. + const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1)); + + // Store for cursor navigation (must match wrapping width) + this.lastWidth = layoutWidth; + + const horizontal = this.borderColor("─"); + + // Layout the text + const layoutLines = this.layoutText(layoutWidth); + + // Calculate max visible lines: 30% of terminal height, minimum 5 lines + const terminalRows = this.tui.terminal.rows; + const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3)); + + // Find the cursor line index in layoutLines + let cursorLineIndex = layoutLines.findIndex((line) => line.hasCursor); + if (cursorLineIndex === -1) cursorLineIndex = 0; + + // Adjust scroll offset to keep cursor visible + if (cursorLineIndex < this.scrollOffset) { + this.scrollOffset = cursorLineIndex; + } else if (cursorLineIndex >= this.scrollOffset + maxVisibleLines) { + this.scrollOffset = cursorLineIndex - maxVisibleLines + 1; + } + + // Clamp scroll offset to valid range + const maxScrollOffset = Math.max(0, layoutLines.length - maxVisibleLines); + this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScrollOffset)); + + // Get visible lines slice + const visibleLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines); + + const result: string[] = []; + const leftPadding = " ".repeat(paddingX); + const rightPadding = leftPadding; + + // Render top border (with scroll indicator if scrolled down) + if (this.scrollOffset > 0) { + const indicator = `─── ↑ ${this.scrollOffset} more `; + const remaining = width - visibleWidth(indicator); + if (remaining >= 0) { + result.push(this.borderColor(indicator + "─".repeat(remaining))); + } else { + result.push(this.borderColor(truncateToWidth(indicator, width))); + } + } else { + result.push(horizontal.repeat(width)); + } + + // Render each visible layout line + // Emit hardware cursor marker when focused so TUI can position the + // hardware cursor for IME candidate-window placement even while + // autocomplete (e.g. slash-command menu) is visible. + const emitCursorMarker = this.focused; + + for (const layoutLine of visibleLines) { + let displayText = layoutLine.text; + let lineVisibleWidth = visibleWidth(layoutLine.text); + let cursorInPadding = false; + + // Add cursor if this line has it + if (layoutLine.hasCursor && layoutLine.cursorPos !== undefined) { + const before = displayText.slice(0, layoutLine.cursorPos); + const after = displayText.slice(layoutLine.cursorPos); + + // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) + const marker = emitCursorMarker ? CURSOR_MARKER : ""; + + if (after.length > 0) { + // Cursor is on a character (grapheme) - replace it with highlighted version + // Get the first grapheme from 'after' + const afterGraphemes = [...this.segment(after, "grapheme")]; + const firstGrapheme = afterGraphemes[0]?.segment || ""; + const restAfter = after.slice(firstGrapheme.length); + const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; + displayText = before + marker + cursor + restAfter; + // lineVisibleWidth stays the same - we're replacing, not adding + } else { + // Cursor is at the end - add highlighted space + const cursor = "\x1b[7m \x1b[0m"; + displayText = before + marker + cursor; + lineVisibleWidth = lineVisibleWidth + 1; + // If cursor overflows content width into the padding, flag it + if (lineVisibleWidth > contentWidth && paddingX > 0) { + cursorInPadding = true; + } + } + } + + // Calculate padding based on actual visible width + const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth)); + const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding; + + // Render the line (no side borders, just horizontal lines above and below) + result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`); + } + + // Render bottom border (with scroll indicator if more content below) + const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length); + if (linesBelow > 0) { + const indicator = `─── ↓ ${linesBelow} more `; + const remaining = width - visibleWidth(indicator); + result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); + } else { + result.push(horizontal.repeat(width)); + } + + // Add autocomplete list if active + if (this.autocompleteState && this.autocompleteList) { + const autocompleteResult = this.autocompleteList.render(contentWidth); + for (const line of autocompleteResult) { + const lineWidth = visibleWidth(line); + const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth)); + result.push(`${leftPadding}${line}${linePadding}${rightPadding}`); + } + } + + return result; + } + + handleInput(data: string): void { + const kb = getKeybindings(); + + // Handle character jump mode (awaiting next character to jump to) + if (this.jumpMode !== null) { + // Cancel if the hotkey is pressed again + if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) { + this.jumpMode = null; + return; + } + + const printable = decodePrintableKey(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined); + if (printable !== undefined) { + // Printable character - perform the jump + const direction = this.jumpMode; + this.jumpMode = null; + this.jumpToChar(printable, direction); + return; + } + + // Control character - cancel and fall through to normal handling + this.jumpMode = null; + } + + // Handle bracketed paste mode + if (data.includes("\x1b[200~")) { + this.isInPaste = true; + this.pasteBuffer = ""; + data = data.replace("\x1b[200~", ""); + } + + if (this.isInPaste) { + this.pasteBuffer += data; + const endIndex = this.pasteBuffer.indexOf("\x1b[201~"); + if (endIndex !== -1) { + const pasteContent = this.pasteBuffer.substring(0, endIndex); + if (pasteContent.length > 0) { + this.handlePaste(pasteContent); + } + this.isInPaste = false; + const remaining = this.pasteBuffer.substring(endIndex + 6); + this.pasteBuffer = ""; + if (remaining.length > 0) { + this.handleInput(remaining); + } + return; + } + return; + } + + // Ctrl+C - let parent handle (exit/clear) + if (kb.matches(data, "tui.input.copy")) { + return; + } + + // Undo + if (kb.matches(data, "tui.editor.undo")) { + this.undo(); + return; + } + + // Handle autocomplete mode + if (this.autocompleteState && this.autocompleteList) { + if (kb.matches(data, "tui.select.cancel")) { + this.cancelAutocomplete(); + return; + } + + if (kb.matches(data, "tui.select.up") || kb.matches(data, "tui.select.down")) { + this.autocompleteList.handleInput(data); + return; + } + + if (kb.matches(data, "tui.input.tab")) { + const selected = this.autocompleteList.getSelectedItem(); + if (selected && this.autocompleteProvider) { + this.pushUndoSnapshot(); + this.lastAction = null; + const result = this.autocompleteProvider.applyCompletion( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + selected, + this.autocompletePrefix, + ); + this.state.lines = result.lines; + this.state.cursorLine = result.cursorLine; + this.setCursorCol(result.cursorCol); + this.cancelAutocomplete(); + if (this.onChange) this.onChange(this.getText()); + } + return; + } + + if (kb.matches(data, "tui.select.confirm")) { + const selected = this.autocompleteList.getSelectedItem(); + if (selected && this.autocompleteProvider) { + this.pushUndoSnapshot(); + this.lastAction = null; + const result = this.autocompleteProvider.applyCompletion( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + selected, + this.autocompletePrefix, + ); + this.state.lines = result.lines; + this.state.cursorLine = result.cursorLine; + this.setCursorCol(result.cursorCol); + + if (this.autocompletePrefix.startsWith("/")) { + this.cancelAutocomplete(); + // Fall through to submit + } else { + this.cancelAutocomplete(); + if (this.onChange) this.onChange(this.getText()); + return; + } + } + } + } + + // Tab - trigger completion + if (kb.matches(data, "tui.input.tab") && !this.autocompleteState) { + this.handleTabCompletion(); + return; + } + + // Deletion actions + if (kb.matches(data, "tui.editor.deleteToLineEnd")) { + this.deleteToEndOfLine(); + return; + } + if (kb.matches(data, "tui.editor.deleteToLineStart")) { + this.deleteToStartOfLine(); + return; + } + if (kb.matches(data, "tui.editor.deleteWordBackward")) { + this.deleteWordBackwards(); + return; + } + if (kb.matches(data, "tui.editor.deleteWordForward")) { + this.deleteWordForward(); + return; + } + if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) { + this.handleBackspace(); + return; + } + if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) { + this.handleForwardDelete(); + return; + } + + // Kill ring actions + if (kb.matches(data, "tui.editor.yank")) { + this.yank(); + return; + } + if (kb.matches(data, "tui.editor.yankPop")) { + this.yankPop(); + return; + } + + // Cursor movement actions + if (kb.matches(data, "tui.editor.cursorLineStart")) { + this.moveToLineStart(); + return; + } + if (kb.matches(data, "tui.editor.cursorLineEnd")) { + this.moveToLineEnd(); + return; + } + if (kb.matches(data, "tui.editor.cursorWordLeft")) { + this.moveWordBackwards(); + return; + } + if (kb.matches(data, "tui.editor.cursorWordRight")) { + this.moveWordForwards(); + return; + } + + // New line + if ( + kb.matches(data, "tui.input.newLine") || + (data.charCodeAt(0) === 10 && data.length > 1) || + data === "\x1b\r" || + data === "\x1b[13;2~" || + (data.length > 1 && data.includes("\x1b") && data.includes("\r")) || + (data === "\n" && data.length === 1) + ) { + if (this.shouldSubmitOnBackslashEnter(data, kb)) { + this.handleBackspace(); + this.submitValue(); + return; + } + this.addNewLine(); + return; + } + + // Submit (Enter) + if (kb.matches(data, "tui.input.submit")) { + if (this.disableSubmit) return; + + // Workaround for terminals without Shift+Enter support: + // If char before cursor is \, delete it and insert newline instead of submitting. + const currentLine = this.state.lines[this.state.cursorLine] || ""; + if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") { + this.handleBackspace(); + this.addNewLine(); + return; + } + + this.submitValue(); + return; + } + + // Arrow key navigation (with history support) + if (kb.matches(data, "tui.editor.cursorUp")) { + if ( + this.isOnFirstVisualLine() && + (this.isEditorEmpty() || this.historyIndex > -1 || this.state.cursorCol === 0) + ) { + this.navigateHistory(-1); + } else if (this.isOnFirstVisualLine()) { + // Already at top - jump to start of line + this.moveToLineStart(); + } else { + this.moveCursor(-1, 0); + } + return; + } + if (kb.matches(data, "tui.editor.cursorDown")) { + if (this.historyIndex > -1 && this.isOnLastVisualLine()) { + this.navigateHistory(1); + } else if (this.isOnLastVisualLine()) { + // Already at bottom - jump to end of line + this.moveToLineEnd(); + } else { + this.moveCursor(1, 0); + } + return; + } + if (kb.matches(data, "tui.editor.cursorRight")) { + this.moveCursor(0, 1); + return; + } + if (kb.matches(data, "tui.editor.cursorLeft")) { + this.moveCursor(0, -1); + return; + } + + // Page up/down - scroll by page and move cursor + if (kb.matches(data, "tui.editor.pageUp")) { + this.pageScroll(-1); + return; + } + if (kb.matches(data, "tui.editor.pageDown")) { + this.pageScroll(1); + return; + } + + // Character jump mode triggers + if (kb.matches(data, "tui.editor.jumpForward")) { + this.jumpMode = "forward"; + return; + } + if (kb.matches(data, "tui.editor.jumpBackward")) { + this.jumpMode = "backward"; + return; + } + + // Shift+Space - insert regular space + if (matchesKey(data, "shift+space")) { + this.insertCharacter(" "); + return; + } + + const printable = decodePrintableKey(data); + if (printable !== undefined) { + this.insertCharacter(printable); + return; + } + + // Regular characters + if (data.charCodeAt(0) >= 32) { + this.insertCharacter(data); + } + } + + private layoutText(contentWidth: number): LayoutLine[] { + const layoutLines: LayoutLine[] = []; + + if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) { + // Empty editor + layoutLines.push({ + text: "", + hasCursor: true, + cursorPos: 0, + }); + return layoutLines; + } + + // Process each logical line + for (let i = 0; i < this.state.lines.length; i++) { + const line = this.state.lines[i] || ""; + const isCurrentLine = i === this.state.cursorLine; + const lineVisibleWidth = visibleWidth(line); + + if (lineVisibleWidth <= contentWidth) { + // Line fits in one layout line + if (isCurrentLine) { + layoutLines.push({ + text: line, + hasCursor: true, + cursorPos: this.state.cursorCol, + }); + } else { + layoutLines.push({ + text: line, + hasCursor: false, + }); + } + } else { + // Line needs wrapping - use word-aware wrapping + const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]); + + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + const chunk = chunks[chunkIndex]; + if (!chunk) continue; + + const cursorPos = this.state.cursorCol; + const isLastChunk = chunkIndex === chunks.length - 1; + + // Determine if cursor is in this chunk + // For word-wrapped chunks, we need to handle the case where + // cursor might be in trimmed whitespace at end of chunk + let hasCursorInChunk = false; + let adjustedCursorPos = 0; + + if (isCurrentLine) { + if (isLastChunk) { + // Last chunk: cursor belongs here if >= startIndex + hasCursorInChunk = cursorPos >= chunk.startIndex; + adjustedCursorPos = cursorPos - chunk.startIndex; + } else { + // Non-last chunk: cursor belongs here if in range [startIndex, endIndex) + // But we need to handle the visual position in the trimmed text + hasCursorInChunk = cursorPos >= chunk.startIndex && cursorPos < chunk.endIndex; + if (hasCursorInChunk) { + adjustedCursorPos = cursorPos - chunk.startIndex; + // Clamp to text length (in case cursor was in trimmed whitespace) + if (adjustedCursorPos > chunk.text.length) { + adjustedCursorPos = chunk.text.length; + } + } + } + } + + if (hasCursorInChunk) { + layoutLines.push({ + text: chunk.text, + hasCursor: true, + cursorPos: adjustedCursorPos, + }); + } else { + layoutLines.push({ + text: chunk.text, + hasCursor: false, + }); + } + } + } + } + + return layoutLines; + } + + getText(): string { + return this.state.lines.join("\n"); + } + + private expandPasteMarkers(text: string): string { + let result = text; + for (const [pasteId, pasteContent] of this.pastes) { + const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g"); + result = result.replace(markerRegex, () => pasteContent); + } + return result; + } + + /** + * Get text with paste markers expanded to their actual content. + * Use this when you need the full content (e.g., for external editor). + */ + getExpandedText(): string { + return this.expandPasteMarkers(this.state.lines.join("\n")); + } + + getLines(): string[] { + return [...this.state.lines]; + } + + getCursor(): { line: number; col: number } { + return { line: this.state.cursorLine, col: this.state.cursorCol }; + } + + setText(text: string): void { + this.cancelAutocomplete(); + this.lastAction = null; + this.exitHistoryBrowsing(); + const normalized = this.normalizeText(text); + // Push undo snapshot if content differs (makes programmatic changes undoable) + if (this.getText() !== normalized) { + this.pushUndoSnapshot(); + } + this.setTextInternal(normalized); + } + + /** + * Insert text at the current cursor position. + * Used for programmatic insertion (e.g., clipboard image markers). + * This is atomic for undo - single undo restores entire pre-insert state. + */ + insertTextAtCursor(text: string): void { + if (!text) return; + this.cancelAutocomplete(); + this.pushUndoSnapshot(); + this.lastAction = null; + this.exitHistoryBrowsing(); + this.insertTextAtCursorInternal(text); + } + + /** + * Normalize text for editor storage: + * - Normalize line endings (\r\n and \r -> \n) + * - Expand tabs to 4 spaces + */ + private normalizeText(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\t/g, " "); + } + + /** + * Internal text insertion at cursor. Handles single and multi-line text. + * Does not push undo snapshots or trigger autocomplete - caller is responsible. + * Normalizes line endings and calls onChange once at the end. + */ + private insertTextAtCursorInternal(text: string): void { + if (!text) return; + + // Normalize line endings and tabs + const normalized = this.normalizeText(text); + const insertedLines = normalized.split("\n"); + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + const afterCursor = currentLine.slice(this.state.cursorCol); + + if (insertedLines.length === 1) { + // Single line - insert at cursor position + this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor; + this.setCursorCol(this.state.cursorCol + normalized.length); + } else { + // Multi-line insertion + this.state.lines = [ + // All lines before current line + ...this.state.lines.slice(0, this.state.cursorLine), + + // The first inserted line merged with text before cursor + beforeCursor + insertedLines[0], + + // All middle inserted lines + ...insertedLines.slice(1, -1), + + // The last inserted line with text after cursor + insertedLines[insertedLines.length - 1] + afterCursor, + + // All lines after current line + ...this.state.lines.slice(this.state.cursorLine + 1), + ]; + + this.state.cursorLine += insertedLines.length - 1; + this.setCursorCol((insertedLines[insertedLines.length - 1] || "").length); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + // All the editor methods from before... + private insertCharacter(char: string, skipUndoCoalescing?: boolean): void { + this.exitHistoryBrowsing(); + + // Undo coalescing (fish-style): + // - Consecutive word chars coalesce into one undo unit + // - Space captures state before itself (so undo removes space+following word together) + // - Each space is separately undoable + // Skip coalescing when called from atomic operations (e.g., handlePaste) + if (!skipUndoCoalescing) { + if (isWhitespaceChar(char) || this.lastAction !== "type-word") { + this.pushUndoSnapshot(); + } + this.lastAction = "type-word"; + } + + const line = this.state.lines[this.state.cursorLine] || ""; + + const before = line.slice(0, this.state.cursorCol); + const after = line.slice(this.state.cursorCol); + + this.state.lines[this.state.cursorLine] = before + char + after; + this.setCursorCol(this.state.cursorCol + char.length); + + if (this.onChange) { + this.onChange(this.getText()); + } + + // Check if we should trigger or update autocomplete + if (!this.autocompleteState) { + // Auto-trigger for "/" at the start of a line (slash commands) + if (char === "/" && this.isAtStartOfMessage()) { + this.tryTriggerAutocomplete(); + } + // Auto-trigger for symbol-based completion like @, #, or provider triggers at token boundaries + else if (this.autocompleteTriggerCharacters.includes(char)) { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2]; + if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") { + this.tryTriggerAutocomplete(); + } + } + // Also auto-trigger when typing letters in a slash command or symbol completion context + else if (/[a-zA-Z0-9.\-_]/.test(char)) { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + // Check if we're in a slash command (with or without space for arguments) + if (this.isInSlashCommandContext(textBeforeCursor)) { + this.tryTriggerAutocomplete(); + } + // Check if we're in a symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { + this.tryTriggerAutocomplete(); + } + } + } else { + this.updateAutocomplete(); + } + } + + private handlePaste(pastedText: string): void { + this.cancelAutocomplete(); + this.exitHistoryBrowsing(); + this.lastAction = null; + + this.pushUndoSnapshot(); + + // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode + // control bytes inside bracketed paste as CSI-u Ctrl+ sequences + // (ESC [ ; 5 u). Decode those back to their literal byte so the + // per-char filter below preserves newlines instead of stripping ESC and + // leaking the printable tail (e.g. "[106;5u") into the editor. + const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => { + const cp = Number(code); + if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96); + if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64); + return match; + }); + + // Clean the pasted text: normalize line endings, expand tabs + const cleanText = this.normalizeText(decodedText); + + // Filter out non-printable characters except newlines + let filteredText = cleanText + .split("") + .filter((char) => char === "\n" || char.charCodeAt(0) >= 32) + .join(""); + + // If pasting a file path (starts with /, ~, or .) and the character before + // the cursor is a word character, prepend a space for better readability + if (/^[/~.]/.test(filteredText)) { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const charBeforeCursor = this.state.cursorCol > 0 ? currentLine[this.state.cursorCol - 1] : ""; + if (charBeforeCursor && /\w/.test(charBeforeCursor)) { + filteredText = ` ${filteredText}`; + } + } + + // Split into lines to check for large paste + const pastedLines = filteredText.split("\n"); + + // Check if this is a large paste (> 10 lines or > 1000 characters) + const totalChars = filteredText.length; + if (pastedLines.length > 10 || totalChars > 1000) { + // Store the paste and insert a marker + this.pasteCounter++; + const pasteId = this.pasteCounter; + this.pastes.set(pasteId, filteredText); + + // Insert marker like "[paste #1 +123 lines]" or "[paste #1 1234 chars]" + const marker = + pastedLines.length > 10 + ? `[paste #${pasteId} +${pastedLines.length} lines]` + : `[paste #${pasteId} ${totalChars} chars]`; + this.insertTextAtCursorInternal(marker); + return; + } + + if (pastedLines.length === 1) { + // Single line - insert atomically (do not trigger autocomplete during paste) + this.insertTextAtCursorInternal(filteredText); + return; + } + + // Multi-line paste - use direct state manipulation + this.insertTextAtCursorInternal(filteredText); + } + + private addNewLine(): void { + this.cancelAutocomplete(); + this.exitHistoryBrowsing(); + this.lastAction = null; + + this.pushUndoSnapshot(); + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + const before = currentLine.slice(0, this.state.cursorCol); + const after = currentLine.slice(this.state.cursorCol); + + // Split current line + this.state.lines[this.state.cursorLine] = before; + this.state.lines.splice(this.state.cursorLine + 1, 0, after); + + // Move cursor to start of new line + this.state.cursorLine++; + this.setCursorCol(0); + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType): boolean { + if (this.disableSubmit) return false; + if (!matchesKey(data, "enter")) return false; + const submitKeys = kb.getKeys("tui.input.submit"); + const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return"); + if (!hasShiftEnter) return false; + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\"; + } + + private submitValue(): void { + this.cancelAutocomplete(); + const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim(); + + this.state = { lines: [""], cursorLine: 0, cursorCol: 0 }; + this.pastes.clear(); + this.pasteCounter = 0; + this.exitHistoryBrowsing(); + this.scrollOffset = 0; + this.undoStack.clear(); + this.lastAction = null; + + if (this.onChange) this.onChange(""); + if (this.onSubmit) this.onSubmit(result); + } + + private handleBackspace(): void { + this.exitHistoryBrowsing(); + this.lastAction = null; + + if (this.state.cursorCol > 0) { + this.pushUndoSnapshot(); + + // Delete grapheme before cursor (handles emojis, combining characters, etc.) + const line = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = line.slice(0, this.state.cursorCol); + + // Find the last grapheme in the text before cursor + const graphemes = [...this.segment(beforeCursor, "grapheme")]; + const lastGrapheme = graphemes[graphemes.length - 1]; + const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; + + const before = line.slice(0, this.state.cursorCol - graphemeLength); + const after = line.slice(this.state.cursorCol); + + this.state.lines[this.state.cursorLine] = before + after; + this.setCursorCol(this.state.cursorCol - graphemeLength); + } else if (this.state.cursorLine > 0) { + this.pushUndoSnapshot(); + + // Merge with previous line + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; + + this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; + this.state.lines.splice(this.state.cursorLine, 1); + + this.state.cursorLine--; + this.setCursorCol(previousLine.length); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + + // Update or re-trigger autocomplete after backspace + if (this.autocompleteState) { + this.updateAutocomplete(); + } else { + // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + // Slash command context + if (this.isInSlashCommandContext(textBeforeCursor)) { + this.tryTriggerAutocomplete(); + } + // Symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { + this.tryTriggerAutocomplete(); + } + } + } + + /** + * Set cursor column and clear preferredVisualCol. + * Use this for all non-vertical cursor movements to reset sticky column behavior. + */ + private setCursorCol(col: number): void { + this.state.cursorCol = col; + this.preferredVisualCol = null; + this.snappedFromCursorCol = null; + } + + /** + * Move cursor to a target visual line, applying sticky column logic. + * Shared by moveCursor() and pageScroll(). + */ + private moveToVisualLine( + visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, + currentVisualLine: number, + targetVisualLine: number, + ): void { + const currentVL = visualLines[currentVisualLine]; + const targetVL = visualLines[targetVisualLine]; + if (!(currentVL && targetVL)) return; + + // When the cursor was snapped to a segment start, resolve the pre-snap + // position against the VL it belongs to. This gives the correct visual + // column even after a resize reshuffles VLs. + let currentVisualCol: number; + if (this.snappedFromCursorCol !== null) { + const vlIndex = this.findVisualLineAt(visualLines, currentVL.logicalLine, this.snappedFromCursorCol); + currentVisualCol = this.snappedFromCursorCol - visualLines[vlIndex].startCol; + } else { + currentVisualCol = this.state.cursorCol - currentVL.startCol; + } + + // For non-last segments, clamp to length-1 to stay within the segment + const isLastSourceSegment = + currentVisualLine === visualLines.length - 1 || + visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine; + const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1); + + const isLastTargetSegment = + targetVisualLine === visualLines.length - 1 || + visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine; + const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1); + + const moveToVisualCol = this.computeVerticalMoveColumn(currentVisualCol, sourceMaxVisualCol, targetMaxVisualCol); + + // Set cursor position + this.state.cursorLine = targetVL.logicalLine; + const targetCol = targetVL.startCol + moveToVisualCol; + const logicalLine = this.state.lines[targetVL.logicalLine] || ""; + this.state.cursorCol = Math.min(targetCol, logicalLine.length); + + // Snap cursor to atomic segment boundary (e.g. paste markers) + // so the cursor never lands in the middle of a multi-grapheme unit. + // Single-grapheme segments don't need snapping. + const segments = [...this.segment(logicalLine, "grapheme")]; + for (const seg of segments) { + if (seg.index > this.state.cursorCol) break; + if (seg.segment.length <= 1) continue; + if (this.state.cursorCol < seg.index + seg.segment.length) { + const isContinuation = seg.index < targetVL.startCol; + const isMovingDown = targetVisualLine > currentVisualLine; + + if (isContinuation && isMovingDown) { + // The segment started on a previous visual line, and we + // already visited it on the way down. Skip all remaining + // continuation VLs and land on the first VL past it. + const segEnd = seg.index + seg.segment.length; + let next = targetVisualLine + 1; + while ( + next < visualLines.length && + visualLines[next].logicalLine === targetVL.logicalLine && + visualLines[next].startCol < segEnd + ) { + next++; + } + if (next < visualLines.length) { + this.moveToVisualLine(visualLines, currentVisualLine, next); + return; + } + } + + // Snap to the start of the segment so it gets highlighted. + // Store the pre-snap position so the next vertical move can + // resolve it to the correct visual column. + this.snappedFromCursorCol = this.state.cursorCol; + this.state.cursorCol = seg.index; + return; + } + } + + // No snap occurred – we moved out of the atomic segment. + this.snappedFromCursorCol = null; + } + + /** + * Compute the target visual column for vertical cursor movement. + * Implements the sticky column decision table: + * + * | P | S | T | U | Scenario | Set Preferred | Move To | + * |---|---|---|---| ---------------------------------------------------- |---------------|-------------| + * | 0 | * | 0 | - | Start nav, target fits | null | current | + * | 0 | * | 1 | - | Start nav, target shorter | current | target end | + * | 1 | 0 | 0 | 0 | Clamped, target fits preferred | null | preferred | + * | 1 | 0 | 0 | 1 | Clamped, target longer but still can't fit preferred | keep | target end | + * | 1 | 0 | 1 | - | Clamped, target even shorter | keep | target end | + * | 1 | 1 | 0 | - | Rewrapped, target fits current | null | current | + * | 1 | 1 | 1 | - | Rewrapped, target shorter than current | current | target end | + * + * Where: + * - P = preferred col is set + * - S = cursor in middle of source line (not clamped to end) + * - T = target line shorter than current visual col + * - U = target line shorter than preferred col + */ + private computeVerticalMoveColumn( + currentVisualCol: number, + sourceMaxVisualCol: number, + targetMaxVisualCol: number, + ): number { + const hasPreferred = this.preferredVisualCol !== null; // P + const cursorInMiddle = currentVisualCol < sourceMaxVisualCol; // S + const targetTooShort = targetMaxVisualCol < currentVisualCol; // T + + if (!hasPreferred || cursorInMiddle) { + if (targetTooShort) { + // Cases 2 and 7 + this.preferredVisualCol = currentVisualCol; + return targetMaxVisualCol; + } + + // Cases 1 and 6 + this.preferredVisualCol = null; + return currentVisualCol; + } + + const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!; // U + if (targetTooShort || targetCantFitPreferred) { + // Cases 4 and 5 + return targetMaxVisualCol; + } + + // Case 3 + const result = this.preferredVisualCol!; + this.preferredVisualCol = null; + return result; + } + + private moveToLineStart(): void { + this.lastAction = null; + this.setCursorCol(0); + } + + private moveToLineEnd(): void { + this.lastAction = null; + const currentLine = this.state.lines[this.state.cursorLine] || ""; + this.setCursorCol(currentLine.length); + } + + private deleteToStartOfLine(): void { + this.exitHistoryBrowsing(); + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + if (this.state.cursorCol > 0) { + this.pushUndoSnapshot(); + + // Calculate text to be deleted and save to kill ring (backward deletion = prepend) + const deletedText = currentLine.slice(0, this.state.cursorCol); + this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + + // Delete from start of line up to cursor + this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol); + this.setCursorCol(0); + } else if (this.state.cursorLine > 0) { + this.pushUndoSnapshot(); + + // At start of line - merge with previous line, treating newline as deleted text + this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + + const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; + this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; + this.state.lines.splice(this.state.cursorLine, 1); + this.state.cursorLine--; + this.setCursorCol(previousLine.length); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private deleteToEndOfLine(): void { + this.exitHistoryBrowsing(); + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + if (this.state.cursorCol < currentLine.length) { + this.pushUndoSnapshot(); + + // Calculate text to be deleted and save to kill ring (forward deletion = append) + const deletedText = currentLine.slice(this.state.cursorCol); + this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + + // Delete from cursor to end of line + this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol); + } else if (this.state.cursorLine < this.state.lines.length - 1) { + this.pushUndoSnapshot(); + + // At end of line - merge with next line, treating newline as deleted text + this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + + const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; + this.state.lines[this.state.cursorLine] = currentLine + nextLine; + this.state.lines.splice(this.state.cursorLine + 1, 1); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private deleteWordBackwards(): void { + this.exitHistoryBrowsing(); + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + // If at start of line, behave like backspace at column 0 (merge with previous line) + if (this.state.cursorCol === 0) { + if (this.state.cursorLine > 0) { + this.pushUndoSnapshot(); + + // Treat newline as deleted text (backward deletion = prepend) + this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + + const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; + this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; + this.state.lines.splice(this.state.cursorLine, 1); + this.state.cursorLine--; + this.setCursorCol(previousLine.length); + } + } else { + this.pushUndoSnapshot(); + + // Save lastAction before cursor movement (moveWordBackwards resets it) + const wasKill = this.lastAction === "kill"; + + const oldCursorCol = this.state.cursorCol; + this.moveWordBackwards(); + const deleteFrom = this.state.cursorCol; + this.setCursorCol(oldCursorCol); + + const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol); + this.killRing.push(deletedText, { prepend: true, accumulate: wasKill }); + this.lastAction = "kill"; + + this.state.lines[this.state.cursorLine] = + currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol); + this.setCursorCol(deleteFrom); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private deleteWordForward(): void { + this.exitHistoryBrowsing(); + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + // If at end of line, merge with next line (delete the newline) + if (this.state.cursorCol >= currentLine.length) { + if (this.state.cursorLine < this.state.lines.length - 1) { + this.pushUndoSnapshot(); + + // Treat newline as deleted text (forward deletion = append) + this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + + const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; + this.state.lines[this.state.cursorLine] = currentLine + nextLine; + this.state.lines.splice(this.state.cursorLine + 1, 1); + } + } else { + this.pushUndoSnapshot(); + + // Save lastAction before cursor movement (moveWordForwards resets it) + const wasKill = this.lastAction === "kill"; + + const oldCursorCol = this.state.cursorCol; + this.moveWordForwards(); + const deleteTo = this.state.cursorCol; + this.setCursorCol(oldCursorCol); + + const deletedText = currentLine.slice(this.state.cursorCol, deleteTo); + this.killRing.push(deletedText, { prepend: false, accumulate: wasKill }); + this.lastAction = "kill"; + + this.state.lines[this.state.cursorLine] = + currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private handleForwardDelete(): void { + this.exitHistoryBrowsing(); + this.lastAction = null; + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + if (this.state.cursorCol < currentLine.length) { + this.pushUndoSnapshot(); + + // Delete grapheme at cursor position (handles emojis, combining characters, etc.) + const afterCursor = currentLine.slice(this.state.cursorCol); + + // Find the first grapheme at cursor + const graphemes = [...this.segment(afterCursor, "grapheme")]; + const firstGrapheme = graphemes[0]; + const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; + + const before = currentLine.slice(0, this.state.cursorCol); + const after = currentLine.slice(this.state.cursorCol + graphemeLength); + this.state.lines[this.state.cursorLine] = before + after; + } else if (this.state.cursorLine < this.state.lines.length - 1) { + this.pushUndoSnapshot(); + + // At end of line - merge with next line + const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; + this.state.lines[this.state.cursorLine] = currentLine + nextLine; + this.state.lines.splice(this.state.cursorLine + 1, 1); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + + // Update or re-trigger autocomplete after forward delete + if (this.autocompleteState) { + this.updateAutocomplete(); + } else { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + // Slash command context + if (this.isInSlashCommandContext(textBeforeCursor)) { + this.tryTriggerAutocomplete(); + } + // Symbol-based completion context like @, #, or provider triggers + else if (this.autocompleteTriggerPattern.test(textBeforeCursor)) { + this.tryTriggerAutocomplete(); + } + } + } + + /** + * Build a mapping from visual lines to logical positions. + * Returns an array where each element represents a visual line with: + * - logicalLine: index into this.state.lines + * - startCol: starting column in the logical line + * - length: length of this visual line segment + */ + private buildVisualLineMap(width: number): Array<{ logicalLine: number; startCol: number; length: number }> { + const visualLines: Array<{ logicalLine: number; startCol: number; length: number }> = []; + + for (let i = 0; i < this.state.lines.length; i++) { + const line = this.state.lines[i] || ""; + const lineVisWidth = visibleWidth(line); + if (line.length === 0) { + // Empty line still takes one visual line + visualLines.push({ logicalLine: i, startCol: 0, length: 0 }); + } else if (lineVisWidth <= width) { + visualLines.push({ logicalLine: i, startCol: 0, length: line.length }); + } else { + // Line needs wrapping - use word-aware wrapping + const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]); + for (const chunk of chunks) { + visualLines.push({ + logicalLine: i, + startCol: chunk.startIndex, + length: chunk.endIndex - chunk.startIndex, + }); + } + } + } + + return visualLines; + } + + /** + * Find the visual line index that contains the given logical position. + */ + private findVisualLineAt( + visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, + line: number, + col: number, + ): number { + for (let i = 0; i < visualLines.length; i++) { + const vl = visualLines[i]; + if (!vl || vl.logicalLine !== line) continue; + const offset = col - vl.startCol; + // Cursor is in this segment if it's within range. For the last + // segment of a logical line, cursor can be at length (end position) + const isLastSegmentOfLine = i === visualLines.length - 1 || visualLines[i + 1]?.logicalLine !== vl.logicalLine; + if (offset >= 0 && (offset < vl.length || (isLastSegmentOfLine && offset === vl.length))) { + return i; + } + } + return visualLines.length - 1; + } + + /** + * Find the visual line index for the current cursor position. + */ + private findCurrentVisualLine( + visualLines: Array<{ logicalLine: number; startCol: number; length: number }>, + ): number { + return this.findVisualLineAt(visualLines, this.state.cursorLine, this.state.cursorCol); + } + + private moveCursor(deltaLine: number, deltaCol: number): void { + this.lastAction = null; + const visualLines = this.buildVisualLineMap(this.lastWidth); + const currentVisualLine = this.findCurrentVisualLine(visualLines); + + if (deltaLine !== 0) { + const targetVisualLine = currentVisualLine + deltaLine; + + if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) { + this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine); + } + } + + if (deltaCol !== 0) { + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + if (deltaCol > 0) { + // Moving right - move by one grapheme (handles emojis, combining characters, etc.) + if (this.state.cursorCol < currentLine.length) { + const afterCursor = currentLine.slice(this.state.cursorCol); + const graphemes = [...this.segment(afterCursor, "grapheme")]; + const firstGrapheme = graphemes[0]; + this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1)); + } else if (this.state.cursorLine < this.state.lines.length - 1) { + // Wrap to start of next logical line + this.state.cursorLine++; + this.setCursorCol(0); + } else { + // At end of last line - can't move, but set preferredVisualCol for up/down navigation + const currentVL = visualLines[currentVisualLine]; + if (currentVL) { + this.preferredVisualCol = this.state.cursorCol - currentVL.startCol; + } + } + } else { + // Moving left - move by one grapheme (handles emojis, combining characters, etc.) + if (this.state.cursorCol > 0) { + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + const graphemes = [...this.segment(beforeCursor, "grapheme")]; + const lastGrapheme = graphemes[graphemes.length - 1]; + this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1)); + } else if (this.state.cursorLine > 0) { + // Wrap to end of previous logical line + this.state.cursorLine--; + const prevLine = this.state.lines[this.state.cursorLine] || ""; + this.setCursorCol(prevLine.length); + } + } + } + + // Keep an open autocomplete picker in sync with the new cursor + // position: cursor movement changes the text before the cursor, so a + // picker computed for the old position is stale. Re-query so it + // refreshes — or closes when the new position yields no suggestions — + // mirroring insertCharacter()/handleBackspace(). Without this, arrowing + // left from `/cmd ` back into the command name leaves the argument + // picker showing against a `/cmd` prefix (and a Tab there would + // concatenate the stale suggestion onto the partial command name). + if (this.autocompleteState) { + this.updateAutocomplete(); + } + } + + /** + * Scroll by a page (direction: -1 for up, 1 for down). + * Moves cursor by the page size while keeping it in bounds. + */ + private pageScroll(direction: -1 | 1): void { + this.lastAction = null; + const terminalRows = this.tui.terminal.rows; + const pageSize = Math.max(5, Math.floor(terminalRows * 0.3)); + + const visualLines = this.buildVisualLineMap(this.lastWidth); + const currentVisualLine = this.findCurrentVisualLine(visualLines); + const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * pageSize)); + + this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine); + } + + private moveWordBackwards(): void { + this.lastAction = null; + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + // If at start of line, move to end of previous line + if (this.state.cursorCol === 0) { + if (this.state.cursorLine > 0) { + this.state.cursorLine--; + const prevLine = this.state.lines[this.state.cursorLine] || ""; + this.setCursorCol(prevLine.length); + } + return; + } + + this.setCursorCol( + findWordBackward(currentLine, this.state.cursorCol, { + segment: (text) => this.segment(text, "word"), + isAtomicSegment: isPasteMarker, + }), + ); + } + + /** + * Yank (paste) the most recent kill ring entry at cursor position. + */ + private yank(): void { + if (this.killRing.length === 0) return; + + this.pushUndoSnapshot(); + + const text = this.killRing.peek()!; + this.insertYankedText(text); + + this.lastAction = "yank"; + } + + /** + * Cycle through kill ring (only works immediately after yank or yank-pop). + * Replaces the last yanked text with the previous entry in the ring. + */ + private yankPop(): void { + // Only works if we just yanked and have more than one entry + if (this.lastAction !== "yank" || this.killRing.length <= 1) return; + + this.pushUndoSnapshot(); + + // Delete the previously yanked text (still at end of ring before rotation) + this.deleteYankedText(); + + // Rotate the ring: move end to front + this.killRing.rotate(); + + // Insert the new most recent entry (now at end after rotation) + const text = this.killRing.peek()!; + this.insertYankedText(text); + + this.lastAction = "yank"; + } + + /** + * Insert text at cursor position (used by yank operations). + */ + private insertYankedText(text: string): void { + this.exitHistoryBrowsing(); + const lines = text.split("\n"); + + if (lines.length === 1) { + // Single line - insert at cursor + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const before = currentLine.slice(0, this.state.cursorCol); + const after = currentLine.slice(this.state.cursorCol); + this.state.lines[this.state.cursorLine] = before + text + after; + this.setCursorCol(this.state.cursorCol + text.length); + } else { + // Multi-line insert + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const before = currentLine.slice(0, this.state.cursorCol); + const after = currentLine.slice(this.state.cursorCol); + + // First line merges with text before cursor + this.state.lines[this.state.cursorLine] = before + (lines[0] || ""); + + // Insert middle lines + for (let i = 1; i < lines.length - 1; i++) { + this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || ""); + } + + // Last line merges with text after cursor + const lastLineIndex = this.state.cursorLine + lines.length - 1; + this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || "") + after); + + // Update cursor position + this.state.cursorLine = lastLineIndex; + this.setCursorCol((lines[lines.length - 1] || "").length); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + /** + * Delete the previously yanked text (used by yank-pop). + * The yanked text is derived from killRing[end] since it hasn't been rotated yet. + */ + private deleteYankedText(): void { + const yankedText = this.killRing.peek(); + if (!yankedText) return; + + const yankLines = yankedText.split("\n"); + + if (yankLines.length === 1) { + // Single line - delete backward from cursor + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const deleteLen = yankedText.length; + const before = currentLine.slice(0, this.state.cursorCol - deleteLen); + const after = currentLine.slice(this.state.cursorCol); + this.state.lines[this.state.cursorLine] = before + after; + this.setCursorCol(this.state.cursorCol - deleteLen); + } else { + // Multi-line delete - cursor is at end of last yanked line + const startLine = this.state.cursorLine - (yankLines.length - 1); + const startCol = (this.state.lines[startLine] || "").length - (yankLines[0] || "").length; + + // Get text after cursor on current line + const afterCursor = (this.state.lines[this.state.cursorLine] || "").slice(this.state.cursorCol); + + // Get text before yank start position + const beforeYank = (this.state.lines[startLine] || "").slice(0, startCol); + + // Remove all lines from startLine to cursorLine and replace with merged line + this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor); + + // Update cursor + this.state.cursorLine = startLine; + this.setCursorCol(startCol); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + private pushUndoSnapshot(): void { + this.undoStack.push(this.state); + } + + private undo(): void { + this.exitHistoryBrowsing(); + const snapshot = this.undoStack.pop(); + if (!snapshot) return; + Object.assign(this.state, snapshot); + this.lastAction = null; + this.preferredVisualCol = null; + if (this.onChange) { + this.onChange(this.getText()); + } + } + + /** + * Jump to the first occurrence of a character in the specified direction. + * Multi-line search. Case-sensitive. Skips the current cursor position. + */ + private jumpToChar(char: string, direction: "forward" | "backward"): void { + this.lastAction = null; + const isForward = direction === "forward"; + const lines = this.state.lines; + + const end = isForward ? lines.length : -1; + const step = isForward ? 1 : -1; + + for (let lineIdx = this.state.cursorLine; lineIdx !== end; lineIdx += step) { + const line = lines[lineIdx] || ""; + const isCurrentLine = lineIdx === this.state.cursorLine; + + // Current line: start after/before cursor; other lines: search full line + const searchFrom = isCurrentLine + ? isForward + ? this.state.cursorCol + 1 + : this.state.cursorCol - 1 + : undefined; + + const idx = isForward ? line.indexOf(char, searchFrom) : line.lastIndexOf(char, searchFrom); + + if (idx !== -1) { + this.state.cursorLine = lineIdx; + this.setCursorCol(idx); + return; + } + } + // No match found - cursor stays in place + } + + private moveWordForwards(): void { + this.lastAction = null; + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + // If at end of line, move to start of next line + if (this.state.cursorCol >= currentLine.length) { + if (this.state.cursorLine < this.state.lines.length - 1) { + this.state.cursorLine++; + this.setCursorCol(0); + } + return; + } + + this.setCursorCol( + findWordForward(currentLine, this.state.cursorCol, { + segment: (text) => this.segment(text, "word"), + isAtomicSegment: isPasteMarker, + }), + ); + } + + // Slash menu only allowed on the first line of the editor + private isSlashMenuAllowed(): boolean { + return this.state.cursorLine === 0; + } + + // Helper method to check if cursor is at start of message (for slash command detection) + private isAtStartOfMessage(): boolean { + if (!this.isSlashMenuAllowed()) return false; + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + return beforeCursor.trim() === "" || beforeCursor.trim() === "/"; + } + + private isInSlashCommandContext(textBeforeCursor: string): boolean { + return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/"); + } + + // Autocomplete methods + /** + * Find the best autocomplete item index for the given prefix. + * Returns -1 if no match is found. + * + * Match priority: + * 1. Exact match (prefix === item.value) -> always selected + * 2. Prefix match -> first item whose value starts with prefix + * 3. No match -> -1 (keep default highlight) + * + * Matching is case-sensitive and checks item.value only. + */ + private getBestAutocompleteMatchIndex(items: Array<{ value: string; label: string }>, prefix: string): number { + if (!prefix) return -1; + + let firstPrefixIndex = -1; + + for (let i = 0; i < items.length; i++) { + const value = items[i]!.value; + if (value === prefix) { + return i; // Exact match always wins + } + if (firstPrefixIndex === -1 && value.startsWith(prefix)) { + firstPrefixIndex = i; + } + } + + return firstPrefixIndex; + } + + private createAutocompleteList( + prefix: string, + items: Array<{ value: string; label: string; description?: string }>, + ): SelectList { + const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined; + return new SelectList(items, this.autocompleteMaxVisible, this.theme.selectList, layout); + } + + private tryTriggerAutocomplete(explicitTab: boolean = false): void { + this.requestAutocomplete({ force: false, explicitTab }); + } + + private handleTabCompletion(): void { + if (!this.autocompleteProvider) return; + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const beforeCursor = currentLine.slice(0, this.state.cursorCol); + + if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) { + this.handleSlashCommandCompletion(); + } else { + this.forceFileAutocomplete(true); + } + } + + private handleSlashCommandCompletion(): void { + this.requestAutocomplete({ force: false, explicitTab: true }); + } + + private forceFileAutocomplete(explicitTab: boolean = false): void { + this.requestAutocomplete({ force: true, explicitTab }); + } + + private requestAutocomplete(options: { force: boolean; explicitTab: boolean }): void { + if (!this.autocompleteProvider) return; + + if (options.force) { + const shouldTrigger = + !this.autocompleteProvider.shouldTriggerFileCompletion || + this.autocompleteProvider.shouldTriggerFileCompletion( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + ); + if (!shouldTrigger) { + return; + } + } + + this.cancelAutocompleteRequest(); + const startToken = ++this.autocompleteStartToken; + + const debounceMs = this.getAutocompleteDebounceMs(options); + if (debounceMs > 0) { + this.autocompleteDebounceTimer = setTimeout(() => { + this.autocompleteDebounceTimer = undefined; + void this.startAutocompleteRequest(startToken, options); + }, debounceMs); + return; + } + + void this.startAutocompleteRequest(startToken, options); + } + + private async startAutocompleteRequest( + startToken: number, + options: { force: boolean; explicitTab: boolean }, + ): Promise { + const previousTask = this.autocompleteRequestTask; + this.autocompleteRequestTask = (async () => { + await previousTask; + if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) { + return; + } + + const controller = new AbortController(); + this.autocompleteAbort = controller; + const requestId = ++this.autocompleteRequestId; + const snapshotText = this.getText(); + const snapshotLine = this.state.cursorLine; + const snapshotCol = this.state.cursorCol; + + await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options); + })(); + await this.autocompleteRequestTask; + } + + private setAutocompleteTriggerCharacters(triggerCharacters: string[]): void { + const next = [...DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS]; + for (const character of triggerCharacters) { + if (character.length !== 1 || character === "/" || isWhitespaceChar(character) || next.includes(character)) { + continue; + } + next.push(character); + } + this.autocompleteTriggerCharacters = next; + this.autocompleteTriggerPattern = buildTriggerPattern(next); + this.autocompleteDebouncePattern = buildDebouncePattern(next); + } + + private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number { + if (options.explicitTab || options.force) { + return 0; + } + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const textBeforeCursor = currentLine.slice(0, this.state.cursorCol); + return this.autocompleteDebouncePattern.test(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0; + } + + private async runAutocompleteRequest( + requestId: number, + controller: AbortController, + snapshotText: string, + snapshotLine: number, + snapshotCol: number, + options: { force: boolean; explicitTab: boolean }, + ): Promise { + if (!this.autocompleteProvider) return; + + const suggestions = await this.autocompleteProvider.getSuggestions( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + { signal: controller.signal, force: options.force }, + ); + + if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) { + return; + } + + this.autocompleteAbort = undefined; + + if (!suggestions || !Array.isArray(suggestions.items) || suggestions.items.length === 0) { + this.cancelAutocomplete(); + this.tui.requestRender(); + return; + } + + if (options.force && options.explicitTab && suggestions.items.length === 1) { + const item = suggestions.items[0]!; + this.pushUndoSnapshot(); + this.lastAction = null; + const result = this.autocompleteProvider.applyCompletion( + this.state.lines, + this.state.cursorLine, + this.state.cursorCol, + item, + suggestions.prefix, + ); + this.state.lines = result.lines; + this.state.cursorLine = result.cursorLine; + this.setCursorCol(result.cursorCol); + if (this.onChange) this.onChange(this.getText()); + this.tui.requestRender(); + return; + } + + this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular"); + this.tui.requestRender(); + } + + private isAutocompleteRequestCurrent( + requestId: number, + controller: AbortController, + snapshotText: string, + snapshotLine: number, + snapshotCol: number, + ): boolean { + return ( + !controller.signal.aborted && + requestId === this.autocompleteRequestId && + this.getText() === snapshotText && + this.state.cursorLine === snapshotLine && + this.state.cursorCol === snapshotCol + ); + } + + private applyAutocompleteSuggestions(suggestions: AutocompleteSuggestions, state: "regular" | "force"): void { + this.autocompletePrefix = suggestions.prefix; + this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items); + + const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix); + if (bestMatchIndex >= 0) { + this.autocompleteList.setSelectedIndex(bestMatchIndex); + } + + this.autocompleteState = state; + } + + private cancelAutocompleteRequest(): void { + this.autocompleteStartToken += 1; + if (this.autocompleteDebounceTimer) { + clearTimeout(this.autocompleteDebounceTimer); + this.autocompleteDebounceTimer = undefined; + } + this.autocompleteAbort?.abort(); + this.autocompleteAbort = undefined; + } + + private clearAutocompleteUi(): void { + this.autocompleteState = null; + this.autocompleteList = undefined; + this.autocompletePrefix = ""; + } + + private cancelAutocomplete(): void { + this.cancelAutocompleteRequest(); + this.clearAutocompleteUi(); + } + + public isShowingAutocomplete(): boolean { + return this.autocompleteState !== null; + } + + private updateAutocomplete(): void { + if (!this.autocompleteState || !this.autocompleteProvider) return; + this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false }); + } +} diff --git a/cactus-code/packages/tui/src/components/image.ts b/cactus-code/packages/tui/src/components/image.ts new file mode 100644 index 000000000..50c1e0f78 --- /dev/null +++ b/cactus-code/packages/tui/src/components/image.ts @@ -0,0 +1,126 @@ +import { + allocateImageId, + getCapabilities, + getCellDimensions, + getImageDimensions, + type ImageDimensions, + imageFallback, + renderImage, +} from "../terminal-image.ts"; +import type { Component } from "../tui.ts"; + +export interface ImageTheme { + fallbackColor: (str: string) => string; +} + +export interface ImageOptions { + maxWidthCells?: number; + maxHeightCells?: number; + filename?: string; + /** Kitty image ID. If provided, reuses this ID (for animations/updates). */ + imageId?: number; +} + +export class Image implements Component { + private base64Data: string; + private mimeType: string; + private dimensions: ImageDimensions; + private theme: ImageTheme; + private options: ImageOptions; + private imageId?: number; + + private cachedLines?: string[]; + private cachedWidth?: number; + + constructor( + base64Data: string, + mimeType: string, + theme: ImageTheme, + options: ImageOptions = {}, + dimensions?: ImageDimensions, + ) { + this.base64Data = base64Data; + this.mimeType = mimeType; + this.theme = theme; + this.options = options; + this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 }; + this.imageId = options.imageId; + } + + /** Get the Kitty image ID used by this image (if any). */ + getImageId(): number | undefined { + return this.imageId; + } + + invalidate(): void { + this.cachedLines = undefined; + this.cachedWidth = undefined; + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) { + return this.cachedLines; + } + + const maxWidth = Math.max(1, Math.min(width - 2, this.options.maxWidthCells ?? 60)); + const cellDimensions = getCellDimensions(); + const defaultMaxHeight = Math.max(1, Math.ceil((maxWidth * cellDimensions.widthPx) / cellDimensions.heightPx)); + const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight; + + const caps = getCapabilities(); + let lines: string[]; + + if (caps.images) { + if (caps.images === "kitty" && this.imageId === undefined) { + this.imageId = allocateImageId(); + } + const result = renderImage(this.base64Data, this.dimensions, { + maxWidthCells: maxWidth, + maxHeightCells: maxHeight, + imageId: this.imageId, + moveCursor: false, + }); + + if (result) { + // Store the image ID for later cleanup + if (result.imageId) { + this.imageId = result.imageId; + } + + if (caps.images === "kitty") { + // For Kitty: C=1 prevents cursor movement. + // Don't need the cursor movement. + lines = [result.sequence]; + + // Return `rows` lines so TUI accounts for image height. + for (let i = 0; i < result.rows - 1; i++) { + lines.push(""); + } + } else { + // Return `rows` lines so TUI accounts for image height. + // First (rows-1) lines are empty and cleared before the image is drawn. + // Last line: move cursor back up, draw the image, then move back down + // so TUI cursor accounting stays inside the scroll area. + lines = []; + for (let i = 0; i < result.rows - 1; i++) { + lines.push(""); + } + const rowOffset = result.rows - 1; + const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : ""; + lines.push(moveUp + result.sequence); + } + } else { + const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); + lines = [this.theme.fallbackColor(fallback)]; + } + } else { + const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); + lines = [this.theme.fallbackColor(fallback)]; + } + + this.cachedLines = lines; + this.cachedWidth = width; + + return lines; + } +} diff --git a/cactus-code/packages/tui/src/components/input.ts b/cactus-code/packages/tui/src/components/input.ts new file mode 100644 index 000000000..a054076d0 --- /dev/null +++ b/cactus-code/packages/tui/src/components/input.ts @@ -0,0 +1,447 @@ +import { getKeybindings } from "../keybindings.ts"; +import { decodeKittyPrintable } from "../keys.ts"; +import { KillRing } from "../kill-ring.ts"; +import { type Component, CURSOR_MARKER, type Focusable } from "../tui.ts"; +import { UndoStack } from "../undo-stack.ts"; +import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.ts"; +import { findWordBackward, findWordForward } from "../word-navigation.ts"; + +const segmenter = getGraphemeSegmenter(); + +interface InputState { + value: string; + cursor: number; +} + +/** + * Input component - single-line text input with horizontal scrolling + */ +export class Input implements Component, Focusable { + private value: string = ""; + private cursor: number = 0; // Cursor position in the value + public onSubmit?: (value: string) => void; + public onEscape?: () => void; + + /** Focusable interface - set by TUI when focus changes */ + focused: boolean = false; + + // Bracketed paste mode buffering + private pasteBuffer: string = ""; + private isInPaste: boolean = false; + + // Kill ring for Emacs-style kill/yank operations + private killRing = new KillRing(); + private lastAction: "kill" | "yank" | "type-word" | null = null; + + // Undo support + private undoStack = new UndoStack(); + + getValue(): string { + return this.value; + } + + setValue(value: string): void { + this.value = value; + this.cursor = Math.min(this.cursor, value.length); + } + + handleInput(data: string): void { + // Handle bracketed paste mode + // Start of paste: \x1b[200~ + // End of paste: \x1b[201~ + + // Check if we're starting a bracketed paste + if (data.includes("\x1b[200~")) { + this.isInPaste = true; + this.pasteBuffer = ""; + data = data.replace("\x1b[200~", ""); + } + + // If we're in a paste, buffer the data + if (this.isInPaste) { + // Check if this chunk contains the end marker + this.pasteBuffer += data; + + const endIndex = this.pasteBuffer.indexOf("\x1b[201~"); + if (endIndex !== -1) { + // Extract the pasted content + const pasteContent = this.pasteBuffer.substring(0, endIndex); + + // Process the complete paste + this.handlePaste(pasteContent); + + // Reset paste state + this.isInPaste = false; + + // Handle any remaining input after the paste marker + const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~ + this.pasteBuffer = ""; + if (remaining) { + this.handleInput(remaining); + } + } + return; + } + + const kb = getKeybindings(); + + // Escape/Cancel + if (kb.matches(data, "tui.select.cancel")) { + if (this.onEscape) this.onEscape(); + return; + } + + // Undo + if (kb.matches(data, "tui.editor.undo")) { + this.undo(); + return; + } + + // Submit + if (kb.matches(data, "tui.input.submit") || data === "\n") { + if (this.onSubmit) this.onSubmit(this.value); + return; + } + + // Deletion + if (kb.matches(data, "tui.editor.deleteCharBackward")) { + this.handleBackspace(); + return; + } + + if (kb.matches(data, "tui.editor.deleteCharForward")) { + this.handleForwardDelete(); + return; + } + + if (kb.matches(data, "tui.editor.deleteWordBackward")) { + this.deleteWordBackwards(); + return; + } + + if (kb.matches(data, "tui.editor.deleteWordForward")) { + this.deleteWordForward(); + return; + } + + if (kb.matches(data, "tui.editor.deleteToLineStart")) { + this.deleteToLineStart(); + return; + } + + if (kb.matches(data, "tui.editor.deleteToLineEnd")) { + this.deleteToLineEnd(); + return; + } + + // Kill ring actions + if (kb.matches(data, "tui.editor.yank")) { + this.yank(); + return; + } + if (kb.matches(data, "tui.editor.yankPop")) { + this.yankPop(); + return; + } + + // Cursor movement + if (kb.matches(data, "tui.editor.cursorLeft")) { + this.lastAction = null; + if (this.cursor > 0) { + const beforeCursor = this.value.slice(0, this.cursor); + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1; + } + return; + } + + if (kb.matches(data, "tui.editor.cursorRight")) { + this.lastAction = null; + if (this.cursor < this.value.length) { + const afterCursor = this.value.slice(this.cursor); + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1; + } + return; + } + + if (kb.matches(data, "tui.editor.cursorLineStart")) { + this.lastAction = null; + this.cursor = 0; + return; + } + + if (kb.matches(data, "tui.editor.cursorLineEnd")) { + this.lastAction = null; + this.cursor = this.value.length; + return; + } + + if (kb.matches(data, "tui.editor.cursorWordLeft")) { + this.moveWordBackwards(); + return; + } + + if (kb.matches(data, "tui.editor.cursorWordRight")) { + this.moveWordForwards(); + return; + } + + // Kitty CSI-u printable character (e.g. \x1b[97u for 'a'). + // Terminals with Kitty protocol flag 1 (disambiguate) send CSI-u for all keys, + // including plain printable characters. Decode before the control-char check + // since CSI-u sequences contain \x1b which would be rejected. + const kittyPrintable = decodeKittyPrintable(data); + if (kittyPrintable !== undefined) { + this.insertCharacter(kittyPrintable); + return; + } + + // Regular character input - accept printable characters including Unicode, + // but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F) + const hasControlChars = [...data].some((ch) => { + const code = ch.charCodeAt(0); + return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); + }); + if (!hasControlChars) { + this.insertCharacter(data); + } + } + + private insertCharacter(char: string): void { + // Undo coalescing: consecutive word chars coalesce into one undo unit + if (isWhitespaceChar(char) || this.lastAction !== "type-word") { + this.pushUndo(); + } + this.lastAction = "type-word"; + + this.value = this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor); + this.cursor += char.length; + } + + private handleBackspace(): void { + this.lastAction = null; + if (this.cursor > 0) { + this.pushUndo(); + const beforeCursor = this.value.slice(0, this.cursor); + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; + this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor); + this.cursor -= graphemeLength; + } + } + + private handleForwardDelete(): void { + this.lastAction = null; + if (this.cursor < this.value.length) { + this.pushUndo(); + const afterCursor = this.value.slice(this.cursor); + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; + this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength); + } + } + + private deleteToLineStart(): void { + if (this.cursor === 0) return; + this.pushUndo(); + const deletedText = this.value.slice(0, this.cursor); + this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + this.value = this.value.slice(this.cursor); + this.cursor = 0; + } + + private deleteToLineEnd(): void { + if (this.cursor >= this.value.length) return; + this.pushUndo(); + const deletedText = this.value.slice(this.cursor); + this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + this.value = this.value.slice(0, this.cursor); + } + + private deleteWordBackwards(): void { + if (this.cursor === 0) return; + + // Save lastAction before cursor movement (moveWordBackwards resets it) + const wasKill = this.lastAction === "kill"; + + this.pushUndo(); + + const oldCursor = this.cursor; + this.moveWordBackwards(); + const deleteFrom = this.cursor; + this.cursor = oldCursor; + + const deletedText = this.value.slice(deleteFrom, this.cursor); + this.killRing.push(deletedText, { prepend: true, accumulate: wasKill }); + this.lastAction = "kill"; + + this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor); + this.cursor = deleteFrom; + } + + private deleteWordForward(): void { + if (this.cursor >= this.value.length) return; + + // Save lastAction before cursor movement (moveWordForwards resets it) + const wasKill = this.lastAction === "kill"; + + this.pushUndo(); + + const oldCursor = this.cursor; + this.moveWordForwards(); + const deleteTo = this.cursor; + this.cursor = oldCursor; + + const deletedText = this.value.slice(this.cursor, deleteTo); + this.killRing.push(deletedText, { prepend: false, accumulate: wasKill }); + this.lastAction = "kill"; + + this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo); + } + + private yank(): void { + const text = this.killRing.peek(); + if (!text) return; + + this.pushUndo(); + + this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); + this.cursor += text.length; + this.lastAction = "yank"; + } + + private yankPop(): void { + if (this.lastAction !== "yank" || this.killRing.length <= 1) return; + + this.pushUndo(); + + // Delete the previously yanked text (still at end of ring before rotation) + const prevText = this.killRing.peek() || ""; + this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor); + this.cursor -= prevText.length; + + // Rotate and insert new entry + this.killRing.rotate(); + const text = this.killRing.peek() || ""; + this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); + this.cursor += text.length; + this.lastAction = "yank"; + } + + private pushUndo(): void { + this.undoStack.push({ value: this.value, cursor: this.cursor }); + } + + private undo(): void { + const snapshot = this.undoStack.pop(); + if (!snapshot) return; + this.value = snapshot.value; + this.cursor = snapshot.cursor; + this.lastAction = null; + } + + private moveWordBackwards(): void { + if (this.cursor === 0) return; + this.lastAction = null; + this.cursor = findWordBackward(this.value, this.cursor); + } + + private moveWordForwards(): void { + if (this.cursor >= this.value.length) return; + this.lastAction = null; + this.cursor = findWordForward(this.value, this.cursor); + } + + private handlePaste(pastedText: string): void { + this.lastAction = null; + this.pushUndo(); + + // Clean the pasted text - remove newlines and carriage returns + const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").replace(/\t/g, " "); + + // Insert at cursor position + this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor); + this.cursor += cleanText.length; + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(width: number): string[] { + // Calculate visible window + const prompt = "> "; + const availableWidth = width - prompt.length; + + if (availableWidth <= 0) { + return [prompt]; + } + + let visibleText = ""; + let cursorDisplay = this.cursor; + const totalWidth = visibleWidth(this.value); + + if (totalWidth < availableWidth) { + // Everything fits (leave room for cursor at end) + visibleText = this.value; + } else { + // Need horizontal scrolling + // Reserve one column for cursor if it's at the end + const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth; + const cursorCol = visibleWidth(this.value.slice(0, this.cursor)); + + if (scrollWidth > 0) { + const halfWidth = Math.floor(scrollWidth / 2); + let startCol = 0; + + if (cursorCol < halfWidth) { + // Cursor near start + startCol = 0; + } else if (cursorCol > totalWidth - halfWidth) { + // Cursor near end + startCol = Math.max(0, totalWidth - scrollWidth); + } else { + // Cursor in middle + startCol = Math.max(0, cursorCol - halfWidth); + } + + visibleText = sliceByColumn(this.value, startCol, scrollWidth, true); + const beforeCursor = sliceByColumn(this.value, startCol, Math.max(0, cursorCol - startCol), true); + cursorDisplay = beforeCursor.length; + } else { + visibleText = ""; + cursorDisplay = 0; + } + } + + // Build line with fake cursor + // Insert cursor character at cursor position + const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))]; + const cursorGrapheme = graphemes[0]; + + const beforeCursor = visibleText.slice(0, cursorDisplay); + const atCursor = cursorGrapheme?.segment ?? " "; // Character at cursor, or space if at end + const afterCursor = visibleText.slice(cursorDisplay + atCursor.length); + + // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) + const marker = this.focused ? CURSOR_MARKER : ""; + + // Use inverse video to show cursor + const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal + const textWithCursor = beforeCursor + marker + cursorChar + afterCursor; + + // Calculate visual width + const visualLength = visibleWidth(textWithCursor); + const padding = " ".repeat(Math.max(0, availableWidth - visualLength)); + const line = prompt + textWithCursor + padding; + + return [line]; + } +} diff --git a/cactus-code/packages/tui/src/components/loader.ts b/cactus-code/packages/tui/src/components/loader.ts new file mode 100644 index 000000000..4a93eb08f --- /dev/null +++ b/cactus-code/packages/tui/src/components/loader.ts @@ -0,0 +1,92 @@ +import type { TUI } from "../tui.ts"; +import { Text } from "./text.ts"; + +export interface LoaderIndicatorOptions { + /** Animation frames. Use an empty array to hide the indicator. */ + frames?: string[]; + /** Frame interval in milliseconds for animated indicators. */ + intervalMs?: number; +} + +const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const DEFAULT_INTERVAL_MS = 80; + +/** + * Loader component that updates with an optional spinning animation. + */ +export class Loader extends Text { + private frames = [...DEFAULT_FRAMES]; + private intervalMs = DEFAULT_INTERVAL_MS; + private currentFrame = 0; + private intervalId: NodeJS.Timeout | null = null; + private ui: TUI | null = null; + private renderIndicatorVerbatim = false; + private spinnerColorFn: (str: string) => string; + private messageColorFn: (str: string) => string; + private message: string = "Loading..."; + + constructor( + ui: TUI, + spinnerColorFn: (str: string) => string, + messageColorFn: (str: string) => string, + message: string = "Loading...", + indicator?: LoaderIndicatorOptions, + ) { + super("", 1, 0); + this.ui = ui; + this.spinnerColorFn = spinnerColorFn; + this.messageColorFn = messageColorFn; + this.message = message; + this.setIndicator(indicator); + } + + render(width: number): string[] { + return ["", ...super.render(width)]; + } + + start(): void { + this.updateDisplay(); + this.restartAnimation(); + } + + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + setMessage(message: string): void { + this.message = message; + this.updateDisplay(); + } + + setIndicator(indicator?: LoaderIndicatorOptions): void { + this.renderIndicatorVerbatim = indicator !== undefined; + this.frames = indicator?.frames !== undefined ? [...indicator.frames] : [...DEFAULT_FRAMES]; + this.intervalMs = indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS; + this.currentFrame = 0; + this.start(); + } + + private restartAnimation(): void { + this.stop(); + if (this.frames.length <= 1) { + return; + } + this.intervalId = setInterval(() => { + this.currentFrame = (this.currentFrame + 1) % this.frames.length; + this.updateDisplay(); + }, this.intervalMs); + } + + private updateDisplay(): void { + const frame = this.frames[this.currentFrame] ?? ""; + const renderedFrame = this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame); + const indicator = frame.length > 0 ? `${renderedFrame} ` : ""; + this.setText(`${indicator}${this.messageColorFn(this.message)}`); + if (this.ui) { + this.ui.requestRender(); + } + } +} diff --git a/cactus-code/packages/tui/src/components/markdown.ts b/cactus-code/packages/tui/src/components/markdown.ts new file mode 100644 index 000000000..1034cb476 --- /dev/null +++ b/cactus-code/packages/tui/src/components/markdown.ts @@ -0,0 +1,852 @@ +import { Marked, type Token, Tokenizer, type Tokens } from "marked"; +import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.ts"; +import type { Component } from "../tui.ts"; +import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; + +const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/; + +class StrictStrikethroughTokenizer extends Tokenizer { + override del(src: string): Tokens.Del | undefined { + const match = STRICT_STRIKETHROUGH_REGEX.exec(src); + if (!match) { + return undefined; + } + + const text = match[2]; + return { + type: "del", + raw: match[0], + text, + tokens: this.lexer.inlineTokens(text), + }; + } +} + +function trimPartialClosingFences(tokens: readonly Token[]): void { + const token = tokens[tokens.length - 1]; + if (token?.type === "list") { + trimPartialClosingFences(token.items[token.items.length - 1]?.tokens ?? []); + return; + } + if (token?.type === "blockquote") { + trimPartialClosingFences(token.tokens ?? []); + return; + } + if (token?.type !== "code") { + return; + } + + // Trim streamed partial closing fences so code blocks do not shrink/flicker + // when the final fence character arrives. See https://github.com/earendil-works/pi/issues/5825. + const marker = /^(`{3,}|~{3,})/.exec(token.raw)?.[1]; + const lastLine = token.raw.split("\n").pop(); + if (!marker || !lastLine || lastLine.length >= marker.length || lastLine !== marker[0]?.repeat(lastLine.length)) { + return; + } + + token.text = token.text.slice(0, -lastLine.length).replace(/\n$/, ""); +} + +const markdownParser = new Marked(); +markdownParser.setOptions({ + tokenizer: new StrictStrikethroughTokenizer(), +}); + +/** + * Default text styling for markdown content. + * Applied to all text unless overridden by markdown formatting. + */ +export interface DefaultTextStyle { + /** Foreground color function */ + color?: (text: string) => string; + /** Background color function */ + bgColor?: (text: string) => string; + /** Bold text */ + bold?: boolean; + /** Italic text */ + italic?: boolean; + /** Strikethrough text */ + strikethrough?: boolean; + /** Underline text */ + underline?: boolean; +} + +/** + * Theme functions for markdown elements. + * Each function takes text and returns styled text with ANSI codes. + */ +export interface MarkdownTheme { + heading: (text: string) => string; + link: (text: string) => string; + linkUrl: (text: string) => string; + code: (text: string) => string; + codeBlock: (text: string) => string; + codeBlockBorder: (text: string) => string; + quote: (text: string) => string; + quoteBorder: (text: string) => string; + hr: (text: string) => string; + listBullet: (text: string) => string; + bold: (text: string) => string; + italic: (text: string) => string; + strikethrough: (text: string) => string; + underline: (text: string) => string; + highlightCode?: (code: string, lang?: string) => string[]; + /** Prefix applied to each rendered code block line (default: " ") */ + codeBlockIndent?: string; +} + +export interface MarkdownOptions { + /** Preserve source list markers instead of normalizing them. */ + preserveOrderedListMarkers?: boolean; +} + +interface InlineStyleContext { + applyText: (text: string) => string; + stylePrefix: string; +} + +export class Markdown implements Component { + private text: string; + private paddingX: number; // Left/right padding + private paddingY: number; // Top/bottom padding + private defaultTextStyle?: DefaultTextStyle; + private theme: MarkdownTheme; + private options: MarkdownOptions; + private defaultStylePrefix?: string; + + // Cache for rendered output + private cachedText?: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor( + text: string, + paddingX: number, + paddingY: number, + theme: MarkdownTheme, + defaultTextStyle?: DefaultTextStyle, + options?: MarkdownOptions, + ) { + this.text = text; + this.paddingX = paddingX; + this.paddingY = paddingY; + this.theme = theme; + this.defaultTextStyle = defaultTextStyle; + this.options = options ? { ...options } : {}; + } + + setText(text: string): void { + this.text = text; + this.invalidate(); + } + + invalidate(): void { + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + render(width: number): string[] { + // Check cache + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + return this.cachedLines; + } + + // Calculate available width for content (subtract horizontal padding) + const contentWidth = Math.max(1, width - this.paddingX * 2); + + // Don't render anything if there's no actual text + if (!this.text || this.text.trim() === "") { + const result: string[] = []; + // Update cache + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + return result; + } + + // Replace tabs with 3 spaces for consistent rendering + const normalizedText = this.text.replace(/\t/g, " "); + + // Parse markdown to HTML-like tokens + const tokens = markdownParser.lexer(normalizedText); + trimPartialClosingFences(tokens); + + // Convert tokens to styled terminal output + const renderedLines: string[] = []; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const nextToken = tokens[i + 1]; + const tokenLines = this.renderToken(token, contentWidth, nextToken?.type); + for (const tokenLine of tokenLines) { + renderedLines.push(tokenLine); + } + } + + // Wrap lines (NO padding, NO background yet) + const wrappedLines: string[] = []; + for (const line of renderedLines) { + if (isImageLine(line)) { + wrappedLines.push(line); + } else { + for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) { + wrappedLines.push(wrappedLine); + } + } + } + + // Add margins and background to each wrapped line + const leftMargin = " ".repeat(this.paddingX); + const rightMargin = " ".repeat(this.paddingX); + const bgFn = this.defaultTextStyle?.bgColor; + const contentLines: string[] = []; + + for (const line of wrappedLines) { + if (isImageLine(line)) { + contentLines.push(line); + continue; + } + + const lineWithMargins = leftMargin + line + rightMargin; + + if (bgFn) { + contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn)); + } else { + // No background - just pad to width + const visibleLen = visibleWidth(lineWithMargins); + const paddingNeeded = Math.max(0, width - visibleLen); + contentLines.push(lineWithMargins + " ".repeat(paddingNeeded)); + } + } + + // Add top/bottom padding (empty lines) + const emptyLine = " ".repeat(width); + const emptyLines: string[] = []; + for (let i = 0; i < this.paddingY; i++) { + const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine; + emptyLines.push(line); + } + + // Combine top padding, content, and bottom padding + const result = emptyLines.concat(contentLines, emptyLines); + + // Update cache + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + + return result.length > 0 ? result : [""]; + } + + /** + * Apply default text style to a string. + * This is the base styling applied to all text content. + * NOTE: Background color is NOT applied here - it's applied at the padding stage + * to ensure it extends to the full line width. + */ + private applyDefaultStyle(text: string): string { + if (!this.defaultTextStyle) { + return text; + } + + let styled = text; + + // Apply foreground color (NOT background - that's applied at padding stage) + if (this.defaultTextStyle.color) { + styled = this.defaultTextStyle.color(styled); + } + + // Apply text decorations using this.theme + if (this.defaultTextStyle.bold) { + styled = this.theme.bold(styled); + } + if (this.defaultTextStyle.italic) { + styled = this.theme.italic(styled); + } + if (this.defaultTextStyle.strikethrough) { + styled = this.theme.strikethrough(styled); + } + if (this.defaultTextStyle.underline) { + styled = this.theme.underline(styled); + } + + return styled; + } + + private getDefaultStylePrefix(): string { + if (!this.defaultTextStyle) { + return ""; + } + + if (this.defaultStylePrefix !== undefined) { + return this.defaultStylePrefix; + } + + const sentinel = "\u0000"; + let styled = sentinel; + + if (this.defaultTextStyle.color) { + styled = this.defaultTextStyle.color(styled); + } + + if (this.defaultTextStyle.bold) { + styled = this.theme.bold(styled); + } + if (this.defaultTextStyle.italic) { + styled = this.theme.italic(styled); + } + if (this.defaultTextStyle.strikethrough) { + styled = this.theme.strikethrough(styled); + } + if (this.defaultTextStyle.underline) { + styled = this.theme.underline(styled); + } + + const sentinelIndex = styled.indexOf(sentinel); + this.defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; + return this.defaultStylePrefix; + } + + private getStylePrefix(styleFn: (text: string) => string): string { + const sentinel = "\u0000"; + const styled = styleFn(sentinel); + const sentinelIndex = styled.indexOf(sentinel); + return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : ""; + } + + private getDefaultInlineStyleContext(): InlineStyleContext { + return { + applyText: (text: string) => this.applyDefaultStyle(text), + stylePrefix: this.getDefaultStylePrefix(), + }; + } + + private renderToken( + token: Token, + width: number, + nextTokenType?: string, + styleContext?: InlineStyleContext, + ): string[] { + const lines: string[] = []; + + switch (token.type) { + case "heading": { + const headingLevel = token.depth; + const headingPrefix = `${"#".repeat(headingLevel)} `; + + // Build a heading-specific style context so inline tokens (codespan, bold, etc.) + // restore heading styling after their own ANSI resets instead of falling back to + // the default text style. + let headingStyleFn: (text: string) => string; + if (headingLevel === 1) { + headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(this.theme.underline(text))); + } else { + headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(text)); + } + + const headingStyleContext: InlineStyleContext = { + applyText: headingStyleFn, + stylePrefix: this.getStylePrefix(headingStyleFn), + }; + + const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext); + const styledHeading = headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText; + lines.push(styledHeading); + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after headings (unless space token follows) + } + break; + } + + case "paragraph": { + const paragraphText = this.renderInlineTokens(token.tokens || [], styleContext); + lines.push(paragraphText); + // Don't add spacing if next token is space or list + if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") { + lines.push(""); + } + break; + } + + case "text": + lines.push(this.renderInlineTokens([token], styleContext)); + break; + + case "code": { + const indent = this.theme.codeBlockIndent ?? " "; + lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); + if (this.theme.highlightCode) { + const highlightedLines = this.theme.highlightCode(token.text, token.lang); + for (const hlLine of highlightedLines) { + lines.push(`${indent}${hlLine}`); + } + } else { + // Split code by newlines and style each line + const codeLines = token.text.split("\n"); + for (const codeLine of codeLines) { + lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); + } + } + lines.push(this.theme.codeBlockBorder("```")); + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after code blocks (unless space token follows) + } + break; + } + + case "list": { + const listLines = this.renderList(token as Tokens.List, 0, width, styleContext); + lines.push(...listLines); + // Don't add spacing after lists if a space token follows + // (the space token will handle it) + break; + } + + case "table": { + const tableLines = this.renderTable(token as Tokens.Table, width, nextTokenType, styleContext); + lines.push(...tableLines); + break; + } + + case "blockquote": { + const quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text)); + const quoteStylePrefix = this.getStylePrefix(quoteStyle); + const applyQuoteStyle = (line: string): string => { + if (!quoteStylePrefix) { + return quoteStyle(line); + } + const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`); + return quoteStyle(lineWithReappliedStyle); + }; + + // Calculate available width for quote content (subtract border "│ " = 2 chars) + const quoteContentWidth = Math.max(1, width - 2); + + // Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render + // children with renderToken() instead of renderInlineTokens(). + // Default message style should not apply inside blockquotes. + const quoteInlineStyleContext: InlineStyleContext = { + applyText: (text: string) => text, + stylePrefix: quoteStylePrefix, + }; + const quoteTokens = token.tokens || []; + const renderedQuoteLines: string[] = []; + for (let i = 0; i < quoteTokens.length; i++) { + const quoteToken = quoteTokens[i]; + const nextQuoteToken = quoteTokens[i + 1]; + renderedQuoteLines.push( + ...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext), + ); + } + + // Avoid rendering an extra empty quote line before the outer blockquote spacing. + while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") { + renderedQuoteLines.pop(); + } + + for (const quoteLine of renderedQuoteLines) { + const styledLine = applyQuoteStyle(quoteLine); + const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth); + for (const wrappedLine of wrappedLines) { + lines.push(this.theme.quoteBorder("│ ") + wrappedLine); + } + } + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after blockquotes (unless space token follows) + } + break; + } + + case "hr": + lines.push(this.theme.hr("─".repeat(Math.min(width, 80)))); + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after horizontal rules (unless space token follows) + } + break; + + case "html": + // Render HTML as plain text (escaped for terminal) + if ("raw" in token && typeof token.raw === "string") { + lines.push(this.applyDefaultStyle(token.raw.trim())); + } + break; + + case "space": + // Space tokens represent blank lines in markdown + lines.push(""); + break; + + default: + // Handle any other token types as plain text + if ("text" in token && typeof token.text === "string") { + lines.push(token.text); + } + } + + return lines; + } + + private renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string { + let result = ""; + const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext(); + const { applyText, stylePrefix } = resolvedStyleContext; + const applyTextWithNewlines = (text: string): string => { + const segments: string[] = text.split("\n"); + return segments.map((segment: string) => applyText(segment)).join("\n"); + }; + + for (const token of tokens) { + switch (token.type) { + case "text": + // Text tokens in list items can have nested tokens for inline formatting + if (token.tokens && token.tokens.length > 0) { + result += this.renderInlineTokens(token.tokens, resolvedStyleContext); + } else { + result += applyTextWithNewlines(token.text); + } + break; + + case "paragraph": + // Paragraph tokens contain nested inline tokens + result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + break; + + case "strong": { + const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.bold(boldContent) + stylePrefix; + break; + } + + case "em": { + const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.italic(italicContent) + stylePrefix; + break; + } + + case "codespan": + result += this.theme.code(token.text) + stylePrefix; + break; + + case "link": { + const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + const styledLink = this.theme.link(this.theme.underline(linkText)); + if (getCapabilities().hyperlinks) { + // OSC 8: render as a clickable hyperlink. The URL is not printed inline, + // so we always show only the link text regardless of whether it matches href. + result += hyperlink(styledLink, token.href) + stylePrefix; + } else { + // Fallback: print URL in parentheses when text differs from href. + // Compare raw token.text (not styled) against href for the equality check. + // For mailto: links strip the prefix (autolinked emails use text="foo@bar.com" + // but href="mailto:foo@bar.com"). + const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href; + if (token.text === token.href || token.text === hrefForComparison) { + result += styledLink + stylePrefix; + } else { + result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix; + } + } + break; + } + + case "br": + result += "\n"; + break; + + case "del": { + const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext); + result += this.theme.strikethrough(delContent) + stylePrefix; + break; + } + + case "html": + // Render inline HTML as plain text + if ("raw" in token && typeof token.raw === "string") { + result += applyTextWithNewlines(token.raw); + } + break; + + default: + // Handle any other inline token types as plain text + if ("text" in token && typeof token.text === "string") { + result += applyTextWithNewlines(token.text); + } + } + } + + while (stylePrefix && result.endsWith(stylePrefix)) { + result = result.slice(0, -stylePrefix.length); + } + + return result; + } + + private getOrderedListMarker(item: Tokens.ListItem): string | undefined { + const match = /^(?: {0,3})(\d{1,9}[.)])[ \t]+/.exec(item.raw); + return match ? `${match[1]} ` : undefined; + } + + private getUnorderedListMarker(item: Tokens.ListItem): string | undefined { + const match = /^(?: {0,3})([-+*])(?:[ \t]+|(?=\r?\n|$))/.exec(item.raw); + return match ? `${match[1]} ` : undefined; + } + + /** + * Render a list with proper nesting support + */ + private renderList(token: Tokens.List, depth: number, width: number, styleContext?: InlineStyleContext): string[] { + const lines: string[] = []; + const indent = " ".repeat(depth); + // Use the list's start property (defaults to 1 for ordered lists) + const startNumber = typeof token.start === "number" ? token.start : 1; + + for (let i = 0; i < token.items.length; i++) { + const item = token.items[i]; + const isLastItem = i === token.items.length - 1; + const bullet = token.ordered + ? this.options.preserveOrderedListMarkers + ? (this.getOrderedListMarker(item) ?? `${startNumber + i}. `) + : `${startNumber + i}. ` + : this.options.preserveOrderedListMarkers + ? (this.getUnorderedListMarker(item) ?? "- ") + : "- "; + const taskMarker = item.task ? `[${item.checked ? "x" : " "}] ` : ""; + const marker = bullet + taskMarker; + const firstPrefix = indent + this.theme.listBullet(marker); + const continuationPrefix = indent + " ".repeat(visibleWidth(marker)); + const itemWidth = Math.max(1, width - visibleWidth(firstPrefix)); + let renderedAnyLine = false; + + for (const itemToken of item.tokens) { + if (itemToken.type === "list") { + lines.push(...this.renderList(itemToken as Tokens.List, depth + 1, width, styleContext)); + renderedAnyLine = true; + continue; + } + + const itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext); + for (const line of itemLines) { + for (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) { + const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix; + lines.push(linePrefix + wrappedLine); + renderedAnyLine = true; + } + } + } + + if (!renderedAnyLine) { + lines.push(firstPrefix); + } + + if (token.loose && !isLastItem) { + lines.push(""); + } + } + + return lines; + } + + /** + * Get the visible width of the longest word in a string. + */ + private getLongestWordWidth(text: string, maxWidth?: number): number { + const words = text.split(/\s+/).filter((word) => word.length > 0); + let longest = 0; + for (const word of words) { + longest = Math.max(longest, visibleWidth(word)); + } + if (maxWidth === undefined) { + return longest; + } + return Math.min(longest, maxWidth); + } + + /** + * Wrap a table cell to fit into a column. + * + * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled + * consistently with the rest of the renderer. + */ + private wrapCellText(text: string, maxWidth: number): string[] { + return wrapTextWithAnsi(text, Math.max(1, maxWidth)); + } + + /** + * Render a table with width-aware cell wrapping. + * Cells that don't fit are wrapped to multiple lines. + */ + private renderTable( + token: Tokens.Table, + availableWidth: number, + nextTokenType?: string, + styleContext?: InlineStyleContext, + ): string[] { + const lines: string[] = []; + const numCols = token.header.length; + + if (numCols === 0) { + return lines; + } + + // Calculate border overhead: "│ " + (n-1) * " │ " + " │" + // = 2 + (n-1) * 3 + 2 = 3n + 1 + const borderOverhead = 3 * numCols + 1; + const availableForCells = availableWidth - borderOverhead; + if (availableForCells < numCols) { + // Too narrow to render a stable table. Fall back to raw markdown. + const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : []; + if (nextTokenType && nextTokenType !== "space") { + fallbackLines.push(""); + } + return fallbackLines; + } + + const maxUnbrokenWordWidth = 30; + + // Calculate natural column widths (what each column needs without constraints) + const naturalWidths: number[] = []; + const minWordWidths: number[] = []; + for (let i = 0; i < numCols; i++) { + const headerText = this.renderInlineTokens(token.header[i].tokens || [], styleContext); + naturalWidths[i] = visibleWidth(headerText); + minWordWidths[i] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth)); + } + for (const row of token.rows) { + for (let i = 0; i < row.length; i++) { + const cellText = this.renderInlineTokens(row[i].tokens || [], styleContext); + naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText)); + minWordWidths[i] = Math.max( + minWordWidths[i] || 1, + this.getLongestWordWidth(cellText, maxUnbrokenWordWidth), + ); + } + } + + let minColumnWidths = minWordWidths; + let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0); + + if (minCellsWidth > availableForCells) { + minColumnWidths = new Array(numCols).fill(1); + const remaining = availableForCells - numCols; + + if (remaining > 0) { + const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0); + const growth = minWordWidths.map((width) => { + const weight = Math.max(0, width - 1); + return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0; + }); + + for (let i = 0; i < numCols; i++) { + minColumnWidths[i] += growth[i] ?? 0; + } + + const allocated = growth.reduce((total, width) => total + width, 0); + let leftover = remaining - allocated; + for (let i = 0; leftover > 0 && i < numCols; i++) { + minColumnWidths[i]++; + leftover--; + } + } + + minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0); + } + + // Calculate column widths that fit within available width + const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead; + let columnWidths: number[]; + + if (totalNaturalWidth <= availableWidth) { + // Everything fits naturally + columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index])); + } else { + // Need to shrink columns to fit + const totalGrowPotential = naturalWidths.reduce((total, width, index) => { + return total + Math.max(0, width - minColumnWidths[index]); + }, 0); + const extraWidth = Math.max(0, availableForCells - minCellsWidth); + columnWidths = minColumnWidths.map((minWidth, index) => { + const naturalWidth = naturalWidths[index]; + const minWidthDelta = Math.max(0, naturalWidth - minWidth); + let grow = 0; + if (totalGrowPotential > 0) { + grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth); + } + return minWidth + grow; + }); + + // Adjust for rounding errors - distribute remaining space + const allocated = columnWidths.reduce((a, b) => a + b, 0); + let remaining = availableForCells - allocated; + while (remaining > 0) { + let grew = false; + for (let i = 0; i < numCols && remaining > 0; i++) { + if (columnWidths[i] < naturalWidths[i]) { + columnWidths[i]++; + remaining--; + grew = true; + } + } + if (!grew) { + break; + } + } + } + + // Render top border + const topBorderCells = columnWidths.map((w) => "─".repeat(w)); + lines.push(`┌─${topBorderCells.join("─┬─")}─┐`); + + // Render header with wrapping + const headerCellLines: string[][] = token.header.map((cell, i) => { + const text = this.renderInlineTokens(cell.tokens || [], styleContext); + return this.wrapCellText(text, columnWidths[i]); + }); + const headerLineCount = Math.max(...headerCellLines.map((c) => c.length)); + + for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) { + const rowParts = headerCellLines.map((cellLines, colIdx) => { + const text = cellLines[lineIdx] || ""; + const padded = text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text))); + return this.theme.bold(padded); + }); + lines.push(`│ ${rowParts.join(" │ ")} │`); + } + + // Render separator + const separatorCells = columnWidths.map((w) => "─".repeat(w)); + const separatorLine = `├─${separatorCells.join("─┼─")}─┤`; + lines.push(separatorLine); + + // Render rows with wrapping + for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) { + const row = token.rows[rowIndex]; + const rowCellLines: string[][] = row.map((cell, i) => { + const text = this.renderInlineTokens(cell.tokens || [], styleContext); + return this.wrapCellText(text, columnWidths[i]); + }); + const rowLineCount = Math.max(...rowCellLines.map((c) => c.length)); + + for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) { + const rowParts = rowCellLines.map((cellLines, colIdx) => { + const text = cellLines[lineIdx] || ""; + return text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text))); + }); + lines.push(`│ ${rowParts.join(" │ ")} │`); + } + + if (rowIndex < token.rows.length - 1) { + lines.push(separatorLine); + } + } + + // Render bottom border + const bottomBorderCells = columnWidths.map((w) => "─".repeat(w)); + lines.push(`└─${bottomBorderCells.join("─┴─")}─┘`); + + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); // Add spacing after table + } + return lines; + } +} diff --git a/cactus-code/packages/tui/src/components/select-list.ts b/cactus-code/packages/tui/src/components/select-list.ts new file mode 100644 index 000000000..26fdb685a --- /dev/null +++ b/cactus-code/packages/tui/src/components/select-list.ts @@ -0,0 +1,229 @@ +import { getKeybindings } from "../keybindings.ts"; +import type { Component } from "../tui.ts"; +import { truncateToWidth, visibleWidth } from "../utils.ts"; + +const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; +const PRIMARY_COLUMN_GAP = 2; +const MIN_DESCRIPTION_WIDTH = 10; + +const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim(); +const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max)); + +export interface SelectItem { + value: string; + label: string; + description?: string; +} + +export interface SelectListTheme { + selectedPrefix: (text: string) => string; + selectedText: (text: string) => string; + description: (text: string) => string; + scrollInfo: (text: string) => string; + noMatch: (text: string) => string; +} + +export interface SelectListTruncatePrimaryContext { + text: string; + maxWidth: number; + columnWidth: number; + item: SelectItem; + isSelected: boolean; +} + +export interface SelectListLayoutOptions { + minPrimaryColumnWidth?: number; + maxPrimaryColumnWidth?: number; + truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string; +} + +export class SelectList implements Component { + private items: SelectItem[] = []; + private filteredItems: SelectItem[] = []; + private selectedIndex: number = 0; + private maxVisible: number = 5; + private theme: SelectListTheme; + private layout: SelectListLayoutOptions; + + public onSelect?: (item: SelectItem) => void; + public onCancel?: () => void; + public onSelectionChange?: (item: SelectItem) => void; + + constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.layout = layout; + } + + setFilter(filter: string): void { + this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase())); + // Reset selection when filter changes + this.selectedIndex = 0; + } + + setSelectedIndex(index: number): void { + this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(width: number): string[] { + const lines: string[] = []; + + // If no items match filter, show message + if (this.filteredItems.length === 0) { + lines.push(this.theme.noMatch(" No matching commands")); + return lines; + } + + const primaryColumnWidth = this.getPrimaryColumnWidth(); + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = this.filteredItems[i]; + if (!item) continue; + + const isSelected = i === this.selectedIndex; + const descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined; + lines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth)); + } + + // Add scroll indicators if needed + if (startIndex > 0 || endIndex < this.filteredItems.length) { + const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`; + // Truncate if too long for terminal + lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, ""))); + } + + return lines; + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + // Up arrow - wrap to bottom when at top + if (kb.matches(keyData, "tui.select.up")) { + this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1; + this.notifySelectionChange(); + } + // Down arrow - wrap to top when at bottom + else if (kb.matches(keyData, "tui.select.down")) { + this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1; + this.notifySelectionChange(); + } + // Enter + else if (kb.matches(keyData, "tui.select.confirm")) { + const selectedItem = this.filteredItems[this.selectedIndex]; + if (selectedItem && this.onSelect) { + this.onSelect(selectedItem); + } + } + // Escape or Ctrl+C + else if (kb.matches(keyData, "tui.select.cancel")) { + if (this.onCancel) { + this.onCancel(); + } + } + } + + private renderItem( + item: SelectItem, + isSelected: boolean, + width: number, + descriptionSingleLine: string | undefined, + primaryColumnWidth: number, + ): string { + const prefix = isSelected ? "→ " : " "; + const prefixWidth = visibleWidth(prefix); + + if (descriptionSingleLine && width > 40) { + const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4)); + const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP); + const truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth); + const truncatedValueWidth = visibleWidth(truncatedValue); + const spacing = " ".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth)); + const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length; + const remainingWidth = width - descriptionStart - 2; // -2 for safety + + if (remainingWidth > MIN_DESCRIPTION_WIDTH) { + const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, ""); + if (isSelected) { + return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`); + } + + const descText = this.theme.description(spacing + truncatedDesc); + return prefix + truncatedValue + descText; + } + } + + const maxWidth = width - prefixWidth - 2; + const truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth); + if (isSelected) { + return this.theme.selectedText(`${prefix}${truncatedValue}`); + } + + return prefix + truncatedValue; + } + + private getPrimaryColumnWidth(): number { + const { min, max } = this.getPrimaryColumnBounds(); + const widestPrimary = this.filteredItems.reduce((widest, item) => { + return Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP); + }, 0); + + return clamp(widestPrimary, min, max); + } + + private getPrimaryColumnBounds(): { min: number; max: number } { + const rawMin = + this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + const rawMax = + this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH; + + return { + min: Math.max(1, Math.min(rawMin, rawMax)), + max: Math.max(1, Math.max(rawMin, rawMax)), + }; + } + + private truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string { + const displayValue = this.getDisplayValue(item); + const truncatedValue = this.layout.truncatePrimary + ? this.layout.truncatePrimary({ + text: displayValue, + maxWidth, + columnWidth, + item, + isSelected, + }) + : truncateToWidth(displayValue, maxWidth, ""); + + return truncateToWidth(truncatedValue, maxWidth, ""); + } + + private getDisplayValue(item: SelectItem): string { + return item.label || item.value; + } + + private notifySelectionChange(): void { + const selectedItem = this.filteredItems[this.selectedIndex]; + if (selectedItem && this.onSelectionChange) { + this.onSelectionChange(selectedItem); + } + } + + getSelectedItem(): SelectItem | null { + const item = this.filteredItems[this.selectedIndex]; + return item || null; + } +} diff --git a/cactus-code/packages/tui/src/components/settings-list.ts b/cactus-code/packages/tui/src/components/settings-list.ts new file mode 100644 index 000000000..88e9433f7 --- /dev/null +++ b/cactus-code/packages/tui/src/components/settings-list.ts @@ -0,0 +1,250 @@ +import { fuzzyFilter } from "../fuzzy.ts"; +import { getKeybindings } from "../keybindings.ts"; +import type { Component } from "../tui.ts"; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; +import { Input } from "./input.ts"; + +export interface SettingItem { + /** Unique identifier for this setting */ + id: string; + /** Display label (left side) */ + label: string; + /** Optional description shown when selected */ + description?: string; + /** Current value to display (right side) */ + currentValue: string; + /** If provided, Enter/Space cycles through these values */ + values?: string[]; + /** If provided, Enter opens this submenu. Receives current value and done callback. */ + submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component; +} + +export interface SettingsListTheme { + label: (text: string, selected: boolean) => string; + value: (text: string, selected: boolean) => string; + description: (text: string) => string; + cursor: string; + hint: (text: string) => string; +} + +export interface SettingsListOptions { + enableSearch?: boolean; +} + +export class SettingsList implements Component { + private items: SettingItem[]; + private filteredItems: SettingItem[]; + private theme: SettingsListTheme; + private selectedIndex = 0; + private maxVisible: number; + private onChange: (id: string, newValue: string) => void; + private onCancel: () => void; + private searchInput?: Input; + private searchEnabled: boolean; + + // Submenu state + private submenuComponent: Component | null = null; + private submenuItemIndex: number | null = null; + + constructor( + items: SettingItem[], + maxVisible: number, + theme: SettingsListTheme, + onChange: (id: string, newValue: string) => void, + onCancel: () => void, + options: SettingsListOptions = {}, + ) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.onChange = onChange; + this.onCancel = onCancel; + this.searchEnabled = options.enableSearch ?? false; + if (this.searchEnabled) { + this.searchInput = new Input(); + } + } + + /** Update an item's currentValue */ + updateValue(id: string, newValue: string): void { + const item = this.items.find((i) => i.id === id); + if (item) { + item.currentValue = newValue; + } + } + + invalidate(): void { + this.submenuComponent?.invalidate?.(); + } + + render(width: number): string[] { + // If submenu is active, render it instead + if (this.submenuComponent) { + return this.submenuComponent.render(width); + } + + return this.renderMainList(width); + } + + private renderMainList(width: number): string[] { + const lines: string[] = []; + + if (this.searchEnabled && this.searchInput) { + lines.push(...this.searchInput.render(width)); + lines.push(""); + } + + if (this.items.length === 0) { + lines.push(this.theme.hint(" No settings available")); + if (this.searchEnabled) { + this.addHintLine(lines, width); + } + return lines; + } + + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + if (displayItems.length === 0) { + lines.push(truncateToWidth(this.theme.hint(" No matching settings"), width)); + this.addHintLine(lines, width); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length); + + // Calculate max label width for alignment + const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label)))); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = displayItems[i]; + if (!item) continue; + + const isSelected = i === this.selectedIndex; + const prefix = isSelected ? this.theme.cursor : " "; + const prefixWidth = visibleWidth(prefix); + + // Pad label to align values + const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label))); + const labelText = this.theme.label(labelPadded, isSelected); + + // Calculate space for value + const separator = " "; + const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator); + const valueMaxWidth = width - usedWidth - 2; + + const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected); + + lines.push(truncateToWidth(prefix + labelText + separator + valueText, width)); + } + + // Add scroll indicator if needed + if (startIndex > 0 || endIndex < displayItems.length) { + const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`; + lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, ""))); + } + + // Add description for selected item + const selectedItem = displayItems[this.selectedIndex]; + if (selectedItem?.description) { + lines.push(""); + const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4); + for (const line of wrappedDesc) { + lines.push(this.theme.description(` ${line}`)); + } + } + + // Add hint + this.addHintLine(lines, width); + + return lines; + } + + handleInput(data: string): void { + // If submenu is active, delegate all input to it + // The submenu's onCancel (triggered by escape) will call done() which closes it + if (this.submenuComponent) { + this.submenuComponent.handleInput?.(data); + return; + } + + // Main list input handling + const kb = getKeybindings(); + const displayItems = this.searchEnabled ? this.filteredItems : this.items; + if (kb.matches(data, "tui.select.up")) { + if (displayItems.length === 0) return; + this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1; + } else if (kb.matches(data, "tui.select.down")) { + if (displayItems.length === 0) return; + this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1; + } else if (kb.matches(data, "tui.select.confirm") || data === " ") { + this.activateItem(); + } else if (kb.matches(data, "tui.select.cancel")) { + this.onCancel(); + } else if (this.searchEnabled && this.searchInput) { + const sanitized = data.replace(/ /g, ""); + if (!sanitized) { + return; + } + this.searchInput.handleInput(sanitized); + this.applyFilter(this.searchInput.getValue()); + } + } + + private activateItem(): void { + const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex]; + if (!item) return; + + if (item.submenu) { + // Open submenu, passing current value so it can pre-select correctly + this.submenuItemIndex = this.selectedIndex; + this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => { + if (selectedValue !== undefined) { + item.currentValue = selectedValue; + this.onChange(item.id, selectedValue); + } + this.closeSubmenu(); + }); + } else if (item.values && item.values.length > 0) { + // Cycle through values + const currentIndex = item.values.indexOf(item.currentValue); + const nextIndex = (currentIndex + 1) % item.values.length; + const newValue = item.values[nextIndex]; + item.currentValue = newValue; + this.onChange(item.id, newValue); + } + } + + private closeSubmenu(): void { + this.submenuComponent = null; + // Restore selection to the item that opened the submenu + if (this.submenuItemIndex !== null) { + this.selectedIndex = this.submenuItemIndex; + this.submenuItemIndex = null; + } + } + + private applyFilter(query: string): void { + this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label); + this.selectedIndex = 0; + } + + private addHintLine(lines: string[], width: number): void { + lines.push(""); + lines.push( + truncateToWidth( + this.theme.hint( + this.searchEnabled + ? " Type to search · Enter/Space to change · Esc to cancel" + : " Enter/Space to change · Esc to cancel", + ), + width, + ), + ); + } +} diff --git a/cactus-code/packages/tui/src/components/spacer.ts b/cactus-code/packages/tui/src/components/spacer.ts new file mode 100644 index 000000000..7abe1551c --- /dev/null +++ b/cactus-code/packages/tui/src/components/spacer.ts @@ -0,0 +1,28 @@ +import type { Component } from "../tui.ts"; + +/** + * Spacer component that renders empty lines + */ +export class Spacer implements Component { + private lines: number; + + constructor(lines: number = 1) { + this.lines = lines; + } + + setLines(lines: number): void { + this.lines = lines; + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(_width: number): string[] { + const result: string[] = []; + for (let i = 0; i < this.lines; i++) { + result.push(""); + } + return result; + } +} diff --git a/cactus-code/packages/tui/src/components/text.ts b/cactus-code/packages/tui/src/components/text.ts new file mode 100644 index 000000000..3809a48a8 --- /dev/null +++ b/cactus-code/packages/tui/src/components/text.ts @@ -0,0 +1,106 @@ +import type { Component } from "../tui.ts"; +import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; + +/** + * Text component - displays multi-line text with word wrapping + */ +export class Text implements Component { + private text: string; + private paddingX: number; // Left/right padding + private paddingY: number; // Top/bottom padding + private customBgFn?: (text: string) => string; + + // Cache for rendered output + private cachedText?: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) { + this.text = text; + this.paddingX = paddingX; + this.paddingY = paddingY; + this.customBgFn = customBgFn; + } + + setText(text: string): void { + this.text = text; + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + setCustomBgFn(customBgFn?: (text: string) => string): void { + this.customBgFn = customBgFn; + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + invalidate(): void { + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + render(width: number): string[] { + // Check cache + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + return this.cachedLines; + } + + // Don't render anything if there's no actual text + if (!this.text || this.text.trim() === "") { + const result: string[] = []; + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + return result; + } + + // Replace tabs with 3 spaces + const normalizedText = this.text.replace(/\t/g, " "); + + // Calculate content width (subtract left/right margins) + const contentWidth = Math.max(1, width - this.paddingX * 2); + + // Wrap text (this preserves ANSI codes but does NOT pad) + const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth); + + // Add margins and background to each line + const leftMargin = " ".repeat(this.paddingX); + const rightMargin = " ".repeat(this.paddingX); + const contentLines: string[] = []; + + for (const line of wrappedLines) { + // Add margins + const lineWithMargins = leftMargin + line + rightMargin; + + // Apply background if specified (this also pads to full width) + if (this.customBgFn) { + contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn)); + } else { + // No background - just pad to width with spaces + const visibleLen = visibleWidth(lineWithMargins); + const paddingNeeded = Math.max(0, width - visibleLen); + contentLines.push(lineWithMargins + " ".repeat(paddingNeeded)); + } + } + + // Add top/bottom padding (empty lines) + const emptyLine = " ".repeat(width); + const emptyLines: string[] = []; + for (let i = 0; i < this.paddingY; i++) { + const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine; + emptyLines.push(line); + } + + const result = [...emptyLines, ...contentLines, ...emptyLines]; + + // Update cache + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = result; + + return result.length > 0 ? result : [""]; + } +} diff --git a/cactus-code/packages/tui/src/components/truncated-text.ts b/cactus-code/packages/tui/src/components/truncated-text.ts new file mode 100644 index 000000000..c26b88929 --- /dev/null +++ b/cactus-code/packages/tui/src/components/truncated-text.ts @@ -0,0 +1,65 @@ +import type { Component } from "../tui.ts"; +import { truncateToWidth, visibleWidth } from "../utils.ts"; + +/** + * Text component that truncates to fit viewport width + */ +export class TruncatedText implements Component { + private text: string; + private paddingX: number; + private paddingY: number; + + constructor(text: string, paddingX: number = 0, paddingY: number = 0) { + this.text = text; + this.paddingX = paddingX; + this.paddingY = paddingY; + } + + invalidate(): void { + // No cached state to invalidate currently + } + + render(width: number): string[] { + const result: string[] = []; + + // Empty line padded to width + const emptyLine = " ".repeat(width); + + // Add vertical padding above + for (let i = 0; i < this.paddingY; i++) { + result.push(emptyLine); + } + + // Calculate available width after horizontal padding + const availableWidth = Math.max(1, width - this.paddingX * 2); + + // Take only the first line (stop at newline) + let singleLineText = this.text; + const newlineIndex = this.text.indexOf("\n"); + if (newlineIndex !== -1) { + singleLineText = this.text.substring(0, newlineIndex); + } + + // Truncate text if needed (accounting for ANSI codes) + const displayText = truncateToWidth(singleLineText, availableWidth); + + // Add horizontal padding + const leftPadding = " ".repeat(this.paddingX); + const rightPadding = " ".repeat(this.paddingX); + const lineWithPadding = leftPadding + displayText + rightPadding; + + // Pad line to exactly width characters + const lineVisibleWidth = visibleWidth(lineWithPadding); + const paddingNeeded = Math.max(0, width - lineVisibleWidth); + const finalLine = lineWithPadding + " ".repeat(paddingNeeded); + + result.push(finalLine); + + // Add vertical padding below + for (let i = 0; i < this.paddingY; i++) { + result.push(emptyLine); + } + + return result; + } +} diff --git a/cactus-code/packages/tui/src/editor-component.ts b/cactus-code/packages/tui/src/editor-component.ts new file mode 100644 index 000000000..595645033 --- /dev/null +++ b/cactus-code/packages/tui/src/editor-component.ts @@ -0,0 +1,74 @@ +import type { AutocompleteProvider } from "./autocomplete.ts"; +import type { Component } from "./tui.ts"; + +/** + * Interface for custom editor components. + * + * This allows extensions to provide their own editor implementation + * (e.g., vim mode, emacs mode, custom keybindings) while maintaining + * compatibility with the core application. + */ +export interface EditorComponent extends Component { + // ========================================================================= + // Core text access (required) + // ========================================================================= + + /** Get the current text content */ + getText(): string; + + /** Set the text content */ + setText(text: string): void; + + /** Handle raw terminal input (key presses, paste sequences, etc.) */ + handleInput(data: string): void; + + // ========================================================================= + // Callbacks (required) + // ========================================================================= + + /** Called when user submits (e.g., Enter key) */ + onSubmit?: (text: string) => void; + + /** Called when text changes */ + onChange?: (text: string) => void; + + // ========================================================================= + // History support (optional) + // ========================================================================= + + /** Add text to history for up/down navigation */ + addToHistory?(text: string): void; + + // ========================================================================= + // Advanced text manipulation (optional) + // ========================================================================= + + /** Insert text at current cursor position */ + insertTextAtCursor?(text: string): void; + + /** + * Get text with any markers expanded (e.g., paste markers). + * Falls back to getText() if not implemented. + */ + getExpandedText?(): string; + + // ========================================================================= + // Autocomplete support (optional) + // ========================================================================= + + /** Set the autocomplete provider */ + setAutocompleteProvider?(provider: AutocompleteProvider): void; + + // ========================================================================= + // Appearance (optional) + // ========================================================================= + + /** Border color function */ + borderColor?: (str: string) => string; + + /** Set horizontal padding */ + setPaddingX?(padding: number): void; + + /** Set max visible items in autocomplete dropdown */ + setAutocompleteMaxVisible?(maxVisible: number): void; +} diff --git a/cactus-code/packages/tui/src/fuzzy.ts b/cactus-code/packages/tui/src/fuzzy.ts new file mode 100644 index 000000000..73c10dcf4 --- /dev/null +++ b/cactus-code/packages/tui/src/fuzzy.ts @@ -0,0 +1,137 @@ +/** + * Fuzzy matching utilities. + * Matches if all query characters appear in order (not necessarily consecutive). + * Lower score = better match. + */ + +export interface FuzzyMatch { + matches: boolean; + score: number; +} + +export function fuzzyMatch(query: string, text: string): FuzzyMatch { + const queryLower = query.toLowerCase(); + const textLower = text.toLowerCase(); + + const matchQuery = (normalizedQuery: string): FuzzyMatch => { + if (normalizedQuery.length === 0) { + return { matches: true, score: 0 }; + } + + if (normalizedQuery.length > textLower.length) { + return { matches: false, score: 0 }; + } + + let queryIndex = 0; + let score = 0; + let lastMatchIndex = -1; + let consecutiveMatches = 0; + + for (let i = 0; i < textLower.length && queryIndex < normalizedQuery.length; i++) { + if (textLower[i] === normalizedQuery[queryIndex]) { + const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!); + + // Reward consecutive matches + if (lastMatchIndex === i - 1) { + consecutiveMatches++; + score -= consecutiveMatches * 5; + } else { + consecutiveMatches = 0; + // Penalize gaps + if (lastMatchIndex >= 0) { + score += (i - lastMatchIndex - 1) * 2; + } + } + + // Reward word boundary matches + if (isWordBoundary) { + score -= 10; + } + + // Slight penalty for later matches + score += i * 0.1; + + lastMatchIndex = i; + queryIndex++; + } + } + + if (queryIndex < normalizedQuery.length) { + return { matches: false, score: 0 }; + } + + if (normalizedQuery === textLower) { + score -= 100; + } + + return { matches: true, score }; + }; + + const primaryMatch = matchQuery(queryLower); + if (primaryMatch.matches) { + return primaryMatch; + } + + const alphaNumericMatch = queryLower.match(/^(?[a-z]+)(?[0-9]+)$/); + const numericAlphaMatch = queryLower.match(/^(?[0-9]+)(?[a-z]+)$/); + const swappedQuery = alphaNumericMatch + ? `${alphaNumericMatch.groups?.digits ?? ""}${alphaNumericMatch.groups?.letters ?? ""}` + : numericAlphaMatch + ? `${numericAlphaMatch.groups?.letters ?? ""}${numericAlphaMatch.groups?.digits ?? ""}` + : ""; + + if (!swappedQuery) { + return primaryMatch; + } + + const swappedMatch = matchQuery(swappedQuery); + if (!swappedMatch.matches) { + return primaryMatch; + } + + return { matches: true, score: swappedMatch.score + 5 }; +} + +/** + * Filter and sort items by fuzzy match quality (best matches first). + * Supports whitespace- and slash-separated tokens: all tokens must match. + */ +export function fuzzyFilter(items: T[], query: string, getText: (item: T) => string): T[] { + if (!query.trim()) { + return items; + } + + const tokens = query + .trim() + .split(/[\s/]+/) + .filter((t) => t.length > 0); + + if (tokens.length === 0) { + return items; + } + + const results: { item: T; totalScore: number }[] = []; + + for (const item of items) { + const text = getText(item); + let totalScore = 0; + let allMatch = true; + + for (const token of tokens) { + const match = fuzzyMatch(token, text); + if (match.matches) { + totalScore += match.score; + } else { + allMatch = false; + break; + } + } + + if (allMatch) { + results.push({ item, totalScore }); + } + } + + results.sort((a, b) => a.totalScore - b.totalScore); + return results.map((r) => r.item); +} diff --git a/cactus-code/packages/tui/src/index.ts b/cactus-code/packages/tui/src/index.ts new file mode 100644 index 000000000..4e76b1079 --- /dev/null +++ b/cactus-code/packages/tui/src/index.ts @@ -0,0 +1,114 @@ +// Core TUI interfaces and classes + +// Autocomplete support +export { + type AutocompleteItem, + type AutocompleteProvider, + type AutocompleteSuggestions, + CombinedAutocompleteProvider, + type SlashCommand, +} from "./autocomplete.ts"; +// Components +export { Box } from "./components/box.ts"; +export { CancellableLoader } from "./components/cancellable-loader.ts"; +export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts"; +export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts"; +export { Input } from "./components/input.ts"; +export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; +export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; +export { + type SelectItem, + SelectList, + type SelectListLayoutOptions, + type SelectListTheme, + type SelectListTruncatePrimaryContext, +} from "./components/select-list.ts"; +export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.ts"; +export { Spacer } from "./components/spacer.ts"; +export { Text } from "./components/text.ts"; +export { TruncatedText } from "./components/truncated-text.ts"; +// Editor component interface (for custom editors) +export type { EditorComponent } from "./editor-component.ts"; +// Fuzzy matching +export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.ts"; +// Keybindings +export { + getKeybindings, + type Keybinding, + type KeybindingConflict, + type KeybindingDefinition, + type KeybindingDefinitions, + type Keybindings, + type KeybindingsConfig, + KeybindingsManager, + setKeybindings, + TUI_KEYBINDINGS, +} from "./keybindings.ts"; +// Keyboard input handling +export { + decodeKittyPrintable, + isKeyRelease, + isKeyRepeat, + isKittyProtocolActive, + Key, + type KeyEventType, + type KeyId, + matchesKey, + parseKey, + setKittyProtocolActive, +} from "./keys.ts"; +// Input buffering for batch splitting +export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; +// Terminal interface and implementations +export { ProcessTerminal, type Terminal } from "./terminal.ts"; +// Terminal colors +export { + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type RgbColor, + type TerminalColorScheme, +} from "./terminal-colors.ts"; +// Terminal image support +export { + allocateImageId, + type CellDimensions, + calculateImageRows, + deleteAllKittyImages, + deleteKittyImage, + detectCapabilities, + encodeITerm2, + encodeKitty, + getCapabilities, + getCellDimensions, + getGifDimensions, + getImageDimensions, + getJpegDimensions, + getPngDimensions, + getWebpDimensions, + hyperlink, + type ImageDimensions, + type ImageProtocol, + type ImageRenderOptions, + imageFallback, + renderImage, + resetCapabilitiesCache, + setCapabilities, + setCellDimensions, + type TerminalCapabilities, +} from "./terminal-image.ts"; +export { + type Component, + Container, + CURSOR_MARKER, + type Focusable, + isFocusable, + type OverlayAnchor, + type OverlayHandle, + type OverlayMargin, + type OverlayOptions, + type OverlayUnfocusOptions, + type SizeValue, + TUI, +} from "./tui.ts"; +// Utilities +export { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts"; diff --git a/cactus-code/packages/tui/src/keybindings.ts b/cactus-code/packages/tui/src/keybindings.ts new file mode 100644 index 000000000..eaf0838b4 --- /dev/null +++ b/cactus-code/packages/tui/src/keybindings.ts @@ -0,0 +1,244 @@ +import { type KeyId, matchesKey } from "./keys.ts"; + +/** + * Global keybinding registry. + * Downstream packages can add keybindings via declaration merging. + */ +export interface Keybindings { + // Editor navigation and editing + "tui.editor.cursorUp": true; + "tui.editor.cursorDown": true; + "tui.editor.cursorLeft": true; + "tui.editor.cursorRight": true; + "tui.editor.cursorWordLeft": true; + "tui.editor.cursorWordRight": true; + "tui.editor.cursorLineStart": true; + "tui.editor.cursorLineEnd": true; + "tui.editor.jumpForward": true; + "tui.editor.jumpBackward": true; + "tui.editor.pageUp": true; + "tui.editor.pageDown": true; + "tui.editor.deleteCharBackward": true; + "tui.editor.deleteCharForward": true; + "tui.editor.deleteWordBackward": true; + "tui.editor.deleteWordForward": true; + "tui.editor.deleteToLineStart": true; + "tui.editor.deleteToLineEnd": true; + "tui.editor.yank": true; + "tui.editor.yankPop": true; + "tui.editor.undo": true; + // Generic input actions + "tui.input.newLine": true; + "tui.input.submit": true; + "tui.input.tab": true; + "tui.input.copy": true; + // Generic selection actions + "tui.select.up": true; + "tui.select.down": true; + "tui.select.pageUp": true; + "tui.select.pageDown": true; + "tui.select.confirm": true; + "tui.select.cancel": true; +} + +export type Keybinding = keyof Keybindings; + +export interface KeybindingDefinition { + defaultKeys: KeyId | KeyId[]; + description?: string; +} + +export type KeybindingDefinitions = Record; +export type KeybindingsConfig = Record; + +export const TUI_KEYBINDINGS = { + "tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" }, + "tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" }, + "tui.editor.cursorLeft": { + defaultKeys: ["left", "ctrl+b"], + description: "Move cursor left", + }, + "tui.editor.cursorRight": { + defaultKeys: ["right", "ctrl+f"], + description: "Move cursor right", + }, + "tui.editor.cursorWordLeft": { + defaultKeys: ["alt+left", "ctrl+left", "alt+b"], + description: "Move cursor word left", + }, + "tui.editor.cursorWordRight": { + defaultKeys: ["alt+right", "ctrl+right", "alt+f"], + description: "Move cursor word right", + }, + "tui.editor.cursorLineStart": { + defaultKeys: ["home", "ctrl+a"], + description: "Move to line start", + }, + "tui.editor.cursorLineEnd": { + defaultKeys: ["end", "ctrl+e"], + description: "Move to line end", + }, + "tui.editor.jumpForward": { + defaultKeys: "ctrl+]", + description: "Jump forward to character", + }, + "tui.editor.jumpBackward": { + defaultKeys: "ctrl+alt+]", + description: "Jump backward to character", + }, + "tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" }, + "tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" }, + "tui.editor.deleteCharBackward": { + defaultKeys: "backspace", + description: "Delete character backward", + }, + "tui.editor.deleteCharForward": { + defaultKeys: ["delete", "ctrl+d"], + description: "Delete character forward", + }, + "tui.editor.deleteWordBackward": { + defaultKeys: ["ctrl+w", "alt+backspace"], + description: "Delete word backward", + }, + "tui.editor.deleteWordForward": { + defaultKeys: ["alt+d", "alt+delete"], + description: "Delete word forward", + }, + "tui.editor.deleteToLineStart": { + defaultKeys: "ctrl+u", + description: "Delete to line start", + }, + "tui.editor.deleteToLineEnd": { + defaultKeys: "ctrl+k", + description: "Delete to line end", + }, + "tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" }, + "tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" }, + "tui.editor.undo": { defaultKeys: "ctrl+-", description: "Undo" }, + "tui.input.newLine": { defaultKeys: ["shift+enter", "ctrl+j"], description: "Insert newline" }, + "tui.input.submit": { defaultKeys: "enter", description: "Submit input" }, + "tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" }, + "tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" }, + "tui.select.up": { defaultKeys: "up", description: "Move selection up" }, + "tui.select.down": { defaultKeys: "down", description: "Move selection down" }, + "tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" }, + "tui.select.pageDown": { + defaultKeys: "pageDown", + description: "Selection page down", + }, + "tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" }, + "tui.select.cancel": { + defaultKeys: ["escape", "ctrl+c"], + description: "Cancel selection", + }, +} as const satisfies KeybindingDefinitions; + +export interface KeybindingConflict { + key: KeyId; + keybindings: string[]; +} + +function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] { + if (keys === undefined) return []; + const keyList = Array.isArray(keys) ? keys : [keys]; + const seen = new Set(); + const result: KeyId[] = []; + for (const key of keyList) { + if (!seen.has(key)) { + seen.add(key); + result.push(key); + } + } + return result; +} + +export class KeybindingsManager { + private definitions: KeybindingDefinitions; + private userBindings: KeybindingsConfig; + private keysById = new Map(); + private conflicts: KeybindingConflict[] = []; + + constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) { + this.definitions = definitions; + this.userBindings = userBindings; + this.rebuild(); + } + + private rebuild(): void { + this.keysById.clear(); + this.conflicts = []; + + const userClaims = new Map>(); + for (const [keybinding, keys] of Object.entries(this.userBindings)) { + if (!(keybinding in this.definitions)) continue; + for (const key of normalizeKeys(keys)) { + const claimants = userClaims.get(key) ?? new Set(); + claimants.add(keybinding as Keybinding); + userClaims.set(key, claimants); + } + } + + for (const [key, keybindings] of userClaims) { + if (keybindings.size > 1) { + this.conflicts.push({ key, keybindings: [...keybindings] }); + } + } + + for (const [id, definition] of Object.entries(this.definitions)) { + const userKeys = this.userBindings[id]; + const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys); + this.keysById.set(id as Keybinding, keys); + } + } + + matches(data: string, keybinding: Keybinding): boolean { + const keys = this.keysById.get(keybinding) ?? []; + for (const key of keys) { + if (matchesKey(data, key)) return true; + } + return false; + } + + getKeys(keybinding: Keybinding): KeyId[] { + return [...(this.keysById.get(keybinding) ?? [])]; + } + + getDefinition(keybinding: Keybinding): KeybindingDefinition { + return this.definitions[keybinding]; + } + + getConflicts(): KeybindingConflict[] { + return this.conflicts.map((conflict) => ({ ...conflict, keybindings: [...conflict.keybindings] })); + } + + setUserBindings(userBindings: KeybindingsConfig): void { + this.userBindings = userBindings; + this.rebuild(); + } + + getUserBindings(): KeybindingsConfig { + return { ...this.userBindings }; + } + + getResolvedBindings(): KeybindingsConfig { + const resolved: KeybindingsConfig = {}; + for (const id of Object.keys(this.definitions)) { + const keys = this.keysById.get(id as Keybinding) ?? []; + resolved[id] = keys.length === 1 ? keys[0]! : [...keys]; + } + return resolved; + } +} + +let globalKeybindings: KeybindingsManager | null = null; + +export function setKeybindings(keybindings: KeybindingsManager): void { + globalKeybindings = keybindings; +} + +export function getKeybindings(): KeybindingsManager { + if (!globalKeybindings) { + globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS); + } + return globalKeybindings; +} diff --git a/cactus-code/packages/tui/src/keys.ts b/cactus-code/packages/tui/src/keys.ts new file mode 100644 index 000000000..0d3c7085f --- /dev/null +++ b/cactus-code/packages/tui/src/keys.ts @@ -0,0 +1,1400 @@ +/** + * Keyboard input handling for terminal applications. + * + * Supports both legacy terminal sequences and Kitty keyboard protocol. + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + * Reference: https://github.com/sst/opentui/blob/7da92b4088aebfe27b9f691c04163a48821e49fd/packages/core/src/lib/parse.keypress.ts + * + * Symbol keys are also supported, however some ctrl+symbol combos + * overlap with ASCII codes, e.g. ctrl+[ = ESC. + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-ctrl-mapping-of-ascii-keys + * Those can still be * used for ctrl+shift combos + * + * API: + * - matchesKey(data, keyId) - Check if input matches a key identifier + * - parseKey(data) - Parse input and return the key identifier + * - Key - Helper object for creating typed key identifiers + * - setKittyProtocolActive(active) - Set global Kitty protocol state + * - isKittyProtocolActive() - Query global Kitty protocol state + */ + +// ============================================================================= +// Global Kitty Protocol State +// ============================================================================= + +let _kittyProtocolActive = false; + +/** + * Set the global Kitty keyboard protocol state. + * Called by ProcessTerminal after detecting protocol support. + */ +export function setKittyProtocolActive(active: boolean): void { + _kittyProtocolActive = active; +} + +/** + * Query whether Kitty keyboard protocol is currently active. + */ +export function isKittyProtocolActive(): boolean { + return _kittyProtocolActive; +} + +// ============================================================================= +// Type-Safe Key Identifiers +// ============================================================================= + +type Letter = + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z"; + +type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"; + +type SymbolKey = + | "`" + | "-" + | "=" + | "[" + | "]" + | "\\" + | ";" + | "'" + | "," + | "." + | "/" + | "!" + | "@" + | "#" + | "$" + | "%" + | "^" + | "&" + | "*" + | "(" + | ")" + | "_" + | "+" + | "|" + | "~" + | "{" + | "}" + | ":" + | "<" + | ">" + | "?"; + +type SpecialKey = + | "escape" + | "esc" + | "enter" + | "return" + | "tab" + | "space" + | "backspace" + | "delete" + | "insert" + | "clear" + | "home" + | "end" + | "pageUp" + | "pageDown" + | "up" + | "down" + | "left" + | "right" + | "f1" + | "f2" + | "f3" + | "f4" + | "f5" + | "f6" + | "f7" + | "f8" + | "f9" + | "f10" + | "f11" + | "f12"; + +type BaseKey = Letter | Digit | SymbolKey | SpecialKey; +type ModifierName = "ctrl" | "shift" | "alt" | "super"; + +type ModifiedKeyId = { + [M in RemainingModifiers]: `${M}+${Key}` | `${M}+${ModifiedKeyId>}`; +}[RemainingModifiers]; + +/** + * Union type of all valid key identifiers. + * Provides autocomplete and catches typos at compile time. + */ +export type KeyId = BaseKey | ModifiedKeyId; + +/** + * Helper object for creating typed key identifiers with autocomplete. + * + * Usage: + * - Key.escape, Key.enter, Key.tab, etc. for special keys + * - Key.backtick, Key.comma, Key.period, etc. for symbol keys + * - Key.ctrl("c"), Key.alt("x"), Key.super("k") for single modifiers + * - Key.ctrlShift("p"), Key.ctrlAlt("x"), Key.ctrlSuper("k") for combined modifiers + */ +export const Key = { + // Special keys + escape: "escape" as const, + esc: "esc" as const, + enter: "enter" as const, + return: "return" as const, + tab: "tab" as const, + space: "space" as const, + backspace: "backspace" as const, + delete: "delete" as const, + insert: "insert" as const, + clear: "clear" as const, + home: "home" as const, + end: "end" as const, + pageUp: "pageUp" as const, + pageDown: "pageDown" as const, + up: "up" as const, + down: "down" as const, + left: "left" as const, + right: "right" as const, + f1: "f1" as const, + f2: "f2" as const, + f3: "f3" as const, + f4: "f4" as const, + f5: "f5" as const, + f6: "f6" as const, + f7: "f7" as const, + f8: "f8" as const, + f9: "f9" as const, + f10: "f10" as const, + f11: "f11" as const, + f12: "f12" as const, + + // Symbol keys + backtick: "`" as const, + hyphen: "-" as const, + equals: "=" as const, + leftbracket: "[" as const, + rightbracket: "]" as const, + backslash: "\\" as const, + semicolon: ";" as const, + quote: "'" as const, + comma: "," as const, + period: "." as const, + slash: "/" as const, + exclamation: "!" as const, + at: "@" as const, + hash: "#" as const, + dollar: "$" as const, + percent: "%" as const, + caret: "^" as const, + ampersand: "&" as const, + asterisk: "*" as const, + leftparen: "(" as const, + rightparen: ")" as const, + underscore: "_" as const, + plus: "+" as const, + pipe: "|" as const, + tilde: "~" as const, + leftbrace: "{" as const, + rightbrace: "}" as const, + colon: ":" as const, + lessthan: "<" as const, + greaterthan: ">" as const, + question: "?" as const, + + // Single modifiers + ctrl: (key: K): `ctrl+${K}` => `ctrl+${key}`, + shift: (key: K): `shift+${K}` => `shift+${key}`, + alt: (key: K): `alt+${K}` => `alt+${key}`, + super: (key: K): `super+${K}` => `super+${key}`, + + // Combined modifiers + ctrlShift: (key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`, + shiftCtrl: (key: K): `shift+ctrl+${K}` => `shift+ctrl+${key}`, + ctrlAlt: (key: K): `ctrl+alt+${K}` => `ctrl+alt+${key}`, + altCtrl: (key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`, + shiftAlt: (key: K): `shift+alt+${K}` => `shift+alt+${key}`, + altShift: (key: K): `alt+shift+${K}` => `alt+shift+${key}`, + ctrlSuper: (key: K): `ctrl+super+${K}` => `ctrl+super+${key}`, + superCtrl: (key: K): `super+ctrl+${K}` => `super+ctrl+${key}`, + shiftSuper: (key: K): `shift+super+${K}` => `shift+super+${key}`, + superShift: (key: K): `super+shift+${K}` => `super+shift+${key}`, + altSuper: (key: K): `alt+super+${K}` => `alt+super+${key}`, + superAlt: (key: K): `super+alt+${K}` => `super+alt+${key}`, + + // Triple modifiers + ctrlShiftAlt: (key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`, + ctrlShiftSuper: (key: K): `ctrl+shift+super+${K}` => `ctrl+shift+super+${key}`, +} as const; + +// ============================================================================= +// Constants +// ============================================================================= + +const SYMBOL_KEYS = new Set([ + "`", + "-", + "=", + "[", + "]", + "\\", + ";", + "'", + ",", + ".", + "/", + "!", + "@", + "#", + "$", + "%", + "^", + "&", + "*", + "(", + ")", + "_", + "+", + "|", + "~", + "{", + "}", + ":", + "<", + ">", + "?", +]); + +const MODIFIERS = { + shift: 1, + alt: 2, + ctrl: 4, + super: 8, +} as const; + +const LOCK_MASK = 64 + 128; // Caps Lock + Num Lock + +const CODEPOINTS = { + escape: 27, + tab: 9, + enter: 13, + space: 32, + backspace: 127, + kpEnter: 57414, // Numpad Enter (Kitty protocol) +} as const; + +const ARROW_CODEPOINTS = { + up: -1, + down: -2, + right: -3, + left: -4, +} as const; + +const FUNCTIONAL_CODEPOINTS = { + delete: -10, + insert: -11, + pageUp: -12, + pageDown: -13, + home: -14, + end: -15, +} as const; + +const KITTY_FUNCTIONAL_KEY_EQUIVALENTS = new Map([ + [57399, 48], // KP_0 -> 0 + [57400, 49], // KP_1 -> 1 + [57401, 50], // KP_2 -> 2 + [57402, 51], // KP_3 -> 3 + [57403, 52], // KP_4 -> 4 + [57404, 53], // KP_5 -> 5 + [57405, 54], // KP_6 -> 6 + [57406, 55], // KP_7 -> 7 + [57407, 56], // KP_8 -> 8 + [57408, 57], // KP_9 -> 9 + [57409, 46], // KP_DECIMAL -> . + [57410, 47], // KP_DIVIDE -> / + [57411, 42], // KP_MULTIPLY -> * + [57412, 45], // KP_SUBTRACT -> - + [57413, 43], // KP_ADD -> + + [57415, 61], // KP_EQUAL -> = + [57416, 44], // KP_SEPARATOR -> , + [57417, ARROW_CODEPOINTS.left], + [57418, ARROW_CODEPOINTS.right], + [57419, ARROW_CODEPOINTS.up], + [57420, ARROW_CODEPOINTS.down], + [57421, FUNCTIONAL_CODEPOINTS.pageUp], + [57422, FUNCTIONAL_CODEPOINTS.pageDown], + [57423, FUNCTIONAL_CODEPOINTS.home], + [57424, FUNCTIONAL_CODEPOINTS.end], + [57425, FUNCTIONAL_CODEPOINTS.insert], + [57426, FUNCTIONAL_CODEPOINTS.delete], +]); + +function normalizeKittyFunctionalCodepoint(codepoint: number): number { + return KITTY_FUNCTIONAL_KEY_EQUIVALENTS.get(codepoint) ?? codepoint; +} + +function normalizeShiftedLetterIdentityCodepoint(codepoint: number, modifier: number): number { + const effectiveModifier = modifier & ~LOCK_MASK; + if ((effectiveModifier & MODIFIERS.shift) !== 0 && codepoint >= 65 && codepoint <= 90) { + return codepoint + 32; + } + return codepoint; +} + +const LEGACY_KEY_SEQUENCES = { + up: ["\x1b[A", "\x1bOA"], + down: ["\x1b[B", "\x1bOB"], + right: ["\x1b[C", "\x1bOC"], + left: ["\x1b[D", "\x1bOD"], + home: ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"], + end: ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"], + insert: ["\x1b[2~"], + delete: ["\x1b[3~"], + pageUp: ["\x1b[5~", "\x1b[[5~"], + pageDown: ["\x1b[6~", "\x1b[[6~"], + clear: ["\x1b[E", "\x1bOE"], + f1: ["\x1bOP", "\x1b[11~", "\x1b[[A"], + f2: ["\x1bOQ", "\x1b[12~", "\x1b[[B"], + f3: ["\x1bOR", "\x1b[13~", "\x1b[[C"], + f4: ["\x1bOS", "\x1b[14~", "\x1b[[D"], + f5: ["\x1b[15~", "\x1b[[E"], + f6: ["\x1b[17~"], + f7: ["\x1b[18~"], + f8: ["\x1b[19~"], + f9: ["\x1b[20~"], + f10: ["\x1b[21~"], + f11: ["\x1b[23~"], + f12: ["\x1b[24~"], +} as const; + +const LEGACY_SHIFT_SEQUENCES = { + up: ["\x1b[a"], + down: ["\x1b[b"], + right: ["\x1b[c"], + left: ["\x1b[d"], + clear: ["\x1b[e"], + insert: ["\x1b[2$"], + delete: ["\x1b[3$"], + pageUp: ["\x1b[5$"], + pageDown: ["\x1b[6$"], + home: ["\x1b[7$"], + end: ["\x1b[8$"], +} as const; + +const LEGACY_CTRL_SEQUENCES = { + up: ["\x1bOa"], + down: ["\x1bOb"], + right: ["\x1bOc"], + left: ["\x1bOd"], + clear: ["\x1bOe"], + insert: ["\x1b[2^"], + delete: ["\x1b[3^"], + pageUp: ["\x1b[5^"], + pageDown: ["\x1b[6^"], + home: ["\x1b[7^"], + end: ["\x1b[8^"], +} as const; + +const LEGACY_SEQUENCE_KEY_IDS: Record = { + "\x1bOA": "up", + "\x1bOB": "down", + "\x1bOC": "right", + "\x1bOD": "left", + "\x1bOH": "home", + "\x1bOF": "end", + "\x1b[E": "clear", + "\x1bOE": "clear", + "\x1bOe": "ctrl+clear", + "\x1b[e": "shift+clear", + "\x1b[2~": "insert", + "\x1b[2$": "shift+insert", + "\x1b[2^": "ctrl+insert", + "\x1b[3$": "shift+delete", + "\x1b[3^": "ctrl+delete", + "\x1b[[5~": "pageUp", + "\x1b[[6~": "pageDown", + "\x1b[a": "shift+up", + "\x1b[b": "shift+down", + "\x1b[c": "shift+right", + "\x1b[d": "shift+left", + "\x1bOa": "ctrl+up", + "\x1bOb": "ctrl+down", + "\x1bOc": "ctrl+right", + "\x1bOd": "ctrl+left", + "\x1b[5$": "shift+pageUp", + "\x1b[6$": "shift+pageDown", + "\x1b[7$": "shift+home", + "\x1b[8$": "shift+end", + "\x1b[5^": "ctrl+pageUp", + "\x1b[6^": "ctrl+pageDown", + "\x1b[7^": "ctrl+home", + "\x1b[8^": "ctrl+end", + "\x1bOP": "f1", + "\x1bOQ": "f2", + "\x1bOR": "f3", + "\x1bOS": "f4", + "\x1b[11~": "f1", + "\x1b[12~": "f2", + "\x1b[13~": "f3", + "\x1b[14~": "f4", + "\x1b[[A": "f1", + "\x1b[[B": "f2", + "\x1b[[C": "f3", + "\x1b[[D": "f4", + "\x1b[[E": "f5", + "\x1b[15~": "f5", + "\x1b[17~": "f6", + "\x1b[18~": "f7", + "\x1b[19~": "f8", + "\x1b[20~": "f9", + "\x1b[21~": "f10", + "\x1b[23~": "f11", + "\x1b[24~": "f12", + "\x1bb": "alt+left", + "\x1bf": "alt+right", + "\x1bp": "alt+up", + "\x1bn": "alt+down", +} as const; + +type LegacyModifierKey = keyof typeof LEGACY_SHIFT_SEQUENCES; + +const matchesLegacySequence = (data: string, sequences: readonly string[]): boolean => sequences.includes(data); + +const matchesLegacyModifierSequence = (data: string, key: LegacyModifierKey, modifier: number): boolean => { + if (modifier === MODIFIERS.shift) { + return matchesLegacySequence(data, LEGACY_SHIFT_SEQUENCES[key]); + } + if (modifier === MODIFIERS.ctrl) { + return matchesLegacySequence(data, LEGACY_CTRL_SEQUENCES[key]); + } + return false; +}; + +// ============================================================================= +// Kitty Protocol Parsing +// ============================================================================= + +/** + * Event types from Kitty keyboard protocol (flag 2) + * 1 = key press, 2 = key repeat, 3 = key release + */ +export type KeyEventType = "press" | "repeat" | "release"; + +interface ParsedKittySequence { + codepoint: number; + shiftedKey?: number; // Shifted version of the key (when shift is pressed) + baseLayoutKey?: number; // Key in standard PC-101 layout (for non-Latin layouts) + modifier: number; + eventType: KeyEventType; +} + +interface ParsedModifyOtherKeysSequence { + codepoint: number; + modifier: number; +} + +// Store the last parsed event type for isKeyRelease() to query +let _lastEventType: KeyEventType = "press"; + +/** + * Check if the last parsed key event was a key release. + * Only meaningful when Kitty keyboard protocol with flag 2 is active. + */ +export function isKeyRelease(data: string): boolean { + // Don't treat bracketed paste content as key release, even if it contains + // patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5"). + // Terminal.ts re-wraps paste content with bracketed paste markers before + // passing to TUI, so pasted data will always contain \x1b[200~. + if (data.includes("\x1b[200~")) { + return false; + } + + // Quick check: release events with flag 2 contain ":3" + // Format: \x1b[;:3u + if ( + data.includes(":3u") || + data.includes(":3~") || + data.includes(":3A") || + data.includes(":3B") || + data.includes(":3C") || + data.includes(":3D") || + data.includes(":3H") || + data.includes(":3F") + ) { + return true; + } + return false; +} + +/** + * Check if the last parsed key event was a key repeat. + * Only meaningful when Kitty keyboard protocol with flag 2 is active. + */ +export function isKeyRepeat(data: string): boolean { + // Don't treat bracketed paste content as key repeat, even if it contains + // patterns like ":2F". See isKeyRelease() for details. + if (data.includes("\x1b[200~")) { + return false; + } + + if ( + data.includes(":2u") || + data.includes(":2~") || + data.includes(":2A") || + data.includes(":2B") || + data.includes(":2C") || + data.includes(":2D") || + data.includes(":2H") || + data.includes(":2F") + ) { + return true; + } + return false; +} + +function parseEventType(eventTypeStr: string | undefined): KeyEventType { + if (!eventTypeStr) return "press"; + const eventType = parseInt(eventTypeStr, 10); + if (eventType === 2) return "repeat"; + if (eventType === 3) return "release"; + return "press"; +} + +function parseKittySequence(data: string): ParsedKittySequence | null { + // CSI u format with alternate keys (flag 4): + // \x1b[u + // \x1b[;u + // \x1b[;:u + // \x1b[:;u + // \x1b[::;u + // \x1b[::;u (no shifted key, only base) + // + // With flag 2, event type is appended after modifier colon: 1=press, 2=repeat, 3=release + // With flag 4, alternate keys are appended after codepoint with colons + const csiUMatch = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/); + if (csiUMatch) { + const codepoint = parseInt(csiUMatch[1]!, 10); + const shiftedKey = csiUMatch[2] && csiUMatch[2].length > 0 ? parseInt(csiUMatch[2], 10) : undefined; + const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined; + const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1; + const eventType = parseEventType(csiUMatch[5]); + _lastEventType = eventType; + return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType }; + } + + // Arrow keys with modifier: \x1b[1;A/B/C/D or \x1b[1;:A/B/C/D + const arrowMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/); + if (arrowMatch) { + const modValue = parseInt(arrowMatch[1]!, 10); + const eventType = parseEventType(arrowMatch[2]); + const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; + _lastEventType = eventType; + return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType }; + } + + // Functional keys: \x1b[~ or \x1b[;~ or \x1b[;:~ + const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/); + if (funcMatch) { + const keyNum = parseInt(funcMatch[1]!, 10); + const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1; + const eventType = parseEventType(funcMatch[3]); + const funcCodes: Record = { + 2: FUNCTIONAL_CODEPOINTS.insert, + 3: FUNCTIONAL_CODEPOINTS.delete, + 5: FUNCTIONAL_CODEPOINTS.pageUp, + 6: FUNCTIONAL_CODEPOINTS.pageDown, + 7: FUNCTIONAL_CODEPOINTS.home, + 8: FUNCTIONAL_CODEPOINTS.end, + }; + const codepoint = funcCodes[keyNum]; + if (codepoint !== undefined) { + _lastEventType = eventType; + return { codepoint, modifier: modValue - 1, eventType }; + } + } + + // Home/End with modifier: \x1b[1;H/F or \x1b[1;:H/F + const homeEndMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/); + if (homeEndMatch) { + const modValue = parseInt(homeEndMatch[1]!, 10); + const eventType = parseEventType(homeEndMatch[2]); + const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; + _lastEventType = eventType; + return { codepoint, modifier: modValue - 1, eventType }; + } + + return null; +} + +function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean { + const parsed = parseKittySequence(data); + if (!parsed) return false; + const actualMod = parsed.modifier & ~LOCK_MASK; + const expectedMod = expectedModifier & ~LOCK_MASK; + + // Check if modifiers match + if (actualMod !== expectedMod) return false; + + const normalizedCodepoint = normalizeShiftedLetterIdentityCodepoint( + normalizeKittyFunctionalCodepoint(parsed.codepoint), + parsed.modifier, + ); + const normalizedExpectedCodepoint = normalizeShiftedLetterIdentityCodepoint( + normalizeKittyFunctionalCodepoint(expectedCodepoint), + expectedModifier, + ); + + // Primary match: codepoint matches directly after normalizing functional keys + if (normalizedCodepoint === normalizedExpectedCodepoint) return true; + + // Alternate match: use base layout key for non-Latin keyboard layouts. + // This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports + // the base layout key (the key in standard PC-101 layout). + // + // Only fall back to base layout key when the codepoint is NOT already a + // recognized Latin letter (a-z) or symbol (e.g., /, -, [, ;, etc.). + // When the codepoint is a recognized key, it is authoritative regardless + // of physical key position. This prevents remapped layouts (Dvorak, Colemak, + // xremap, etc.) from causing false matches: both letters and symbols move + // to different physical positions, so Ctrl+K could falsely match Ctrl+V + // (letter remapping) and Ctrl+/ could falsely match Ctrl+[ (symbol remapping) + // if the base layout key were always considered. + if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) { + const cp = normalizedCodepoint; + const isLatinLetter = cp >= 97 && cp <= 122; // a-z + const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(cp)); + if (!isLatinLetter && !isKnownSymbol) return true; + } + + return false; +} + +function parseModifyOtherKeysSequence(data: string): ParsedModifyOtherKeysSequence | null { + const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/); + if (!match) return null; + const modValue = parseInt(match[1]!, 10); + const codepoint = parseInt(match[2]!, 10); + return { codepoint, modifier: modValue - 1 }; +} + +/** + * Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~ + * This is used by terminals when Kitty protocol is not enabled. + * Modifier values are 1-indexed: 2=shift, 3=alt, 5=ctrl, etc. + */ +function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed) return false; + return parsed.codepoint === expectedKeycode && parsed.modifier === expectedModifier; +} + +function isWindowsTerminalSession(): boolean { + return ( + Boolean(process.env.WT_SESSION) && !process.env.SSH_CONNECTION && !process.env.SSH_CLIENT && !process.env.SSH_TTY + ); +} + +/** + * Raw 0x08 (BS) is ambiguous in legacy terminals. + * + * - Windows Terminal uses it for Ctrl+Backspace. + * - Some legacy terminals and tmux setups send it for plain Backspace. + * + * Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are + * available. Fall back to a Windows Terminal heuristic only for raw BS bytes. + */ +function matchesRawBackspace(data: string, expectedModifier: number): boolean { + if (data === "\x7f") return expectedModifier === 0; + if (data !== "\x08") return false; + return isWindowsTerminalSession() ? expectedModifier === MODIFIERS.ctrl : expectedModifier === 0; +} + +// ============================================================================= +// Generic Key Matching +// ============================================================================= + +/** + * Get the control character for a key. + * Uses the universal formula: code & 0x1f (mask to lower 5 bits) + * + * Works for: + * - Letters a-z → 1-26 + * - Symbols [\]_ → 27, 28, 29, 31 + * - Also maps - to same as _ (same physical key on US keyboards) + */ +function rawCtrlChar(key: string): string | null { + const char = key.toLowerCase(); + const code = char.charCodeAt(0); + if ((code >= 97 && code <= 122) || char === "[" || char === "\\" || char === "]" || char === "_") { + return String.fromCharCode(code & 0x1f); + } + // Handle - as _ (same physical key on US keyboards) + if (char === "-") { + return String.fromCharCode(31); // Same as Ctrl+_ + } + return null; +} + +function isDigitKey(key: string): boolean { + return key >= "0" && key <= "9"; +} + +function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { + if (expectedModifier === 0) return false; + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed || parsed.modifier !== expectedModifier) return false; + return ( + normalizeShiftedLetterIdentityCodepoint(parsed.codepoint, parsed.modifier) === + normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier) + ); +} + +function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined { + const mods: string[] = []; + const effectiveMod = modifier & ~LOCK_MASK; + const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt | MODIFIERS.super; + if ((effectiveMod & ~supportedModifierMask) !== 0) return undefined; + if (effectiveMod & MODIFIERS.shift) mods.push("shift"); + if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl"); + if (effectiveMod & MODIFIERS.alt) mods.push("alt"); + if (effectiveMod & MODIFIERS.super) mods.push("super"); + return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; +} + +function parseKeyId( + keyId: string, +): { key: string; ctrl: boolean; shift: boolean; alt: boolean; super: boolean } | null { + const parts = keyId.toLowerCase().split("+"); + const key = parts[parts.length - 1]; + if (!key) return null; + return { + key, + ctrl: parts.includes("ctrl"), + shift: parts.includes("shift"), + alt: parts.includes("alt"), + super: parts.includes("super"), + }; +} + +/** + * Match input data against a key identifier string. + * + * Supported key identifiers: + * - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space" + * - Arrow keys: "up", "down", "left", "right" + * - Ctrl combinations: "ctrl+c", "ctrl+z", etc. + * - Shift combinations: "shift+tab", "shift+enter" + * - Alt combinations: "alt+enter", "alt+backspace" + * - Super combinations: "super+k", "super+enter" + * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x", "ctrl+super+k" + * + * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p"), Key.super("k") + * + * @param data - Raw input data from terminal + * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c")) + */ +export function matchesKey(data: string, keyId: KeyId): boolean { + const parsed = parseKeyId(keyId); + if (!parsed) return false; + + const { key, ctrl, shift, alt, super: superModifier } = parsed; + let modifier = 0; + if (shift) modifier |= MODIFIERS.shift; + if (alt) modifier |= MODIFIERS.alt; + if (ctrl) modifier |= MODIFIERS.ctrl; + if (superModifier) modifier |= MODIFIERS.super; + + switch (key) { + case "escape": + case "esc": + if (modifier !== 0) return false; + return ( + data === "\x1b" || + matchesKittySequence(data, CODEPOINTS.escape, 0) || + matchesModifyOtherKeys(data, CODEPOINTS.escape, 0) + ); + + case "space": + if (!_kittyProtocolActive) { + if (modifier === MODIFIERS.ctrl && data === "\x00") { + return true; + } + if (modifier === MODIFIERS.alt && data === "\x1b ") { + return true; + } + } + if (modifier === 0) { + return ( + data === " " || + matchesKittySequence(data, CODEPOINTS.space, 0) || + matchesModifyOtherKeys(data, CODEPOINTS.space, 0) + ); + } + return ( + matchesKittySequence(data, CODEPOINTS.space, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.space, modifier) + ); + + case "tab": + if (modifier === MODIFIERS.shift) { + return ( + data === "\x1b[Z" || + matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift) || + matchesModifyOtherKeys(data, CODEPOINTS.tab, MODIFIERS.shift) + ); + } + if (modifier === 0) { + return data === "\t" || matchesKittySequence(data, CODEPOINTS.tab, 0); + } + return ( + matchesKittySequence(data, CODEPOINTS.tab, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.tab, modifier) + ); + + case "enter": + case "return": + if (modifier === MODIFIERS.shift) { + // CSI u sequences (standard Kitty protocol) + if ( + matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) || + matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift) + ) { + return true; + } + // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) + if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) { + return true; + } + // When Kitty protocol is active, legacy sequences are custom terminal mappings + // \x1b\r = Kitty's "map shift+enter send_text all \e\r" + // \n = Ghostty's "keybind = shift+enter=text:\n" + if (_kittyProtocolActive) { + return data === "\x1b\r" || data === "\n"; + } + return false; + } + if (modifier === MODIFIERS.alt) { + // CSI u sequences (standard Kitty protocol) + if ( + matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) || + matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt) + ) { + return true; + } + // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) + if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.alt)) { + return true; + } + // \x1b\r is alt+enter only in legacy mode (no Kitty protocol) + // When Kitty protocol is active, alt+enter comes as CSI u sequence + if (!_kittyProtocolActive) { + return data === "\x1b\r"; + } + return false; + } + if (modifier === 0) { + return ( + data === "\r" || + (!_kittyProtocolActive && data === "\n") || + data === "\x1bOM" || // SS3 M (numpad enter in some terminals) + matchesKittySequence(data, CODEPOINTS.enter, 0) || + matchesKittySequence(data, CODEPOINTS.kpEnter, 0) + ); + } + return ( + matchesKittySequence(data, CODEPOINTS.enter, modifier) || + matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.enter, modifier) + ); + + case "backspace": + if (modifier === MODIFIERS.alt) { + if (data === "\x1b\x7f" || data === "\x1b\b") { + return true; + } + return ( + matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.alt) + ); + } + if (modifier === MODIFIERS.ctrl) { + // Legacy raw 0x08 is ambiguous: it can be Ctrl+Backspace on Windows + // Terminal or plain Backspace on other terminals, while also + // overlapping with Ctrl+H. + if (matchesRawBackspace(data, MODIFIERS.ctrl)) return true; + return ( + matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.ctrl) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.ctrl) + ); + } + if (modifier === 0) { + return ( + matchesRawBackspace(data, 0) || + matchesKittySequence(data, CODEPOINTS.backspace, 0) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, 0) + ); + } + return ( + matchesKittySequence(data, CODEPOINTS.backspace, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.backspace, modifier) + ); + + case "insert": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.insert) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, 0) + ); + } + if (matchesLegacyModifierSequence(data, "insert", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, modifier); + + case "delete": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.delete) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0) + ); + } + if (matchesLegacyModifierSequence(data, "delete", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, modifier); + + case "clear": + if (modifier === 0) { + return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.clear); + } + return matchesLegacyModifierSequence(data, "clear", modifier); + + case "home": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.home) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0) + ); + } + if (matchesLegacyModifierSequence(data, "home", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, modifier); + + case "end": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.end) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0) + ); + } + if (matchesLegacyModifierSequence(data, "end", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier); + + case "pageup": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageUp) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, 0) + ); + } + if (matchesLegacyModifierSequence(data, "pageUp", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, modifier); + + case "pagedown": + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageDown) || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, 0) + ); + } + if (matchesLegacyModifierSequence(data, "pageDown", modifier)) { + return true; + } + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, modifier); + + case "up": + if (modifier === MODIFIERS.alt) { + return data === "\x1bp" || matchesKittySequence(data, ARROW_CODEPOINTS.up, MODIFIERS.alt); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.up) || + matchesKittySequence(data, ARROW_CODEPOINTS.up, 0) + ); + } + if (matchesLegacyModifierSequence(data, "up", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier); + + case "down": + if (modifier === MODIFIERS.alt) { + return data === "\x1bn" || matchesKittySequence(data, ARROW_CODEPOINTS.down, MODIFIERS.alt); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.down) || + matchesKittySequence(data, ARROW_CODEPOINTS.down, 0) + ); + } + if (matchesLegacyModifierSequence(data, "down", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier); + + case "left": + if (modifier === MODIFIERS.alt) { + return ( + data === "\x1b[1;3D" || + (!_kittyProtocolActive && data === "\x1bB") || + data === "\x1bb" || + matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt) + ); + } + if (modifier === MODIFIERS.ctrl) { + return ( + data === "\x1b[1;5D" || + matchesLegacyModifierSequence(data, "left", MODIFIERS.ctrl) || + matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl) + ); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.left) || + matchesKittySequence(data, ARROW_CODEPOINTS.left, 0) + ); + } + if (matchesLegacyModifierSequence(data, "left", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier); + + case "right": + if (modifier === MODIFIERS.alt) { + return ( + data === "\x1b[1;3C" || + (!_kittyProtocolActive && data === "\x1bF") || + data === "\x1bf" || + matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt) + ); + } + if (modifier === MODIFIERS.ctrl) { + return ( + data === "\x1b[1;5C" || + matchesLegacyModifierSequence(data, "right", MODIFIERS.ctrl) || + matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl) + ); + } + if (modifier === 0) { + return ( + matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.right) || + matchesKittySequence(data, ARROW_CODEPOINTS.right, 0) + ); + } + if (matchesLegacyModifierSequence(data, "right", modifier)) { + return true; + } + return matchesKittySequence(data, ARROW_CODEPOINTS.right, modifier); + + case "f1": + case "f2": + case "f3": + case "f4": + case "f5": + case "f6": + case "f7": + case "f8": + case "f9": + case "f10": + case "f11": + case "f12": { + if (modifier !== 0) { + return false; + } + const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES; + return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey]); + } + } + + // Handle single letter/digit keys and symbols + if (key.length === 1 && ((key >= "a" && key <= "z") || isDigitKey(key) || SYMBOL_KEYS.has(key))) { + const codepoint = key.charCodeAt(0); + const rawCtrl = rawCtrlChar(key); + const isLetter = key >= "a" && key <= "z"; + const isDigit = isDigitKey(key); + + if (modifier === MODIFIERS.ctrl + MODIFIERS.alt && !_kittyProtocolActive && rawCtrl) { + // Legacy: ctrl+alt+key is ESC followed by the control character. + // If that legacy form does not match, continue so CSI-u and + // modifyOtherKeys sequences from tmux can still be recognized. + if (data === `\x1b${rawCtrl}`) return true; + } + + if (modifier === MODIFIERS.alt && !_kittyProtocolActive && (isLetter || isDigit)) { + // Legacy: alt+letter/digit is ESC followed by the key + if (data === `\x1b${key}`) return true; + } + + if (modifier === MODIFIERS.ctrl) { + // Legacy: ctrl+key sends the control character + if (rawCtrl && data === rawCtrl) return true; + return ( + matchesKittySequence(data, codepoint, MODIFIERS.ctrl) || + matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.ctrl) + ); + } + + if (modifier === MODIFIERS.shift + MODIFIERS.ctrl) { + return ( + matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) || + matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) + ); + } + + if (modifier === MODIFIERS.shift) { + // Legacy: shift+letter produces uppercase + if (isLetter && data === key.toUpperCase()) return true; + return ( + matchesKittySequence(data, codepoint, MODIFIERS.shift) || + matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift) + ); + } + + if (modifier !== 0) { + return ( + matchesKittySequence(data, codepoint, modifier) || + matchesPrintableModifyOtherKeys(data, codepoint, modifier) + ); + } + + // Check both raw char and Kitty sequence (needed for release events) + return data === key || matchesKittySequence(data, codepoint, 0); + } + + return false; +} + +/** + * Parse input data and return the key identifier if recognized. + * + * @param data - Raw input data from terminal + * @returns Key identifier string (e.g., "ctrl+c") or undefined + */ +function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: number): string | undefined { + const normalizedCodepoint = normalizeKittyFunctionalCodepoint(codepoint); + const identityCodepoint = normalizeShiftedLetterIdentityCodepoint(normalizedCodepoint, modifier); + + // Use base layout key only when codepoint is not a recognized Latin + // letter (a-z), digit (0-9), or symbol (/, -, [, ;, etc.). For those, + // the codepoint is authoritative regardless of physical key position. + // This prevents remapped layouts (Dvorak, Colemak, xremap, etc.) from + // reporting the wrong key name based on the QWERTY physical position. + const isLatinLetter = identityCodepoint >= 97 && identityCodepoint <= 122; // a-z + const isDigit = identityCodepoint >= 48 && identityCodepoint <= 57; // 0-9 + const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(identityCodepoint)); + const effectiveCodepoint = + isLatinLetter || isDigit || isKnownSymbol ? identityCodepoint : (baseLayoutKey ?? identityCodepoint); + + let keyName: string | undefined; + if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape"; + else if (effectiveCodepoint === CODEPOINTS.tab) keyName = "tab"; + else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter) keyName = "enter"; + else if (effectiveCodepoint === CODEPOINTS.space) keyName = "space"; + else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = "backspace"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = "insert"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = "right"; + else if (effectiveCodepoint >= 48 && effectiveCodepoint <= 57) keyName = String.fromCharCode(effectiveCodepoint); + else if (effectiveCodepoint >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint); + else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint))) keyName = String.fromCharCode(effectiveCodepoint); + + if (!keyName) return undefined; + return formatKeyNameWithModifiers(keyName, modifier); +} + +export function parseKey(data: string): string | undefined { + const kitty = parseKittySequence(data); + if (kitty) { + return formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey); + } + + const modifyOtherKeys = parseModifyOtherKeysSequence(data); + if (modifyOtherKeys) { + return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier); + } + + // Mode-aware legacy sequences + // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings: + // - \x1b\r = shift+enter (Kitty mapping), not alt+enter + // - \n = shift+enter (Ghostty mapping) + if (_kittyProtocolActive) { + if (data === "\x1b\r" || data === "\n") return "shift+enter"; + } + + const legacySequenceKeyId = LEGACY_SEQUENCE_KEY_IDS[data]; + if (legacySequenceKeyId) return legacySequenceKeyId; + + // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences) + if (data === "\x1b") return "escape"; + if (data === "\x1c") return "ctrl+\\"; + if (data === "\x1d") return "ctrl+]"; + if (data === "\x1f") return "ctrl+-"; + if (data === "\x1b\x1b") return "ctrl+alt+["; + if (data === "\x1b\x1c") return "ctrl+alt+\\"; + if (data === "\x1b\x1d") return "ctrl+alt+]"; + if (data === "\x1b\x1f") return "ctrl+alt+-"; + if (data === "\t") return "tab"; + if (data === "\r" || (!_kittyProtocolActive && data === "\n") || data === "\x1bOM") return "enter"; + if (data === "\x00") return "ctrl+space"; + if (data === " ") return "space"; + if (data === "\x7f") return "backspace"; + if (data === "\x08") return isWindowsTerminalSession() ? "ctrl+backspace" : "backspace"; + if (data === "\x1b[Z") return "shift+tab"; + if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter"; + if (!_kittyProtocolActive && data === "\x1b ") return "alt+space"; + if (data === "\x1b\x7f" || data === "\x1b\b") return "alt+backspace"; + if (!_kittyProtocolActive && data === "\x1bB") return "alt+left"; + if (!_kittyProtocolActive && data === "\x1bF") return "alt+right"; + if (!_kittyProtocolActive && data.length === 2 && data[0] === "\x1b") { + const code = data.charCodeAt(1); + if (code >= 1 && code <= 26) { + return `ctrl+alt+${String.fromCharCode(code + 96)}`; + } + // Legacy alt+letter/digit (ESC followed by the key) + if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57)) { + return `alt+${String.fromCharCode(code)}`; + } + } + if (data === "\x1b[A") return "up"; + if (data === "\x1b[B") return "down"; + if (data === "\x1b[C") return "right"; + if (data === "\x1b[D") return "left"; + if (data === "\x1b[H" || data === "\x1bOH") return "home"; + if (data === "\x1b[F" || data === "\x1bOF") return "end"; + if (data === "\x1b[3~") return "delete"; + if (data === "\x1b[5~") return "pageUp"; + if (data === "\x1b[6~") return "pageDown"; + + // Raw Ctrl+letter + if (data.length === 1) { + const code = data.charCodeAt(0); + if (code >= 1 && code <= 26) { + return `ctrl+${String.fromCharCode(code + 96)}`; + } + if (code >= 32 && code <= 126) { + return data; + } + } + + return undefined; +} + +// ============================================================================= +// Kitty CSI-u Printable Decoding +// ============================================================================= + +const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/; +const KITTY_PRINTABLE_ALLOWED_MODIFIERS = MODIFIERS.shift | LOCK_MASK; + +/** + * Decode a Kitty CSI-u sequence into a printable character, if applicable. + * + * When Kitty keyboard protocol flag 1 (disambiguate) is active, terminals send + * CSI-u sequences for all keys, including plain printable characters. This + * function extracts the printable character from such sequences. + * + * Only accepts plain or Shift-modified keys. Rejects Ctrl, Alt, and unsupported + * modifier combinations (those are handled by keybinding matching instead). + * Prefers the shifted keycode when Shift is held and a shifted key is reported. + * + * @param data - Raw input data from terminal + * @returns The printable character, or undefined if not a printable CSI-u sequence + */ +export function decodeKittyPrintable(data: string): string | undefined { + const match = data.match(KITTY_CSI_U_REGEX); + if (!match) return undefined; + + // CSI-u groups: [:[:]];[:]u + const codepoint = Number.parseInt(match[1] ?? "", 10); + if (!Number.isFinite(codepoint)) return undefined; + + const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined; + const modValue = match[4] ? Number.parseInt(match[4], 10) : 1; + // Modifiers are 1-indexed in CSI-u; normalize to our bitmask. + const modifier = Number.isFinite(modValue) ? modValue - 1 : 0; + + // Only accept printable CSI-u input for plain or Shift-modified text keys. + // Reject unsupported modifier bits (e.g. Super/Meta) to avoid inserting + // characters from modifier-only terminal events. + if ((modifier & ~KITTY_PRINTABLE_ALLOWED_MODIFIERS) !== 0) return undefined; + if (modifier & (MODIFIERS.alt | MODIFIERS.ctrl)) return undefined; + + // Prefer the shifted keycode when Shift is held. + let effectiveCodepoint = codepoint; + if (modifier & MODIFIERS.shift && typeof shiftedKey === "number") { + effectiveCodepoint = shiftedKey; + } + effectiveCodepoint = normalizeKittyFunctionalCodepoint(effectiveCodepoint); + // Drop control characters or invalid codepoints. + if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined; + + try { + return String.fromCodePoint(effectiveCodepoint); + } catch { + return undefined; + } +} + +function decodeModifyOtherKeysPrintable(data: string): string | undefined { + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed) return undefined; + const modifier = parsed.modifier & ~LOCK_MASK; + if ((modifier & ~MODIFIERS.shift) !== 0) return undefined; + if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined; + + try { + return String.fromCodePoint(parsed.codepoint); + } catch { + return undefined; + } +} + +export function decodePrintableKey(data: string): string | undefined { + return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data); +} diff --git a/cactus-code/packages/tui/src/kill-ring.ts b/cactus-code/packages/tui/src/kill-ring.ts new file mode 100644 index 000000000..2292f91aa --- /dev/null +++ b/cactus-code/packages/tui/src/kill-ring.ts @@ -0,0 +1,46 @@ +/** + * Ring buffer for Emacs-style kill/yank operations. + * + * Tracks killed (deleted) text entries. Consecutive kills can accumulate + * into a single entry. Supports yank (paste most recent) and yank-pop + * (cycle through older entries). + */ +export class KillRing { + private ring: string[] = []; + + /** + * Add text to the kill ring. + * + * @param text - The killed text to add + * @param opts - Push options + * @param opts.prepend - If accumulating, prepend (backward deletion) or append (forward deletion) + * @param opts.accumulate - Merge with the most recent entry instead of creating a new one + */ + push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void { + if (!text) return; + + if (opts.accumulate && this.ring.length > 0) { + const last = this.ring.pop()!; + this.ring.push(opts.prepend ? text + last : last + text); + } else { + this.ring.push(text); + } + } + + /** Get most recent entry without modifying the ring. */ + peek(): string | undefined { + return this.ring.length > 0 ? this.ring[this.ring.length - 1] : undefined; + } + + /** Move last entry to front (for yank-pop cycling). */ + rotate(): void { + if (this.ring.length > 1) { + const last = this.ring.pop()!; + this.ring.unshift(last); + } + } + + get length(): number { + return this.ring.length; + } +} diff --git a/cactus-code/packages/tui/src/native-modifiers.ts b/cactus-code/packages/tui/src/native-modifiers.ts new file mode 100644 index 000000000..e2cd631cb --- /dev/null +++ b/cactus-code/packages/tui/src/native-modifiers.ts @@ -0,0 +1,59 @@ +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const cjsRequire = createRequire(import.meta.url); + +export type ModifierKey = "shift" | "command" | "control" | "option"; + +type NativeModifiersHelper = { + isModifierPressed: (name: ModifierKey) => boolean; +}; + +let nativeModifiersHelper: NativeModifiersHelper | null | undefined; + +function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper { + if (typeof value !== "object" || value === null) return false; + const candidate = (value as { isModifierPressed?: unknown }).isModifierPressed; + return typeof candidate === "function"; +} + +function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { + if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined; + nativeModifiersHelper = null; + if (process.platform !== "darwin") return undefined; + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") return undefined; + + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node"); + const candidates = [ + path.join(moduleDir, "..", nativePath), + path.join(moduleDir, nativePath), + path.join(path.dirname(process.execPath), nativePath), + ]; + + for (const modulePath of candidates) { + try { + const helper = cjsRequire(modulePath) as unknown; + if (isNativeModifiersHelper(helper)) { + nativeModifiersHelper = helper; + return helper; + } + } catch { + // Try the next possible packaging location. + } + } + + return undefined; +} + +export function isNativeModifierPressed(key: ModifierKey): boolean { + const helper = loadNativeModifiersHelper(); + if (!helper) return false; + try { + return helper.isModifierPressed(key) === true; + } catch { + return false; + } +} diff --git a/cactus-code/packages/tui/src/stdin-buffer.ts b/cactus-code/packages/tui/src/stdin-buffer.ts new file mode 100644 index 000000000..a8b0b8478 --- /dev/null +++ b/cactus-code/packages/tui/src/stdin-buffer.ts @@ -0,0 +1,434 @@ +/** + * StdinBuffer buffers input and emits complete sequences. + * + * This is necessary because stdin data events can arrive in partial chunks, + * especially for escape sequences like mouse events. Without buffering, + * partial sequences can be misinterpreted as regular keypresses. + * + * For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as: + * - Event 1: `\x1b` + * - Event 2: `[<35` + * - Event 3: `;20;5m` + * + * The buffer accumulates these until a complete sequence is detected. + * Call the `process()` method to feed input data. + * + * Based on code from OpenTUI (https://github.com/anomalyco/opentui) + * MIT License - Copyright (c) 2025 opentui + */ + +import { EventEmitter } from "events"; + +const ESC = "\x1b"; +const BRACKETED_PASTE_START = "\x1b[200~"; +const BRACKETED_PASTE_END = "\x1b[201~"; + +/** + * Check if a string is a complete escape sequence or needs more data + */ +function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" { + if (!data.startsWith(ESC)) { + return "not-escape"; + } + + if (data.length === 1) { + return "incomplete"; + } + + const afterEsc = data.slice(1); + + // CSI sequences: ESC [ + if (afterEsc.startsWith("[")) { + // Check for old-style mouse sequence: ESC[M + 3 bytes + if (afterEsc.startsWith("[M")) { + // Old-style mouse needs ESC[M + 3 bytes = 6 total + return data.length >= 6 ? "complete" : "incomplete"; + } + return isCompleteCsiSequence(data); + } + + // OSC sequences: ESC ] + if (afterEsc.startsWith("]")) { + return isCompleteOscSequence(data); + } + + // DCS sequences: ESC P ... ESC \ (includes XTVersion responses) + if (afterEsc.startsWith("P")) { + return isCompleteDcsSequence(data); + } + + // APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses) + if (afterEsc.startsWith("_")) { + return isCompleteApcSequence(data); + } + + // SS3 sequences: ESC O + if (afterEsc.startsWith("O")) { + // ESC O followed by a single character + return afterEsc.length >= 2 ? "complete" : "incomplete"; + } + + // Meta key sequences: ESC followed by a single character + if (afterEsc.length === 1) { + return "complete"; + } + + // Unknown escape sequence - treat as complete + return "complete"; +} + +/** + * Check if CSI sequence is complete + * CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E) + */ +function isCompleteCsiSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}[`)) { + return "complete"; + } + + // Need at least ESC [ and one more character + if (data.length < 3) { + return "incomplete"; + } + + const payload = data.slice(2); + + // CSI sequences end with a byte in the range 0x40-0x7E (@-~) + // This includes all letters and several special characters + const lastChar = payload[payload.length - 1]; + const lastCharCode = lastChar.charCodeAt(0); + + if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) { + // Special handling for SGR mouse sequences + // Format: ESC[ /^\d+$/.test(p))) { + return "complete"; + } + } + + return "incomplete"; + } + + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if OSC sequence is complete + * OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL) + */ +function isCompleteOscSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}]`)) { + return "complete"; + } + + // OSC sequences end with ST (ESC \) or BEL (\x07) + if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if DCS (Device Control String) sequence is complete + * DCS sequences: ESC P ... ST (where ST is ESC \) + * Used for XTVersion responses like ESC P >| ... ESC \ + */ +function isCompleteDcsSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}P`)) { + return "complete"; + } + + // DCS sequences end with ST (ESC \) + if (data.endsWith(`${ESC}\\`)) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if APC (Application Program Command) sequence is complete + * APC sequences: ESC _ ... ST (where ST is ESC \) + * Used for Kitty graphics responses like ESC _ G ... ESC \ + */ +function isCompleteApcSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}_`)) { + return "complete"; + } + + // APC sequences end with ST (ESC \) + if (data.endsWith(`${ESC}\\`)) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Split accumulated buffer into complete sequences + */ +function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined { + const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/); + if (!match) return undefined; + + const codepoint = parseInt(match[1]!, 10); + return codepoint >= 32 ? codepoint : undefined; +} + +function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } { + const sequences: string[] = []; + let pos = 0; + + while (pos < buffer.length) { + const remaining = buffer.slice(pos); + + // Try to extract a sequence starting at this position + if (remaining.startsWith(ESC)) { + // Find the end of this escape sequence + let seqEnd = 1; + while (seqEnd <= remaining.length) { + const candidate = remaining.slice(0, seqEnd); + const status = isCompleteSequence(candidate); + + if (status === "complete") { + // WezTerm with enable_kitty_keyboard sends the Escape key press as a + // raw '\x1b' byte (simple text path in encode_kitty, ignoring + // DISAMBIGUATE_ESCAPE_CODES) and the release as a full Kitty CSI-u + // sequence. These arrive concatenated as '\x1b\x1b[27;...u'. + // The buffer would normally treat '\x1b\x1b' as a complete meta-key + // sequence (ESC + single char), leaving '[27;...u' to be typed as + // plain text. If the character immediately following '\x1b\x1b' + // would begin a new escape sequence, emit only the first ESC and + // restart from the second. + if (candidate === "\x1b\x1b") { + const nextChar = remaining[seqEnd]; + if ( + nextChar === "[" || // CSI + nextChar === "]" || // OSC + nextChar === "O" || // SS3 + nextChar === "P" || // DCS + nextChar === "_" // APC + ) { + sequences.push(ESC); + pos += 1; + break; + } + } + sequences.push(candidate); + pos += seqEnd; + break; + } else if (status === "incomplete") { + seqEnd++; + } else { + // Should not happen when starting with ESC + sequences.push(candidate); + pos += seqEnd; + break; + } + } + + if (seqEnd > remaining.length) { + return { sequences, remainder: remaining }; + } + } else { + // Not an escape sequence - take a single character + sequences.push(remaining[0]!); + pos++; + } + } + + return { sequences, remainder: "" }; +} + +export type StdinBufferOptions = { + /** + * Maximum time to wait for sequence completion (default: 10ms) + * After this time, the buffer is flushed even if incomplete + */ + timeout?: number; +}; + +export type StdinBufferEventMap = { + data: [string]; + paste: [string]; +}; + +/** + * Buffers stdin input and emits complete sequences via the 'data' event. + * Handles partial escape sequences that arrive across multiple chunks. + */ +export class StdinBuffer extends EventEmitter { + private buffer: string = ""; + private timeout: ReturnType | null = null; + private readonly timeoutMs: number; + private pasteMode: boolean = false; + private pasteBuffer: string = ""; + private pendingKittyPrintableCodepoint: number | undefined; + + constructor(options: StdinBufferOptions = {}) { + super(); + this.timeoutMs = options.timeout ?? 10; + } + + public process(data: string | Buffer): void { + // Clear any pending timeout + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + + // Handle high-byte conversion (for compatibility with parseKeypress) + // If buffer has single byte > 127, convert to ESC + (byte - 128) + let str: string; + if (Buffer.isBuffer(data)) { + if (data.length === 1 && data[0]! > 127) { + const byte = data[0]! - 128; + str = `\x1b${String.fromCharCode(byte)}`; + } else { + str = data.toString(); + } + } else { + str = data; + } + + if (str.length === 0 && this.buffer.length === 0) { + this.emitDataSequence(""); + return; + } + + this.buffer += str; + + if (this.pasteMode) { + this.pasteBuffer += this.buffer; + this.buffer = ""; + + const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END); + if (endIndex !== -1) { + const pastedContent = this.pasteBuffer.slice(0, endIndex); + const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); + + this.pasteMode = false; + this.pasteBuffer = ""; + this.pendingKittyPrintableCodepoint = undefined; + + this.emit("paste", pastedContent); + + if (remaining.length > 0) { + this.process(remaining); + } + } + return; + } + + const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START); + if (startIndex !== -1) { + if (startIndex > 0) { + const beforePaste = this.buffer.slice(0, startIndex); + const result = extractCompleteSequences(beforePaste); + for (const sequence of result.sequences) { + this.emitDataSequence(sequence); + } + } + + this.pendingKittyPrintableCodepoint = undefined; + this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length); + this.pasteMode = true; + this.pasteBuffer = this.buffer; + this.buffer = ""; + + const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END); + if (endIndex !== -1) { + const pastedContent = this.pasteBuffer.slice(0, endIndex); + const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); + + this.pasteMode = false; + this.pasteBuffer = ""; + this.pendingKittyPrintableCodepoint = undefined; + + this.emit("paste", pastedContent); + + if (remaining.length > 0) { + this.process(remaining); + } + } + return; + } + + const result = extractCompleteSequences(this.buffer); + this.buffer = result.remainder; + + for (const sequence of result.sequences) { + this.emitDataSequence(sequence); + } + + if (this.buffer.length > 0) { + this.timeout = setTimeout(() => { + const flushed = this.flush(); + + for (const sequence of flushed) { + this.emitDataSequence(sequence); + } + }, this.timeoutMs); + } + } + + private emitDataSequence(sequence: string): void { + const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined; + if (rawCodepoint !== undefined && rawCodepoint === this.pendingKittyPrintableCodepoint) { + this.pendingKittyPrintableCodepoint = undefined; + return; + } + + this.pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence); + this.emit("data", sequence); + } + + flush(): string[] { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + + if (this.buffer.length === 0) { + return []; + } + + const sequences = [this.buffer]; + this.buffer = ""; + this.pendingKittyPrintableCodepoint = undefined; + return sequences; + } + + clear(): void { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + this.buffer = ""; + this.pasteMode = false; + this.pasteBuffer = ""; + this.pendingKittyPrintableCodepoint = undefined; + } + + getBuffer(): string { + return this.buffer; + } + + destroy(): void { + this.clear(); + } +} diff --git a/cactus-code/packages/tui/src/terminal-colors.ts b/cactus-code/packages/tui/src/terminal-colors.ts new file mode 100644 index 000000000..cff6dc8e8 --- /dev/null +++ b/cactus-code/packages/tui/src/terminal-colors.ts @@ -0,0 +1,73 @@ +export interface RgbColor { + r: number; + g: number; + b: number; +} + +export type TerminalColorScheme = "dark" | "light"; + +function hexToRgb(hex: string): RgbColor { + const normalized = hex.startsWith("#") ? hex.slice(1) : hex; + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + return { r, g, b }; +} + +function parseOscHexChannel(channel: string): number | undefined { + if (!/^[0-9a-f]+$/i.test(channel)) { + return undefined; + } + const max = 16 ** channel.length - 1; + if (max <= 0) { + return undefined; + } + return Math.round((parseInt(channel, 16) / max) * 255); +} + +const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i; +const COLOR_SCHEME_REPORT_PATTERN = /^\x1b\[\?997;(1|2)n$/; + +export function isOsc11BackgroundColorResponse(data: string): boolean { + return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data); +} + +export function parseOsc11BackgroundColor(data: string): RgbColor | undefined { + const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN); + if (!match) { + return undefined; + } + + const value = match[1].trim(); + if (value.startsWith("#")) { + const hex = value.slice(1); + if (/^[0-9a-f]{6}$/i.test(hex)) { + return hexToRgb(value); + } + if (/^[0-9a-f]{12}$/i.test(hex)) { + const r = parseOscHexChannel(hex.slice(0, 4)); + const g = parseOscHexChannel(hex.slice(4, 8)); + const b = parseOscHexChannel(hex.slice(8, 12)); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; + } + return undefined; + } + + const rgbValue = value.replace(/^rgba?:/i, ""); + const [red, green, blue] = rgbValue.split("/"); + if (red === undefined || green === undefined || blue === undefined) { + return undefined; + } + const r = parseOscHexChannel(red); + const g = parseOscHexChannel(green); + const b = parseOscHexChannel(blue); + return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined; +} + +export function parseTerminalColorSchemeReport(data: string): TerminalColorScheme | undefined { + const match = data.match(COLOR_SCHEME_REPORT_PATTERN); + if (!match) { + return undefined; + } + return match[1] === "2" ? "light" : "dark"; +} diff --git a/cactus-code/packages/tui/src/terminal-image.ts b/cactus-code/packages/tui/src/terminal-image.ts new file mode 100644 index 000000000..8854a86a7 --- /dev/null +++ b/cactus-code/packages/tui/src/terminal-image.ts @@ -0,0 +1,488 @@ +import { execSync } from "node:child_process"; + +export type ImageProtocol = "kitty" | "iterm2" | null; + +export interface TerminalCapabilities { + images: ImageProtocol; + trueColor: boolean; + hyperlinks: boolean; +} + +export interface CellDimensions { + widthPx: number; + heightPx: number; +} + +export interface ImageDimensions { + widthPx: number; + heightPx: number; +} + +export interface ImageRenderOptions { + maxWidthCells?: number; + maxHeightCells?: number; + preserveAspectRatio?: boolean; + /** Kitty image ID. If provided, reuses/replaces existing image with this ID. */ + imageId?: number; + /** Whether Kitty should apply its default cursor movement after placement. */ + moveCursor?: boolean; +} + +let cachedCapabilities: TerminalCapabilities | null = null; + +// Default cell dimensions - updated by TUI when terminal responds to query +let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }; + +export function getCellDimensions(): CellDimensions { + return cellDimensions; +} + +export function setCellDimensions(dims: CellDimensions): void { + cellDimensions = dims; +} + +/** + * Checks whether the attached tmux client forwards OSC 8 hyperlinks to the + * outer terminal. tmux only re-emits them when its `client_termfeatures` lists + * `hyperlinks`, and strips them otherwise. On any error fallbacks `false`. + */ +function probeTmuxHyperlinks(): boolean { + try { + const termfeatures = execSync("tmux display-message -p '#{client_termfeatures}'", { + encoding: "utf8", + timeout: 250, + stdio: ["ignore", "pipe", "ignore"], + }); + return termfeatures + .split(",") + .map((feature) => feature.trim()) + .includes("hyperlinks"); + } catch { + return false; + } +} + +export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities { + const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; + const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || ""; + const term = process.env.TERM?.toLowerCase() || ""; + const colorTerm = process.env.COLORTERM?.toLowerCase() || ""; + const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; + + // Emit OSC 8 hyperlinks only when tmux confirms it forwards. + // Image protocols are unreliable under tmux, so leave `images: null`. + if (process.env.TMUX || term.startsWith("tmux")) { + return { images: null, trueColor: hasTrueColorHint, hyperlinks: tmuxForwardsHyperlink() }; + } + + // screen does not forward OSC 8 hyperlinks, so keep them off there. + if (term.startsWith("screen")) { + return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; + } + + if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + + if (termProgram === "ghostty" || term.includes("ghostty") || process.env.GHOSTTY_RESOURCES_DIR) { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + + if (process.env.WEZTERM_PANE || termProgram === "wezterm") { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + + // Warp supports the Kitty graphics protocol and OSC 8 hyperlinks. + if (termProgram === "warpterminal" || process.env.WARP_SESSION_ID || process.env.WARP_TERMINAL_SESSION_UUID) { + return { images: "kitty", trueColor: true, hyperlinks: true }; + } + + if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") { + return { images: "iterm2", trueColor: true, hyperlinks: true }; + } + + if (process.env.WT_SESSION) { + return { images: null, trueColor: true, hyperlinks: true }; + } + + if (termProgram === "vscode") { + return { images: null, trueColor: true, hyperlinks: true }; + } + + if (termProgram === "alacritty") { + return { images: null, trueColor: true, hyperlinks: true }; + } + + if (terminalEmulator === "jetbrains-jediterm") { + return { images: null, trueColor: true, hyperlinks: false }; + } + + // Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just + // text" on terminals that swallow it, which means the URL disappears from + // the rendered output. Default to the legacy `text (url)` behavior unless we + // have positively identified a hyperlink-capable terminal above. + return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; +} + +export function getCapabilities(): TerminalCapabilities { + if (!cachedCapabilities) { + cachedCapabilities = detectCapabilities(); + } + return cachedCapabilities; +} + +export function resetCapabilitiesCache(): void { + cachedCapabilities = null; +} + +/** Override the cached capabilities. Useful in tests to exercise both code paths. */ +export function setCapabilities(caps: TerminalCapabilities): void { + cachedCapabilities = caps; +} + +const KITTY_PREFIX = "\x1b_G"; +const ITERM2_PREFIX = "\x1b]1337;File="; + +export function isImageLine(line: string): boolean { + // Fast path: sequence at line start (single-row images) + if (line.startsWith(KITTY_PREFIX) || line.startsWith(ITERM2_PREFIX)) { + return true; + } + // Slow path: sequence elsewhere (multi-row images have cursor-up prefix) + return line.includes(KITTY_PREFIX) || line.includes(ITERM2_PREFIX); +} + +/** + * Generate a random image ID for Kitty graphics protocol. + * Uses random IDs to avoid collisions between different module instances + * (e.g., main app vs extensions). + */ +export function allocateImageId(): number { + // Use random ID in range [1, 0xffffffff] to avoid collisions + return Math.floor(Math.random() * 0xfffffffe) + 1; +} + +export function encodeKitty( + base64Data: string, + options: { + columns?: number; + rows?: number; + imageId?: number; + /** Whether Kitty should apply its default cursor movement after placement. Default: true. */ + moveCursor?: boolean; + } = {}, +): string { + const CHUNK_SIZE = 4096; + + const params: string[] = ["a=T", "f=100", "q=2"]; + + if (options.moveCursor === false) params.push("C=1"); + if (options.columns) params.push(`c=${options.columns}`); + if (options.rows) params.push(`r=${options.rows}`); + if (options.imageId) params.push(`i=${options.imageId}`); + + if (base64Data.length <= CHUNK_SIZE) { + return `\x1b_G${params.join(",")};${base64Data}\x1b\\`; + } + + const chunks: string[] = []; + let offset = 0; + let isFirst = true; + + while (offset < base64Data.length) { + const chunk = base64Data.slice(offset, offset + CHUNK_SIZE); + const isLast = offset + CHUNK_SIZE >= base64Data.length; + + if (isFirst) { + chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`); + isFirst = false; + } else if (isLast) { + chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`); + } else { + chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`); + } + + offset += CHUNK_SIZE; + } + + return chunks.join(""); +} + +/** + * Delete a Kitty graphics image by ID. + * Uses uppercase 'I' to also free the image data. + */ +export function deleteKittyImage(imageId: number): string { + return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`; +} + +/** + * Delete all visible Kitty graphics images. + * Uses uppercase 'A' to also free the image data. + */ +export function deleteAllKittyImages(): string { + return "\x1b_Ga=d,d=A,q=2\x1b\\"; +} + +export function encodeITerm2( + base64Data: string, + options: { + width?: number | string; + height?: number | string; + name?: string; + preserveAspectRatio?: boolean; + inline?: boolean; + } = {}, +): string { + const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`]; + + if (options.width !== undefined) params.push(`width=${options.width}`); + if (options.height !== undefined) params.push(`height=${options.height}`); + if (options.name) { + const nameBase64 = Buffer.from(options.name).toString("base64"); + params.push(`name=${nameBase64}`); + } + if (options.preserveAspectRatio === false) { + params.push("preserveAspectRatio=0"); + } + + return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`; +} + +export interface ImageCellSize { + columns: number; + rows: number; +} + +export function calculateImageCellSize( + imageDimensions: ImageDimensions, + maxWidthCells: number, + maxHeightCells?: number, + cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, +): ImageCellSize { + const maxWidth = Math.max(1, Math.floor(maxWidthCells)); + const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells)); + const imageWidth = Math.max(1, imageDimensions.widthPx); + const imageHeight = Math.max(1, imageDimensions.heightPx); + + const widthScale = (maxWidth * cellDimensions.widthPx) / imageWidth; + const heightScale = maxHeight === undefined ? widthScale : (maxHeight * cellDimensions.heightPx) / imageHeight; + const scale = Math.min(widthScale, heightScale); + + const scaledWidthPx = imageWidth * scale; + const scaledHeightPx = imageHeight * scale; + const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx); + const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx); + + return { + columns: Math.max(1, Math.min(maxWidth, columns)), + rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)), + }; +} + +export function calculateImageRows( + imageDimensions: ImageDimensions, + targetWidthCells: number, + cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 }, +): number { + return calculateImageCellSize(imageDimensions, targetWidthCells, undefined, cellDimensions).rows; +} + +export function getPngDimensions(base64Data: string): ImageDimensions | null { + try { + const buffer = Buffer.from(base64Data, "base64"); + + if (buffer.length < 24) { + return null; + } + + if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) { + return null; + } + + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + + return { widthPx: width, heightPx: height }; + } catch { + return null; + } +} + +export function getJpegDimensions(base64Data: string): ImageDimensions | null { + try { + const buffer = Buffer.from(base64Data, "base64"); + + if (buffer.length < 2) { + return null; + } + + if (buffer[0] !== 0xff || buffer[1] !== 0xd8) { + return null; + } + + let offset = 2; + while (offset < buffer.length - 9) { + if (buffer[offset] !== 0xff) { + offset++; + continue; + } + + const marker = buffer[offset + 1]; + + if (marker >= 0xc0 && marker <= 0xc2) { + const height = buffer.readUInt16BE(offset + 5); + const width = buffer.readUInt16BE(offset + 7); + return { widthPx: width, heightPx: height }; + } + + if (offset + 3 >= buffer.length) { + return null; + } + const length = buffer.readUInt16BE(offset + 2); + if (length < 2) { + return null; + } + offset += 2 + length; + } + + return null; + } catch { + return null; + } +} + +export function getGifDimensions(base64Data: string): ImageDimensions | null { + try { + const buffer = Buffer.from(base64Data, "base64"); + + if (buffer.length < 10) { + return null; + } + + const sig = buffer.slice(0, 6).toString("ascii"); + if (sig !== "GIF87a" && sig !== "GIF89a") { + return null; + } + + const width = buffer.readUInt16LE(6); + const height = buffer.readUInt16LE(8); + + return { widthPx: width, heightPx: height }; + } catch { + return null; + } +} + +export function getWebpDimensions(base64Data: string): ImageDimensions | null { + try { + const buffer = Buffer.from(base64Data, "base64"); + + if (buffer.length < 30) { + return null; + } + + const riff = buffer.slice(0, 4).toString("ascii"); + const webp = buffer.slice(8, 12).toString("ascii"); + if (riff !== "RIFF" || webp !== "WEBP") { + return null; + } + + const chunk = buffer.slice(12, 16).toString("ascii"); + if (chunk === "VP8 ") { + if (buffer.length < 30) return null; + const width = buffer.readUInt16LE(26) & 0x3fff; + const height = buffer.readUInt16LE(28) & 0x3fff; + return { widthPx: width, heightPx: height }; + } else if (chunk === "VP8L") { + if (buffer.length < 25) return null; + const bits = buffer.readUInt32LE(21); + const width = (bits & 0x3fff) + 1; + const height = ((bits >> 14) & 0x3fff) + 1; + return { widthPx: width, heightPx: height }; + } else if (chunk === "VP8X") { + if (buffer.length < 30) return null; + const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1; + const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1; + return { widthPx: width, heightPx: height }; + } + + return null; + } catch { + return null; + } +} + +export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null { + if (mimeType === "image/png") { + return getPngDimensions(base64Data); + } + if (mimeType === "image/jpeg") { + return getJpegDimensions(base64Data); + } + if (mimeType === "image/gif") { + return getGifDimensions(base64Data); + } + if (mimeType === "image/webp") { + return getWebpDimensions(base64Data); + } + return null; +} + +export function renderImage( + base64Data: string, + imageDimensions: ImageDimensions, + options: ImageRenderOptions = {}, +): { sequence: string; rows: number; imageId?: number } | null { + const caps = getCapabilities(); + + if (!caps.images) { + return null; + } + + const maxWidth = options.maxWidthCells ?? 80; + const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions()); + + if (caps.images === "kitty") { + const sequence = encodeKitty(base64Data, { + columns: size.columns, + rows: size.rows, + imageId: options.imageId, + moveCursor: options.moveCursor, + }); + return { sequence, rows: size.rows, imageId: options.imageId }; + } + + if (caps.images === "iterm2") { + const sequence = encodeITerm2(base64Data, { + width: size.columns, + height: "auto", + preserveAspectRatio: options.preserveAspectRatio ?? true, + }); + return { sequence, rows: size.rows }; + } + + return null; +} + +/** + * Wrap text in an OSC 8 hyperlink sequence. + * The text is rendered as a clickable hyperlink in terminals that support OSC 8 + * (Ghostty, Kitty, WezTerm, iTerm2, VSCode, and others). + * In terminals that do not support OSC 8, the escape sequences are ignored + * and only the plain text is displayed. + * + * @param text - The visible text to display + * @param url - The URL to link to + */ +export function hyperlink(text: string, url: string): string { + return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`; +} + +export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string { + const parts: string[] = []; + if (filename) parts.push(filename); + parts.push(`[${mimeType}]`); + if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`); + return `[Image: ${parts.join(" ")}]`; +} diff --git a/cactus-code/packages/tui/src/terminal.ts b/cactus-code/packages/tui/src/terminal.ts new file mode 100644 index 000000000..2542286ae --- /dev/null +++ b/cactus-code/packages/tui/src/terminal.ts @@ -0,0 +1,531 @@ +import * as fs from "node:fs"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { setKittyProtocolActive } from "./keys.ts"; +import { isNativeModifierPressed } from "./native-modifiers.ts"; +import { StdinBuffer } from "./stdin-buffer.ts"; + +const cjsRequire = createRequire(import.meta.url); + +const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; +const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07"; +const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07"; +const APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; +const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; +const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150; +const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`; + +export type KeyboardProtocolNegotiationSequence = + | { type: "kitty-flags"; flags: number } + | { type: "device-attributes" }; + +export function parseKeyboardProtocolNegotiationSequence( + sequence: string, +): KeyboardProtocolNegotiationSequence | undefined { + const kittyFlags = sequence.match(/^\x1b\[\?(\d+)u$/); + if (kittyFlags) { + return { type: "kitty-flags", flags: Number.parseInt(kittyFlags[1]!, 10) }; + } + if (/^\x1b\[\?[\d;]*c$/.test(sequence)) { + return { type: "device-attributes" }; + } + return undefined; +} + +function isKeyboardProtocolNegotiationSequencePrefix(sequence: string): boolean { + return sequence === "\x1b[" || /^\x1b\[\?[\d;]*$/.test(sequence); +} + +export function isAppleTerminalSession(): boolean { + return process.platform === "darwin" && process.env.TERM_PROGRAM === "Apple_Terminal"; +} + +export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string { + if (isAppleTerminal && data === "\r" && isShiftPressed) return APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE; + return data; +} + +/** + * Minimal terminal interface for TUI + */ +export interface Terminal { + // Start the terminal with input and resize handlers + start(onInput: (data: string) => void, onResize: () => void): void; + + // Stop the terminal and restore state + stop(): void; + + /** + * Drain stdin before exiting to prevent Kitty key release events from + * leaking to the parent shell over slow SSH connections. + * @param maxMs - Maximum time to drain (default: 1000ms) + * @param idleMs - Exit early if no input arrives within this time (default: 50ms) + */ + drainInput(maxMs?: number, idleMs?: number): Promise; + + // Write output to terminal + write(data: string): void; + + // Get terminal dimensions + get columns(): number; + get rows(): number; + + // Whether Kitty keyboard protocol is active + get kittyProtocolActive(): boolean; + + // Cursor positioning (relative to current position) + moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines + + // Cursor visibility + hideCursor(): void; // Hide the cursor + showCursor(): void; // Show the cursor + + // Clear operations + clearLine(): void; // Clear current line + clearFromCursor(): void; // Clear from cursor to end of screen + clearScreen(): void; // Clear entire screen and move cursor to (0,0) + + // Title operations + setTitle(title: string): void; // Set terminal window title + + // Progress indicator (OSC 9;4) + setProgress(active: boolean): void; +} + +/** + * Real terminal using process.stdin/stdout + */ +export class ProcessTerminal implements Terminal { + private wasRaw = false; + private inputHandler?: (data: string) => void; + private resizeHandler?: () => void; + private _kittyProtocolActive = false; + private _modifyOtherKeysActive = false; + private keyboardProtocolPushed = false; + private keyboardProtocolNegotiationBuffer = ""; + private keyboardProtocolBufferFlushTimer?: ReturnType; + private stdinBuffer?: StdinBuffer; + private stdinDataHandler?: (data: string) => void; + private progressInterval?: ReturnType; + private writeLogPath = (() => { + const env = process.env.PI_TUI_WRITE_LOG || ""; + if (!env) return ""; + try { + if (fs.statSync(env).isDirectory()) { + const now = new Date(); + const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}-${String(now.getSeconds()).padStart(2, "0")}`; + return path.join(env, `tui-${ts}-${process.pid}.log`); + } + } catch { + // Not an existing directory - use as-is (file path) + } + return env; + })(); + + get kittyProtocolActive(): boolean { + return this._kittyProtocolActive; + } + + get modifyOtherKeysActive(): boolean { + return this._modifyOtherKeysActive; + } + + start(onInput: (data: string) => void, onResize: () => void): void { + this.inputHandler = onInput; + this.resizeHandler = onResize; + + // Save previous state and enable raw mode + this.wasRaw = process.stdin.isRaw || false; + if (process.stdin.setRawMode) { + process.stdin.setRawMode(true); + } + process.stdin.setEncoding("utf8"); + process.stdin.resume(); + + // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~ + process.stdout.write("\x1b[?2004h"); + + // Set up resize handler immediately + process.stdout.on("resize", this.resizeHandler); + + // Refresh terminal dimensions - they may be stale after suspend/resume + // (SIGWINCH is lost while process is stopped). Unix only. + if (process.platform !== "win32") { + process.kill(process.pid, "SIGWINCH"); + } + + // On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends + // VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console + // events that lose modifier information. Must run AFTER setRawMode(true) + // since that resets console mode flags. + this.enableWindowsVTInput(); + + // Query Kitty keyboard protocol and fall back to modifyOtherKeys when DA confirms no Kitty response. + // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + this.queryAndEnableKittyProtocol(); + } + + /** + * Set up StdinBuffer to split batched input into individual sequences. + * This ensures components receive single events, making matchesKey/isKeyRelease work correctly. + * + * Also watches for Kitty protocol response and enables it when detected. + * This is done here (after stdinBuffer parsing) rather than on raw stdin + * to handle the case where the response arrives split across multiple events. + */ + private setupStdinBuffer(): void { + this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + + // Forward individual sequences to the input handler + this.stdinBuffer.on("data", (sequence) => { + const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence); + if (negotiationSequence === "pending") { + this.scheduleKeyboardProtocolNegotiationBufferFlush(); + return; // Wait briefly for the rest of a split Kitty response. + } + if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) { + return; + } + + this.forwardInputSequence(sequence); + }); + + // Re-wrap paste content with bracketed paste markers for existing editor handling + this.stdinBuffer.on("paste", (content) => { + if (this.inputHandler) { + this.inputHandler(`\x1b[200~${content}\x1b[201~`); + } + }); + + // Handler that pipes stdin data through the buffer + this.stdinDataHandler = (data: string) => { + this.stdinBuffer!.process(data); + }; + } + + /** + * Query terminal for Kitty keyboard protocol support and enable it if available. + * + * Kitty's progressive enhancement detection requires requesting the desired + * flags before querying them. The trailing DA query is a sentinel supported by + * terminals that do not know Kitty keyboard protocol; receiving DA before a + * Kitty response enables modifyOtherKeys fallback without a startup timeout. + * + * The requested flags are: + * - 1 = disambiguate escape codes + * - 2 = report event types (press/repeat/release) + * - 4 = report alternate keys (shifted key, base layout key) + */ + private queryAndEnableKittyProtocol(): void { + this.setupStdinBuffer(); + process.stdin.on("data", this.stdinDataHandler!); + this.keyboardProtocolPushed = true; + this.clearKeyboardProtocolNegotiationBuffer(); + process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); + } + + private handleKeyboardProtocolNegotiationSequence( + negotiationSequence: KeyboardProtocolNegotiationSequence | undefined, + ): boolean { + if (!negotiationSequence) return false; + this.clearKeyboardProtocolNegotiationBuffer(); + if (negotiationSequence.type === "kitty-flags") { + if (negotiationSequence.flags !== 0) { + this.disableModifyOtherKeys(); + if (!this._kittyProtocolActive) { + this._kittyProtocolActive = true; + setKittyProtocolActive(true); + } + } else { + this.enableModifyOtherKeys(); + } + return true; + } + + if (!this._kittyProtocolActive) { + this.enableModifyOtherKeys(); + } + return true; + } + + private readKeyboardProtocolNegotiationSequence( + sequence: string, + ): KeyboardProtocolNegotiationSequence | "pending" | undefined { + if (this.keyboardProtocolNegotiationBuffer) { + const bufferedSequence = this.keyboardProtocolNegotiationBuffer + sequence; + const negotiationSequence = parseKeyboardProtocolNegotiationSequence(bufferedSequence); + if (negotiationSequence) { + this.clearKeyboardProtocolNegotiationBuffer(); + return negotiationSequence; + } + if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence)) { + this.setKeyboardProtocolNegotiationBuffer(bufferedSequence); + return "pending"; + } + this.flushKeyboardProtocolNegotiationBufferAsInput(); + } + + const negotiationSequence = parseKeyboardProtocolNegotiationSequence(sequence); + if (negotiationSequence) return negotiationSequence; + if (isKeyboardProtocolNegotiationSequencePrefix(sequence)) { + this.setKeyboardProtocolNegotiationBuffer(sequence); + return "pending"; + } + return undefined; + } + + private setKeyboardProtocolNegotiationBuffer(sequence: string): void { + this.clearKeyboardProtocolNegotiationBufferFlushTimer(); + this.keyboardProtocolNegotiationBuffer = sequence; + } + + private clearKeyboardProtocolNegotiationBuffer(): void { + this.clearKeyboardProtocolNegotiationBufferFlushTimer(); + this.keyboardProtocolNegotiationBuffer = ""; + } + + private flushKeyboardProtocolNegotiationBufferAsInput(): void { + if (!this.keyboardProtocolNegotiationBuffer) return; + const sequence = this.keyboardProtocolNegotiationBuffer; + this.clearKeyboardProtocolNegotiationBuffer(); + this.forwardInputSequence(sequence); + } + + private scheduleKeyboardProtocolNegotiationBufferFlush(): void { + if (!this.keyboardProtocolNegotiationBuffer || this.keyboardProtocolBufferFlushTimer) return; + this.keyboardProtocolBufferFlushTimer = setTimeout(() => { + this.keyboardProtocolBufferFlushTimer = undefined; + this.flushKeyboardProtocolNegotiationBufferAsInput(); + }, KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS); + } + + private clearKeyboardProtocolNegotiationBufferFlushTimer(): void { + if (!this.keyboardProtocolBufferFlushTimer) return; + clearTimeout(this.keyboardProtocolBufferFlushTimer); + this.keyboardProtocolBufferFlushTimer = undefined; + } + + private forwardInputSequence(sequence: string): void { + if (!this.inputHandler) return; + const isAppleTerminal = sequence === "\r" && isAppleTerminalSession(); + const input = normalizeAppleTerminalInput( + sequence, + isAppleTerminal, + isAppleTerminal && isNativeModifierPressed("shift"), + ); + this.inputHandler(input); + } + + private enableModifyOtherKeys(): void { + if (this._kittyProtocolActive || this._modifyOtherKeysActive) return; + process.stdout.write("\x1b[>4;2m"); + this._modifyOtherKeysActive = true; + } + + private disableModifyOtherKeys(): void { + if (!this._modifyOtherKeysActive) return; + process.stdout.write("\x1b[>4;0m"); + this._modifyOtherKeysActive = false; + } + + /** + * On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200) to the stdin + * console handle so the terminal sends VT sequences for modified keys + * (e.g. \x1b[Z for Shift+Tab). Without this, libuv's ReadConsoleInputW + * discards modifier state and Shift+Tab arrives as plain \t. + */ + private enableWindowsVTInput(): void { + if (process.platform !== "win32") return; + try { + const arch = process.arch; + if (arch !== "x64" && arch !== "arm64") return; + + // Dynamic require so non-Windows and bundled/browser paths never load the + // native helper. In the npm package native/ is next to dist/; in compiled + // binary archives native/ is copied next to the executable. + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node"); + const candidates = [ + path.join(moduleDir, "..", nativePath), + path.join(moduleDir, nativePath), + path.join(path.dirname(process.execPath), nativePath), + ]; + for (const modulePath of candidates) { + try { + const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean }; + helper.enableVirtualTerminalInput?.(); + return; + } catch { + // Try the next possible packaging location. + } + } + } catch { + // Native helper not available — Shift+Tab won't be distinguishable from Tab. + } + } + + async drainInput(maxMs = 1000, idleMs = 50): Promise { + const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive; + this.clearKeyboardProtocolNegotiationBuffer(); + if (shouldDisableKittyProtocol) { + // Disable Kitty keyboard protocol first so any late key releases + // do not generate new Kitty escape sequences. + process.stdout.write("\x1b[ { + lastDataTime = Date.now(); + }; + + process.stdin.on("data", onData); + const endTime = Date.now() + maxMs; + + try { + while (true) { + const now = Date.now(); + const timeLeft = endTime - now; + if (timeLeft <= 0) break; + if (now - lastDataTime >= idleMs) break; + await new Promise((resolve) => setTimeout(resolve, Math.min(idleMs, timeLeft))); + } + } finally { + process.stdin.removeListener("data", onData); + this.inputHandler = previousHandler; + } + } + + stop(): void { + if (this.clearProgressInterval()) { + process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE); + } + + // Disable bracketed paste mode + process.stdout.write("\x1b[?2004l"); + + const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive; + this.clearKeyboardProtocolNegotiationBuffer(); + + // Disable Kitty keyboard protocol if not already done by drainInput() + if (shouldDisableKittyProtocol) { + process.stdout.write("\x1b[ 0) { + // Move down + process.stdout.write(`\x1b[${lines}B`); + } else if (lines < 0) { + // Move up + process.stdout.write(`\x1b[${-lines}A`); + } + // lines === 0: no movement + } + + hideCursor(): void { + process.stdout.write("\x1b[?25l"); + } + + showCursor(): void { + process.stdout.write("\x1b[?25h"); + } + + clearLine(): void { + process.stdout.write("\x1b[K"); + } + + clearFromCursor(): void { + process.stdout.write("\x1b[J"); + } + + clearScreen(): void { + process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) + } + + setTitle(title: string): void { + // OSC 0;title BEL - set terminal window title + process.stdout.write(`\x1b]0;${title}\x07`); + } + + setProgress(active: boolean): void { + if (active) { + // OSC 9;4;3 - indeterminate progress + process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE); + if (!this.progressInterval) { + this.progressInterval = setInterval(() => { + process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE); + }, TERMINAL_PROGRESS_KEEPALIVE_MS); + } + } else { + this.clearProgressInterval(); + // OSC 9;4;0 - clear progress + process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE); + } + } + + private clearProgressInterval(): boolean { + if (!this.progressInterval) return false; + clearInterval(this.progressInterval); + this.progressInterval = undefined; + return true; + } +} diff --git a/cactus-code/packages/tui/src/tui.ts b/cactus-code/packages/tui/src/tui.ts new file mode 100644 index 000000000..c1c1cbbe0 --- /dev/null +++ b/cactus-code/packages/tui/src/tui.ts @@ -0,0 +1,1714 @@ +/** + * Minimal TUI implementation with differential rendering + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { performance } from "node:perf_hooks"; +import { isKeyRelease, matchesKey } from "./keys.ts"; +import type { Terminal } from "./terminal.ts"; +import { + isOsc11BackgroundColorResponse, + parseOsc11BackgroundColor, + parseTerminalColorSchemeReport, + type RgbColor, + type TerminalColorScheme, +} from "./terminal-colors.ts"; +import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; +import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; + +const KITTY_SEQUENCE_PREFIX = "\x1b_G"; + +interface KittyImageHeader { + ids: number[]; + rows: number; +} + +function parseKittyImageHeader(line: string): KittyImageHeader | undefined { + const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX); + if (sequenceStart === -1) return undefined; + + const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length; + const paramsEnd = line.indexOf(";", paramsStart); + if (paramsEnd === -1) return undefined; + + const ids: number[] = []; + let rows = 1; + const params = line.slice(paramsStart, paramsEnd); + for (const param of params.split(",")) { + const [key, value] = param.split("=", 2); + if (value === undefined) continue; + const numberValue = Number(value); + if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff) continue; + if (key === "i") { + ids.push(numberValue); + } else if (key === "r") { + rows = numberValue; + } + } + return { ids, rows }; +} + +function extractKittyImageIds(line: string): number[] { + return parseKittyImageHeader(line)?.ids ?? []; +} + +function extractKittyImageRows(line: string): number { + return parseKittyImageHeader(line)?.rows ?? 1; +} + +/** + * Component interface - all components must implement this + */ +export interface Component { + /** + * Render the component to lines for the given viewport width + * @param width - Current viewport width + * @returns Array of strings, each representing a line + */ + render(width: number): string[]; + + /** + * Optional handler for keyboard input when component has focus + */ + handleInput?(data: string): void; + + /** + * If true, component receives key release events (Kitty protocol). + * Default is false - release events are filtered out. + */ + wantsKeyRelease?: boolean; + + /** + * Invalidate any cached rendering state. + * Called when theme changes or when component needs to re-render from scratch. + */ + invalidate(): void; +} + +type InputListenerResult = { consume?: boolean; data?: string } | undefined; +type InputListener = (data: string) => InputListenerResult; +type PendingOsc11BackgroundQuery = { + settled: boolean; + resolve: ((rgb: RgbColor | undefined) => void) | undefined; + timer: NodeJS.Timeout | undefined; +}; + +/** + * Interface for components that can receive focus and display a hardware cursor. + * When focused, the component should emit CURSOR_MARKER at the cursor position + * in its render output. TUI will find this marker and position the hardware + * cursor there for proper IME candidate window positioning. + */ +export interface Focusable { + /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */ + focused: boolean; +} + +/** Type guard to check if a component implements Focusable */ +export function isFocusable(component: Component | null): component is Component & Focusable { + return component !== null && "focused" in component; +} + +/** + * Cursor position marker - APC (Application Program Command) sequence. + * This is a zero-width escape sequence that terminals ignore. + * Components emit this at the cursor position when focused. + * TUI finds and strips this marker, then positions the hardware cursor there. + */ +export const CURSOR_MARKER = "\x1b_pi:c\x07"; + +export { visibleWidth }; + +/** + * Anchor position for overlays + */ +export type OverlayAnchor = + | "center" + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "top-center" + | "bottom-center" + | "left-center" + | "right-center"; + +/** + * Margin configuration for overlays + */ +export interface OverlayMargin { + top?: number; + right?: number; + bottom?: number; + left?: number; +} + +/** Value that can be absolute (number) or percentage (string like "50%") */ +export type SizeValue = number | `${number}%`; + +/** Parse a SizeValue into absolute value given a reference size */ +function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined { + if (value === undefined) return undefined; + if (typeof value === "number") return value; + // Parse percentage string like "50%" + const match = value.match(/^(\d+(?:\.\d+)?)%$/); + if (match) { + return Math.floor((referenceSize * parseFloat(match[1])) / 100); + } + return undefined; +} + +function isTermuxSession(): boolean { + return Boolean(process.env.TERMUX_VERSION); +} + +/** + * Options for overlay positioning and sizing. + * Values can be absolute numbers or percentage strings (e.g., "50%"). + */ +export interface OverlayOptions { + // === Sizing === + /** Width in columns, or percentage of terminal width (e.g., "50%") */ + width?: SizeValue; + /** Minimum width in columns */ + minWidth?: number; + /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */ + maxHeight?: SizeValue; + + // === Positioning - anchor-based === + /** Anchor point for positioning (default: 'center') */ + anchor?: OverlayAnchor; + /** Horizontal offset from anchor position (positive = right) */ + offsetX?: number; + /** Vertical offset from anchor position (positive = down) */ + offsetY?: number; + + // === Positioning - percentage or absolute === + /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */ + row?: SizeValue; + /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */ + col?: SizeValue; + + // === Margin from terminal edges === + /** Margin from terminal edges. Number applies to all sides. */ + margin?: OverlayMargin | number; + + // === Visibility === + /** + * Control overlay visibility based on terminal dimensions. + * If provided, overlay is only rendered when this returns true. + * Called each render cycle with current terminal dimensions. + */ + visible?: (termWidth: number, termHeight: number) => boolean; + /** If true, don't capture keyboard focus when shown */ + nonCapturing?: boolean; +} + +/** Options for {@link OverlayHandle.unfocus}. */ +export interface OverlayUnfocusOptions { + /** Explicit target to focus after releasing this overlay. */ + target: Component | null; +} + +/** + * Handle returned by showOverlay for controlling the overlay + */ +export interface OverlayHandle { + /** Permanently remove the overlay (cannot be shown again) */ + hide(): void; + /** Temporarily hide or show the overlay */ + setHidden(hidden: boolean): void; + /** Check if overlay is temporarily hidden */ + isHidden(): boolean; + /** Focus this overlay and bring it to the visual front */ + focus(): void; + /** Release focus to the next visible capturing overlay or previous target, or to an explicit target when provided */ + unfocus(options?: OverlayUnfocusOptions): void; + /** Check if this overlay currently has focus */ + isFocused(): boolean; +} + +type OverlayStackEntry = { + component: Component; + options?: OverlayOptions; + preFocus: Component | null; + hidden: boolean; + focusOrder: number; +}; + +type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null }; +type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry }; +type BlockedOverlayFocusRestoreState = { + status: "blocked"; + overlay: OverlayStackEntry; + blockedBy: Component; + resume: OverlayBlockedFocusResume; +}; +type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState; +type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState; +type OverlayFocusRestorePolicy = "clear" | "preserve"; + +/** + * Container - a component that contains other components + */ +export class Container implements Component { + children: Component[] = []; + + addChild(component: Component): void { + this.children.push(component); + } + + removeChild(component: Component): void { + const index = this.children.indexOf(component); + if (index !== -1) { + this.children.splice(index, 1); + } + } + + clear(): void { + this.children = []; + } + + invalidate(): void { + for (const child of this.children) { + child.invalidate?.(); + } + } + + render(width: number): string[] { + const lines: string[] = []; + for (const child of this.children) { + const childLines = child.render(width); + for (const line of childLines) { + lines.push(line); + } + } + return lines; + } +} + +/** + * TUI - Main class for managing terminal UI with differential rendering + */ +export class TUI extends Container { + public terminal: Terminal; + private previousLines: string[] = []; + private previousKittyImageIds = new Set(); + private previousWidth = 0; + private previousHeight = 0; + private focusedComponent: Component | null = null; + private inputListeners = new Set(); + + /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ + public onDebug?: () => void; + private renderRequested = false; + private renderTimer: NodeJS.Timeout | undefined; + private lastRenderAt = 0; + private static readonly MIN_RENDER_INTERVAL_MS = 16; + private cursorRow = 0; // Logical cursor row (end of rendered content) + private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) + private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1"; + private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off) + private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered) + private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves + private fullRedrawCount = 0; + private stopped = false; + private pendingOsc11BackgroundReplies = 0; + private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; + private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>(); + private terminalColorSchemeNotificationsEnabled = false; + + // Overlay stack for modal components rendered on top of base content + private focusOrderCounter = 0; + private overlayStack: OverlayStackEntry[] = []; + private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" }; + + constructor(terminal: Terminal, showHardwareCursor?: boolean) { + super(); + this.terminal = terminal; + if (showHardwareCursor !== undefined) { + this.showHardwareCursor = showHardwareCursor; + } + } + + get fullRedraws(): number { + return this.fullRedrawCount; + } + + getShowHardwareCursor(): boolean { + return this.showHardwareCursor; + } + + setShowHardwareCursor(enabled: boolean): void { + if (this.showHardwareCursor === enabled) return; + this.showHardwareCursor = enabled; + if (!enabled) { + this.terminal.hideCursor(); + } + this.requestRender(); + } + + getClearOnShrink(): boolean { + return this.clearOnShrink; + } + + /** + * Set whether to trigger full re-render when content shrinks. + * When true (default), empty rows are cleared when content shrinks. + * When false, empty rows remain (reduces redraws on slower terminals). + */ + setClearOnShrink(enabled: boolean): void { + this.clearOnShrink = enabled; + } + + setFocus(component: Component | null): void { + this.setFocusInternal({ component, overlayFocusRestore: "clear" }); + } + + private setFocusInternal({ + component, + overlayFocusRestore, + }: { + component: Component | null; + overlayFocusRestore: OverlayFocusRestorePolicy; + }): void { + const previousFocus = this.focusedComponent; + let nextFocus = component; + const previousFocusedOverlay = previousFocus + ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry)) + : undefined; + const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false; + const restoreState = this.getVisibleOverlayFocusRestore(); + if (nextFocus && !nextFocusIsOverlay) { + if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { + if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) { + nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); + } else { + this.overlayFocusRestore = { + status: "blocked", + overlay: restoreState.overlay, + blockedBy: nextFocus, + resume: restoreState.resume, + }; + } + } else if ( + previousFocusedOverlay && + restoreState.status !== "inactive" && + restoreState.overlay === previousFocusedOverlay && + !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus) + ) { + this.overlayFocusRestore = { + status: "blocked", + overlay: previousFocusedOverlay, + blockedBy: nextFocus, + resume: { status: "restore-overlay" }, + }; + } + } else if (nextFocus === null) { + if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { + nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); + } else if (overlayFocusRestore === "clear") { + this.clearOverlayFocusRestore(); + } + } + + if (isFocusable(this.focusedComponent)) { + this.focusedComponent.focused = false; + } + + this.focusedComponent = nextFocus; + + if (isFocusable(nextFocus)) { + nextFocus.focused = true; + } + + const focusedOverlay = nextFocus + ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry)) + : undefined; + if (focusedOverlay) { + this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay }; + } + } + + private clearOverlayFocusRestore(): void { + this.overlayFocusRestore = { status: "inactive" }; + } + + private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void { + if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) { + this.clearOverlayFocusRestore(); + } + } + + private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null { + if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component; + this.clearOverlayFocusRestore(); + return restoreState.resume.target; + } + + private getVisibleOverlayFocusRestore(): OverlayFocusRestoreState { + const restoreState = this.overlayFocusRestore; + if (restoreState.status === "inactive") return restoreState; + if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) { + return { status: "inactive" }; + } + return restoreState; + } + + private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean { + const visited = new Set(); + let current = entry.preFocus; + while (current && !visited.has(current)) { + visited.add(current); + if (current === component) return true; + current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null; + } + return false; + } + + private retargetOverlayPreFocus(removed: OverlayStackEntry): void { + for (const overlay of this.overlayStack) { + if (overlay !== removed && overlay.preFocus === removed.component) { + overlay.preFocus = removed.preFocus; + } + } + } + + private isComponentMounted(component: Component): boolean { + return this.children.some((child) => this.containsComponent(child, component)); + } + + private containsComponent(root: Component, target: Component): boolean { + if (root === target) return true; + if (!(root instanceof Container)) return false; + return root.children.some((child) => this.containsComponent(child, target)); + } + + /** + * Show an overlay component with configurable positioning and sizing. + * Returns a handle to control the overlay's visibility. + */ + showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { + const entry: OverlayStackEntry = { + component, + ...(options === undefined ? {} : { options }), + preFocus: this.focusedComponent, + hidden: false, + focusOrder: ++this.focusOrderCounter, + }; + this.overlayStack.push(entry); + // Only focus if overlay is actually visible + if (!options?.nonCapturing && this.isOverlayVisible(entry)) { + this.setFocus(component); + } + this.terminal.hideCursor(); + this.requestRender(); + + // Return handle for controlling this overlay + return { + hide: () => { + const index = this.overlayStack.indexOf(entry); + if (index !== -1) { + this.clearOverlayFocusRestoreFor(entry); + this.retargetOverlayPreFocus(entry); + this.overlayStack.splice(index, 1); + // Restore focus if this overlay had focus + if (this.focusedComponent === component) { + const topVisible = this.getTopmostVisibleOverlay(); + this.setFocus(topVisible?.component ?? entry.preFocus); + } + if (this.overlayStack.length === 0) this.terminal.hideCursor(); + this.requestRender(); + } + }, + setHidden: (hidden: boolean) => { + if (entry.hidden === hidden) return; + entry.hidden = hidden; + // Update focus when hiding/showing + if (hidden) { + this.clearOverlayFocusRestoreFor(entry); + // If this overlay had focus, move focus to next visible or preFocus + if (this.focusedComponent === component) { + const topVisible = this.getTopmostVisibleOverlay(); + this.setFocus(topVisible?.component ?? entry.preFocus); + } + } else { + // Restore focus to this overlay when showing (if it's actually visible) + if (!options?.nonCapturing && this.isOverlayVisible(entry)) { + entry.focusOrder = ++this.focusOrderCounter; + this.setFocus(component); + } + } + this.requestRender(); + }, + isHidden: () => entry.hidden, + focus: () => { + if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return; + entry.focusOrder = ++this.focusOrderCounter; + this.setFocus(component); + this.requestRender(); + }, + unfocus: (unfocusOptions) => { + const isFocused = this.focusedComponent === component; + const restoreState = this.overlayFocusRestore; + const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry; + if (!isFocused && !hasPendingRestore) return; + if ( + restoreState.status === "blocked" && + restoreState.overlay === entry && + this.focusedComponent === restoreState.blockedBy + ) { + if (unfocusOptions) { + this.overlayFocusRestore = { + status: "blocked", + overlay: entry, + blockedBy: restoreState.blockedBy, + resume: { status: "focus-target", target: unfocusOptions.target }, + }; + } else { + this.clearOverlayFocusRestore(); + } + this.requestRender(); + return; + } + this.clearOverlayFocusRestoreFor(entry); + if (isFocused || unfocusOptions) { + const topVisible = this.getTopmostVisibleOverlay(); + const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus; + this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget); + } + this.requestRender(); + }, + isFocused: () => this.focusedComponent === component, + }; + } + + /** Hide the topmost overlay and restore previous focus. */ + hideOverlay(): void { + const overlay = this.overlayStack[this.overlayStack.length - 1]; + if (!overlay) return; + this.clearOverlayFocusRestoreFor(overlay); + this.retargetOverlayPreFocus(overlay); + this.overlayStack.pop(); + if (this.focusedComponent === overlay.component) { + // Find topmost visible overlay, or fall back to preFocus + const topVisible = this.getTopmostVisibleOverlay(); + this.setFocus(topVisible?.component ?? overlay.preFocus); + } + if (this.overlayStack.length === 0) this.terminal.hideCursor(); + this.requestRender(); + } + + /** Check if there are any visible overlays */ + hasOverlay(): boolean { + return this.overlayStack.some((o) => this.isOverlayVisible(o)); + } + + /** Check if an overlay entry is currently visible */ + private isOverlayVisible(entry: OverlayStackEntry): boolean { + if (entry.hidden) return false; + if (entry.options?.visible) { + return entry.options.visible(this.terminal.columns, this.terminal.rows); + } + return true; + } + + /** Find the visual-frontmost visible capturing overlay, if any */ + private getTopmostVisibleOverlay(): OverlayStackEntry | undefined { + let topmost: OverlayStackEntry | undefined; + for (const overlay of this.overlayStack) { + if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue; + if (!topmost || overlay.focusOrder > topmost.focusOrder) { + topmost = overlay; + } + } + return topmost; + } + + override invalidate(): void { + super.invalidate(); + for (const overlay of this.overlayStack) overlay.component.invalidate?.(); + } + + start(): void { + this.stopped = false; + this.terminal.start( + (data) => this.handleInput(data), + () => this.requestRender(), + ); + this.terminal.hideCursor(); + if (this.terminalColorSchemeNotificationsEnabled) { + this.terminal.write("\x1b[?2031h"); + } + this.queryCellSize(); + this.requestRender(); + } + + addInputListener(listener: InputListener): () => void { + this.inputListeners.add(listener); + return () => { + this.inputListeners.delete(listener); + }; + } + + removeInputListener(listener: InputListener): void { + this.inputListeners.delete(listener); + } + + onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void { + this.terminalColorSchemeListeners.add(listener); + return () => { + this.terminalColorSchemeListeners.delete(listener); + }; + } + + setTerminalColorSchemeNotifications(enabled: boolean): void { + if (this.terminalColorSchemeNotificationsEnabled === enabled) { + return; + } + this.terminalColorSchemeNotificationsEnabled = enabled; + if (!this.stopped) { + this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l"); + } + } + + private queryCellSize(): void { + // Only query if terminal supports images (cell size is only used for image rendering) + if (!getCapabilities().images) { + return; + } + // Query terminal for cell size in pixels: CSI 16 t + // Response format: CSI 6 ; height ; width t + this.terminal.write("\x1b[16t"); + } + + stop(): void { + this.stopped = true; + if (this.renderTimer) { + clearTimeout(this.renderTimer); + this.renderTimer = undefined; + } + if (this.terminalColorSchemeNotificationsEnabled) { + this.terminal.write("\x1b[?2031l"); + } + // Move cursor to the end of the content to prevent overwriting/artifacts on exit + if (this.previousLines.length > 0) { + const targetRow = this.previousLines.length; // Line after the last content + const lineDiff = targetRow - this.hardwareCursorRow; + if (lineDiff > 0) { + this.terminal.write(`\x1b[${lineDiff}B`); + } else if (lineDiff < 0) { + this.terminal.write(`\x1b[${-lineDiff}A`); + } + this.terminal.write("\r\n"); + } + + this.terminal.showCursor(); + this.terminal.stop(); + } + + requestRender(force = false): void { + if (force) { + this.previousLines = []; + this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear + this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear + this.cursorRow = 0; + this.hardwareCursorRow = 0; + this.maxLinesRendered = 0; + this.previousViewportTop = 0; + if (this.renderTimer) { + clearTimeout(this.renderTimer); + this.renderTimer = undefined; + } + this.renderRequested = true; + process.nextTick(() => { + if (this.stopped || !this.renderRequested) { + return; + } + this.renderRequested = false; + this.lastRenderAt = performance.now(); + this.doRender(); + }); + return; + } + if (this.renderRequested) return; + this.renderRequested = true; + process.nextTick(() => this.scheduleRender()); + } + + private scheduleRender(): void { + if (this.stopped || this.renderTimer || !this.renderRequested) { + return; + } + const elapsed = performance.now() - this.lastRenderAt; + const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed); + this.renderTimer = setTimeout(() => { + this.renderTimer = undefined; + if (this.stopped || !this.renderRequested) { + return; + } + this.renderRequested = false; + this.lastRenderAt = performance.now(); + this.doRender(); + if (this.renderRequested) { + this.scheduleRender(); + } + }, delay); + } + + private handleInput(data: string): void { + if (this.consumeOsc11BackgroundResponse(data)) { + return; + } + if (this.consumeTerminalColorSchemeReport(data)) { + return; + } + + if (this.inputListeners.size > 0) { + let current = data; + for (const listener of this.inputListeners) { + const result = listener(current); + if (result?.consume) { + return; + } + if (result?.data !== undefined) { + current = result.data; + } + } + if (current.length === 0) { + return; + } + data = current; + } + + // Consume terminal cell size responses without blocking unrelated input. + if (this.consumeCellSizeResponse(data)) { + return; + } + + // Global debug key handler (Shift+Ctrl+D) + if (matchesKey(data, "shift+ctrl+d") && this.onDebug) { + this.onDebug(); + return; + } + + // If focused component is an overlay, verify it's still visible + // (visibility can change due to terminal resize or visible() callback) + const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent); + if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) { + // Focused overlay is no longer visible, redirect to topmost visible overlay + const topVisible = this.getTopmostVisibleOverlay(); + if (topVisible) { + this.setFocus(topVisible.component); + } else { + this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" }); + } + } + + const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent); + if (!focusIsOverlay) { + const restoreState = this.getVisibleOverlayFocusRestore(); + if (restoreState.status === "eligible") { + this.setFocus(restoreState.overlay.component); + } else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) { + if (restoreState.resume.status === "restore-overlay") { + this.setFocus(restoreState.overlay.component); + } else { + this.clearOverlayFocusRestore(); + this.setFocus(restoreState.resume.target); + } + } + } + + // Pass input to focused component (including Ctrl+C) + // The focused component can decide how to handle Ctrl+C + if (this.focusedComponent?.handleInput) { + // Filter out key release events unless component opts in + if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { + return; + } + this.focusedComponent.handleInput(data); + this.requestRender(); + } + } + + private consumeOsc11BackgroundResponse(data: string): boolean { + if (this.pendingOsc11BackgroundReplies <= 0) { + return false; + } + + if (!isOsc11BackgroundColorResponse(data)) { + return false; + } + + const rgb = parseOsc11BackgroundColor(data); + this.pendingOsc11BackgroundReplies -= 1; + const query = this.pendingOsc11BackgroundQueries.shift(); + if (query && !query.settled) { + query.settled = true; + if (query.timer) { + clearTimeout(query.timer); + query.timer = undefined; + } + query.resolve?.(rgb); + query.resolve = undefined; + } + return true; + } + + private consumeTerminalColorSchemeReport(data: string): boolean { + const scheme = parseTerminalColorSchemeReport(data); + if (!scheme) { + return false; + } + + for (const listener of this.terminalColorSchemeListeners) { + listener(scheme); + } + return true; + } + + private consumeCellSizeResponse(data: string): boolean { + // Response format: ESC [ 6 ; height ; width t + const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/); + if (!match) { + return false; + } + + const heightPx = parseInt(match[1], 10); + const widthPx = parseInt(match[2], 10); + if (heightPx <= 0 || widthPx <= 0) { + return true; + } + + setCellDimensions({ widthPx, heightPx }); + // Invalidate all components so images re-render with correct dimensions. + this.invalidate(); + this.requestRender(); + return true; + } + + /** + * Resolve overlay layout from options. + * Returns { width, row, col, maxHeight } for rendering. + */ + private resolveOverlayLayout( + options: OverlayOptions | undefined, + overlayHeight: number, + termWidth: number, + termHeight: number, + ): { width: number; row: number; col: number; maxHeight: number | undefined } { + const opt = options ?? {}; + + // Parse margin (clamp to non-negative) + const margin = + typeof opt.margin === "number" + ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin } + : (opt.margin ?? {}); + const marginTop = Math.max(0, margin.top ?? 0); + const marginRight = Math.max(0, margin.right ?? 0); + const marginBottom = Math.max(0, margin.bottom ?? 0); + const marginLeft = Math.max(0, margin.left ?? 0); + + // Available space after margins + const availWidth = Math.max(1, termWidth - marginLeft - marginRight); + const availHeight = Math.max(1, termHeight - marginTop - marginBottom); + + // === Resolve width === + let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth); + // Apply minWidth + if (opt.minWidth !== undefined) { + width = Math.max(width, opt.minWidth); + } + // Clamp to available space + width = Math.max(1, Math.min(width, availWidth)); + + // === Resolve maxHeight === + let maxHeight = parseSizeValue(opt.maxHeight, termHeight); + // Clamp to available space + if (maxHeight !== undefined) { + maxHeight = Math.max(1, Math.min(maxHeight, availHeight)); + } + + // Effective overlay height (may be clamped by maxHeight) + const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight; + + // === Resolve position === + let row: number; + let col: number; + + if (opt.row !== undefined) { + if (typeof opt.row === "string") { + // Percentage: 0% = top, 100% = bottom (overlay stays within bounds) + const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/); + if (match) { + const maxRow = Math.max(0, availHeight - effectiveHeight); + const percent = parseFloat(match[1]) / 100; + row = marginTop + Math.floor(maxRow * percent); + } else { + // Invalid format, fall back to center + row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop); + } + } else { + // Absolute row position + row = opt.row; + } + } else { + // Anchor-based (default: center) + const anchor = opt.anchor ?? "center"; + row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop); + } + + if (opt.col !== undefined) { + if (typeof opt.col === "string") { + // Percentage: 0% = left, 100% = right (overlay stays within bounds) + const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/); + if (match) { + const maxCol = Math.max(0, availWidth - width); + const percent = parseFloat(match[1]) / 100; + col = marginLeft + Math.floor(maxCol * percent); + } else { + // Invalid format, fall back to center + col = this.resolveAnchorCol("center", width, availWidth, marginLeft); + } + } else { + // Absolute column position + col = opt.col; + } + } else { + // Anchor-based (default: center) + const anchor = opt.anchor ?? "center"; + col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft); + } + + // Apply offsets + if (opt.offsetY !== undefined) row += opt.offsetY; + if (opt.offsetX !== undefined) col += opt.offsetX; + + // Clamp to terminal bounds (respecting margins) + row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight)); + col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width)); + + return { width, row, col, maxHeight }; + } + + private resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number { + switch (anchor) { + case "top-left": + case "top-center": + case "top-right": + return marginTop; + case "bottom-left": + case "bottom-center": + case "bottom-right": + return marginTop + availHeight - height; + case "left-center": + case "center": + case "right-center": + return marginTop + Math.floor((availHeight - height) / 2); + } + } + + private resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number { + switch (anchor) { + case "top-left": + case "left-center": + case "bottom-left": + return marginLeft; + case "top-right": + case "right-center": + case "bottom-right": + return marginLeft + availWidth - width; + case "top-center": + case "center": + case "bottom-center": + return marginLeft + Math.floor((availWidth - width) / 2); + } + } + + /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */ + private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { + if (this.overlayStack.length === 0) return lines; + const result = [...lines]; + + // Pre-render all visible overlays and calculate positions + const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = []; + let minLinesNeeded = result.length; + + const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e)); + visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder); + for (const entry of visibleEntries) { + const { component, options } = entry; + + // Get layout with height=0 first to determine width and maxHeight + // (width and maxHeight don't depend on overlay height) + const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight); + + // Render component at calculated width + let overlayLines = component.render(width); + + // Apply maxHeight if specified + if (maxHeight !== undefined && overlayLines.length > maxHeight) { + overlayLines = overlayLines.slice(0, maxHeight); + } + + // Get final row/col with actual overlay height + const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); + + rendered.push({ overlayLines, row, col, w: width }); + minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); + } + + // Pad to at least terminal height so overlays have screen-relative positions. + // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing + // inflation that pushed content into scrollback on terminal widen. + const workingHeight = Math.max(result.length, termHeight, minLinesNeeded); + + // Extend result with empty lines if content is too short for overlay placement or working area + while (result.length < workingHeight) { + result.push(""); + } + + const viewportStart = Math.max(0, workingHeight - termHeight); + + // Composite each overlay + for (const { overlayLines, row, col, w } of rendered) { + for (let i = 0; i < overlayLines.length; i++) { + const idx = viewportStart + row + i; + if (idx >= 0 && idx < result.length) { + // Defensive: truncate overlay line to declared width before compositing + // (components should already respect width, but this ensures it) + const truncatedOverlayLine = + visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i]; + result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth); + } + } + } + + return result; + } + + private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; + + private applyLineResets(lines: string[]): string[] { + const reset = TUI.SEGMENT_RESET; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!isImageLine(line)) { + lines[i] = normalizeTerminalOutput(line) + reset; + } + } + return lines; + } + + private collectKittyImageIds(lines: string[]): Set { + const ids = new Set(); + for (const line of lines) { + for (const id of extractKittyImageIds(line)) { + ids.add(id); + } + } + return ids; + } + + private deleteKittyImages(ids: Iterable): string { + let buffer = ""; + for (const id of ids) { + buffer += deleteKittyImage(id); + } + return buffer; + } + + private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number { + const rows = extractKittyImageRows(lines[index] ?? ""); + if (rows <= 1) return 1; + + const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index); + let reservedRows = 1; + while (reservedRows < maxRows) { + const line = lines[index + reservedRows] ?? ""; + if (isImageLine(line) || visibleWidth(line) > 0) break; + reservedRows++; + } + return reservedRows; + } + + private expandChangedRangeForKittyImages( + firstChanged: number, + lastChanged: number, + newLines: string[], + ): { firstChanged: number; lastChanged: number } { + let expandedFirstChanged = firstChanged; + let expandedLastChanged = lastChanged; + const expandForLines = (lines: string[]): void => { + for (let i = 0; i < lines.length; i++) { + if (extractKittyImageIds(lines[i]).length === 0) continue; + const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1; + if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) { + expandedFirstChanged = Math.min(expandedFirstChanged, i); + expandedLastChanged = Math.max(expandedLastChanged, blockEnd); + } + } + }; + + expandForLines(this.previousLines); + expandForLines(newLines); + return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; + } + + private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string { + if (firstChanged < 0 || lastChanged < firstChanged) return ""; + + const ids = new Set(); + const maxLine = Math.min(lastChanged, this.previousLines.length - 1); + for (let i = firstChanged; i <= maxLine; i++) { + for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) { + ids.add(id); + } + } + + return this.deleteKittyImages(ids); + } + + /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ + private compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, + ): string { + if (isImageLine(baseLine)) return baseLine; + + // Single pass through baseLine extracts both before and after segments + const afterStart = startCol + overlayWidth; + const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true); + + // Extract overlay with width tracking (strict=true to exclude wide chars at boundary) + const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true); + + // Pad segments to target widths + const beforePad = Math.max(0, startCol - base.beforeWidth); + const overlayPad = Math.max(0, overlayWidth - overlay.width); + const actualBeforeWidth = Math.max(startCol, base.beforeWidth); + const actualOverlayWidth = Math.max(overlayWidth, overlay.width); + const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth); + const afterPad = Math.max(0, afterTarget - base.afterWidth); + + // Compose result + const r = TUI.SEGMENT_RESET; + const result = + base.before + + " ".repeat(beforePad) + + r + + overlay.text + + " ".repeat(overlayPad) + + r + + base.after + + " ".repeat(afterPad); + + // CRITICAL: Always verify and truncate to terminal width. + // This is the final safeguard against width overflow which would crash the TUI. + // Width tracking can drift from actual visible width due to: + // - Complex ANSI/OSC sequences (hyperlinks, colors) + // - Wide characters at segment boundaries + // - Edge cases in segment extraction + const resultWidth = visibleWidth(result); + if (resultWidth <= totalWidth) { + return result; + } + // Truncate with strict=true to ensure we don't exceed totalWidth + return sliceByColumn(result, 0, totalWidth, true); + } + + /** + * Find and extract cursor position from rendered lines. + * Searches for CURSOR_MARKER, calculates its position, and strips it from the output. + * Only scans the bottom terminal height lines (visible viewport). + * @param lines - Rendered lines to search + * @param height - Terminal height (visible viewport size) + * @returns Cursor position { row, col } or null if no marker found + */ + private extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null { + // Only scan the bottom `height` lines (visible viewport) + const viewportTop = Math.max(0, lines.length - height); + for (let row = lines.length - 1; row >= viewportTop; row--) { + const line = lines[row]; + const markerIndex = line.indexOf(CURSOR_MARKER); + if (markerIndex !== -1) { + // Calculate visual column (width of text before marker) + const beforeMarker = line.slice(0, markerIndex); + const col = visibleWidth(beforeMarker); + + // Strip marker from the line + lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length); + + return { row, col }; + } + } + return null; + } + + private doRender(): void { + if (this.stopped) return; + const width = this.terminal.columns; + const height = this.terminal.rows; + const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; + const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; + const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height; + let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop; + let viewportTop = prevViewportTop; + let hardwareCursorRow = this.hardwareCursorRow; + const computeLineDiff = (targetRow: number): number => { + const currentScreenRow = hardwareCursorRow - prevViewportTop; + const targetScreenRow = targetRow - viewportTop; + return targetScreenRow - currentScreenRow; + }; + + // Render all components to get new lines + let newLines = this.render(width); + + // Composite overlays into the rendered lines (before differential compare) + if (this.overlayStack.length > 0) { + newLines = this.compositeOverlays(newLines, width, height); + } + + // Extract cursor position before applying line resets (marker must be found first) + const cursorPos = this.extractCursorPosition(newLines, height); + + newLines = this.applyLineResets(newLines); + + // Helper to clear scrollback and viewport and render all new lines + const fullRender = (clear: boolean): void => { + this.fullRedrawCount += 1; + let buffer = "\x1b[?2026h"; // Begin synchronized output + if (clear) { + buffer += this.deleteKittyImages(this.previousKittyImageIds); + buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback + } + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + const line = newLines[i]; + const isImage = isImageLine(line); + const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1; + if (imageReservedRows > 1 && imageReservedRows <= height) { + for (let row = 1; row < imageReservedRows; row++) { + buffer += "\r\n"; + } + buffer += `\x1b[${imageReservedRows - 1}A`; + buffer += line; + buffer += `\x1b[${imageReservedRows - 1}B`; + i += imageReservedRows - 1; + continue; + } + buffer += line; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = this.cursorRow; + // Reset max lines when clearing, otherwise track growth + if (clear) { + this.maxLinesRendered = newLines.length; + } else { + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + } + const bufferLength = Math.max(height, newLines.length); + this.previousViewportTop = Math.max(0, bufferLength - height); + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + }; + + const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; + const logRedraw = (reason: string): void => { + if (!debugRedraw) return; + const logPath = path.join(os.homedir(), ".cactus", "agent", "cactus-debug.log"); + const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`; + fs.appendFileSync(logPath, msg); + }; + + // First render - just output everything without clearing (assumes clean screen) + if (this.previousLines.length === 0 && !widthChanged && !heightChanged) { + logRedraw("first render"); + fullRender(false); + return; + } + + // Width changes always need a full re-render because wrapping changes. + if (widthChanged) { + logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`); + fullRender(true); + return; + } + + // Height changes normally need a full re-render to keep the visible viewport aligned, + // but Termux changes height when the software keyboard shows or hides. + // In that environment, a full redraw causes the entire history to replay on every toggle. + if (heightChanged && !isTermuxSession()) { + logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`); + fullRender(true); + return; + } + + // Content shrunk below the working area and no overlays - re-render to clear empty rows + // (overlays need the padding, so only do this when no overlays are active) + // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var + if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) { + logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`); + fullRender(true); + return; + } + + // Find first and last changed lines + let firstChanged = -1; + let lastChanged = -1; + const maxLines = Math.max(newLines.length, this.previousLines.length); + for (let i = 0; i < maxLines; i++) { + const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; + const newLine = i < newLines.length ? newLines[i] : ""; + + if (oldLine !== newLine) { + if (firstChanged === -1) { + firstChanged = i; + } + lastChanged = i; + } + } + const appendedLines = newLines.length > this.previousLines.length; + if (appendedLines) { + if (firstChanged === -1) { + firstChanged = this.previousLines.length; + } + lastChanged = newLines.length - 1; + } + if (firstChanged !== -1) { + const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines); + firstChanged = expandedRange.firstChanged; + lastChanged = expandedRange.lastChanged; + } + const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0; + + // No changes - but still need to update hardware cursor position if it moved + if (firstChanged === -1) { + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousViewportTop = prevViewportTop; + this.previousHeight = height; + return; + } + + // All changes are in deleted lines (nothing to render, just clear) + if (firstChanged >= newLines.length) { + if (this.previousLines.length > newLines.length) { + let buffer = "\x1b[?2026h"; + buffer += this.deleteChangedKittyImages(firstChanged, lastChanged); + // Move to end of new content (clamp to 0 for empty content) + const targetRow = Math.max(0, newLines.length - 1); + if (targetRow < prevViewportTop) { + logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`); + fullRender(true); + return; + } + const lineDiff = computeLineDiff(targetRow); + if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; + else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; + buffer += "\r"; + // Clear extra lines without scrolling + const extraLines = this.previousLines.length - newLines.length; + if (extraLines > height) { + logRedraw(`extraLines > height (${extraLines} > ${height})`); + fullRender(true); + return; + } + const clearStartOffset = newLines.length === 0 ? 0 : 1; + if (extraLines > 0 && clearStartOffset > 0) { + buffer += `\x1b[${clearStartOffset}B`; + } + for (let i = 0; i < extraLines; i++) { + buffer += "\r\x1b[2K"; + if (i < extraLines - 1) buffer += "\x1b[1B"; + } + const moveBack = Math.max(0, extraLines - 1 + clearStartOffset); + if (moveBack > 0) { + buffer += `\x1b[${moveBack}A`; + } + buffer += "\x1b[?2026l"; + this.terminal.write(buffer); + this.cursorRow = targetRow; + this.hardwareCursorRow = targetRow; + } + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + this.previousViewportTop = prevViewportTop; + return; + } + + // Differential rendering can only touch what was actually visible. + // If the first changed line is above the previous viewport, we need a full redraw. + if (firstChanged < prevViewportTop) { + logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`); + fullRender(true); + return; + } + + // Render from first changed line to end + // Build buffer with all updates wrapped in synchronized output + let buffer = "\x1b[?2026h"; // Begin synchronized output + buffer += this.deleteChangedKittyImages(firstChanged, lastChanged); + const prevViewportBottom = prevViewportTop + height - 1; + const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged; + if (moveTargetRow > prevViewportBottom) { + const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop)); + const moveToBottom = height - 1 - currentScreenRow; + if (moveToBottom > 0) { + buffer += `\x1b[${moveToBottom}B`; + } + const scroll = moveTargetRow - prevViewportBottom; + buffer += "\r\n".repeat(scroll); + prevViewportTop += scroll; + viewportTop += scroll; + hardwareCursorRow = moveTargetRow; + } + + // Move cursor to first changed line (use hardwareCursorRow for actual position) + const lineDiff = computeLineDiff(moveTargetRow); + if (lineDiff > 0) { + buffer += `\x1b[${lineDiff}B`; // Move down + } else if (lineDiff < 0) { + buffer += `\x1b[${-lineDiff}A`; // Move up + } + + buffer += appendStart ? "\r\n" : "\r"; // Move to column 0 + + // Only render changed lines (firstChanged to lastChanged), not all lines to end + // This reduces flicker when only a single line changes (e.g., spinner animation) + const renderEnd = Math.min(lastChanged, newLines.length - 1); + for (let i = firstChanged; i <= renderEnd; i++) { + if (i > firstChanged) buffer += "\r\n"; + const line = newLines[i]; + const isImage = isImageLine(line); + const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1; + if (imageReservedRows > 1) { + const imageStartScreenRow = i - viewportTop; + if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) { + logRedraw( + `kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`, + ); + fullRender(true); + return; + } + + buffer += "\x1b[2K"; + for (let row = 1; row < imageReservedRows; row++) { + buffer += "\r\n\x1b[2K"; + } + buffer += `\x1b[${imageReservedRows - 1}A`; + buffer += line; + buffer += `\x1b[${imageReservedRows - 1}B`; + i += imageReservedRows - 1; + continue; + } + + buffer += "\x1b[2K"; // Clear current line + if (!isImage && visibleWidth(line) > width) { + // Log all lines to crash file for debugging + const crashLogPath = path.join(os.homedir(), ".cactus", "agent", "cactus-crash.log"); + const crashData = [ + `Crash at ${new Date().toISOString()}`, + `Terminal width: ${width}`, + `Line ${i} visible width: ${visibleWidth(line)}`, + "", + "=== All rendered lines ===", + ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`), + "", + ].join("\n"); + fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); + fs.writeFileSync(crashLogPath, crashData); + + // Clean up terminal state before throwing + this.stop(); + + const errorMsg = [ + `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, + "", + "This is likely caused by a custom TUI component not truncating its output.", + "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", + "", + `Debug log written to: ${crashLogPath}`, + ].join("\n"); + throw new Error(errorMsg); + } + buffer += line; + } + + // Track where cursor ended up after rendering + let finalCursorRow = renderEnd; + + // If we had more lines before, clear them and move cursor back + if (this.previousLines.length > newLines.length) { + // Move to end of new content first if we stopped before it + if (renderEnd < newLines.length - 1) { + const moveDown = newLines.length - 1 - renderEnd; + buffer += `\x1b[${moveDown}B`; + finalCursorRow = newLines.length - 1; + } + const extraLines = this.previousLines.length - newLines.length; + for (let i = newLines.length; i < this.previousLines.length; i++) { + buffer += "\r\n\x1b[2K"; + } + // Move cursor back to end of new content + buffer += `\x1b[${extraLines}A`; + } + + buffer += "\x1b[?2026l"; // End synchronized output + + if (process.env.PI_TUI_DEBUG === "1") { + const debugDir = "/tmp/tui"; + fs.mkdirSync(debugDir, { recursive: true }); + const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`); + const debugData = [ + `firstChanged: ${firstChanged}`, + `viewportTop: ${viewportTop}`, + `cursorRow: ${this.cursorRow}`, + `height: ${height}`, + `lineDiff: ${lineDiff}`, + `hardwareCursorRow: ${hardwareCursorRow}`, + `renderEnd: ${renderEnd}`, + `finalCursorRow: ${finalCursorRow}`, + `cursorPos: ${JSON.stringify(cursorPos)}`, + `newLines.length: ${newLines.length}`, + `previousLines.length: ${this.previousLines.length}`, + "", + "=== newLines ===", + JSON.stringify(newLines, null, 2), + "", + "=== previousLines ===", + JSON.stringify(this.previousLines, null, 2), + "", + "=== buffer ===", + JSON.stringify(buffer), + ].join("\n"); + fs.writeFileSync(debugPath, debugData); + } + + // Write entire buffer at once + this.terminal.write(buffer); + + // Track cursor position for next render + // cursorRow tracks end of content (for viewport calculation) + // hardwareCursorRow tracks actual terminal cursor position (for movement) + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = finalCursorRow; + // Track terminal's working area (grows but doesn't shrink unless cleared) + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1); + + // Position hardware cursor for IME + this.positionHardwareCursor(cursorPos, newLines.length); + + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + } + + /** + * Position the hardware cursor for IME candidate window. + * @param cursorPos The cursor position extracted from rendered output, or null + * @param totalLines Total number of rendered lines + */ + private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void { + if (!cursorPos || totalLines <= 0) { + this.terminal.hideCursor(); + return; + } + + // Clamp cursor position to valid range + const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1)); + const targetCol = Math.max(0, cursorPos.col); + + // Move cursor from current position to target + const rowDelta = targetRow - this.hardwareCursorRow; + let buffer = ""; + if (rowDelta > 0) { + buffer += `\x1b[${rowDelta}B`; // Move down + } else if (rowDelta < 0) { + buffer += `\x1b[${-rowDelta}A`; // Move up + } + // Move to absolute column (1-indexed) + buffer += `\x1b[${targetCol + 1}G`; + + if (buffer) { + this.terminal.write(buffer); + } + + this.hardwareCursorRow = targetRow; + if (this.showHardwareCursor) { + this.terminal.showCursor(); + } else { + this.terminal.hideCursor(); + } + } + + /** + * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`). + * @param timeoutMs Query timeout in milliseconds. + * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse. + */ + queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise { + return new Promise((resolve) => { + const query: PendingOsc11BackgroundQuery = { + settled: false, + resolve, + timer: undefined, + }; + + query.timer = setTimeout(() => { + if (query.settled) { + return; + } + query.settled = true; + query.timer = undefined; + query.resolve?.(undefined); + query.resolve = undefined; + }, timeoutMs); + this.pendingOsc11BackgroundQueries.push(query); + this.pendingOsc11BackgroundReplies += 1; + this.terminal.write("\x1b]11;?\x07"); + }); + } + + /** + * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`). + * Terminals that support the color palette notification protocol reply with + * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light. + */ + queryTerminalColorScheme({ timeoutMs }: { timeoutMs: number }): Promise { + return new Promise((resolve) => { + let settled = false; + let timer: NodeJS.Timeout | undefined; + let unsubscribe: () => void = () => {}; + const settle = (scheme: TerminalColorScheme | undefined) => { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + unsubscribe(); + resolve(scheme); + }; + + unsubscribe = this.onTerminalColorSchemeChange(settle); + timer = setTimeout(() => settle(undefined), timeoutMs); + this.terminal.write("\x1b[?996n"); + }); + } +} diff --git a/cactus-code/packages/tui/src/undo-stack.ts b/cactus-code/packages/tui/src/undo-stack.ts new file mode 100644 index 000000000..5b9a7e9ce --- /dev/null +++ b/cactus-code/packages/tui/src/undo-stack.ts @@ -0,0 +1,28 @@ +/** + * Generic undo stack with clone-on-push semantics. + * + * Stores deep clones of state snapshots. Popped snapshots are returned + * directly (no re-cloning) since they are already detached. + */ +export class UndoStack { + private stack: S[] = []; + + /** Push a deep clone of the given state onto the stack. */ + push(state: S): void { + this.stack.push(structuredClone(state)); + } + + /** Pop and return the most recent snapshot, or undefined if empty. */ + pop(): S | undefined { + return this.stack.pop(); + } + + /** Remove all snapshots. */ + clear(): void { + this.stack.length = 0; + } + + get length(): number { + return this.stack.length; + } +} diff --git a/cactus-code/packages/tui/src/utils.ts b/cactus-code/packages/tui/src/utils.ts new file mode 100644 index 000000000..b074a3a24 --- /dev/null +++ b/cactus-code/packages/tui/src/utils.ts @@ -0,0 +1,1188 @@ +import { eastAsianWidth } from "get-east-asian-width"; + +// segmenters (shared instance) +const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" }); + +/** + * Get the shared grapheme segmenter instance. + */ +export function getGraphemeSegmenter(): Intl.Segmenter { + return graphemeSegmenter; +} + +/** + * Get the shared word segmenter instance. + */ +export function getWordSegmenter(): Intl.Segmenter { + return wordSegmenter; +} + +/** + * Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji. + * This is a fast heuristic to avoid the expensive rgiEmojiRegex test. + * The tested Unicode blocks are deliberately broad to account for future + * Unicode additions. + */ +function couldBeEmoji(segment: string): boolean { + const cp = segment.codePointAt(0)!; + return ( + (cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph + (cp >= 0x2300 && cp <= 0x23ff) || // Misc technical + (cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats + (cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles + segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector) + segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.) + ); +} + +// Regexes for character classification (same as string-width library) +const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v; +const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; +const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; + +// Cache for non-ASCII strings +const WIDTH_CACHE_SIZE = 512; +const widthCache = new Map(); + +export const cjkBreakRegex = + /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u; + +function isPrintableAscii(str: string): boolean { + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + return false; + } + } + return true; +} + +function truncateFragmentToWidth(text: string, maxWidth: number): { text: string; width: number } { + if (maxWidth <= 0 || text.length === 0) { + return { text: "", width: 0 }; + } + + if (isPrintableAscii(text)) { + const clipped = text.slice(0, maxWidth); + return { text: clipped, width: clipped.length }; + } + + const hasAnsi = text.includes("\x1b"); + const hasTabs = text.includes("\t"); + if (!hasAnsi && !hasTabs) { + let result = ""; + let width = 0; + for (const { segment } of graphemeSegmenter.segment(text)) { + const w = graphemeWidth(segment); + if (width + w > maxWidth) { + break; + } + result += segment; + width += w; + } + return { text: result, width }; + } + + let result = ""; + let width = 0; + let i = 0; + let pendingAnsi = ""; + + while (i < text.length) { + const ansi = extractAnsiCode(text, i); + if (ansi) { + pendingAnsi += ansi.code; + i += ansi.length; + continue; + } + + if (text[i] === "\t") { + if (width + 3 > maxWidth) { + break; + } + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += "\t"; + width += 3; + i++; + continue; + } + + let end = i; + while (end < text.length && text[end] !== "\t") { + const nextAnsi = extractAnsiCode(text, end); + if (nextAnsi) { + break; + } + end++; + } + + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const w = graphemeWidth(segment); + if (width + w > maxWidth) { + return { text: result, width }; + } + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += segment; + width += w; + } + i = end; + } + + return { text: result, width }; +} + +function finalizeTruncatedResult( + prefix: string, + prefixWidth: number, + ellipsis: string, + ellipsisWidth: number, + maxWidth: number, + pad: boolean, +): string { + const reset = "\x1b[0m"; + const visibleWidth = prefixWidth + ellipsisWidth; + let result: string; + + if (ellipsis.length > 0) { + result = `${prefix}${reset}${ellipsis}${reset}`; + } else { + result = `${prefix}${reset}`; + } + + return pad ? result + " ".repeat(Math.max(0, maxWidth - visibleWidth)) : result; +} + +/** + * Calculate the terminal width of a single grapheme cluster. + * Based on code from the string-width library, but includes a possible-emoji + * check to avoid running the RGI_Emoji regex unnecessarily. + */ +function graphemeWidth(segment: string): number { + if (segment === "\t") { + return 3; + } + + // Zero-width clusters + if (zeroWidthRegex.test(segment)) { + return 0; + } + + // Emoji check with pre-filter + if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) { + return 2; + } + + // Get base visible codepoint + const base = segment.replace(leadingNonPrintingRegex, ""); + const cp = base.codePointAt(0); + if (cp === undefined) { + return 0; + } + + // Regional indicator symbols (U+1F1E6..U+1F1FF) are often rendered as + // full-width emoji in terminals, even when isolated during streaming. + // Keep width conservative (2) to avoid terminal auto-wrap drift artifacts. + if (cp >= 0x1f1e6 && cp <= 0x1f1ff) { + return 2; + } + + let width = eastAsianWidth(cp); + + // Trailing halfwidth/fullwidth forms and AM vowels that segment with a base. + if (segment.length > 1) { + for (const char of segment.slice(1)) { + const c = char.codePointAt(0)!; + if (c >= 0xff00 && c <= 0xffef) { + width += eastAsianWidth(c); + } else if (c === 0x0e33 || c === 0x0eb3) { + width += 1; + } + } + } + + return width; +} + +/** + * Calculate the visible width of a string in terminal columns. + */ +export function visibleWidth(str: string): number { + if (str.length === 0) { + return 0; + } + + // Fast path: pure ASCII printable + if (isPrintableAscii(str)) { + return str.length; + } + + // Check cache + const cached = widthCache.get(str); + if (cached !== undefined) { + return cached; + } + + // Normalize: tabs to 3 spaces, strip ANSI escape codes + let clean = str; + if (str.includes("\t")) { + clean = clean.replace(/\t/g, " "); + } + if (clean.includes("\x1b")) { + // Strip supported ANSI/OSC/APC escape sequences in one pass. + // This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers, + // and APC sequences like CURSOR_MARKER. + let stripped = ""; + let i = 0; + while (i < clean.length) { + const ansi = extractAnsiCode(clean, i); + if (ansi) { + i += ansi.length; + continue; + } + stripped += clean[i]; + i++; + } + clean = stripped; + } + + // Calculate width + let width = 0; + for (const { segment } of graphemeSegmenter.segment(clean)) { + width += graphemeWidth(segment); + } + + // Cache result + if (widthCache.size >= WIDTH_CACHE_SIZE) { + const firstKey = widthCache.keys().next().value; + if (firstKey !== undefined) { + widthCache.delete(firstKey); + } + } + widthCache.set(str, width); + + return width; +} + +/** + * Normalize text for terminal output without changing logical editor content. + * Some terminals render precomposed Thai/Lao AM vowels inconsistently during + * differential repaint. Their compatibility decompositions have the same cell + * width but avoid stale-cell artifacts in terminal renderers. + */ +const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/; +const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g; + +export function normalizeTerminalOutput(str: string): string { + if (!THAI_LAO_AM_REGEX.test(str)) return str; + return str.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2")); +} + +/** + * Extract ANSI escape sequences from a string at the given position. + */ +export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null { + if (pos >= str.length || str[pos] !== "\x1b") return null; + + const next = str[pos + 1]; + + // CSI sequence: ESC [ ... m/G/K/H/J + if (next === "[") { + let j = pos + 2; + while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++; + if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos }; + return null; + } + + // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \) + // Used for hyperlinks (OSC 8), window titles, etc. + if (next === "]") { + let j = pos + 2; + while (j < str.length) { + if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos }; + if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos }; + j++; + } + return null; + } + + // APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \) + // Used for cursor marker and application-specific commands + if (next === "_") { + let j = pos + 2; + while (j < str.length) { + if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos }; + if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos }; + j++; + } + return null; + } + + return null; +} + +type Osc8Terminator = "\x07" | "\x1b\\"; + +interface ActiveHyperlink { + params: string; + url: string; + terminator: Osc8Terminator; +} + +function parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined { + if (!ansiCode.startsWith("\x1b]8;")) { + return undefined; + } + + const terminator: Osc8Terminator = ansiCode.endsWith("\x07") ? "\x07" : "\x1b\\"; + const body = ansiCode.slice(4, terminator === "\x07" ? -1 : -2); + const separatorIndex = body.indexOf(";"); + if (separatorIndex === -1) { + return undefined; + } + + const params = body.slice(0, separatorIndex); + const url = body.slice(separatorIndex + 1); + if (!url) { + return null; + } + return { params, url, terminator }; +} + +function formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string { + return `\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`; +} + +function formatOsc8Close(terminator: Osc8Terminator): string { + return `\x1b]8;;${terminator}`; +} + +/** + * Track active ANSI SGR codes to preserve styling across line breaks. + */ +class AnsiCodeTracker { + // Track individual attributes separately so we can reset them specifically + private bold = false; + private dim = false; + private italic = false; + private underline = false; + private blink = false; + private inverse = false; + private hidden = false; + private strikethrough = false; + private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240" + private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240" + private activeHyperlink: ActiveHyperlink | null = null; + + process(ansiCode: string): void { + // OSC 8 hyperlink: \x1b]8;;\x1b\\ (open) or \x1b]8;;\x1b\\ (close). + // Preserve the original terminator because some terminals only make BEL-terminated + // links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST + // made only the first physical line clickable in those terminals. + const hyperlink = parseOsc8Hyperlink(ansiCode); + if (hyperlink !== undefined) { + this.activeHyperlink = hyperlink; + return; + } + + if (!ansiCode.endsWith("m")) { + return; + } + + // Extract the parameters between \x1b[ and m + const match = ansiCode.match(/\x1b\[([\d;]*)m/); + if (!match) return; + + const params = match[1]; + if (params === "" || params === "0") { + // Full reset + this.reset(); + return; + } + + // Parse parameters (can be semicolon-separated) + const parts = params.split(";"); + let i = 0; + while (i < parts.length) { + const code = Number.parseInt(parts[i], 10); + + // Handle 256-color and RGB codes which consume multiple parameters + if (code === 38 || code === 48) { + // 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg) + // 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg) + if (parts[i + 1] === "5" && parts[i + 2] !== undefined) { + // 256 color: 38;5;N or 48;5;N + const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`; + if (code === 38) { + this.fgColor = colorCode; + } else { + this.bgColor = colorCode; + } + i += 3; + continue; + } else if (parts[i + 1] === "2" && parts[i + 4] !== undefined) { + // RGB color: 38;2;R;G;B or 48;2;R;G;B + const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`; + if (code === 38) { + this.fgColor = colorCode; + } else { + this.bgColor = colorCode; + } + i += 5; + continue; + } + } + + // Standard SGR codes + switch (code) { + case 0: + this.reset(); + break; + case 1: + this.bold = true; + break; + case 2: + this.dim = true; + break; + case 3: + this.italic = true; + break; + case 4: + this.underline = true; + break; + case 5: + this.blink = true; + break; + case 7: + this.inverse = true; + break; + case 8: + this.hidden = true; + break; + case 9: + this.strikethrough = true; + break; + case 21: + this.bold = false; + break; // Some terminals + case 22: + this.bold = false; + this.dim = false; + break; + case 23: + this.italic = false; + break; + case 24: + this.underline = false; + break; + case 25: + this.blink = false; + break; + case 27: + this.inverse = false; + break; + case 28: + this.hidden = false; + break; + case 29: + this.strikethrough = false; + break; + case 39: + this.fgColor = null; + break; // Default fg + case 49: + this.bgColor = null; + break; // Default bg + default: + // Standard foreground colors 30-37, 90-97 + if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { + this.fgColor = String(code); + } + // Standard background colors 40-47, 100-107 + else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + this.bgColor = String(code); + } + break; + } + i++; + } + } + + private reset(): void { + this.bold = false; + this.dim = false; + this.italic = false; + this.underline = false; + this.blink = false; + this.inverse = false; + this.hidden = false; + this.strikethrough = false; + this.fgColor = null; + this.bgColor = null; + // SGR reset does not affect OSC 8 hyperlink state + } + + /** Clear all state for reuse. */ + clear(): void { + this.reset(); + this.activeHyperlink = null; + } + + getActiveCodes(): string { + const codes: string[] = []; + if (this.bold) codes.push("1"); + if (this.dim) codes.push("2"); + if (this.italic) codes.push("3"); + if (this.underline) codes.push("4"); + if (this.blink) codes.push("5"); + if (this.inverse) codes.push("7"); + if (this.hidden) codes.push("8"); + if (this.strikethrough) codes.push("9"); + if (this.fgColor) codes.push(this.fgColor); + if (this.bgColor) codes.push(this.bgColor); + + let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : ""; + if (this.activeHyperlink) { + result += formatOsc8Hyperlink(this.activeHyperlink); + } + return result; + } + + hasActiveCodes(): boolean { + return ( + this.bold || + this.dim || + this.italic || + this.underline || + this.blink || + this.inverse || + this.hidden || + this.strikethrough || + this.fgColor !== null || + this.bgColor !== null || + this.activeHyperlink !== null + ); + } + + /** + * Get reset codes for attributes that need to be turned off at line end. + * Underline must be closed to prevent bleeding into padding. + * Active OSC 8 hyperlinks must be closed and re-opened on the next line. + * Returns empty string if no attributes need closing. + */ + getLineEndReset(): string { + let result = ""; + if (this.underline) { + result += "\x1b[24m"; // Underline off only + } + if (this.activeHyperlink) { + result += formatOsc8Close(this.activeHyperlink.terminator); // Re-opened at line start via getActiveCodes() + } + return result; + } +} + +function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void { + let i = 0; + while (i < text.length) { + const ansiResult = extractAnsiCode(text, i); + if (ansiResult) { + tracker.process(ansiResult.code); + i += ansiResult.length; + } else { + i++; + } + } +} + +/** + * Split text into words while keeping ANSI codes attached. + */ +function splitIntoTokensWithAnsi(text: string): string[] { + const tokens: string[] = []; + let current = ""; + let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content + let currentKind: "space" | "word" | null = null; + let i = 0; + + const flushCurrent = (): void => { + if (!current) { + return; + } + tokens.push(current); + current = ""; + currentKind = null; + }; + + while (i < text.length) { + const ansiResult = extractAnsiCode(text, i); + if (ansiResult) { + // Hold ANSI codes separately - they'll be attached to the next visible char + pendingAnsi += ansiResult.code; + i += ansiResult.length; + continue; + } + + let end = i; + while (end < text.length && !extractAnsiCode(text, end)) { + end++; + } + + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const segmentIsSpace = segment === " "; + if (!segmentIsSpace && cjkBreakRegex.test(segment)) { + flushCurrent(); + const token = pendingAnsi + segment; + pendingAnsi = ""; + tokens.push(token); + continue; + } + + const segmentKind = segmentIsSpace ? "space" : "word"; + if (current && currentKind !== segmentKind) { + flushCurrent(); + } + + // Attach any pending ANSI codes to this visible character + if (pendingAnsi) { + current += pendingAnsi; + pendingAnsi = ""; + } + + currentKind = segmentKind; + current += segment; + } + + i = end; + } + + // Handle any remaining pending ANSI codes (attach to last token) + if (pendingAnsi) { + if (current) { + current += pendingAnsi; + } else if (tokens.length > 0) { + tokens[tokens.length - 1] += pendingAnsi; + } else { + current = pendingAnsi; + } + } + + if (current) { + tokens.push(current); + } + + return tokens; +} + +/** + * Wrap text with ANSI codes preserved. + * + * ONLY does word wrapping - NO padding, NO background colors. + * Returns lines where each line is <= width visible chars. + * Active ANSI codes are preserved across line breaks. + * + * @param text - Text to wrap (may contain ANSI codes and newlines) + * @param width - Maximum visible width per line + * @returns Array of wrapped lines (NOT padded to width) + */ +export function wrapTextWithAnsi(text: string, width: number): string[] { + if (!text) { + return [""]; + } + + // Handle newlines by processing each line separately + // Track ANSI state across lines so styles carry over after literal newlines + const inputLines = text.split("\n"); + const result: string[] = []; + const tracker = new AnsiCodeTracker(); + + for (const inputLine of inputLines) { + // Prepend active ANSI codes from previous lines (except for first line) + const prefix = result.length > 0 ? tracker.getActiveCodes() : ""; + const wrappedLines = wrapSingleLine(prefix + inputLine, width); + for (const wrappedLine of wrappedLines) { + result.push(wrappedLine); + } + // Update tracker with codes from this line for next iteration + updateTrackerFromText(inputLine, tracker); + } + + return result.length > 0 ? result : [""]; +} + +function wrapSingleLine(line: string, width: number): string[] { + if (!line) { + return [""]; + } + + const visibleLength = visibleWidth(line); + if (visibleLength <= width) { + return [line]; + } + + const wrapped: string[] = []; + const tracker = new AnsiCodeTracker(); + const tokens = splitIntoTokensWithAnsi(line); + + let currentLine = ""; + let currentVisibleLength = 0; + + for (const token of tokens) { + const tokenVisibleLength = visibleWidth(token); + const isWhitespace = token.trim() === ""; + + // Token itself is too long - break it character by character + if (tokenVisibleLength > width && !isWhitespace) { + if (currentLine) { + // Add specific reset for underline only (preserves background) + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + currentLine += lineEndReset; + } + wrapped.push(currentLine); + currentLine = ""; + currentVisibleLength = 0; + } + + // Break long token - breakLongWord handles its own resets + const broken = breakLongWord(token, width, tracker); + for (let i = 0; i < broken.length - 1; i++) { + wrapped.push(broken[i]!); + } + currentLine = broken[broken.length - 1]; + currentVisibleLength = visibleWidth(currentLine); + continue; + } + + // Check if adding this token would exceed width + const totalNeeded = currentVisibleLength + tokenVisibleLength; + + if (totalNeeded > width && currentVisibleLength > 0) { + // Trim trailing whitespace, then add underline reset (not full reset, to preserve background) + let lineToWrap = currentLine.trimEnd(); + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + lineToWrap += lineEndReset; + } + wrapped.push(lineToWrap); + if (isWhitespace) { + // Don't start new line with whitespace + currentLine = tracker.getActiveCodes(); + currentVisibleLength = 0; + } else { + currentLine = tracker.getActiveCodes() + token; + currentVisibleLength = tokenVisibleLength; + } + } else { + // Add to current line + currentLine += token; + currentVisibleLength += tokenVisibleLength; + } + + updateTrackerFromText(token, tracker); + } + + if (currentLine) { + // No reset at end of final line - let caller handle it + wrapped.push(currentLine); + } + + // Trailing whitespace can cause lines to exceed the requested width + return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""]; +} + +export const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/; + +/** + * Check if a character is whitespace. + */ +export function isWhitespaceChar(char: string): boolean { + return /\s/.test(char); +} + +/** + * Check if a character is punctuation. + */ +export function isPunctuationChar(char: string): boolean { + return PUNCTUATION_REGEX.test(char); +} + +function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): string[] { + const lines: string[] = []; + let currentLine = tracker.getActiveCodes(); + let currentWidth = 0; + + // First, separate ANSI codes from visible content + // We need to handle ANSI codes specially since they're not graphemes + let i = 0; + const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = []; + + while (i < word.length) { + const ansiResult = extractAnsiCode(word, i); + if (ansiResult) { + segments.push({ type: "ansi", value: ansiResult.code }); + i += ansiResult.length; + } else { + // Find the next ANSI code or end of string + let end = i; + while (end < word.length) { + const nextAnsi = extractAnsiCode(word, end); + if (nextAnsi) break; + end++; + } + // Segment this non-ANSI portion into graphemes + const textPortion = word.slice(i, end); + for (const seg of graphemeSegmenter.segment(textPortion)) { + segments.push({ type: "grapheme", value: seg.segment }); + } + i = end; + } + } + + // Now process segments + for (const seg of segments) { + if (seg.type === "ansi") { + currentLine += seg.value; + tracker.process(seg.value); + continue; + } + + const grapheme = seg.value; + // Skip empty graphemes to avoid issues with string-width calculation + if (!grapheme) continue; + + const graphemeWidth = visibleWidth(grapheme); + + if (currentWidth + graphemeWidth > width) { + // Add specific reset for underline only (preserves background) + const lineEndReset = tracker.getLineEndReset(); + if (lineEndReset) { + currentLine += lineEndReset; + } + lines.push(currentLine); + currentLine = tracker.getActiveCodes(); + currentWidth = 0; + } + + currentLine += grapheme; + currentWidth += graphemeWidth; + } + + if (currentLine) { + // No reset at end of final segment - caller handles continuation + lines.push(currentLine); + } + + return lines.length > 0 ? lines : [""]; +} + +/** + * Apply background color to a line, padding to full width. + * + * @param line - Line of text (may contain ANSI codes) + * @param width - Total width to pad to + * @param bgFn - Background color function + * @returns Line with background applied and padded to width + */ +export function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string { + // Calculate padding needed + const visibleLen = visibleWidth(line); + const paddingNeeded = Math.max(0, width - visibleLen); + const padding = " ".repeat(paddingNeeded); + + // Apply background to content + padding + const withPadding = line + padding; + return bgFn(withPadding); +} + +/** + * Truncate text to fit within a maximum visible width, adding ellipsis if needed. + * Optionally pad with spaces to reach exactly maxWidth. + * Properly handles ANSI escape codes (they don't count toward width). + * + * @param text - Text to truncate (may contain ANSI codes) + * @param maxWidth - Maximum visible width + * @param ellipsis - Ellipsis string to append when truncating (default: "...") + * @param pad - If true, pad result with spaces to exactly maxWidth (default: false) + * @returns Truncated text, optionally padded to exactly maxWidth + */ +export function truncateToWidth( + text: string, + maxWidth: number, + ellipsis: string = "...", + pad: boolean = false, +): string { + if (maxWidth <= 0) { + return ""; + } + + if (text.length === 0) { + return pad ? " ".repeat(maxWidth) : ""; + } + + const ellipsisWidth = visibleWidth(ellipsis); + if (ellipsisWidth >= maxWidth) { + const textWidth = visibleWidth(text); + if (textWidth <= maxWidth) { + return pad ? text + " ".repeat(maxWidth - textWidth) : text; + } + + const clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth); + if (clippedEllipsis.width === 0) { + return pad ? " ".repeat(maxWidth) : ""; + } + return finalizeTruncatedResult("", 0, clippedEllipsis.text, clippedEllipsis.width, maxWidth, pad); + } + + if (isPrintableAscii(text)) { + if (text.length <= maxWidth) { + return pad ? text + " ".repeat(maxWidth - text.length) : text; + } + const targetWidth = maxWidth - ellipsisWidth; + return finalizeTruncatedResult(text.slice(0, targetWidth), targetWidth, ellipsis, ellipsisWidth, maxWidth, pad); + } + + const targetWidth = maxWidth - ellipsisWidth; + let result = ""; + let pendingAnsi = ""; + let visibleSoFar = 0; + let keptWidth = 0; + let keepContiguousPrefix = true; + let overflowed = false; + let exhaustedInput = false; + const hasAnsi = text.includes("\x1b"); + const hasTabs = text.includes("\t"); + + if (!hasAnsi && !hasTabs) { + for (const { segment } of graphemeSegmenter.segment(text)) { + const width = graphemeWidth(segment); + if (keepContiguousPrefix && keptWidth + width <= targetWidth) { + result += segment; + keptWidth += width; + } else { + keepContiguousPrefix = false; + } + visibleSoFar += width; + if (visibleSoFar > maxWidth) { + overflowed = true; + break; + } + } + exhaustedInput = !overflowed; + } else { + let i = 0; + while (i < text.length) { + const ansi = extractAnsiCode(text, i); + if (ansi) { + pendingAnsi += ansi.code; + i += ansi.length; + continue; + } + + if (text[i] === "\t") { + if (keepContiguousPrefix && keptWidth + 3 <= targetWidth) { + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += "\t"; + keptWidth += 3; + } else { + keepContiguousPrefix = false; + pendingAnsi = ""; + } + visibleSoFar += 3; + if (visibleSoFar > maxWidth) { + overflowed = true; + break; + } + i++; + continue; + } + + let end = i; + while (end < text.length && text[end] !== "\t") { + const nextAnsi = extractAnsiCode(text, end); + if (nextAnsi) { + break; + } + end++; + } + + for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) { + const width = graphemeWidth(segment); + if (keepContiguousPrefix && keptWidth + width <= targetWidth) { + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += segment; + keptWidth += width; + } else { + keepContiguousPrefix = false; + pendingAnsi = ""; + } + + visibleSoFar += width; + if (visibleSoFar > maxWidth) { + overflowed = true; + break; + } + } + if (overflowed) { + break; + } + i = end; + } + exhaustedInput = i >= text.length; + } + + if (!overflowed && exhaustedInput) { + return pad ? text + " ".repeat(Math.max(0, maxWidth - visibleSoFar)) : text; + } + + return finalizeTruncatedResult(result, keptWidth, ellipsis, ellipsisWidth, maxWidth, pad); +} + +/** + * Extract a range of visible columns from a line. Handles ANSI codes and wide chars. + * @param strict - If true, exclude wide chars at boundary that would extend past the range + */ +export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string { + return sliceWithWidth(line, startCol, length, strict).text; +} + +/** Like sliceByColumn but also returns the actual visible width of the result. */ +export function sliceWithWidth( + line: string, + startCol: number, + length: number, + strict = false, +): { text: string; width: number } { + if (length <= 0) return { text: "", width: 0 }; + const endCol = startCol + length; + let result = "", + resultWidth = 0, + currentCol = 0, + i = 0, + pendingAnsi = ""; + + while (i < line.length) { + const ansi = extractAnsiCode(line, i); + if (ansi) { + if (currentCol >= startCol && currentCol < endCol) result += ansi.code; + else if (currentCol < startCol) pendingAnsi += ansi.code; + i += ansi.length; + continue; + } + + let textEnd = i; + while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; + + for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { + const w = graphemeWidth(segment); + const inRange = currentCol >= startCol && currentCol < endCol; + const fits = !strict || currentCol + w <= endCol; + if (inRange && fits) { + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += segment; + resultWidth += w; + } + currentCol += w; + if (currentCol >= endCol) break; + } + i = textEnd; + if (currentCol >= endCol) break; + } + return { text: result, width: resultWidth }; +} + +// Pooled tracker instance for extractSegments (avoids allocation per call) +const pooledStyleTracker = new AnsiCodeTracker(); + +/** + * Extract "before" and "after" segments from a line in a single pass. + * Used for overlay compositing where we need content before and after the overlay region. + * Preserves styling from before the overlay that should affect content after it. + */ +export function extractSegments( + line: string, + beforeEnd: number, + afterStart: number, + afterLen: number, + strictAfter = false, +): { before: string; beforeWidth: number; after: string; afterWidth: number } { + let before = "", + beforeWidth = 0, + after = "", + afterWidth = 0; + let currentCol = 0, + i = 0; + let pendingAnsiBefore = ""; + let afterStarted = false; + const afterEnd = afterStart + afterLen; + + // Track styling state so "after" inherits styling from before the overlay + pooledStyleTracker.clear(); + + while (i < line.length) { + const ansi = extractAnsiCode(line, i); + if (ansi) { + // Track all SGR codes to know styling state at afterStart + pooledStyleTracker.process(ansi.code); + // Include ANSI codes in their respective segments + if (currentCol < beforeEnd) { + pendingAnsiBefore += ansi.code; + } else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) { + // Only include after we've started "after" (styling already prepended) + after += ansi.code; + } + i += ansi.length; + continue; + } + + let textEnd = i; + while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; + + for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { + const w = graphemeWidth(segment); + + if (currentCol < beforeEnd && currentCol + w <= beforeEnd) { + if (pendingAnsiBefore) { + before += pendingAnsiBefore; + pendingAnsiBefore = ""; + } + before += segment; + beforeWidth += w; + } else if (currentCol >= afterStart && currentCol < afterEnd) { + const fits = !strictAfter || currentCol + w <= afterEnd; + if (fits) { + // On first "after" grapheme, prepend inherited styling from before overlay + if (!afterStarted) { + after += pooledStyleTracker.getActiveCodes(); + afterStarted = true; + } + after += segment; + afterWidth += w; + } + } + + currentCol += w; + // Early exit: done with "before" only, or done with both segments + if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break; + } + i = textEnd; + if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break; + } + + return { before, beforeWidth, after, afterWidth }; +} diff --git a/cactus-code/packages/tui/src/word-navigation.ts b/cactus-code/packages/tui/src/word-navigation.ts new file mode 100644 index 000000000..7c7eced2b --- /dev/null +++ b/cactus-code/packages/tui/src/word-navigation.ts @@ -0,0 +1,117 @@ +import { getWordSegmenter, isWhitespaceChar, PUNCTUATION_REGEX } from "./utils.ts"; + +const wordSegmenter = getWordSegmenter(); + +/** + * Options for word navigation functions. + * When omitted, uses the default Intl.Segmenter word segmentation. + */ +export interface WordNavigationOptions { + /** Custom segmenter returning word segments for the given text. */ + segment?: (text: string) => Iterable; + /** Predicate identifying atomic segments that should be treated as single units (e.g. paste markers). */ + isAtomicSegment?: (segment: string) => boolean; +} + +/** + * Find the cursor position after moving one word backward from `cursor` in `text`. + * Skips trailing whitespace, then stops at the next word/punctuation boundary. + * + * Pure function - does not mutate any state. + */ +export function findWordBackward(text: string, cursor: number, options?: WordNavigationOptions): number { + if (cursor <= 0) return 0; + + const textBeforeCursor = text.slice(0, cursor); + const segmentFn = options?.segment; + const isAtomic = options?.isAtomicSegment; + const segments = segmentFn ? [...segmentFn(textBeforeCursor)] : [...wordSegmenter.segment(textBeforeCursor)]; + let newCursor = cursor; + + // Skip trailing whitespace + while ( + segments.length > 0 && + !isAtomic?.(segments[segments.length - 1]?.segment || "") && + isWhitespaceChar(segments[segments.length - 1]?.segment || "") + ) { + newCursor -= segments.pop()?.segment.length || 0; + } + + if (segments.length === 0) return newCursor; + + const last = segments[segments.length - 1]!; + + if (isAtomic?.(last.segment)) { + // Skip one atomic segment. + newCursor -= last.segment.length; + } else if (last.isWordLike) { + // Skip inside one word-like segment, preserving ASCII punctuation boundaries. + const segment = last.segment; + const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, "g"))]; + if (matches.length <= 0) { + newCursor -= segment.length; + } else { + const lastMatch = matches[matches.length - 1]!; + newCursor -= segment.length - (lastMatch.index + lastMatch[0].length); + } + } else { + // Skip non-word non-whitespace run (punctuation) + while ( + segments.length > 0 && + !isAtomic?.(segments[segments.length - 1]?.segment || "") && + !segments[segments.length - 1]?.isWordLike && + !isWhitespaceChar(segments[segments.length - 1]?.segment || "") + ) { + newCursor -= segments.pop()?.segment.length || 0; + } + } + + return newCursor; +} + +/** + * Find the cursor position after moving one word forward from `cursor` in `text`. + * Skips leading whitespace, then stops at the next word/punctuation boundary. + * + * Pure function - does not mutate any state. + */ +export function findWordForward(text: string, cursor: number, options?: WordNavigationOptions): number { + if (cursor >= text.length) return text.length; + + const textAfterCursor = text.slice(cursor); + const segmentFn = options?.segment; + const isAtomic = options?.isAtomicSegment; + const segments = segmentFn ? segmentFn(textAfterCursor) : wordSegmenter.segment(textAfterCursor); + const iterator = segments[Symbol.iterator](); + let next = iterator.next(); + let newCursor = cursor; + + // Skip leading whitespace + while (!next.done && !isAtomic?.(next.value.segment) && isWhitespaceChar(next.value.segment)) { + newCursor += next.value.segment.length; + next = iterator.next(); + } + + if (next.done) return newCursor; + + if (isAtomic?.(next.value.segment)) { + // Skip one atomic segment. + newCursor += next.value.segment.length; + } else if (next.value.isWordLike) { + // Skip inside one word-like segment, preserving ASCII punctuation boundaries. + newCursor += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length; + } else { + // Skip non-word non-whitespace run (punctuation) + while ( + !next.done && + !isAtomic?.(next.value.segment) && + !next.value.isWordLike && + !isWhitespaceChar(next.value.segment) + ) { + newCursor += next.value.segment.length; + next = iterator.next(); + } + } + + return newCursor; +} diff --git a/cactus-code/packages/tui/tsconfig.build.json b/cactus-code/packages/tui/tsconfig.build.json new file mode 100644 index 000000000..5ce430296 --- /dev/null +++ b/cactus-code/packages/tui/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/cactus-code/scripts/bundle-for-pypi.sh b/cactus-code/scripts/bundle-for-pypi.sh new file mode 100644 index 000000000..4455b2986 --- /dev/null +++ b/cactus-code/scripts/bundle-for-pypi.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Bundle the built Cactus coding agent into the Python package so it ships in the +# pypi wheel and `cactus code` works after `pip install cactus-compute`. +# +# Produces a self-contained payload at python/cactus/code/ with the four built +# packages plus a production-only node_modules (workspace packages copied in via +# --install-links, so it's portable — no symlinks back into the repo). +# +# Requires: node >=22, npm. Run from anywhere. +set -euo pipefail + +CC_ROOT="$(cd "$(dirname "$0")/.." && pwd)" # cactus-code/ +OUT="$(cd "$CC_ROOT/.." && pwd)/python/cactus/code" # python/cactus/code/ + +echo "==> Building cactus-code" +cd "$CC_ROOT" +npm run build + +echo "==> Staging payload at $OUT" +rm -rf "$OUT" +mkdir -p "$OUT/packages" +for p in ai agent tui coding-agent; do + mkdir -p "$OUT/packages/$p" + cp -R "packages/$p/dist" "$OUT/packages/$p/dist" + cp "packages/$p/package.json" "$OUT/packages/$p/package.json" +done +# tui ships prebuilt native bindings (not part of dist); copy them so the TUI +# loads its key-modifier addon after a pip install. +if [ -d "packages/tui/native" ]; then + mkdir -p "$OUT/packages/tui/native" + cp -R "packages/tui/native" "$OUT/packages/tui/" +fi +cp package.json "$OUT/package.json" +[ -f package-lock.json ] && cp package-lock.json "$OUT/package-lock.json" || true +# Ship the Cactus version so the agent can display it after a pip install. +[ -f "$CC_ROOT/../CACTUS_VERSION" ] && cp "$CC_ROOT/../CACTUS_VERSION" "$OUT/CACTUS_VERSION" || true + +echo "==> Installing production dependencies" +cd "$OUT" +# --install-links copies workspace packages as real dirs; --omit=dev drops +# typescript/tsgo/etc. Optional deps (the native clipboard module used by /copy +# and image paste) are kept so those work; the wheel is platform-specific anyway +# since the Cactus engine ships native libraries per platform. +# --ignore-scripts: the bundled root package.json carries dev lifecycle hooks +# (e.g. `prepare: husky`) that must not run when installing the wheel payload. +npm install --omit=dev --install-links --no-audit --no-fund --ignore-scripts + +echo "==> Making payload wheel-safe (dereference symlinks, drop .bin)" +# Workspace packages get symlinked by npm; wheels don't carry symlinks reliably, +# so replace them with real directory copies. +if [ -d "$OUT/node_modules/@earendil-works" ]; then + for d in "$OUT/node_modules/@earendil-works"/*; do + if [ -L "$d" ]; then + real="$(cd "$d" && pwd -P)" + rm "$d" + cp -R "$real" "$d" + fi + done +fi +# .bin holds only symlinks to dev tools; not needed at runtime. +rm -rf "$OUT/node_modules/.bin" +# Drop any other stray symlinks so the wheel is fully self-contained. +find "$OUT" -type l -delete 2>/dev/null || true + +echo "==> Done. Bundled agent at: $OUT" +echo " entry: $OUT/packages/coding-agent/dist/cli.js" diff --git a/cactus-code/tsconfig.base.json b/cactus-code/tsconfig.base.json new file mode 100644 index 000000000..57e97d6e3 --- /dev/null +++ b/cactus-code/tsconfig.base.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "lib": ["ES2022"], + "strict": true, + "erasableSyntaxOnly": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "inlineSources": true, + "inlineSourceMap": false, + "moduleResolution": "Node16", + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "useDefineForClassFields": false, + "types": ["node"] + } +} diff --git a/cactus-engine/src/cloud.cpp b/cactus-engine/src/cloud.cpp index d5e094c94..3ef099947 100644 --- a/cactus-engine/src/cloud.cpp +++ b/cactus-engine/src/cloud.cpp @@ -133,9 +133,19 @@ static std::string build_cloud_text_prompt(const CloudCompletionRequest& request oss << "4) If a tool call is required, return ONLY JSON with this exact shape:\\n"; oss << "[{\"name\":\"tool_name\",\"arguments\":{\"arg\":\"value\"}}]\\n"; oss << "5) Do not include any prose before or after that JSON tool-call output.\\n"; + oss << "6) Never write a tool call as a function-call literal such as " + "tool_name({...}) or tool_name(arg=value); only the JSON array shape above is accepted.\\n"; oss << "\\nConversation:\\n"; for (const auto& m : request.messages) { - oss << "[" << m.role << "] " << m.content << "\\n"; + if (m.role == "tool") { + oss << "[tool:" << (m.name.empty() ? "result" : m.name) << "] " << m.content << "\\n"; + continue; + } + oss << "[" << m.role << "] " << m.content; + for (const auto& tc : m.tool_calls) { + oss << " (called tool " << tc.name << " with arguments " << tc.arguments << ")"; + } + oss << "\\n"; } if (!request.tools.empty()) { @@ -152,6 +162,91 @@ static std::string build_cloud_text_prompt(const CloudCompletionRequest& request return oss.str(); } +static void extract_textual_tool_calls(std::string& response, + const std::vector& tools, + std::vector& function_calls) { + if (tools.empty()) return; + for (const auto& tool : tools) { + const std::string& name = tool.name; + if (name.empty()) continue; + size_t search = 0; + while (true) { + size_t pos = response.find(name, search); + if (pos == std::string::npos) break; + bool left_ok = (pos == 0) || + !(std::isalnum(static_cast(response[pos - 1])) || response[pos - 1] == '_'); + size_t paren = pos + name.size(); + while (paren < response.size() && + std::isspace(static_cast(response[paren]))) { + ++paren; + } + if (!left_ok || paren >= response.size() || response[paren] != '(') { + search = pos + name.size(); + continue; + } + size_t args_end = find_matching_delimiter(response, paren, '(', ')'); + if (args_end > response.size()) { + search = pos + name.size(); + continue; + } + std::string inner = trim_string(response.substr(paren + 1, args_end - paren - 2)); + std::string args_json; + if (inner.empty()) { + args_json = "{}"; + } else if (inner.front() == '{' && inner.back() == '}') { + args_json = inner; + } else { + search = pos + name.size(); + continue; + } + function_calls.push_back("{\"name\":\"" + name + "\",\"arguments\":" + args_json + "}"); + response.erase(pos, args_end - pos); + search = pos; + } + } + if (!function_calls.empty()) response = trim_string(response); +} + +static void extract_called_tool_phrases(std::string& response, + const std::vector& tools, + std::vector& function_calls) { + const std::string marker = "called tool "; + size_t search = 0; + while (true) { + size_t pos = response.find(marker, search); + if (pos == std::string::npos) break; + size_t name_start = pos + marker.size(); + size_t name_end = name_start; + while (name_end < response.size() && + (std::isalnum(static_cast(response[name_end])) || response[name_end] == '_')) { + ++name_end; + } + std::string name = response.substr(name_start, name_end - name_start); + bool known = false; + for (const auto& t : tools) { + if (t.name == name) { known = true; break; } + } + if (name.empty() || !known) { search = pos + marker.size(); continue; } + size_t with = response.find("with arguments", name_end); + size_t brace = (with == std::string::npos) ? std::string::npos : response.find('{', with); + if (brace == std::string::npos) { search = pos + marker.size(); continue; } + size_t end = find_matching_delimiter(response, brace, '{', '}'); + if (end > response.size()) { search = pos + marker.size(); continue; } + std::string args = trim_string(response.substr(brace, end - brace)); + function_calls.push_back("{\"name\":\"" + name + "\",\"arguments\":" + args + "}"); + size_t erase_end = end; + while (erase_end < response.size() && + std::isspace(static_cast(response[erase_end]))) { + ++erase_end; + } + if (erase_end < response.size() && response[erase_end] == ')') ++erase_end; + size_t erase_start = (pos > 0 && response[pos - 1] == '(') ? pos - 1 : pos; + response.erase(erase_start, erase_end - erase_start); + search = erase_start; + } + if (!function_calls.empty()) response = trim_string(response); +} + static std::string call_cloud_endpoint(const std::string& url, const std::string& payload, long timeout_ms, @@ -443,18 +538,15 @@ CloudCompletionResult cloud_complete_request(const CloudCompletionRequest& reque } if (!response.empty() && function_calls.empty()) { - auto first = response.find_first_not_of(" \t\n\r"); - auto last = response.find_last_not_of(" \t\n\r"); - if (first != std::string::npos && last != std::string::npos) { - std::string trimmed = response.substr(first, last - first + 1); - if (!trimmed.empty() && trimmed.front() == '[' && trimmed.back() == ']' && - trimmed.find("\"name\"") != std::string::npos) { - function_calls = split_json_array(trimmed); - response.clear(); - } - } + extract_textual_tool_calls(response, request.tools, function_calls); } + if (!response.empty() && function_calls.empty()) { + extract_called_tool_phrases(response, request.tools, function_calls); + } + + sanitize_function_calls(function_calls); + if (response.empty() && function_calls.empty()) { return {false, false, "", {}, "missing_text"}; } diff --git a/cactus-engine/src/complete.cpp b/cactus-engine/src/complete.cpp index 6d0d99ddb..51ef39b06 100644 --- a/cactus-engine/src/complete.cpp +++ b/cactus-engine/src/complete.cpp @@ -215,6 +215,55 @@ std::vector extract_schema_required(const std::string& schema) { return required; } +static bool json_object_has_key(const std::string& obj, const std::string& key) { + const std::string target = "\"" + key + "\""; + int depth = 0; + bool in_str = false, esc = false; + for (size_t i = 0; i < obj.size(); ++i) { + char c = obj[i]; + if (in_str) { + if (esc) esc = false; + else if (c == '\\') esc = true; + else if (c == '"') in_str = false; + continue; + } + if (c == '"') { + if (depth == 1 && obj.compare(i, target.size(), target) == 0) { + size_t j = i + target.size(); + while (j < obj.size() && std::isspace(static_cast(obj[j]))) ++j; + if (j < obj.size() && obj[j] == ':') return true; + } + in_str = true; + continue; + } + if (c == '{' || c == '[') ++depth; + else if (c == '}' || c == ']') --depth; + } + return false; +} + +static bool function_calls_missing_required(const std::vector& calls, + const std::vector& tools) { + if (calls.empty() || tools.empty()) return false; + for (const auto& call : calls) { + std::string name = json_string_field(call, "name"); + const ToolFunction* tool = nullptr; + for (const auto& t : tools) { + if (t.name == name) { tool = &t; break; } + } + if (!tool) continue; + auto schema_it = tool->parameters.find("schema"); + if (schema_it == tool->parameters.end()) continue; + std::vector required = extract_schema_required(schema_it->second); + if (required.empty()) continue; + std::string args = extract_json_object_field(call, "arguments"); + for (const auto& req : required) { + if (!json_object_has_key(args, req)) return true; + } + } + return false; +} + std::string serialize_needle_tools(const std::vector& tools) { if (tools.empty()) return "[]"; @@ -861,7 +910,7 @@ int cactus_complete( if (cloud_eligible && prompt.options.confidence_threshold >= 1.0f) { pre_generation_cloud_attempted = true; - CACTUS_LOG_WARN("cloud_handoff", "Cloud handoff triggered before local generation; waiting up to " + CACTUS_LOG_INFO("cloud_handoff", "Cloud handoff triggered before local generation; waiting up to " << prompt.options.cloud_timeout_ms << " ms before falling back"); auto cloud_result = cloud_complete_request( make_cloud_request("", {}), @@ -938,7 +987,7 @@ int cactus_complete( && !defer_local_stream_until_probe && !pre_generation_cloud_attempted && confidence < prompt.options.confidence_threshold) { - CACTUS_LOG_WARN("cloud_handoff", "Cloud handoff triggered before local streaming; waiting up to " + CACTUS_LOG_INFO("cloud_handoff", "Cloud handoff triggered before local streaming; waiting up to " << prompt.options.cloud_timeout_ms << " ms before falling back"); CloudCompletionResult cloud_result = cloud_complete_request( make_cloud_request("", {}), @@ -1028,7 +1077,7 @@ int cactus_complete( std::string regular_response; std::vector function_calls; - parse_function_calls_from_response(response_text, regular_response, function_calls); + parse_function_calls_from_response(response_text, regular_response, function_calls, prompt.tools); std::string thinking_text; if (prompt.model_type == Config::ModelType::GEMMA4 || prompt.options.enable_thinking_if_supported) { @@ -1051,10 +1100,15 @@ int cactus_complete( std::vector primary_function_calls = function_calls; bool handoff_succeeded = false; - if (defer_local_stream_until_probe && !pre_generation_cloud_attempted - && confidence < prompt.options.confidence_threshold) { - CACTUS_LOG_WARN("cloud_handoff", "Cloud handoff triggered by Gemma4 probe: p_wrong=" - << (1.0f - confidence) << " confidence=" << confidence + const bool low_confidence = defer_local_stream_until_probe + && confidence < prompt.options.confidence_threshold; + const bool invalid_local_tool_call = cloud_eligible && defer_local_stream_until_probe + && function_calls_missing_required(function_calls, prompt.tools); + if (!pre_generation_cloud_attempted && (low_confidence || invalid_local_tool_call)) { + const char* trigger_reason = invalid_local_tool_call && !low_confidence + ? "local tool call missing required args" : "low confidence (probe)"; + CACTUS_LOG_INFO("cloud_handoff", "Cloud handoff triggered (" << trigger_reason + << "): p_wrong=" << (1.0f - confidence) << " confidence=" << confidence << "; waiting up to " << prompt.options.cloud_timeout_ms << " ms"); CloudCompletionResult cloud_result = cloud_complete_request( make_cloud_request(local_completion, function_calls), @@ -1066,7 +1120,7 @@ int cactus_complete( handle->model->clear_tool_constraints(); } return return_cloud_completion(cloud_result, elapsed_ms, elapsed_ms, confidence, prompt_tokens, - "low confidence (probe)"); + trigger_reason); } cloud_error = cloud_result.error.empty() ? "cloud completion failed" : cloud_result.error; CACTUS_LOG_WARN("cloud_handoff", "Cloud completion failed after probe handoff, falling back to local output: " diff --git a/cactus-engine/src/engine.h b/cactus-engine/src/engine.h index 34678a10f..021b53b46 100644 --- a/cactus-engine/src/engine.h +++ b/cactus-engine/src/engine.h @@ -312,6 +312,33 @@ inline std::string extract_json_string(const std::string& json, size_t& pos) { else if (json[pos] == 'r') value += '\r'; else if (json[pos] == '"') value += '"'; else if (json[pos] == '\\') value += '\\'; + else if (json[pos] == 'u' && pos + 4 < json.size()) { + unsigned int cp = 0; + bool ok = true; + for (int k = 1; k <= 4; k++) { + char h = json[pos + k]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + else { ok = false; break; } + } + if (ok) { + pos += 4; + if (cp < 0x80) { + value += static_cast(cp); + } else if (cp < 0x800) { + value += static_cast(0xC0 | (cp >> 6)); + value += static_cast(0x80 | (cp & 0x3F)); + } else { + value += static_cast(0xE0 | (cp >> 12)); + value += static_cast(0x80 | ((cp >> 6) & 0x3F)); + value += static_cast(0x80 | (cp & 0x3F)); + } + } else { + value += json[pos]; + } + } else value += json[pos]; } else { value += json[pos]; diff --git a/cactus-engine/src/gemma_tools.h b/cactus-engine/src/gemma_tools.h index 9497eb3b4..8d3ccc6af 100644 --- a/cactus-engine/src/gemma_tools.h +++ b/cactus-engine/src/gemma_tools.h @@ -9,6 +9,7 @@ #include #include "json_escape.h" +#include "picojson.h" namespace gemma { @@ -45,13 +46,42 @@ inline std::string extract_json_string(const std::string& json, size_t& pos) { else if (json[pos] == 'r') value += '\r'; else if (json[pos] == '"') value += '"'; else if (json[pos] == '\\') value += '\\'; + else if (json[pos] == 'u' && pos + 4 < json.length()) { + // Decode \uXXXX (BMP) -> UTF-8. Without this, JSON-escaped + // chars like < leak literally as "u003c" in tool args. + unsigned int cp = 0; + bool ok = true; + for (int k = 1; k <= 4; k++) { + char h = json[pos + k]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + else { ok = false; break; } + } + if (ok) { + pos += 4; + if (cp < 0x80) { + value += static_cast(cp); + } else if (cp < 0x800) { + value += static_cast(0xC0 | (cp >> 6)); + value += static_cast(0x80 | (cp & 0x3F)); + } else { + value += static_cast(0xE0 | (cp >> 12)); + value += static_cast(0x80 | ((cp >> 6) & 0x3F)); + value += static_cast(0x80 | (cp & 0x3F)); + } + } else { + value += json[pos]; + } + } else value += json[pos]; } else { value += json[pos]; } pos++; } - if (pos < json.length()) pos++; + if (pos < json.length()) pos++; return value; } @@ -452,89 +482,272 @@ inline std::string to_json_value(const std::string& v) { return "\"" + escape_json_string(v) + "\""; } -inline std::string args_to_json(const std::string& args_content) { - std::string result = "{"; +inline void append_utf8(std::string& out, unsigned cp) { + if (cp < 0x80) { out += static_cast(cp); } + else if (cp < 0x800) { out += static_cast(0xC0 | (cp >> 6)); out += static_cast(0x80 | (cp & 0x3F)); } + else if (cp < 0x10000) { out += static_cast(0xE0 | (cp >> 12)); out += static_cast(0x80 | ((cp >> 6) & 0x3F)); out += static_cast(0x80 | (cp & 0x3F)); } + else { out += static_cast(0xF0 | (cp >> 18)); out += static_cast(0x80 | ((cp >> 12) & 0x3F)); out += static_cast(0x80 | ((cp >> 6) & 0x3F)); out += static_cast(0x80 | (cp & 0x3F)); } +} + +inline std::string read_quoted_json_string(const std::string& s, size_t& pos, char quote = '"') { + std::string out; + if (pos < s.size() && s[pos] == quote) pos++; + auto hex4 = [&](size_t p, unsigned& v) -> bool { + if (p + 4 > s.size()) return false; + v = 0; + for (int k = 0; k < 4; ++k) { + char h = s[p + k]; v <<= 4; + if (h >= '0' && h <= '9') v |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') v |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') v |= static_cast(h - 'A' + 10); + else return false; + } + return true; + }; + while (pos < s.size()) { + char c = s[pos]; + if (c == quote) { pos++; break; } + if (c == '\\' && pos + 1 < s.size()) { + char n = s[pos + 1]; + switch (n) { + case '\'': out += '\''; pos += 2; break; + case 'n': out += '\n'; pos += 2; break; + case 't': out += '\t'; pos += 2; break; + case 'r': out += '\r'; pos += 2; break; + case 'b': out += '\b'; pos += 2; break; + case 'f': out += '\f'; pos += 2; break; + case '"': out += '"'; pos += 2; break; + case '\\': out += '\\'; pos += 2; break; + case '/': out += '/'; pos += 2; break; + case 'u': { + unsigned cp; + if (hex4(pos + 2, cp)) { + size_t adv = pos + 6; + if (cp >= 0xD800 && cp <= 0xDBFF) { + unsigned lo; + if (adv + 6 <= s.size() && s[adv] == '\\' && s[adv + 1] == 'u' && + hex4(adv + 2, lo) && lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); adv += 6; + } else { cp = 0xFFFD; } + } else if (cp >= 0xDC00 && cp <= 0xDFFF) { cp = 0xFFFD; } + append_utf8(out, cp); pos = adv; + } else { out += '\\'; out += 'u'; pos += 2; } + break; + } + default: out += n; pos += 2; break; + } + } else { + out += c; pos++; + } + } + return out; +} + +inline std::string repair_json(const std::string& s) { + std::string out; + out.reserve(s.size() + 16); + bool in_str = false, esc = false; + for (size_t i = 0; i < s.size(); ++i) { + char c = s[i]; + if (in_str) { + if (esc) { out += c; esc = false; continue; } + if (c == '\\') { out += c; esc = true; continue; } + if (c == '"') { out += c; in_str = false; continue; } + unsigned char uc = static_cast(c); + if (uc == '\n') out += "\\n"; + else if (uc == '\t') out += "\\t"; + else if (uc == '\r') out += "\\r"; + else if (uc < 0x20) { char b[8]; std::snprintf(b, sizeof(b), "\\u%04x", uc); out += b; } + else out += c; + } else { + if (c == '"') { in_str = true; out += c; } + else if (c == ',') { + size_t j = i + 1; + while (j < s.size() && std::isspace(static_cast(s[j]))) j++; + bool trailing = (j < s.size() && (s[j] == '}' || s[j] == ']')); + if (!trailing) out += c; + } else out += c; + } + } + return out; +} + +inline size_t find_raw_value_end(const std::string& s, size_t pos, + const std::vector& known_keys) { + int brace = 0, brack = 0; + for (size_t i = pos; i < s.size(); ++i) { + char c = s[i]; + if (c == '{') brace++; + else if (c == '}') { if (brace == 0) return i; brace--; } + else if (c == '[') brack++; + else if (c == ']') { if (brack > 0) brack--; } + else if (c == ',' && brace == 0 && brack == 0) { + size_t j = i + 1; + while (j < s.size() && std::isspace(static_cast(s[j]))) j++; + bool quoted = (j < s.size() && s[j] == '"'); + size_t ks = quoted ? j + 1 : j; + for (const auto& k : known_keys) { + if (k.empty() || s.compare(ks, k.size(), k) != 0) continue; + size_t m = ks + k.size(); + if (quoted) { if (m < s.size() && s[m] == '"') m++; else continue; } + while (m < s.size() && std::isspace(static_cast(s[m]))) m++; + if (m < s.size() && s[m] == ':') return i; + } + } + } + return s.size(); +} + +inline std::string args_to_json(const std::string& args_content, + const std::vector& known_keys = {}, + const std::set& string_keys = {}); +inline std::string parse_array_items(const std::string& inner); + +inline std::string try_json_object(const std::string& s) { + auto attempt = [](const std::string& c) -> std::string { + picojson::value v; + const char* b = c.data(); + const char* e = b + c.size(); + std::string err; + const char* end = picojson::parse(v, b, e, &err); + while (end < e && std::isspace(static_cast(*end))) ++end; + return (err.empty() && end == e && v.is()) ? v.serialize() : std::string(); + }; + std::string r = attempt(s); + if (!r.empty()) return r; + std::string repaired = repair_json(s); + return (repaired != s) ? attempt(repaired) : std::string(); +} + +inline bool skip_delimited_string(const std::string& s, size_t& i) { + if (size_t qtag = match_quote_tag(s, i)) { + size_t end = find_quote_tag(s, i + qtag); + i = (end == std::string::npos) ? s.size() : end + match_quote_tag(s, end); + return true; + } + char q = (i < s.size()) ? s[i] : '\0'; + if (q == '"' || q == '\'') { + for (i++; i < s.size(); ++i) { + if (s[i] == '\\') { i++; continue; } + if (s[i] == q) { i++; break; } + } + return true; + } + return false; +} + +inline std::string read_balanced(const std::string& s, size_t& pos, char open, char close) { + size_t start = pos; + int depth = 0; + while (pos < s.size()) { + if (skip_delimited_string(s, pos)) continue; + char c = s[pos++]; + if (c == open) depth++; + else if (c == close && --depth == 0) break; + } + return s.substr(start, pos - start); +} + +inline bool read_delimited_string(const std::string& s, size_t& pos, std::string& out) { + if (size_t qtag = match_quote_tag(s, pos)) { + pos += qtag; + size_t end = find_quote_tag(s, pos); + size_t stop = (end == std::string::npos) ? s.size() : end; + out = "\"" + escape_json_string(s.substr(pos, stop - pos)) + "\""; + pos = (end == std::string::npos) ? s.size() : end + match_quote_tag(s, end); + return true; + } + if (pos < s.size() && (s[pos] == '"' || s[pos] == '\'')) { + out = "\"" + escape_json_string(read_quoted_json_string(s, pos, s[pos])) + "\""; + return true; + } + return false; +} + +inline std::string read_raw_value(const std::string& s, size_t& pos, + const std::vector& known_keys, bool want_string) { + size_t start = pos; + size_t end = known_keys.empty() ? s.find_first_of(",}", pos) + : find_raw_value_end(s, pos, known_keys); + if (end == std::string::npos) end = s.size(); + std::string raw = s.substr(start, end - start); + pos = end; + + std::string trimmed = raw; + while (!trimmed.empty() && std::isspace(static_cast(trimmed.back()))) trimmed.pop_back(); + bool scalar = !want_string && (trimmed == "true" || trimmed == "false" || trimmed == "null"); + if (!want_string && !scalar && !trimmed.empty()) { + char* ep = nullptr; + std::strtod(trimmed.c_str(), &ep); + scalar = (ep == trimmed.c_str() + trimmed.size()); + } + return scalar ? trimmed : ("\"" + escape_json_string(raw) + "\""); +} + +inline std::string parse_array_items(const std::string& inner) { + std::string out = "["; size_t pos = 0; bool first = true; + while (pos < inner.size()) { + while (pos < inner.size() && (std::isspace(static_cast(inner[pos])) || inner[pos] == ',')) pos++; + if (pos >= inner.size()) break; + if (!first) out += ","; + first = false; + + std::string item; + if (read_delimited_string(inner, pos, item)) { + out += item; + } else if (inner[pos] == '{') { + out += args_to_json(read_balanced(inner, pos, '{', '}')); + } else if (inner[pos] == '[') { + std::string nested = read_balanced(inner, pos, '[', ']'); + out += parse_array_items(nested.substr(1, nested.size() - 2)); + } else { + out += read_raw_value(inner, pos, {}, false); + } + } + out += "]"; + return out; +} - if (!args_content.empty() && args_content[0] == '{') pos = 1; +inline std::string args_to_json(const std::string& args_content, + const std::vector& known_keys, + const std::set& string_keys) { + if (std::string json = try_json_object(args_content); !json.empty()) return json; - while (pos < args_content.length()) { - while (pos < args_content.length() && std::isspace(args_content[pos])) pos++; - if (pos >= args_content.length() || args_content[pos] == '}') break; + std::string result = "{"; + size_t pos = (!args_content.empty() && args_content[0] == '{') ? 1 : 0; + bool first = true; + + while (pos < args_content.size()) { + while (pos < args_content.size() && std::isspace(static_cast(args_content[pos]))) pos++; + if (pos >= args_content.size() || args_content[pos] == '}') break; if (args_content[pos] == ',') { pos++; continue; } - size_t key_start = pos; - while (pos < args_content.length() && args_content[pos] != ':') pos++; - std::string key = args_content.substr(key_start, pos - key_start); - if (pos < args_content.length()) pos++; + std::string key; + if (args_content[pos] == '"') { + key = read_quoted_json_string(args_content, pos); + } else { + size_t ks = pos; + while (pos < args_content.size() && args_content[pos] != ':') pos++; + key = args_content.substr(ks, pos - ks); + while (!key.empty() && std::isspace(static_cast(key.back()))) key.pop_back(); + } + while (pos < args_content.size() && args_content[pos] != ':') pos++; + if (pos < args_content.size()) pos++; + while (pos < args_content.size() && std::isspace(static_cast(args_content[pos]))) pos++; + const bool want_string = string_keys.count(key) > 0; std::string value; - while (pos < args_content.length() && std::isspace(args_content[pos])) pos++; - - if (pos < args_content.length()) { - size_t qtag_len = match_quote_tag(args_content, pos); - if (qtag_len > 0) { - pos += qtag_len; - size_t val_end = find_quote_tag(args_content, pos); - if (val_end != std::string::npos) { - value = "\"" + escape_json_string(args_content.substr(pos, val_end - pos)) + "\""; - pos = val_end + match_quote_tag(args_content, val_end); - } - } else if (args_content[pos] == '{') { - int depth = 1; - size_t start = pos; - pos++; - while (pos < args_content.length() && depth > 0) { - if (args_content[pos] == '{') depth++; - else if (args_content[pos] == '}') depth--; - pos++; - } - value = args_to_json(args_content.substr(start, pos - start)); - } else if (args_content[pos] == '[') { - int depth = 1; - size_t start = pos; - pos++; - while (pos < args_content.length() && depth > 0) { - if (args_content[pos] == '[') depth++; - else if (args_content[pos] == ']') depth--; - pos++; - } - std::string arr_content = args_content.substr(start + 1, pos - start - 2); - value = "["; - size_t arr_pos = 0; - bool first_item = true; - while (arr_pos < arr_content.length()) { - while (arr_pos < arr_content.length() && (std::isspace(arr_content[arr_pos]) || arr_content[arr_pos] == ',')) arr_pos++; - if (arr_pos >= arr_content.length()) break; - - if (!first_item) value += ","; - first_item = false; - - size_t aq_len = match_quote_tag(arr_content, arr_pos); - if (aq_len > 0) { - arr_pos += aq_len; - size_t end = find_quote_tag(arr_content, arr_pos); - if (end != std::string::npos) { - value += "\"" + escape_json_string(arr_content.substr(arr_pos, end - arr_pos)) + "\""; - arr_pos = end + match_quote_tag(arr_content, end); - } - } else { - size_t end = arr_content.find_first_of(",]", arr_pos); - if (end == std::string::npos) end = arr_content.length(); - value += to_json_value(arr_content.substr(arr_pos, end - arr_pos)); - arr_pos = end; - } - } - value += "]"; + if (pos < args_content.size()) { + if (read_delimited_string(args_content, pos, value)) { + } else if (!want_string && args_content[pos] == '{') { + value = args_to_json(read_balanced(args_content, pos, '{', '}')); + } else if (!want_string && args_content[pos] == '[') { + std::string arr = read_balanced(args_content, pos, '[', ']'); + value = parse_array_items(arr.substr(1, arr.size() - 2)); } else { - size_t val_start = pos; - while (pos < args_content.length() && args_content[pos] != ',' && args_content[pos] != '}') { - pos++; - } - value = args_content.substr(val_start, pos - val_start); - while (!value.empty() && std::isspace(value.back())) value.pop_back(); - value = to_json_value(value); + value = read_raw_value(args_content, pos, known_keys, want_string); } } @@ -547,14 +760,25 @@ inline std::string args_to_json(const std::string& args_content) { return result; } -inline void parse_function_calls(std::string& response, std::vector& function_calls) { - +inline void parse_function_calls_core(std::string& response, std::vector& function_calls, + const std::map>& fn_keys, + const std::map>& fn_strkeys = {}) { const std::string CALL_START = (response.find("<|tool_call>") != std::string::npos) ? "<|tool_call>" : ""; const std::string CALL_END = (CALL_START == "<|tool_call>") ? "" : ""; size_t pos = 0; + auto emit_call = [&](const std::string& fn, std::string args_content) { + if (args_content.empty() || args_content.back() != '}') args_content += "}"; + std::vector keys; + std::set strkeys; + if (auto it = fn_keys.find(fn); it != fn_keys.end()) keys = it->second; + if (auto it = fn_strkeys.find(fn); it != fn_strkeys.end()) strkeys = it->second; + std::string args_json = args_to_json(args_content, keys, strkeys); + function_calls.push_back("{\"name\":\"" + escape_json_string(fn) + "\",\"arguments\":" + args_json + "}"); + }; + while ((pos = response.find(CALL_START, pos)) != std::string::npos) { size_t content_start = pos + CALL_START.length(); size_t call_end_pos = response.find(CALL_END, content_start); @@ -564,31 +788,15 @@ inline void parse_function_calls(std::string& response, std::vector if (call_content.compare(0, 5, "call:") == 0) { size_t brace_pos = call_content.find('{'); - - if (brace_pos == std::string::npos) { + if (brace_pos != std::string::npos) { + emit_call(call_content.substr(5, brace_pos - 5), call_content.substr(brace_pos)); + } else { size_t sep_pos = call_content.find_first_of(", ", 5); if (sep_pos != std::string::npos) { - std::string func_name = call_content.substr(5, sep_pos - 5); - size_t args_start = sep_pos + 1; - while (args_start < call_content.length() && - (call_content[args_start] == ' ' || call_content[args_start] == ',')) { - args_start++; - } - std::string args_content = "{" + call_content.substr(args_start); - if (args_content.back() != '}') args_content += "}"; - - std::string args_json = args_to_json(args_content); - std::string json_call = "{\"name\":\"" + escape_json_string(func_name) + "\",\"arguments\":" + args_json + "}"; - function_calls.push_back(json_call); + size_t args_start = call_content.find_first_not_of(", ", sep_pos); + emit_call(call_content.substr(5, sep_pos - 5), + "{" + (args_start == std::string::npos ? std::string() : call_content.substr(args_start))); } - } else { - std::string func_name = call_content.substr(5, brace_pos - 5); - std::string args_content = call_content.substr(brace_pos); - if (args_content.back() != '}') args_content += "}"; - - std::string args_json = args_to_json(args_content); - std::string json_call = "{\"name\":\"" + func_name + "\",\"arguments\":" + args_json + "}"; - function_calls.push_back(json_call); } } @@ -598,4 +806,40 @@ inline void parse_function_calls(std::string& response, std::vector } } +inline void parse_function_calls(std::string& response, std::vector& function_calls) { + parse_function_calls_core(response, function_calls, {}); +} + +template +inline void parse_function_calls(std::string& response, std::vector& function_calls, + const std::vector& tools) { + std::map> fn_keys; + std::map> fn_strkeys; + for (const auto& t : tools) { + auto it = t.parameters.find("schema"); + if (it == t.parameters.end()) continue; + picojson::value v; + if (!picojson::parse(v, it->second).empty() || !v.is()) continue; + const auto& obj = v.get(); + auto pit = obj.find("properties"); + if (pit == obj.end() || !pit->second.is()) continue; + std::vector names; + std::set strkeys; + for (const auto& kv : pit->second.get()) { + names.push_back(kv.first); + if (kv.second.is()) { + const auto& prop = kv.second.get(); + auto tit = prop.find("type"); + if (tit != prop.end() && tit->second.is() && + tit->second.get() == "string") { + strkeys.insert(kv.first); + } + } + } + fn_keys[t.name] = std::move(names); + fn_strkeys[t.name] = std::move(strkeys); + } + parse_function_calls_core(response, function_calls, fn_keys, fn_strkeys); +} + } // namespace gemma \ No newline at end of file diff --git a/cactus-engine/src/utils.h b/cactus-engine/src/utils.h index 95e2368da..23f6b399c 100644 --- a/cactus-engine/src/utils.h +++ b/cactus-engine/src/utils.h @@ -601,6 +601,34 @@ inline std::string json_string_field(const std::string& json, const std::string& case 'n': out.push_back('\n'); break; case 'r': out.push_back('\r'); break; case 't': out.push_back('\t'); break; + case 'u': { + unsigned int cp = 0; + bool ok = i + 4 <= json.size(); + for (int k = 0; ok && k < 4; k++) { + char h = json[i + k]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + else ok = false; + } + if (ok) { + i += 4; + if (cp < 0x80) { + out.push_back(static_cast(cp)); + } else if (cp < 0x800) { + out.push_back(static_cast(0xC0 | (cp >> 6))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else { + out.push_back(static_cast(0xE0 | (cp >> 12))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } + } else { + out.push_back('u'); + } + break; + } default: out.push_back(e); break; } continue; @@ -676,6 +704,139 @@ inline std::vector split_json_array(const std::string& array_json) return out; } +namespace jsonval { + +inline void skip_ws(const std::string& s, size_t& p) { + while (p < s.size() && (s[p] == ' ' || s[p] == '\t' || s[p] == '\n' || s[p] == '\r')) p++; +} + +inline bool parse_value(const std::string& s, size_t& p); + +inline bool parse_string(const std::string& s, size_t& p) { + if (p >= s.size() || s[p] != '"') return false; + p++; + while (p < s.size()) { + char c = s[p++]; + if (c == '"') return true; + if (c == '\\') { + if (p >= s.size()) return false; + char e = s[p++]; + if (e == 'u') { + if (p + 4 > s.size()) return false; + for (int i = 0; i < 4; i++) { + if (!std::isxdigit(static_cast(s[p++]))) return false; + } + } else if (e != '"' && e != '\\' && e != '/' && e != 'b' && + e != 'f' && e != 'n' && e != 'r' && e != 't') { + return false; + } + } else if (static_cast(c) < 0x20) { + return false; + } + } + return false; +} + +inline bool parse_number(const std::string& s, size_t& p) { + size_t start = p; + if (p < s.size() && s[p] == '-') p++; + if (p < s.size() && s[p] == '0') { + p++; + } else if (p < s.size() && s[p] >= '1' && s[p] <= '9') { + while (p < s.size() && std::isdigit(static_cast(s[p]))) p++; + } else { + return false; + } + if (p < s.size() && s[p] == '.') { + p++; + if (p >= s.size() || !std::isdigit(static_cast(s[p]))) return false; + while (p < s.size() && std::isdigit(static_cast(s[p]))) p++; + } + if (p < s.size() && (s[p] == 'e' || s[p] == 'E')) { + p++; + if (p < s.size() && (s[p] == '+' || s[p] == '-')) p++; + if (p >= s.size() || !std::isdigit(static_cast(s[p]))) return false; + while (p < s.size() && std::isdigit(static_cast(s[p]))) p++; + } + return p > start; +} + +inline bool parse_literal(const std::string& s, size_t& p, const std::string& lit) { + if (s.compare(p, lit.size(), lit) == 0) { + p += lit.size(); + return true; + } + return false; +} + +inline bool parse_object(const std::string& s, size_t& p) { + p++; + skip_ws(s, p); + if (p < s.size() && s[p] == '}') { p++; return true; } + while (true) { + skip_ws(s, p); + if (!parse_string(s, p)) return false; + skip_ws(s, p); + if (p >= s.size() || s[p] != ':') return false; + p++; + if (!parse_value(s, p)) return false; + skip_ws(s, p); + if (p >= s.size()) return false; + if (s[p] == ',') { p++; continue; } + if (s[p] == '}') { p++; return true; } + return false; + } +} + +inline bool parse_array(const std::string& s, size_t& p) { + p++; + skip_ws(s, p); + if (p < s.size() && s[p] == ']') { p++; return true; } + while (true) { + if (!parse_value(s, p)) return false; + skip_ws(s, p); + if (p >= s.size()) return false; + if (s[p] == ',') { p++; continue; } + if (s[p] == ']') { p++; return true; } + return false; + } +} + +inline bool parse_value(const std::string& s, size_t& p) { + skip_ws(s, p); + if (p >= s.size()) return false; + char c = s[p]; + if (c == '{') return parse_object(s, p); + if (c == '[') return parse_array(s, p); + if (c == '"') return parse_string(s, p); + if (c == 't') return parse_literal(s, p, "true"); + if (c == 'f') return parse_literal(s, p, "false"); + if (c == 'n') return parse_literal(s, p, "null"); + return parse_number(s, p); +} + +} // namespace jsonval + +inline bool is_valid_json(const std::string& s) { + size_t p = 0; + if (!jsonval::parse_value(s, p)) return false; + jsonval::skip_ws(s, p); + return p == s.size(); +} + +inline void sanitize_function_calls(std::vector& calls) { + std::vector valid; + valid.reserve(calls.size()); + for (const auto& call : calls) { + std::string trimmed = trim_string(call); + if (!trimmed.empty() && trimmed.front() == '{' && + trimmed.find("\"name\"") != std::string::npos && is_valid_json(trimmed)) { + valid.push_back(trimmed); + } + } + calls.swap(valid); +} + inline std::string serialize_tools_json(const std::vector& tools) { if (tools.empty()) return ""; std::ostringstream oss; @@ -1148,6 +1309,34 @@ inline std::vector parse_json_string_array_field(const std::string& case 'n': value.push_back('\n'); break; case 'r': value.push_back('\r'); break; case 't': value.push_back('\t'); break; + case 'u': { + unsigned int cp = 0; + bool ok = i + 4 <= array_json.size(); + for (int k = 0; ok && k < 4; k++) { + char h = array_json[i + k]; + cp <<= 4; + if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); + else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); + else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); + else ok = false; + } + if (ok) { + i += 4; + if (cp < 0x80) { + value.push_back(static_cast(cp)); + } else if (cp < 0x800) { + value.push_back(static_cast(0xC0 | (cp >> 6))); + value.push_back(static_cast(0x80 | (cp & 0x3F))); + } else { + value.push_back(static_cast(0xE0 | (cp >> 12))); + value.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + value.push_back(static_cast(0x80 | (cp & 0x3F))); + } + } else { + value.push_back('u'); + } + break; + } default: value.push_back(c); break; } escaped = false; @@ -1535,7 +1724,8 @@ inline InferenceOptions parse_inference_options_json(const std::string& json) { inline void parse_function_calls_from_response(const std::string& response_text, std::string& regular_response, - std::vector& function_calls) { + std::vector& function_calls, + const std::vector& tools = {}) { regular_response = response_text; function_calls.clear(); @@ -1562,12 +1752,13 @@ inline void parse_function_calls_from_response(const std::string& response_text, } regular_response = trim_string( regular_response.substr(0, needle_pos) + regular_response.substr(array_end)); + sanitize_function_calls(function_calls); return; } } } - gemma::parse_function_calls(regular_response, function_calls); + gemma::parse_function_calls(regular_response, function_calls, tools); chat_tools::extract_qwen_tool_calls(regular_response, function_calls); chat_tools::extract_lfm2_tool_calls(regular_response, function_calls); @@ -1600,6 +1791,45 @@ inline void parse_function_calls_from_response(const std::string& response_text, } search_pos = json_end; } + + if (function_calls.empty()) { + size_t pos = 0; + while (pos < regular_response.size()) { + char open = regular_response[pos]; + if (open != '[' && open != '{') { ++pos; continue; } + size_t end = find_matching_delimiter(regular_response, pos, open, open == '[' ? ']' : '}'); + if (end > regular_response.size()) { ++pos; continue; } + std::string candidate = regular_response.substr(pos, end - pos); + bool call_shape = candidate.find("\"name\"") != std::string::npos && + (open == '[' || candidate.find("\"arguments\"") != std::string::npos); + if (call_shape && is_valid_json(candidate)) { + if (open == '[') { + for (const auto& item : split_json_array(candidate)) function_calls.push_back(item); + } else { + function_calls.push_back(candidate); + } + regular_response = trim_string( + regular_response.substr(0, pos) + regular_response.substr(end)); + pos = 0; + continue; + } + ++pos; + } + } + + if (function_calls.empty()) { + std::string remainder = trim_string(regular_response); + if (!remainder.empty() && (remainder.front() == '{' || remainder.front() == '[')) { + size_t span_end = find_matching_delimiter(remainder, 0, remainder.front(), + remainder.front() == '[' ? ']' : '}'); + bool whole_span = span_end == remainder.size(); + bool call_shape = remainder.find("\"name\"") != std::string::npos && + (remainder.front() == '[' || remainder.find("\"arguments\"") != std::string::npos); + if (whole_span && call_shape) regular_response.clear(); + } + } + + sanitize_function_calls(function_calls); } inline void strip_tag_blocks(std::string& text, std::string& extracted, diff --git a/python/cactus/cli/__init__.py b/python/cactus/cli/__init__.py index 770ba1a88..0e01978b7 100644 --- a/python/cactus/cli/__init__.py +++ b/python/cactus/cli/__init__.py @@ -29,6 +29,7 @@ from .auth import cmd_auth from .clean import cmd_clean +from .code import cmd_code def _telemetry_parent(): @@ -188,6 +189,7 @@ def create_parser(): (quantizes weights to CQ, then builds the runtime graph) --bits 1|2|3|4 CQ quantization (default: 4) + --platform {_PLATFORM_PIPE:<22} target platform (default: auto) --token HuggingFace token (defaults to $HF_TOKEN) --reconvert force weight conversion from source --lora merge a LoRA adapter before converting @@ -309,6 +311,27 @@ def create_parser(): help="Confidence threshold below which completions hand off to cloud (1.0 forces cloud handoff)") serve_parser.add_argument("--cloud-timeout-ms", type=_non_negative_int, default=None, help="Maximum time to wait for cloud handoff before falling back locally") + serve_parser.add_argument("--no-access-log", action="store_true", + help="Disable per-request HTTP access logging") + + code_parser = subparsers.add_parser("code", help="Run the Cactus coding agent (TUI / print mode)", + parents=[_build_parent()]) + code_parser.add_argument("--serve-model", default=None, + help="If no server is running, start `cactus serve` with this model") + code_parser.add_argument("--host", default="127.0.0.1", + help="Server bind address to use/start (default: 127.0.0.1)") + code_parser.add_argument("--port", type=_port_int, default=8080, + help="Server port to use/start (default: 8080)") + code_parser.add_argument("--no-serve", action="store_true", + help="Do not auto-start a server; require one to already be running") + code_parser.add_argument("--no-cloud-handoff", action="store_true", + help="Disable automatic cloud handoff on the auto-started server") + code_parser.add_argument("--confidence-threshold", type=_unit_float, default=None, + help="Confidence threshold below which completions hand off to cloud") + code_parser.add_argument("--cloud-timeout-ms", type=_non_negative_int, default=None, + help="Maximum time to wait for cloud handoff before falling back locally") + code_parser.add_argument("agent_args", nargs=argparse.REMAINDER, + help="Arguments passed through to the coding agent (prefix with -- )") test_parser = subparsers.add_parser("test", help="Run the test suite", parents=[_build_parent()]) @@ -348,6 +371,8 @@ def create_parser(): help="Output directory (default: weights/)") convert_parser.add_argument("--bits", type=int, choices=[1, 2, 3, 4], default=4, help="CQ quantization bits (default: 4)") + convert_parser.add_argument("--platform", choices=_PLATFORM_CHOICES, default="auto", + help=_PLATFORM_HELP) convert_parser.add_argument("--token", help="HuggingFace token") convert_parser.add_argument("--lora", help="Path or HF id of a LoRA adapter to merge before converting (requires `peft`)") @@ -439,6 +464,7 @@ def create_parser(): "auth": cmd_auth, "clean": cmd_clean, + "code": cmd_code, "convert": cmd_convert, "upload": cmd_upload, } diff --git a/python/cactus/cli/clean.py b/python/cactus/cli/clean.py index be21a9e21..0901997e8 100644 --- a/python/cactus/cli/clean.py +++ b/python/cactus/cli/clean.py @@ -1,6 +1,8 @@ +import os import shutil import subprocess import sys +from pathlib import Path from .common import ( PROJECT_ROOT, @@ -33,32 +35,13 @@ def cmd_clean(args): print_color(YELLOW, "Aborted.") return 0 - def remove_if_exists(path): - if path.is_dir(): - print(f"Removing: {path}") - shutil.rmtree(path) - else: - print(f"Not found: {path}") - - remove_if_exists(PROJECT_ROOT / "android" / "build") - - for apple_build in sorted((PROJECT_ROOT / "apple").glob("build*")): - remove_if_exists(apple_build) - - remove_if_exists(PROJECT_ROOT / "cactus-engine" / "build") - remove_if_exists(PROJECT_ROOT / "cactus-engine" / "tests" / "build") - remove_if_exists(PROJECT_ROOT / "cactus-graph" / "build") - remove_if_exists(PROJECT_ROOT / "cactus-kernels" / "build") - - remove_if_exists(PROJECT_ROOT / "python" / "cactus" / "bin") - - remove_if_exists(PROJECT_ROOT / "venv") - - remove_if_exists(PROJECT_ROOT / "weights") - remove_if_exists(PROJECT_ROOT / "transpiled") - + print("Stopping any running Cactus server...") + try: + stopped = subprocess.run(["pkill", "-f", "cactus serve"], capture_output=True) + print("Stopped running Cactus server(s)." if stopped.returncode == 0 else "No running Cactus server found.") + except FileNotFoundError: + print_color(YELLOW, "Could not stop the server automatically; stop it manually if one is running.") print() - print("Removing compiled libraries and frameworks...") preserve_roots = [ (PROJECT_ROOT / "cactus-engine" / "libs" / "curl").resolve(), @@ -66,65 +49,58 @@ def remove_if_exists(path): (PROJECT_ROOT / "libs" / "mbedtls").resolve(), ] - def should_preserve_artifact(path): + def should_preserve(path): resolved = path.resolve() return any(resolved.is_relative_to(root) for root in preserve_roots) - so_count = 0 - for so_file in PROJECT_ROOT.rglob("*.so"): - so_file.unlink() - so_count += 1 - print(f"Removed {so_count} .so files" if so_count else "No .so files found") - - a_count = 0 - for a_file in PROJECT_ROOT.rglob("*.a"): - if should_preserve_artifact(a_file): - continue - a_file.unlink() - a_count += 1 - print(f"Removed {a_count} .a files" if a_count else "No .a files found") - - bin_count = 0 - for bin_file in PROJECT_ROOT.rglob("*.bin"): - bin_file.unlink() - bin_count += 1 - print(f"Removed {bin_count} .bin files" if bin_count else "No .bin files found") - - dylib_count = 0 - for dylib_file in PROJECT_ROOT.rglob("*.dylib"): - if should_preserve_artifact(dylib_file): - continue - dylib_file.unlink() - dylib_count += 1 - print(f"Removed {dylib_count} .dylib files" if dylib_count else "No .dylib files found") - - xcf_count = 0 - for xcf_dir in PROJECT_ROOT.rglob("*.xcframework"): - if xcf_dir.is_dir(): - shutil.rmtree(xcf_dir) - xcf_count += 1 - print(f"Removed {xcf_count} .xcframework directories" if xcf_count else "No .xcframework directories found") - - pycache_count = 0 - for pycache_dir in PROJECT_ROOT.rglob("__pycache__"): - if pycache_dir.is_dir(): - shutil.rmtree(pycache_dir) - pycache_count += 1 - print(f"Removed {pycache_count} __pycache__ directories" if pycache_count else "No __pycache__ directories found") + def remove_if_exists(path): + if path.is_dir(): + print(f"Removing: {path}") + shutil.rmtree(path, ignore_errors=True) - egg_count = 0 - for egg_dir in PROJECT_ROOT.rglob("*.egg-info"): - if egg_dir.is_dir(): - shutil.rmtree(egg_dir) - egg_count += 1 - print(f"Removed {egg_count} .egg-info directories" if egg_count else "No .egg-info directories found") + for tree in ("venv", "weights", "transpiled"): + remove_if_exists(PROJECT_ROOT / tree) + remove_if_exists(PROJECT_ROOT / "python" / "cactus" / "bin") + remove_if_exists(PROJECT_ROOT / "python" / "cactus" / "code") - pytest_cache_count = 0 - for cache_dir in PROJECT_ROOT.rglob(".pytest_cache"): - if cache_dir.is_dir(): - shutil.rmtree(cache_dir) - pytest_cache_count += 1 - print(f"Removed {pytest_cache_count} .pytest_cache directories" if pytest_cache_count else "No .pytest_cache directories found") + print() + print("Sweeping build, dependency, and cache artifacts across the tree...") + + def is_artifact_dir(name): + return ( + name in {"node_modules", "dist", "__pycache__", ".pytest_cache"} + or name == "build" + or name.startswith(("build-", "build_")) + or name.endswith((".egg-info", ".xcframework")) + ) + + artifact_file_suffixes = {".so", ".a", ".bin", ".dylib"} + + removed_dirs = 0 + removed_files = 0 + for root, dirs, files in os.walk(PROJECT_ROOT, topdown=True): + root_path = Path(root) + kept = [] + for d in dirs: + if d == ".git": + continue + full = root_path / d + if is_artifact_dir(d) and not should_preserve(full): + print(f"Removing: {full}") + shutil.rmtree(full, ignore_errors=True) + removed_dirs += 1 + else: + kept.append(d) + dirs[:] = kept + for f in files: + full = root_path / f + if full.suffix in artifact_file_suffixes and not should_preserve(full): + try: + full.unlink() + removed_files += 1 + except OSError: + pass + print(f"Removed {removed_dirs} build/dependency directories and {removed_files} compiled artifacts") print() print_color(GREEN, "Clean complete!") diff --git a/python/cactus/cli/code.py b/python/cactus/cli/code.py new file mode 100644 index 000000000..87d023a20 --- /dev/null +++ b/python/cactus/cli/code.py @@ -0,0 +1,187 @@ +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.request +from pathlib import Path + +from .common import BLUE, DEFAULT_MODEL_ID, GREEN, RED, YELLOW, is_valid_bundle, print_color, weights_root + +_PACKAGE_DIR = Path(__file__).resolve().parent.parent +_PROJECT_ROOT = _PACKAGE_DIR.parent.parent + + +def _find_agent_cli() -> Path | None: + bundled = _PACKAGE_DIR / "code" / "packages" / "coding-agent" / "dist" / "cli.js" + if bundled.exists(): + return bundled + dev = _PROJECT_ROOT / "cactus-code" / "packages" / "coding-agent" / "dist" / "cli.js" + if dev.exists(): + return dev + return None + + +def _ensure_agent_built() -> Path | None: + cli = _find_agent_cli() + if cli is not None: + return cli + source_dir = _PROJECT_ROOT / "cactus-code" + if not (source_dir / "package.json").exists(): + return None + npm = shutil.which("npm") + if not npm: + print_color(RED, "Error: npm is required to build the Cactus coding agent on first run.") + print_color(YELLOW, "Install Node.js >= 22 (includes npm) from https://nodejs.org and retry.") + return None + print_color(BLUE, "Building the Cactus coding agent (first run, this can take a minute) ...") + try: + subprocess.run([npm, "install", "--no-audit", "--no-fund"], cwd=source_dir, check=True) + subprocess.run([npm, "run", "build"], cwd=source_dir, check=True) + except subprocess.CalledProcessError: + print_color(RED, "Error: failed to build the Cactus coding agent.") + return None + return _find_agent_cli() + + +def _discover_local_model() -> str | None: + try: + root = weights_root() + if not root.is_dir(): + return None + bundles = sorted(p for p in root.iterdir() if p.is_dir() and is_valid_bundle(p)) + return str(bundles[0]) if bundles else None + except Exception: + return None + + +def _clear_console() -> None: + if sys.stdout.isatty(): + sys.stdout.write("\033[2J\033[3J\033[H") + sys.stdout.flush() + + +def _base_url(host: str, port: int) -> str: + env = os.environ.get("CACTUS_BASE_URL") + if env: + return env.rstrip("/") + return f"http://{host}:{port}/v1" + + +def _server_alive(base_url: str, timeout: float = 1.5) -> bool: + try: + with urllib.request.urlopen(f"{base_url}/models", timeout=timeout) as resp: + return resp.status == 200 + except Exception: + return False + + +def _echo_log_tail(log_path: Path, pos: int) -> int: + try: + with open(log_path, "r", errors="replace") as f: + f.seek(pos) + chunk = f.read() + new_pos = f.tell() + if chunk: + sys.stdout.write(chunk) + sys.stdout.flush() + return new_pos + except Exception: + return pos + + +def _wait_for_server(base_url: str, proc: "subprocess.Popen | None" = None, timeout: float = 1800.0, + log_path: "Path | None" = None) -> bool: + deadline = time.time() + timeout + pos = 0 + while time.time() < deadline: + if log_path is not None: + pos = _echo_log_tail(log_path, pos) + if _server_alive(base_url): + return True + if proc is not None and proc.poll() is not None: + return False + time.sleep(0.5) + return False + + +def cmd_code(args) -> int: + node = shutil.which("node") + if not node: + print_color(RED, "Error: Node.js (>=22) is required to run `cactus code`.") + print_color(YELLOW, "Install it from https://nodejs.org and try again.") + return 1 + + agent_was_built = _find_agent_cli() is None + agent_cli = _ensure_agent_built() + if agent_cli is None: + print_color(RED, "Error: the Cactus coding agent is not available in this installation.") + return 1 + + base_url = _base_url(args.host, args.port) + started_server: subprocess.Popen | None = None + + if not _server_alive(base_url): + if args.no_serve or os.environ.get("CACTUS_BASE_URL"): + print_color(RED, f"Error: no Cactus server reachable at {base_url}.") + print_color(YELLOW, "Start one with `cactus serve ` first.") + return 1 + model_flags_given = ( + getattr(args, "reconvert", False) + or getattr(args, "bits", 4) != 4 + or getattr(args, "platform", "auto") != "auto" + or bool(getattr(args, "token", None)) + ) + if args.serve_model: + serve_model = args.serve_model + elif model_flags_given: + serve_model = DEFAULT_MODEL_ID + else: + serve_model = _discover_local_model() or DEFAULT_MODEL_ID + if serve_model == DEFAULT_MODEL_ID and not args.serve_model: + print_color(BLUE, f"No model specified; using the default ({DEFAULT_MODEL_ID}), downloading on first run ...") + print_color(BLUE, f"Starting Cactus server with {serve_model} ...") + serve_cmd = [ + sys.executable, "-m", "cactus", "serve", serve_model, + "--host", args.host, "--port", str(args.port), "--no-access-log", + "--bits", str(getattr(args, "bits", 4)), + "--platform", getattr(args, "platform", "auto"), + ] + if getattr(args, "token", None): + serve_cmd += ["--token", args.token] + if getattr(args, "reconvert", False): + serve_cmd.append("--reconvert") + if getattr(args, "no_cloud_handoff", False): + serve_cmd.append("--no-cloud-handoff") + if getattr(args, "confidence_threshold", None) is not None: + serve_cmd += ["--confidence-threshold", str(args.confidence_threshold)] + if getattr(args, "cloud_timeout_ms", None) is not None: + serve_cmd += ["--cloud-timeout-ms", str(args.cloud_timeout_ms)] + serve_log_path = Path(tempfile.gettempdir()) / "cactus-code-serve.log" + serve_log = open(serve_log_path, "w") + started_server = subprocess.Popen(serve_cmd, stdout=serve_log, stderr=subprocess.STDOUT) + if not _wait_for_server(base_url, started_server, log_path=serve_log_path): + print_color(RED, "Error: the Cactus server did not become ready in time.") + print_color(YELLOW, f"See server logs: {serve_log_path}") + started_server.terminate() + return 1 + print_color(GREEN, f"Cactus server ready (logs: {serve_log_path})") + + env = dict(os.environ) + env["CACTUS_BASE_URL"] = base_url + agent_args = [a for a in (args.agent_args or []) if a != "--"] + interactive = not any(a in ("-p", "--print") for a in agent_args) + if interactive and (started_server is not None or agent_was_built): + _clear_console() + try: + return subprocess.run([node, str(agent_cli), *agent_args], env=env).returncode + except KeyboardInterrupt: + return 130 + finally: + if started_server is not None: + started_server.terminate() + try: + started_server.wait(timeout=10) + except Exception: + started_server.kill() diff --git a/python/cactus/cli/convert.py b/python/cactus/cli/convert.py index 91486e31a..55cf3333e 100644 --- a/python/cactus/cli/convert.py +++ b/python/cactus/cli/convert.py @@ -3,7 +3,7 @@ from pathlib import Path from .common import GREEN, RED, YELLOW, print_color -from .download import get_weights_dir +from .download import get_bundle_dir, get_weights_dir, resolve_platform def _merge_lora_adapter(base_model_id, lora_path, token=None): @@ -56,12 +56,19 @@ def cmd_convert(args): return 1 source_model_id = merged_dir - output_dir = args.output_dir or str(get_weights_dir(args.model_id)) + platform = resolve_platform(getattr(args, "platform", "auto")) + output_dir = args.output_dir or str( + get_bundle_dir(args.model_id, bits=args.bits, platform=platform) + ) + + if platform == "apple" and not getattr(args, "npu", False): + args.npu = True try: ensure_weights( source_model_id, bits=args.bits, + platform=platform, token=args.token, reconvert=args.reconvert, output_dir=output_dir, diff --git a/python/cactus/cli/serve.py b/python/cactus/cli/serve.py index d5dc0ab7e..5f587ef9e 100644 --- a/python/cactus/cli/serve.py +++ b/python/cactus/cli/serve.py @@ -19,9 +19,23 @@ def _resolve_model_arg(model: str | None) -> tuple[Path | None, str | None]: return None, model +def _ensure_engine_library() -> bool: + from .runtime import ensure_python_runtime_library + + try: + ensure_python_runtime_library() + return True + except Exception as exc: + print_color(RED, f"Error: could not build the Cactus engine: {exc}") + print_color(YELLOW, "Build it manually with `cactus build --python` (needs cmake and a C++ toolchain).") + return False + + def cmd_serve(args): """Start the OpenAI-compatible HTTP server.""" apply_runtime_env(args) + if not _ensure_engine_library(): + return 1 model_path, model_name = _resolve_model_arg(args.model_id) if args.model_id and model_path is None: from .model import prepare_bundle @@ -65,5 +79,6 @@ def cmd_serve(args): if args.host not in {"127.0.0.1", "localhost", "::1"}: print_color(YELLOW, f"Warning: binding to {args.host} exposes the server beyond loopback; no auth is enforced.") print_color(BLUE, f"Starting server on {args.host}:{args.port}") - uvicorn.run(application, host=args.host, port=args.port, log_level="info") + uvicorn.run(application, host=args.host, port=args.port, log_level="info", + access_log=not getattr(args, "no_access_log", False)) return 0 diff --git a/python/cactus/server.py b/python/cactus/server.py index 18d23dc4b..83a4caa90 100644 --- a/python/cactus/server.py +++ b/python/cactus/server.py @@ -2,19 +2,25 @@ from __future__ import annotations import asyncio +import base64 import json import logging +import math +import os import re +import tempfile import time import uuid from contextlib import asynccontextmanager from dataclasses import dataclass +from io import BytesIO from pathlib import Path from typing import Any from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.responses import PlainTextResponse, StreamingResponse -from pydantic import BaseModel +from PIL import Image +from pydantic import BaseModel, Field from .bindings.cactus import ( cactus_complete, @@ -273,7 +279,7 @@ class ToolChoiceObject(Permissive): class ChatRequest(Permissive): model: str - messages: list[ChatMessage] + messages: list[ChatMessage] = Field(min_length=1) temperature: float | None = None top_p: float | None = None top_k: int | None = None @@ -281,6 +287,7 @@ class ChatRequest(Permissive): max_completion_tokens: int | None = None stop: str | list[str] | None = None stream: bool = False + reasoning_effort: str | None = None tools: list[Tool] | None = None tool_choice: str | ToolChoiceObject | None = None @@ -290,16 +297,68 @@ class EmbeddingRequest(Permissive): input: str | list[str] +_DATA_URI_RE = re.compile(r"^data:(?P[^;,]+)?(?P;base64)?,(?P.*)$", re.DOTALL) + +_ENGINE_IMAGE_FORMATS = {"PNG", "JPEG", "GIF"} + + +def _decodable_image(source: Any) -> bool: + try: + with Image.open(source) as im: + if im.format not in _ENGINE_IMAGE_FORMATS or im.width <= 0 or im.height <= 0: + return False + im.load() + return True + except Exception: + return False + + +def _materialize_image(url: str) -> str | None: + if not isinstance(url, str) or not url: + return None + if url.startswith("data:"): + match = _DATA_URI_RE.match(url) + if not match: + return None + mime = (match.group("mime") or "image/png").lower() + ext = "." + mime.split("/")[-1].split("+")[0] if "/" in mime else ".png" + payload = match.group("data") + try: + raw = base64.b64decode(payload) if match.group("b64") else payload.encode("utf-8") + except ValueError: + return None + if not _decodable_image(BytesIO(raw)): + return None + fd, path = tempfile.mkstemp(suffix=ext, prefix="cactus_img_") + with os.fdopen(fd, "wb") as fh: + fh.write(raw) + return path + if url.startswith("file://"): + url = url[len("file://"):] + if Path(url).exists(): + return url if _decodable_image(url) else None + return None + + def _flatten_message(msg: ChatMessage) -> dict[str, Any]: out: dict[str, Any] = {"role": msg.role} if isinstance(msg.content, list): parts = [] + images: list[str] = [] for part in msg.content: if isinstance(part, dict) and part.get("type") == "text": parts.append(str(part.get("text", ""))) + elif isinstance(part, dict) and part.get("type") == "image_url": + raw = part.get("image_url") + url = raw.get("url") if isinstance(raw, dict) else (raw if isinstance(raw, str) else part.get("url")) + resolved = _materialize_image(url) if isinstance(url, str) else None + if resolved: + images.append(resolved) elif isinstance(part, str): parts.append(part) out["content"] = "\n".join(parts) + if images: + out["images"] = images elif msg.content is not None: out["content"] = msg.content if msg.tool_call_id is not None: @@ -342,27 +401,118 @@ def _make_tool_calls(function_calls: list[Any], *, with_index: bool = False) -> return out +def _clamp_finite(value: Any, lo: float, hi: float) -> float | None: + """Clamp a sampling param to a finite, sane range so extreme/non-finite + values (e.g. 1e308, inf, nan) can't overflow the native sampler.""" + if value is None: + return None + try: + v = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(v): + return None + return max(lo, min(hi, v)) + + def _chat_options(req: ChatRequest) -> dict[str, Any]: options: dict[str, Any] = {} - for key in ("temperature", "top_p", "top_k"): - value = getattr(req, key) - if value is not None: - options[key] = value + temperature = _clamp_finite(req.temperature, 0.0, 2.0) + options["temperature"] = temperature if temperature is not None else 0.7 + top_p = _clamp_finite(req.top_p, 0.0, 1.0) + if top_p is not None: + options["top_p"] = top_p + if req.top_k is not None: + options["top_k"] = max(0, min(int(req.top_k), 1 << 20)) max_tokens = req.max_tokens if req.max_tokens is not None else req.max_completion_tokens - if max_tokens is not None: - options["max_tokens"] = max_tokens + options["max_tokens"] = max_tokens if max_tokens is not None else 4096 if req.stop: options["stop_sequences"] = [req.stop] if isinstance(req.stop, str) else req.stop + if req.reasoning_effort and req.reasoning_effort.lower() not in ("none", "off"): + options["enable_thinking_if_supported"] = True return options +_HARMONY_MARKER = "<|channel" +_HARMONY_THOUGHT_CHANNELS = {"thought", "thinking", "analysis", "reflection"} +_HARMONY_CHANNEL_RE = re.compile(r"<\|channel\|?>\s*([a-zA-Z_]+)\s*\n?") +_HARMONY_CONTROL_RE = re.compile(r"<\|[^>]*>") + + +def _split_harmony_channels(text: str) -> tuple[str, str | None]: + if not text or _HARMONY_MARKER not in text: + return text, None + segments = _HARMONY_CHANNEL_RE.split(text) + content_parts: list[str] = [] + reasoning_parts: list[str] = [] + if segments[0].strip(): + content_parts.append(segments[0]) + for i in range(1, len(segments) - 1, 2): + body = segments[i + 1] + target = reasoning_parts if segments[i].lower() in _HARMONY_THOUGHT_CHANNELS else content_parts + target.append(body) + content = _HARMONY_CONTROL_RE.sub("", "".join(content_parts)).strip() + reasoning = _HARMONY_CONTROL_RE.sub("", "\n".join(reasoning_parts)).strip() or None + if not content and reasoning: + return reasoning, None + return content, reasoning + + +class _HarmonyStreamSplitter: + def __init__(self) -> None: + self._buf = "" + self._channel = "final" + + def _kind(self) -> str: + return "reasoning" if self._channel in _HARMONY_THOUGHT_CHANNELS else "content" + + def _hold_partial(self, s: str) -> str: + for k in range(min(len(s), len(_HARMONY_MARKER) - 1), 0, -1): + if _HARMONY_MARKER.startswith(s[-k:]): + return s[:-k] + return s + + def feed(self, text: str) -> list[tuple[str, str]]: + self._buf += text + out: list[tuple[str, str]] = [] + while True: + idx = self._buf.find(_HARMONY_MARKER) + if idx == -1: + safe = self._hold_partial(self._buf) + if safe: + out.append((self._kind(), _HARMONY_CONTROL_RE.sub("", safe))) + self._buf = self._buf[len(safe):] + break + before = self._buf[:idx] + if before: + out.append((self._kind(), _HARMONY_CONTROL_RE.sub("", before))) + rest = self._buf[idx + len(_HARMONY_MARKER):] + m = re.match(r"\|?>\s*([a-zA-Z_]+)(?:[ \t]*\n|(?=<))", rest) + if not m: + self._buf = self._buf[idx:] + break + self._channel = m.group(1).lower() + self._buf = rest[m.end():] + return out + + def flush(self) -> list[tuple[str, str]]: + if not self._buf: + return [] + out = [(self._kind(), _HARMONY_CONTROL_RE.sub("", self._buf))] + self._buf = "" + return out + + def _build_chat_response(result: dict[str, Any], model_id: str, request_id: str) -> dict[str, Any]: function_calls = result.get("function_calls") or [] tool_calls = _make_tool_calls(function_calls) has_tool_calls = bool(tool_calls) prefill = int(result.get("prefill_tokens") or 0) decode = int(result.get("decode_tokens") or 0) - message: dict[str, Any] = {"role": "assistant", "content": None if has_tool_calls else result.get("response", "")} + content, reasoning = _split_harmony_channels(result.get("response", "") or "") + message: dict[str, Any] = {"role": "assistant", "content": None if has_tool_calls else content} + if reasoning: + message["reasoning_content"] = reasoning if has_tool_calls: message["tool_calls"] = tool_calls return { @@ -381,6 +531,7 @@ def _build_chat_response(result: dict[str, Any], model_id: str, request_id: str) "completion_tokens": decode, "total_tokens": prefill + decode, }, + "cloud_handoff": bool(result.get("cloud_handoff", False)), } @@ -424,20 +575,33 @@ async def run_inference(): result = None error = None + splitter = _HarmonyStreamSplitter() + + def _delta_chunk(piece_kind: str, text: str) -> str: + field = "reasoning_content" if piece_kind == "reasoning" else "content" + return _event({ + "id": request_id, + "object": "chat.completion.chunk", + "created": created, + "model": req.model, + "choices": [{"index": 0, "delta": {field: text}, "logprobs": None, "finish_reason": None}], + }) + + def _consume(pieces: list[tuple[str, str]]) -> list[str]: + return [_delta_chunk(piece_kind, piece_text) for piece_kind, piece_text in pieces if piece_text] + while True: kind, value = await queue.get() if kind == "token": - yield _event({ - "id": request_id, - "object": "chat.completion.chunk", - "created": created, - "model": req.model, - "choices": [{"index": 0, "delta": {"content": value}, "logprobs": None, "finish_reason": None}], - }) + for event in _consume(splitter.feed(value)): + yield event elif kind == "done": result, error = value break + for event in _consume(splitter.flush()): + yield event + await task if error is not None: # OpenAI streaming spec embeds errors in a `data:` line with an `error` @@ -477,6 +641,7 @@ async def run_inference(): "completion_tokens": int((result or {}).get("decode_tokens") or 0), "total_tokens": int((result or {}).get("total_tokens") or 0), }, + "cloud_handoff": bool((result or {}).get("cloud_handoff", False)), } yield _event(final) yield "data: [DONE]\n\n" @@ -550,6 +715,8 @@ async def chat_completions(request: Request, req: ChatRequest): messages = [_flatten_message(m) for m in req.messages] tools, force_tools = _translate_tools(req.tools, req.tool_choice) options = _chat_options(req) + if "max_tokens" not in options: + options["max_tokens"] = info.context_length if info.context_length > 0 else 8192 state = request.app.state if not state.auto_handoff: options["auto_handoff"] = False @@ -658,7 +825,7 @@ async def create_embeddings(request: Request, req: EmbeddingRequest): if not info.supports_embedding: raise HTTPException(status_code=400, detail=f"Model '{req.model}' is not an embedding model") inputs = [req.input] if isinstance(req.input, str) else list(req.input) - if not inputs: + if not inputs or any(not text for text in inputs): raise HTTPException(status_code=400, detail="'input' must not be empty") mgr: ModelManager = request.app.state.manager diff --git a/python/pyproject.toml b/python/pyproject.toml index ed62f66e2..f5c09791e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -63,10 +63,11 @@ version = {attr = "cactus._version.__version__"} [tool.setuptools.packages.find] where = ["."] include = ["cactus*"] +exclude = ["cactus.code*"] [tool.setuptools.package-data] "cactus.bindings" = ["lib/*.dylib", "lib/*.so"] -"cactus" = ["bin/run", "bin/transcribe", "assets/*"] +"cactus" = ["bin/run", "bin/transcribe", "assets/*", "code/**/*"] "cactus.convert" = ["assets/*/*"] [tool.pytest.ini_options] From 8c1fa72d6bae7d9036725388232fcaf5d2f168ab Mon Sep 17 00:00:00 2001 From: jakmro Date: Fri, 26 Jun 2026 07:33:16 +0200 Subject: [PATCH 2/3] fix python workflow Signed-off-by: jakmro --- .github/workflows/pypi.yml | 12 +++--------- cactus-engine/src/engine.h | 27 --------------------------- 2 files changed, 3 insertions(+), 36 deletions(-) diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index eaffb7cc4..0da58124d 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -42,6 +42,8 @@ jobs: run: | (cd cactus-code && npm install --no-audit --no-fund) bash cactus-code/scripts/bundle-for-pypi.sh + rm -rf python/cactus/code/packages/tui/native/win32 + rm -rf python/cactus/code/packages/tui/native/darwin/prebuilds/darwin-x64 - name: Build and repair wheel working-directory: python @@ -87,6 +89,7 @@ jobs: run: | (cd cactus-code && npm install --no-audit --no-fund) bash cactus-code/scripts/bundle-for-pypi.sh + rm -rf python/cactus/code/packages/tui/native - name: Build and repair wheel working-directory: python @@ -112,15 +115,6 @@ jobs: - uses: ./.github/actions/stamp-version - - uses: actions/setup-node@v4 - with: - node-version: "22" - - - name: Bundle coding agent into the sdist - run: | - (cd cactus-code && npm install --no-audit --no-fund) - bash cactus-code/scripts/bundle-for-pypi.sh - - name: Build sdist working-directory: python run: | diff --git a/cactus-engine/src/engine.h b/cactus-engine/src/engine.h index 021b53b46..34678a10f 100644 --- a/cactus-engine/src/engine.h +++ b/cactus-engine/src/engine.h @@ -312,33 +312,6 @@ inline std::string extract_json_string(const std::string& json, size_t& pos) { else if (json[pos] == 'r') value += '\r'; else if (json[pos] == '"') value += '"'; else if (json[pos] == '\\') value += '\\'; - else if (json[pos] == 'u' && pos + 4 < json.size()) { - unsigned int cp = 0; - bool ok = true; - for (int k = 1; k <= 4; k++) { - char h = json[pos + k]; - cp <<= 4; - if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); - else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); - else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); - else { ok = false; break; } - } - if (ok) { - pos += 4; - if (cp < 0x80) { - value += static_cast(cp); - } else if (cp < 0x800) { - value += static_cast(0xC0 | (cp >> 6)); - value += static_cast(0x80 | (cp & 0x3F)); - } else { - value += static_cast(0xE0 | (cp >> 12)); - value += static_cast(0x80 | ((cp >> 6) & 0x3F)); - value += static_cast(0x80 | (cp & 0x3F)); - } - } else { - value += json[pos]; - } - } else value += json[pos]; } else { value += json[pos]; From 76b96d4b2f2c5bc8754b2d3675047633c2159bd6 Mon Sep 17 00:00:00 2001 From: jakmro Date: Fri, 26 Jun 2026 15:04:00 +0200 Subject: [PATCH 3/3] parsing fixes --- cactus-engine/src/gemma_tools.h | 19 +++++++++- python/cactus/server.py | 66 ++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/cactus-engine/src/gemma_tools.h b/cactus-engine/src/gemma_tools.h index 8d3ccc6af..6b10a74a0 100644 --- a/cactus-engine/src/gemma_tools.h +++ b/cactus-engine/src/gemma_tools.h @@ -781,12 +781,27 @@ inline void parse_function_calls_core(std::string& response, std::vector(response[content_start]))) { + content_start++; + } + + const bool is_call = response.compare(content_start, 5, "call:") == 0; + size_t scan_from = content_start; + if (is_call) { + size_t brace_pos = response.find('{', content_start + 5); + if (brace_pos != std::string::npos) { + size_t bp = brace_pos; + read_balanced(response, bp, '{', '}'); + scan_from = bp; + } + } + size_t call_end_pos = response.find(CALL_END, scan_from); size_t content_end = (call_end_pos != std::string::npos) ? call_end_pos : response.length(); std::string call_content = response.substr(content_start, content_end - content_start); - if (call_content.compare(0, 5, "call:") == 0) { + if (is_call) { size_t brace_pos = call_content.find('{'); if (brace_pos != std::string::npos) { emit_call(call_content.substr(5, brace_pos - 5), call_content.substr(brace_pos)); diff --git a/python/cactus/server.py b/python/cactus/server.py index 83a4caa90..0192c0e0b 100644 --- a/python/cactus/server.py +++ b/python/cactus/server.py @@ -502,6 +502,55 @@ def flush(self) -> list[tuple[str, str]]: self._buf = "" return out +_TOOLCALL_PAIRS = ( + ("<|tool_call>", ""), + ("", ""), + ("", ""), + ("<|tool_call_start|>", "<|tool_call_end|>"), +) +_TOOLCALL_OPENERS = tuple(o for o, _ in _TOOLCALL_PAIRS) + + +def _strip_tool_call_markers(text: str) -> str: + if not text or ("call:" not in text and "tool_call" not in text + and "function_call" not in text and '<|"|>' not in text): + return text + s = text + for open_m, close_m in _TOOLCALL_PAIRS: + while True: + ci = s.find(close_m) + if ci == -1: + break + oi = s.rfind(open_m, 0, ci) + if oi == -1: + oi = s.rfind("call:", 0, ci) + if oi == -1: + s = s[:ci] + s[ci + len(close_m):] + else: + s = s[:oi] + s[ci + len(close_m):] + cut = None + for open_m in _TOOLCALL_OPENERS: + oi = s.find(open_m) + if oi != -1: + cut = oi if cut is None else min(cut, oi) + if cut is not None: + s = s[:cut] + ci = s.find("call:") + while ci != -1: + j = ci + 5 + k = j + while k < len(s) and (s[k].isalnum() or s[k] in "_-."): + k += 1 + m = k + while m < len(s) and s[m] == " ": + m += 1 + if k > j and m < len(s) and s[m] == "{": + s = s[:ci] + break + ci = s.find("call:", ci + 5) + s = s.replace('<|"|>', '"') + return s.strip() if s != text else text + def _build_chat_response(result: dict[str, Any], model_id: str, request_id: str) -> dict[str, Any]: function_calls = result.get("function_calls") or [] @@ -510,6 +559,7 @@ def _build_chat_response(result: dict[str, Any], model_id: str, request_id: str) prefill = int(result.get("prefill_tokens") or 0) decode = int(result.get("decode_tokens") or 0) content, reasoning = _split_harmony_channels(result.get("response", "") or "") + content = _strip_tool_call_markers(content) message: dict[str, Any] = {"role": "assistant", "content": None if has_tool_calls else content} if reasoning: message["reasoning_content"] = reasoning @@ -587,8 +637,17 @@ def _delta_chunk(piece_kind: str, text: str) -> str: "choices": [{"index": 0, "delta": {field: text}, "logprobs": None, "finish_reason": None}], }) + tools_present = bool(tools) + def _consume(pieces: list[tuple[str, str]]) -> list[str]: - return [_delta_chunk(piece_kind, piece_text) for piece_kind, piece_text in pieces if piece_text] + out: list[str] = [] + for piece_kind, piece_text in pieces: + if not piece_text: + continue + if tools_present and piece_kind != "reasoning": + continue + out.append(_delta_chunk(piece_kind, piece_text)) + return out while True: kind, value = await queue.get() @@ -622,6 +681,11 @@ def _consume(pieces: list[tuple[str, str]]) -> list[str]: function_calls = (result or {}).get("function_calls") or [] tool_calls = _make_tool_calls(function_calls, with_index=True) finish_reason = "tool_calls" if tool_calls else "stop" + if tools_present and not tool_calls: + clean_content, _ = _split_harmony_channels((result or {}).get("response", "") or "") + clean_content = _strip_tool_call_markers(clean_content) + if clean_content: + yield _delta_chunk("content", clean_content) if tool_calls: yield _event({ "id": request_id,