painapple-code 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- painapple_code/__init__.py +30 -0
- painapple_code/__main__.py +18 -0
- painapple_code/_version.py +24 -0
- painapple_code/auth_middleware.py +930 -0
- painapple_code/cli/__init__.py +204 -0
- painapple_code/cli/deploy/__init__.py +15 -0
- painapple_code/cli/deploy/claude_seed.py +41 -0
- painapple_code/cli/deploy/config.py +260 -0
- painapple_code/cli/deploy/container.py +715 -0
- painapple_code/cli/deploy/launch.py +163 -0
- painapple_code/cli/deploy/runtime.py +223 -0
- painapple_code/cli/lifecycle_cmd.py +443 -0
- painapple_code/cli/list_cmd.py +572 -0
- painapple_code/cli/manage_cmd.py +670 -0
- painapple_code/cli/netinfo.py +125 -0
- painapple_code/cli/profiles.py +390 -0
- painapple_code/cli/serve_args.py +163 -0
- painapple_code/cli/serve_config.py +256 -0
- painapple_code/cli/setup_cmd.py +664 -0
- painapple_code/cli/ui.py +438 -0
- painapple_code/cost_analytics.py +879 -0
- painapple_code/data/models.defaults.yaml +18 -0
- painapple_code/data/models.yaml +18 -0
- painapple_code/data/presets.defaults.json +32 -0
- painapple_code/data/strings.yaml +2269 -0
- painapple_code/helpers.py +406 -0
- painapple_code/paths.py +1514 -0
- painapple_code/prompt_explorer.py +984 -0
- painapple_code/providers/__init__.py +224 -0
- painapple_code/providers/base.py +710 -0
- painapple_code/providers/claude/__init__.py +74 -0
- painapple_code/providers/claude/capabilities.py +218 -0
- painapple_code/providers/claude/errors.py +79 -0
- painapple_code/providers/claude/launch.py +50 -0
- painapple_code/providers/claude/summary.py +89 -0
- painapple_code/providers/claude/translate.py +74 -0
- painapple_code/providers/claude_sdk/__init__.py +139 -0
- painapple_code/providers/claude_sdk/driver.py +412 -0
- painapple_code/providers/codex/__init__.py +87 -0
- painapple_code/providers/codex/capabilities.py +321 -0
- painapple_code/providers/codex/errors.py +70 -0
- painapple_code/providers/codex/launch.py +142 -0
- painapple_code/providers/codex/summary.py +196 -0
- painapple_code/providers/codex/translate.py +204 -0
- painapple_code/providers/codex_app_server/__init__.py +100 -0
- painapple_code/providers/codex_app_server/capabilities.py +41 -0
- painapple_code/providers/codex_app_server/launch.py +202 -0
- painapple_code/providers/codex_app_server/summary.py +291 -0
- painapple_code/providers/codex_app_server/translate.py +263 -0
- painapple_code/providers/codex_app_server/transport.py +238 -0
- painapple_code/routes/__init__.py +0 -0
- painapple_code/routes/api_agents.py +441 -0
- painapple_code/routes/api_app.py +368 -0
- painapple_code/routes/api_app_commit_sections.py +127 -0
- painapple_code/routes/api_app_config.py +997 -0
- painapple_code/routes/api_app_session_prefs.py +217 -0
- painapple_code/routes/api_browser.py +690 -0
- painapple_code/routes/api_commands.py +374 -0
- painapple_code/routes/api_costs.py +128 -0
- painapple_code/routes/api_drafts.py +139 -0
- painapple_code/routes/api_exec.py +149 -0
- painapple_code/routes/api_files.py +542 -0
- painapple_code/routes/api_git.py +657 -0
- painapple_code/routes/api_logs.py +649 -0
- painapple_code/routes/api_plugins.py +275 -0
- painapple_code/routes/api_project_config.py +418 -0
- painapple_code/routes/api_prompts.py +231 -0
- painapple_code/routes/api_providers.py +53 -0
- painapple_code/routes/api_search.py +411 -0
- painapple_code/routes/api_session_stash.py +234 -0
- painapple_code/routes/api_session_welcome.py +151 -0
- painapple_code/routes/api_sessions.py +742 -0
- painapple_code/routes/api_shadow.py +323 -0
- painapple_code/routes/api_shadow_db.py +512 -0
- painapple_code/routes/api_shadow_files.py +332 -0
- painapple_code/routes/api_shadow_search.py +476 -0
- painapple_code/routes/api_skills.py +610 -0
- painapple_code/routes/api_tasks.py +147 -0
- painapple_code/routes/api_terminal.py +540 -0
- painapple_code/routes/api_upload.py +293 -0
- painapple_code/routes/api_viewer.py +229 -0
- painapple_code/routes/dependencies.py +109 -0
- painapple_code/routes/ws_chat.py +837 -0
- painapple_code/server.py +2208 -0
- painapple_code/server_logging.py +263 -0
- painapple_code/services/__init__.py +0 -0
- painapple_code/services/agent_session.py +2738 -0
- painapple_code/session_store.py +253 -0
- painapple_code/session_store_core.py +879 -0
- painapple_code/shadow_db.py +830 -0
- painapple_code/shadow_db_plans.py +369 -0
- painapple_code/shadow_db_queries.py +595 -0
- painapple_code/shadow_db_schema.py +676 -0
- painapple_code/shadow_git.py +1296 -0
- painapple_code/shadow_git_frontmatter.py +314 -0
- painapple_code/shadow_git_sections.py +520 -0
- painapple_code/shadow_git_summary.py +462 -0
- painapple_code/shadow_parser.py +560 -0
- painapple_code/static/css/00-variables.css +276 -0
- painapple_code/static/css/01-base.css +245 -0
- painapple_code/static/css/02-tooltips.css +36 -0
- painapple_code/static/css/09-left-rail.css +448 -0
- painapple_code/static/css/10-header.css +777 -0
- painapple_code/static/css/11-connection-bar.css +120 -0
- painapple_code/static/css/20-messages.css +750 -0
- painapple_code/static/css/21-tool-blocks.css +2340 -0
- painapple_code/static/css/22-edit-diff.css +619 -0
- painapple_code/static/css/23-thinking.css +1584 -0
- painapple_code/static/css/24-background-tasks.css +359 -0
- painapple_code/static/css/25-context-block.css +252 -0
- painapple_code/static/css/26-session-setup.css +144 -0
- painapple_code/static/css/30-markdown.css +854 -0
- painapple_code/static/css/40-input.css +2418 -0
- painapple_code/static/css/41-autocomplete.css +127 -0
- painapple_code/static/css/42-activity-strip.css +97 -0
- painapple_code/static/css/43-file-autocomplete.css +121 -0
- painapple_code/static/css/44-snippets-autocomplete.css +224 -0
- painapple_code/static/css/45-selection-bar.css +433 -0
- painapple_code/static/css/46-keyboard-bar.css +119 -0
- painapple_code/static/css/47-keyboard-longpress.css +116 -0
- painapple_code/static/css/47-token-profile.css +106 -0
- painapple_code/static/css/50-welcome.css +3095 -0
- painapple_code/static/css/51-modals.css +301 -0
- painapple_code/static/css/52-utilities.css +77 -0
- painapple_code/static/css/53-image-upload.css +456 -0
- painapple_code/static/css/54-todo-list.css +102 -0
- painapple_code/static/css/55-file-upload.css +113 -0
- painapple_code/static/css/56-chat-navigator.css +218 -0
- painapple_code/static/css/56-permission-card.css +332 -0
- painapple_code/static/css/56-plan-approval.css +204 -0
- painapple_code/static/css/56-question-form.css +602 -0
- painapple_code/static/css/57-swipe-indicator.css +95 -0
- painapple_code/static/css/58-session-families.css +627 -0
- painapple_code/static/css/59-about.css +196 -0
- painapple_code/static/css/61-log-explorer.css +723 -0
- painapple_code/static/css/62-json-tree.css +489 -0
- painapple_code/static/css/62-skills-widget.css +650 -0
- painapple_code/static/css/63-commands-widget.css +30 -0
- painapple_code/static/css/63-plugins-widget.css +174 -0
- painapple_code/static/css/63-terminal.css +516 -0
- painapple_code/static/css/64-search-files-widget.css +448 -0
- painapple_code/static/css/65-snippets-widget.css +426 -0
- painapple_code/static/css/66-active-sessions.css +979 -0
- painapple_code/static/css/66-agents-widget.css +265 -0
- painapple_code/static/css/66-file-explorer-widget.css +855 -0
- painapple_code/static/css/66-git-panel.css +791 -0
- painapple_code/static/css/66-history-explorer.css +1496 -0
- painapple_code/static/css/67-diff-viewer.css +602 -0
- painapple_code/static/css/67-editor-view.css +748 -0
- painapple_code/static/css/67-file-preview-widget.css +1873 -0
- painapple_code/static/css/68-browser-widget.css +337 -0
- painapple_code/static/css/68-compare-wizard.css +355 -0
- painapple_code/static/css/68-csv-preview.css +106 -0
- painapple_code/static/css/68-diff-preview.css +103 -0
- painapple_code/static/css/68-helpers-install-widget.css +491 -0
- painapple_code/static/css/68-image-annotate.css +346 -0
- painapple_code/static/css/68-terminal-view.css +98 -0
- painapple_code/static/css/68-uploads-widget.css +229 -0
- painapple_code/static/css/69-cost-analytics.css +537 -0
- painapple_code/static/css/70-config-panel.css +3736 -0
- painapple_code/static/css/70-excalidraw.css +136 -0
- painapple_code/static/css/70-prompt-explorer.css +890 -0
- painapple_code/static/css/71-chart.css +111 -0
- painapple_code/static/css/72-discussion-sidebar.css +678 -0
- painapple_code/static/css/74-debug-logs.css +656 -0
- painapple_code/static/css/80-open-dialog.css +146 -0
- painapple_code/static/css/80-quick-switcher.css +383 -0
- painapple_code/static/css/80-widget-system.css +1763 -0
- painapple_code/static/css/81-quick-actions.css +1020 -0
- painapple_code/static/css/82-context-popover.css +1233 -0
- painapple_code/static/css/83-context-menu.css +339 -0
- painapple_code/static/css/85-lazy-loading.css +102 -0
- painapple_code/static/css/86-zen-mode.css +708 -0
- painapple_code/static/css/87-grid-switcher.css +346 -0
- painapple_code/static/css/login.css +198 -0
- painapple_code/static/dist/app.bundle.js +7177 -0
- painapple_code/static/feature-triage.html +446 -0
- painapple_code/static/icons/apple-touch-icon.png +0 -0
- painapple_code/static/icons/favicon-32.png +0 -0
- painapple_code/static/icons/favicon.ico +0 -0
- painapple_code/static/icons/icon-128.png +0 -0
- painapple_code/static/icons/icon-144.png +0 -0
- painapple_code/static/icons/icon-152.png +0 -0
- painapple_code/static/icons/icon-180.png +0 -0
- painapple_code/static/icons/icon-192.png +0 -0
- painapple_code/static/icons/icon-384.png +0 -0
- painapple_code/static/icons/icon-512.png +0 -0
- painapple_code/static/icons/icon-72.png +0 -0
- painapple_code/static/icons/icon-96.png +0 -0
- painapple_code/static/js/action-delegate.js +187 -0
- painapple_code/static/js/activity-strip.js +193 -0
- painapple_code/static/js/app/context-menus.js +619 -0
- painapple_code/static/js/app/file-preview.js +163 -0
- painapple_code/static/js/app/init-ui.js +574 -0
- painapple_code/static/js/app/input-events.js +561 -0
- painapple_code/static/js/app/input-resize.js +313 -0
- painapple_code/static/js/app/message-actions.js +419 -0
- painapple_code/static/js/app/panels.js +210 -0
- painapple_code/static/js/app/rendering-delegators.js +246 -0
- painapple_code/static/js/app/server-loading.js +365 -0
- painapple_code/static/js/app/session-ops.js +287 -0
- painapple_code/static/js/app/session-switch.js +443 -0
- painapple_code/static/js/app/settings-helpers.js +280 -0
- painapple_code/static/js/app/startup-welcome.js +290 -0
- painapple_code/static/js/app-context.js +157 -0
- painapple_code/static/js/app.js +2134 -0
- painapple_code/static/js/auth-fetch.js +46 -0
- painapple_code/static/js/background-tasks.js +274 -0
- painapple_code/static/js/boot.js +73 -0
- painapple_code/static/js/caret-position.js +171 -0
- painapple_code/static/js/chat-navigator.js +710 -0
- painapple_code/static/js/chat-search.js +323 -0
- painapple_code/static/js/command-executor.js +176 -0
- painapple_code/static/js/command-store.js +497 -0
- painapple_code/static/js/components.js +1414 -0
- painapple_code/static/js/config.js +218 -0
- painapple_code/static/js/context-menu.js +740 -0
- painapple_code/static/js/controllers/chat-controller.js +4252 -0
- painapple_code/static/js/controllers/dialog-controller.js +217 -0
- painapple_code/static/js/controllers/tab-controller.js +1828 -0
- painapple_code/static/js/controllers/thinking-controller.js +915 -0
- painapple_code/static/js/diff-utils.js +486 -0
- painapple_code/static/js/drafts-pill.js +95 -0
- painapple_code/static/js/editor-view.js +815 -0
- painapple_code/static/js/effort-settings.js +430 -0
- painapple_code/static/js/feature-triage.js +811 -0
- painapple_code/static/js/file-autocomplete.js +516 -0
- painapple_code/static/js/file-tabs.js +123 -0
- painapple_code/static/js/gestures.js +448 -0
- painapple_code/static/js/grid-switcher.js +334 -0
- painapple_code/static/js/input-handler.js +1472 -0
- painapple_code/static/js/keyboard-bar.js +495 -0
- painapple_code/static/js/layout-switcher.js +109 -0
- painapple_code/static/js/linkify-utils.js +200 -0
- painapple_code/static/js/login.js +141 -0
- painapple_code/static/js/open-dialog.js +879 -0
- painapple_code/static/js/orphan-terminals.js +373 -0
- painapple_code/static/js/path-utils.js +179 -0
- painapple_code/static/js/perf-marks.js +102 -0
- painapple_code/static/js/permission-settings.js +475 -0
- painapple_code/static/js/pointer-input.js +59 -0
- painapple_code/static/js/preview/json-tree-menu.js +226 -0
- painapple_code/static/js/preview/json-tree-toolbar.js +291 -0
- painapple_code/static/js/preview/json-tree.js +457 -0
- painapple_code/static/js/preview/preview-breadcrumb.js +387 -0
- painapple_code/static/js/preview/preview-edit.js +367 -0
- painapple_code/static/js/preview/preview-events.js +361 -0
- painapple_code/static/js/preview/preview-history.js +567 -0
- painapple_code/static/js/preview/preview-inline-edit.js +869 -0
- painapple_code/static/js/preview/preview-poll.js +108 -0
- painapple_code/static/js/preview/preview-render.js +572 -0
- painapple_code/static/js/preview/preview-search.js +421 -0
- painapple_code/static/js/preview/preview-state.js +256 -0
- painapple_code/static/js/preview/preview-utils.js +251 -0
- painapple_code/static/js/preview-plugins/chart-plugin.js +22 -0
- painapple_code/static/js/preview-plugins/csv-plugin.js +188 -0
- painapple_code/static/js/preview-plugins/diff-plugin.js +258 -0
- painapple_code/static/js/preview-plugins/excalidraw-plugin.js +25 -0
- painapple_code/static/js/preview-plugins/html-plugin.js +104 -0
- painapple_code/static/js/preview-plugins/image-plugin.js +20 -0
- painapple_code/static/js/preview-plugins/index.js +34 -0
- painapple_code/static/js/preview-plugins/json-plugin.js +99 -0
- painapple_code/static/js/preview-plugins/jsonl-plugin.js +152 -0
- painapple_code/static/js/preview-plugins/markdown-plugin.js +266 -0
- painapple_code/static/js/preview-plugins/panzoom-plugin.js +63 -0
- painapple_code/static/js/preview-plugins/plugin-helpers.js +282 -0
- painapple_code/static/js/project-color-picker.js +110 -0
- painapple_code/static/js/project-colors.js +163 -0
- painapple_code/static/js/prompt-favorites.js +187 -0
- painapple_code/static/js/quick-actions-menu.js +1736 -0
- painapple_code/static/js/quick-actions-registry.js +1239 -0
- painapple_code/static/js/quick-switcher/controller.js +294 -0
- painapple_code/static/js/quick-switcher/fuzzy-scorer.js +123 -0
- painapple_code/static/js/quick-switcher/index.js +30 -0
- painapple_code/static/js/quick-switcher/providers/base-provider.js +51 -0
- painapple_code/static/js/quick-switcher/providers/command-provider.js +56 -0
- painapple_code/static/js/quick-switcher/providers/file-provider.js +291 -0
- painapple_code/static/js/quick-switcher/providers/panel-provider.js +132 -0
- painapple_code/static/js/quick-switcher/providers/project-provider.js +361 -0
- painapple_code/static/js/quick-switcher/providers/read-files-provider.js +104 -0
- painapple_code/static/js/quick-switcher/providers/skills-provider.js +129 -0
- painapple_code/static/js/quick-switcher/registry.js +69 -0
- painapple_code/static/js/quick-switcher/ui/item.js +54 -0
- painapple_code/static/js/quick-switcher/ui/picker.js +464 -0
- painapple_code/static/js/recent-opens.js +40 -0
- painapple_code/static/js/scroll-manager.js +530 -0
- painapple_code/static/js/scroll-state-machine.js +359 -0
- painapple_code/static/js/selection/action-bar.js +793 -0
- painapple_code/static/js/selection/index.js +15 -0
- painapple_code/static/js/selection/selection-handler.js +877 -0
- painapple_code/static/js/selection/state.js +181 -0
- painapple_code/static/js/session/agent-progress.js +202 -0
- painapple_code/static/js/session/handle-agent-message.js +481 -0
- painapple_code/static/js/session/handle-message.js +550 -0
- painapple_code/static/js/session/interactive.js +587 -0
- painapple_code/static/js/session/message-store.js +271 -0
- painapple_code/static/js/session/messages.js +253 -0
- painapple_code/static/js/session/persistence.js +179 -0
- painapple_code/static/js/session/restore.js +136 -0
- painapple_code/static/js/session/sync.js +333 -0
- painapple_code/static/js/session-container-pool.js +371 -0
- painapple_code/static/js/session-setup-panel.js +274 -0
- painapple_code/static/js/session.js +1047 -0
- painapple_code/static/js/shortcut-hints.js +272 -0
- painapple_code/static/js/shortcuts.js +1146 -0
- painapple_code/static/js/skills-autocomplete.js +262 -0
- painapple_code/static/js/snippets-autocomplete.js +738 -0
- painapple_code/static/js/stash-refs-view.js +83 -0
- painapple_code/static/js/stash-ui.js +768 -0
- painapple_code/static/js/stash.js +785 -0
- painapple_code/static/js/status-bar.js +1184 -0
- painapple_code/static/js/token-profile.js +274 -0
- painapple_code/static/js/tool-renderer-blocks.js +1626 -0
- painapple_code/static/js/tool-renderer-thinking.js +813 -0
- painapple_code/static/js/tool-renderer.js +1500 -0
- painapple_code/static/js/tooltips.js +197 -0
- painapple_code/static/js/upload-manager.js +1019 -0
- painapple_code/static/js/utils.js +521 -0
- painapple_code/static/js/welcome/api.js +363 -0
- painapple_code/static/js/welcome/cards.js +626 -0
- painapple_code/static/js/welcome/context-menu.js +585 -0
- painapple_code/static/js/welcome/families.js +801 -0
- painapple_code/static/js/welcome/preview.js +234 -0
- painapple_code/static/js/welcome/state.js +129 -0
- painapple_code/static/js/welcome.js +1874 -0
- painapple_code/static/js/widget-system/base-widget.js +717 -0
- painapple_code/static/js/widget-system/device-manager.js +178 -0
- painapple_code/static/js/widget-system/event-bus.js +152 -0
- painapple_code/static/js/widget-system/icons.js +120 -0
- painapple_code/static/js/widget-system/index.js +49 -0
- painapple_code/static/js/widget-system/init.js +54 -0
- painapple_code/static/js/widget-system/types/bottom-sheet.js +307 -0
- painapple_code/static/js/widget-system/types/floating.js +915 -0
- painapple_code/static/js/widget-system/types/index.js +40 -0
- painapple_code/static/js/widget-system/types/modal.js +147 -0
- painapple_code/static/js/widget-system/types/sidebar.js +158 -0
- painapple_code/static/js/widget-system/types/tab.js +146 -0
- painapple_code/static/js/widget-system/types/top-sheet.js +203 -0
- painapple_code/static/js/widget-system/widget-manager.js +1187 -0
- painapple_code/static/js/widgets/about-widget.js +260 -0
- painapple_code/static/js/widgets/active-sessions-widget.js +760 -0
- painapple_code/static/js/widgets/agents-widget.js +897 -0
- painapple_code/static/js/widgets/browser-widget.js +579 -0
- painapple_code/static/js/widgets/commands-widget.js +694 -0
- painapple_code/static/js/widgets/config/commit-sections.js +449 -0
- painapple_code/static/js/widgets/config/dir-autocomplete.js +224 -0
- painapple_code/static/js/widgets/config/gestures.js +161 -0
- painapple_code/static/js/widgets/config/models-tab.js +988 -0
- painapple_code/static/js/widgets/config/quick-actions-tab.js +882 -0
- painapple_code/static/js/widgets/config/shortcut-editor.js +587 -0
- painapple_code/static/js/widgets/config/state.js +517 -0
- painapple_code/static/js/widgets/config/system-controls.js +90 -0
- painapple_code/static/js/widgets/config-widget.js +1535 -0
- painapple_code/static/js/widgets/cost-analytics-widget.js +621 -0
- painapple_code/static/js/widgets/debug-widget.js +693 -0
- painapple_code/static/js/widgets/diff-viewer-widget.js +1420 -0
- painapple_code/static/js/widgets/discussion-widget.js +1107 -0
- painapple_code/static/js/widgets/file-explorer-widget.js +2166 -0
- painapple_code/static/js/widgets/file-preview-widget.js +861 -0
- painapple_code/static/js/widgets/git-widget.js +794 -0
- painapple_code/static/js/widgets/helpers-install-widget.js +720 -0
- painapple_code/static/js/widgets/history-explorer-widget.js +1804 -0
- painapple_code/static/js/widgets/image-annotate-widget.js +951 -0
- painapple_code/static/js/widgets/image-preview-widget.js +692 -0
- painapple_code/static/js/widgets/index.js +176 -0
- painapple_code/static/js/widgets/log-explorer-widget.js +1057 -0
- painapple_code/static/js/widgets/plugins-widget.js +305 -0
- painapple_code/static/js/widgets/prompt-explorer-widget.js +1119 -0
- painapple_code/static/js/widgets/search-files-widget.js +529 -0
- painapple_code/static/js/widgets/skills-widget.js +843 -0
- painapple_code/static/js/widgets/snippets-widget.js +479 -0
- painapple_code/static/js/widgets/sub-agents-widget.js +333 -0
- painapple_code/static/js/widgets/tasks-widget.js +379 -0
- painapple_code/static/js/widgets/terminal/connection.js +283 -0
- painapple_code/static/js/widgets/terminal/gestures.js +357 -0
- painapple_code/static/js/widgets/terminal/init.js +642 -0
- painapple_code/static/js/widgets/terminal/link-providers.js +532 -0
- painapple_code/static/js/widgets/terminal/osc52.js +213 -0
- painapple_code/static/js/widgets/terminal/render.js +159 -0
- painapple_code/static/js/widgets/terminal/size.js +54 -0
- painapple_code/static/js/widgets/terminal/state.js +211 -0
- painapple_code/static/js/widgets/terminal/wrap-utils.js +101 -0
- painapple_code/static/js/widgets/terminal-widget.js +793 -0
- painapple_code/static/js/widgets/uploads-widget.js +461 -0
- painapple_code/static/js/widgets/zen-widget.js +1001 -0
- painapple_code/static/login.html +51 -0
- painapple_code/static/manifest.json +73 -0
- painapple_code/static/sw.js +293 -0
- painapple_code/static/vendor/LICENSES.txt +769 -0
- painapple_code/static/vendor/README.md +103 -0
- painapple_code/static/vendor/codemirror.js +35 -0
- painapple_code/static/vendor/codemirror.js.LEGAL.txt +304 -0
- painapple_code/static/vendor/github-markdown-dark.min.css +1124 -0
- painapple_code/static/vendor/highlight-github-dark.min.css +10 -0
- painapple_code/static/vendor/highlight-lang-dockerfile.min.js +8 -0
- painapple_code/static/vendor/highlight-lang-nginx.min.js +21 -0
- painapple_code/static/vendor/highlight-lang-properties.min.js +10 -0
- painapple_code/static/vendor/highlight-lang-scala.min.js +28 -0
- painapple_code/static/vendor/highlight.min.js +1213 -0
- painapple_code/static/vendor/marked.min.js +6 -0
- painapple_code/static/vendor/purify.min.js +3 -0
- painapple_code/static/vendor/xterm-addon-fit.js +2 -0
- painapple_code/static/vendor/xterm.css +209 -0
- painapple_code/static/vendor/xterm.js +2 -0
- painapple_code/static/web-client.html +635 -0
- painapple_code/subprocess_registry.py +535 -0
- painapple_code/tls_cert.py +79 -0
- painapple_code/tools/agents/shadow-git-helper.md +400 -0
- painapple_code/tools/excalidraw-to-svg.js +143 -0
- painapple_code/tools/install-helpers.sh +148 -0
- painapple_code/tools/shadow-git +320 -0
- painapple_code/tools/shadow-query +120 -0
- painapple_code/tools/vegalite-to-svg.js +79 -0
- painapple_code/turn_query.py +766 -0
- painapple_code/turn_tracker.py +221 -0
- painapple_code/utils/__init__.py +0 -0
- painapple_code/utils/agent_cli.py +306 -0
- painapple_code/utils/chart.py +103 -0
- painapple_code/utils/excalidraw.py +107 -0
- painapple_code/utils/file_lock.py +162 -0
- painapple_code/utils/file_paths.py +648 -0
- painapple_code/utils/generate_icons.py +251 -0
- painapple_code/utils/proc.py +205 -0
- painapple_code/utils/pty_backend.py +59 -0
- painapple_code/utils/pty_posix.py +160 -0
- painapple_code/utils/pty_windows.py +245 -0
- painapple_code/utils/token_profiles.py +91 -0
- painapple_code/viewer_templates.py +657 -0
- painapple_code/welcome_search.py +879 -0
- painapple_code-1.0.0.dist-info/METADATA +292 -0
- painapple_code-1.0.0.dist-info/RECORD +436 -0
- painapple_code-1.0.0.dist-info/WHEEL +5 -0
- painapple_code-1.0.0.dist-info/entry_points.txt +2 -0
- painapple_code-1.0.0.dist-info/licenses/LICENSE +661 -0
- painapple_code-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +261 -0
- painapple_code-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session Logs API Routes - Log explorer and file changes tracking
|
|
3
|
+
|
|
4
|
+
These endpoints provide:
|
|
5
|
+
- Session log overview (file sizes, message counts)
|
|
6
|
+
- Parsed message retrieval with pagination and filtering
|
|
7
|
+
- Raw I/O log access for debugging
|
|
8
|
+
- Tool output file management
|
|
9
|
+
- File changes tracking (Edit/Write tool usage analysis)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import difflib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
21
|
+
from fastapi.responses import FileResponse
|
|
22
|
+
|
|
23
|
+
from painapple_code import paths
|
|
24
|
+
from painapple_code.session_store import SessionStore
|
|
25
|
+
from painapple_code.routes.dependencies import get_session_store
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
router = APIRouter(tags=["logs"])
|
|
30
|
+
|
|
31
|
+
# Raster image types safe to serve inline (needed for <img> preview / image
|
|
32
|
+
# re-attach). These cannot execute script. Everything else — notably .svg
|
|
33
|
+
# (image-shaped but scriptable) and .html/.js — is served as an inert
|
|
34
|
+
# attachment so an uploaded file can never run in the authenticated origin.
|
|
35
|
+
_INLINE_IMAGE_TYPES = {
|
|
36
|
+
".png": "image/png",
|
|
37
|
+
".jpg": "image/jpeg",
|
|
38
|
+
".jpeg": "image/jpeg",
|
|
39
|
+
".gif": "image/gif",
|
|
40
|
+
".webp": "image/webp",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
45
|
+
# Session Log Overview
|
|
46
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
@router.get("/api/sessions/{session_id}/logs")
|
|
49
|
+
async def get_session_logs_overview(session_id: str):
|
|
50
|
+
"""Get overview of session logs - files and their sizes."""
|
|
51
|
+
store, meta = SessionStore._find_session(session_id)
|
|
52
|
+
if not store:
|
|
53
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
54
|
+
|
|
55
|
+
session_dir = store._session_dir(session_id)
|
|
56
|
+
|
|
57
|
+
messages_path = store._messages_path(session_id)
|
|
58
|
+
raw_path = store._raw_log_path(session_id)
|
|
59
|
+
tools_dir = store._tools_dir(session_id)
|
|
60
|
+
|
|
61
|
+
def count_lines(path):
|
|
62
|
+
if not path.exists():
|
|
63
|
+
return 0
|
|
64
|
+
with open(path, encoding="utf-8") as f:
|
|
65
|
+
return sum(1 for line in f if line.strip())
|
|
66
|
+
|
|
67
|
+
def count_user_lines(path):
|
|
68
|
+
"""Count lines where role is 'user' in messages.jsonl."""
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return 0
|
|
71
|
+
count = 0
|
|
72
|
+
with open(path, encoding="utf-8") as f:
|
|
73
|
+
for line in f:
|
|
74
|
+
line = line.strip()
|
|
75
|
+
if line and ('"role": "user"' in line or '"role":"user"' in line):
|
|
76
|
+
count += 1
|
|
77
|
+
return count
|
|
78
|
+
|
|
79
|
+
tool_files = []
|
|
80
|
+
if tools_dir.exists():
|
|
81
|
+
for f in sorted(tools_dir.iterdir()):
|
|
82
|
+
if f.is_file():
|
|
83
|
+
tool_files.append({
|
|
84
|
+
"name": f.name,
|
|
85
|
+
"size": f.stat().st_size,
|
|
86
|
+
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat() + "Z"
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
"session_id": session_id,
|
|
91
|
+
"meta": meta,
|
|
92
|
+
"files": {
|
|
93
|
+
"messages": {
|
|
94
|
+
"path": str(messages_path),
|
|
95
|
+
"exists": messages_path.exists(),
|
|
96
|
+
"size": messages_path.stat().st_size if messages_path.exists() else 0,
|
|
97
|
+
"lines": count_lines(messages_path),
|
|
98
|
+
"user_lines": count_user_lines(messages_path)
|
|
99
|
+
},
|
|
100
|
+
"raw": {
|
|
101
|
+
"path": str(raw_path),
|
|
102
|
+
"exists": raw_path.exists(),
|
|
103
|
+
"size": raw_path.stat().st_size if raw_path.exists() else 0,
|
|
104
|
+
"lines": count_lines(raw_path)
|
|
105
|
+
},
|
|
106
|
+
"tools": {
|
|
107
|
+
"path": str(tools_dir),
|
|
108
|
+
"exists": tools_dir.exists(),
|
|
109
|
+
"count": len(tool_files),
|
|
110
|
+
"files": tool_files
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
117
|
+
# Session Messages
|
|
118
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
119
|
+
|
|
120
|
+
@router.get("/api/sessions/{session_id}/logs/messages")
|
|
121
|
+
async def get_session_messages(
|
|
122
|
+
session_id: str,
|
|
123
|
+
offset: int = 0,
|
|
124
|
+
limit: int = 50,
|
|
125
|
+
role: str = None,
|
|
126
|
+
sort: str = "asc",
|
|
127
|
+
since: str = None,
|
|
128
|
+
store: SessionStore = Depends(get_session_store),
|
|
129
|
+
):
|
|
130
|
+
"""Get parsed messages with pagination, filtering, and timestamp filtering."""
|
|
131
|
+
messages_path = store._messages_path(session_id)
|
|
132
|
+
if not messages_path.exists():
|
|
133
|
+
return {"messages": [], "total": 0, "offset": offset, "limit": limit, "since": since}
|
|
134
|
+
|
|
135
|
+
# Load favorites set for quick lookup
|
|
136
|
+
favorites_data = paths.load_prompt_favorites()
|
|
137
|
+
favorite_ids = set(favorites_data.get("prompts", {}).keys())
|
|
138
|
+
|
|
139
|
+
all_messages = []
|
|
140
|
+
line_number = 0
|
|
141
|
+
with open(messages_path, 'r', encoding="utf-8") as f:
|
|
142
|
+
for line in f:
|
|
143
|
+
line_number += 1
|
|
144
|
+
line = line.strip()
|
|
145
|
+
if line:
|
|
146
|
+
try:
|
|
147
|
+
msg = json.loads(line)
|
|
148
|
+
if since and msg.get("timestamp", "") < since:
|
|
149
|
+
continue
|
|
150
|
+
if role is None or msg.get("role") == role:
|
|
151
|
+
# Stable server identity for client-side dedup: an explicit
|
|
152
|
+
# stored id wins (e.g. compact-<uuid> on boundary rows);
|
|
153
|
+
# otherwise derive from the append-only line number — the
|
|
154
|
+
# same "{session}:{line}" scheme promptId already uses, so
|
|
155
|
+
# every historical message gets identity retroactively.
|
|
156
|
+
# Client matching/dedup keys on this (`sid`), never on
|
|
157
|
+
# content+time heuristics when both sides carry one.
|
|
158
|
+
msg["sid"] = msg.get("id") or f"{session_id}:{line_number}"
|
|
159
|
+
if msg.get("role") == "user":
|
|
160
|
+
prompt_id = f"{session_id}:{line_number}"
|
|
161
|
+
msg["promptId"] = prompt_id
|
|
162
|
+
msg["isFavorite"] = prompt_id in favorite_ids
|
|
163
|
+
all_messages.append(msg)
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Hydrate tool outputs
|
|
168
|
+
for msg in all_messages:
|
|
169
|
+
store._hydrate_tool_output(session_id, msg)
|
|
170
|
+
|
|
171
|
+
total = len(all_messages)
|
|
172
|
+
|
|
173
|
+
if sort == "desc":
|
|
174
|
+
all_messages = all_messages[::-1]
|
|
175
|
+
|
|
176
|
+
paginated = all_messages[offset:offset + limit]
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
"messages": paginated,
|
|
180
|
+
"total": total,
|
|
181
|
+
"offset": offset,
|
|
182
|
+
"limit": limit,
|
|
183
|
+
"has_more": offset + limit < total,
|
|
184
|
+
"since": since
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
189
|
+
# Raw Log
|
|
190
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
191
|
+
|
|
192
|
+
@router.get("/api/sessions/{session_id}/logs/raw")
|
|
193
|
+
async def get_session_raw_log(
|
|
194
|
+
session_id: str,
|
|
195
|
+
offset: int = 0,
|
|
196
|
+
limit: int = 100,
|
|
197
|
+
direction: str = None,
|
|
198
|
+
errors_only: bool = False,
|
|
199
|
+
sort: str = "asc",
|
|
200
|
+
store: SessionStore = Depends(get_session_store),
|
|
201
|
+
):
|
|
202
|
+
"""Get raw Claude I/O log with filtering."""
|
|
203
|
+
raw_path = store._raw_log_path(session_id)
|
|
204
|
+
if not raw_path.exists():
|
|
205
|
+
return {"entries": [], "total": 0, "offset": offset, "limit": limit}
|
|
206
|
+
|
|
207
|
+
all_entries = []
|
|
208
|
+
with open(raw_path, 'r', encoding="utf-8") as f:
|
|
209
|
+
for line in f:
|
|
210
|
+
line = line.strip()
|
|
211
|
+
if line:
|
|
212
|
+
try:
|
|
213
|
+
entry = json.loads(line)
|
|
214
|
+
if errors_only and entry.get("dir") != "error":
|
|
215
|
+
continue
|
|
216
|
+
if direction and entry.get("dir") != direction:
|
|
217
|
+
continue
|
|
218
|
+
all_entries.append(entry)
|
|
219
|
+
except json.JSONDecodeError:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
total = len(all_entries)
|
|
223
|
+
|
|
224
|
+
if sort == "desc":
|
|
225
|
+
all_entries = all_entries[::-1]
|
|
226
|
+
|
|
227
|
+
paginated = all_entries[offset:offset + limit]
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
"entries": paginated,
|
|
231
|
+
"total": total,
|
|
232
|
+
"offset": offset,
|
|
233
|
+
"limit": limit,
|
|
234
|
+
"has_more": offset + limit < total
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
239
|
+
# Tool Output Files
|
|
240
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
241
|
+
|
|
242
|
+
@router.get("/api/sessions/{session_id}/logs/tools")
|
|
243
|
+
async def get_session_tool_files(session_id: str, store: SessionStore = Depends(get_session_store)):
|
|
244
|
+
"""List all tool output files for a session."""
|
|
245
|
+
tools_dir = store._tools_dir(session_id)
|
|
246
|
+
if not tools_dir.exists():
|
|
247
|
+
return {"files": []}
|
|
248
|
+
|
|
249
|
+
files = []
|
|
250
|
+
for f in sorted(tools_dir.iterdir()):
|
|
251
|
+
if f.is_file():
|
|
252
|
+
name_parts = f.stem.rsplit("_", 1)
|
|
253
|
+
tool_name = name_parts[0] if len(name_parts) > 1 else f.stem
|
|
254
|
+
|
|
255
|
+
files.append({
|
|
256
|
+
"name": f.name,
|
|
257
|
+
"tool_name": tool_name,
|
|
258
|
+
"size": f.stat().st_size,
|
|
259
|
+
"modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat() + "Z"
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
return {"files": files, "count": len(files)}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@router.get("/api/sessions/{session_id}/logs/tools/{filename}")
|
|
266
|
+
async def get_session_tool_output(session_id: str, filename: str, store: SessionStore = Depends(get_session_store)):
|
|
267
|
+
"""Get content of a specific tool output file."""
|
|
268
|
+
tools_dir = store._tools_dir(session_id)
|
|
269
|
+
file_path = tools_dir / filename
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
file_path = file_path.resolve()
|
|
273
|
+
# is_relative_to, not startswith: a string prefix test would also accept
|
|
274
|
+
# a sibling directory whose name merely starts with the same characters
|
|
275
|
+
# (".../tools" is a prefix of ".../tools_extra").
|
|
276
|
+
if not file_path.is_relative_to(tools_dir.resolve()):
|
|
277
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
278
|
+
except Exception:
|
|
279
|
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
|
280
|
+
|
|
281
|
+
if not file_path.exists():
|
|
282
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
content = file_path.read_text(encoding="utf-8", errors='replace')
|
|
286
|
+
return {
|
|
287
|
+
"filename": filename,
|
|
288
|
+
"size": len(content),
|
|
289
|
+
"content": content
|
|
290
|
+
}
|
|
291
|
+
except Exception as e:
|
|
292
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@router.get("/api/sessions/{session_id}/uploads")
|
|
299
|
+
async def list_session_uploads(session_id: str, scope: str = "session", store: SessionStore = Depends(get_session_store)):
|
|
300
|
+
"""List uploaded files. scope='session' (default) lists this session only;
|
|
301
|
+
scope='project' lists uploads across every session in the same project."""
|
|
302
|
+
def _entry(path, sid: str) -> dict:
|
|
303
|
+
stat = path.stat()
|
|
304
|
+
return {
|
|
305
|
+
"name": path.name,
|
|
306
|
+
"session_id": sid,
|
|
307
|
+
# Absolute path — non-image uploads re-attach to the chat input by
|
|
308
|
+
# path (the prompt carries "Uploaded file: <path>"), so the browser
|
|
309
|
+
# needs it without a second round-trip.
|
|
310
|
+
"path": str(path.resolve()),
|
|
311
|
+
"size": stat.st_size,
|
|
312
|
+
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat() + "Z",
|
|
313
|
+
"is_image": path.suffix.lower() in IMAGE_EXTENSIONS,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if scope == "project":
|
|
317
|
+
files = []
|
|
318
|
+
for sess_dir in store.base_dir.iterdir():
|
|
319
|
+
if not sess_dir.is_dir():
|
|
320
|
+
continue
|
|
321
|
+
uploads_dir = sess_dir / "uploads"
|
|
322
|
+
if not uploads_dir.exists():
|
|
323
|
+
continue
|
|
324
|
+
for f in uploads_dir.iterdir():
|
|
325
|
+
if f.is_file():
|
|
326
|
+
files.append(_entry(f, sess_dir.name))
|
|
327
|
+
files.sort(key=lambda x: x["modified"], reverse=True)
|
|
328
|
+
return {"files": files, "count": len(files), "scope": "project"}
|
|
329
|
+
|
|
330
|
+
uploads_dir = store._uploads_dir(session_id)
|
|
331
|
+
if not uploads_dir.exists():
|
|
332
|
+
return {"files": [], "count": 0, "scope": "session"}
|
|
333
|
+
|
|
334
|
+
files = [_entry(f, session_id) for f in sorted(uploads_dir.iterdir()) if f.is_file()]
|
|
335
|
+
return {"files": files, "count": len(files), "scope": "session"}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@router.get("/api/sessions/{session_id}/uploads/{filename}")
|
|
339
|
+
async def get_session_upload(session_id: str, filename: str, base64_encode: bool = False, store: SessionStore = Depends(get_session_store)):
|
|
340
|
+
"""Serve an uploaded file (image/file) from a session's uploads directory.
|
|
341
|
+
With ?base64_encode=true, returns JSON with base64-encoded data (for image restore)."""
|
|
342
|
+
uploads_dir = store._uploads_dir(session_id)
|
|
343
|
+
file_path = (uploads_dir / filename).resolve()
|
|
344
|
+
|
|
345
|
+
# Path traversal protection. is_relative_to, not startswith: a string prefix
|
|
346
|
+
# test would also accept a sibling directory whose name merely starts with
|
|
347
|
+
# the same characters (".../uploads" is a prefix of ".../uploads_old").
|
|
348
|
+
if not file_path.is_relative_to(uploads_dir.resolve()):
|
|
349
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
350
|
+
|
|
351
|
+
if not file_path.exists():
|
|
352
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
353
|
+
|
|
354
|
+
if not base64_encode:
|
|
355
|
+
ext = file_path.suffix.lower()
|
|
356
|
+
# Never cache authenticated user content in shared/proxy caches.
|
|
357
|
+
headers = {
|
|
358
|
+
"Cache-Control": "private, no-store",
|
|
359
|
+
"X-Content-Type-Options": "nosniff",
|
|
360
|
+
}
|
|
361
|
+
inline_type = _INLINE_IMAGE_TYPES.get(ext)
|
|
362
|
+
if inline_type:
|
|
363
|
+
# Raster image: safe to render inline (can't execute).
|
|
364
|
+
return FileResponse(file_path, media_type=inline_type, headers=headers)
|
|
365
|
+
# Anything else (.svg, .html, .js, arbitrary uploads): force an inert
|
|
366
|
+
# download so it cannot execute in the app origin.
|
|
367
|
+
return FileResponse(
|
|
368
|
+
file_path,
|
|
369
|
+
media_type="application/octet-stream",
|
|
370
|
+
filename=file_path.name,
|
|
371
|
+
headers=headers,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Return base64-encoded data for image restoration after page refresh
|
|
375
|
+
import base64 as b64
|
|
376
|
+
content = file_path.read_bytes()
|
|
377
|
+
ext = file_path.suffix.lower()
|
|
378
|
+
media_type = _INLINE_IMAGE_TYPES.get(ext, 'application/octet-stream')
|
|
379
|
+
encoded = b64.standard_b64encode(content).decode('utf-8')
|
|
380
|
+
return {"filename": filename, "media_type": media_type, "size": len(content), "data": encoded}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
384
|
+
# Session Changes (Edit/Write tracking)
|
|
385
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
386
|
+
|
|
387
|
+
@router.get("/api/sessions/{session_id}/changes")
|
|
388
|
+
async def get_session_changes(session_id: str, store: SessionStore = Depends(get_session_store)):
|
|
389
|
+
"""Get file changes made by Claude during this session."""
|
|
390
|
+
return await asyncio.to_thread(compute_session_changes, store, session_id)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def compute_session_changes(store, session_id: str) -> dict:
|
|
394
|
+
"""Compute file changes from messages.jsonl. Reusable helper.
|
|
395
|
+
|
|
396
|
+
Returns: {"files": [...], "summary": {...}}
|
|
397
|
+
"""
|
|
398
|
+
messages_path = store._messages_path(session_id)
|
|
399
|
+
tools_dir = store._session_dir(session_id) / "tools"
|
|
400
|
+
if not messages_path.exists():
|
|
401
|
+
return {"files": [], "summary": {"totalFiles": 0, "modified": 0, "created": 0, "linesAdded": 0, "linesRemoved": 0}}
|
|
402
|
+
|
|
403
|
+
changes_by_file = {}
|
|
404
|
+
|
|
405
|
+
def count_lines(text):
|
|
406
|
+
if not text:
|
|
407
|
+
return 0
|
|
408
|
+
return len([line for line in text.split('\n') if line.strip()])
|
|
409
|
+
|
|
410
|
+
def count_diff_changes(old_string, new_string):
|
|
411
|
+
old_lines = old_string.split('\n') if old_string else []
|
|
412
|
+
new_lines = new_string.split('\n') if new_string else []
|
|
413
|
+
|
|
414
|
+
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
|
|
415
|
+
|
|
416
|
+
added = 0
|
|
417
|
+
removed = 0
|
|
418
|
+
|
|
419
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
420
|
+
if tag == 'replace':
|
|
421
|
+
removed += (i2 - i1)
|
|
422
|
+
added += (j2 - j1)
|
|
423
|
+
elif tag == 'delete':
|
|
424
|
+
removed += (i2 - i1)
|
|
425
|
+
elif tag == 'insert':
|
|
426
|
+
added += (j2 - j1)
|
|
427
|
+
|
|
428
|
+
return added, removed
|
|
429
|
+
|
|
430
|
+
def load_tool_output(tool_output, tool_output_file):
|
|
431
|
+
if tool_output and tool_output.startswith('[Stored in ') and tool_output.endswith(']'):
|
|
432
|
+
ref_file = tool_output[11:-1]
|
|
433
|
+
file_path = tools_dir / ref_file
|
|
434
|
+
if file_path.exists():
|
|
435
|
+
try:
|
|
436
|
+
return file_path.read_text(encoding="utf-8")
|
|
437
|
+
except Exception:
|
|
438
|
+
pass
|
|
439
|
+
if tool_output_file:
|
|
440
|
+
file_path = tools_dir / tool_output_file
|
|
441
|
+
if file_path.exists():
|
|
442
|
+
try:
|
|
443
|
+
return file_path.read_text(encoding="utf-8")
|
|
444
|
+
except Exception:
|
|
445
|
+
pass
|
|
446
|
+
return tool_output or ''
|
|
447
|
+
|
|
448
|
+
def parse_line_number(tool_output, new_string):
|
|
449
|
+
if not tool_output or not new_string:
|
|
450
|
+
return None
|
|
451
|
+
line_pattern = re.compile(r'^\s*(\d+)(?:\u2192|\t)(.*)$', re.MULTILINE)
|
|
452
|
+
first_new_line = new_string.split('\n')[0] if new_string else ''
|
|
453
|
+
|
|
454
|
+
for match in line_pattern.finditer(tool_output):
|
|
455
|
+
line_num, content = match.groups()
|
|
456
|
+
if content.strip() == first_new_line.strip():
|
|
457
|
+
return int(line_num)
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
def process_tool(tool_name, tool_id, tool_input, tool_output, tool_output_file, timestamp):
|
|
461
|
+
if tool_name not in ('Edit', 'Write'):
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
file_path = tool_input.get('file_path', '')
|
|
465
|
+
if not file_path:
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
if file_path not in changes_by_file:
|
|
469
|
+
changes_by_file[file_path] = {
|
|
470
|
+
'filePath': file_path,
|
|
471
|
+
'fileName': Path(file_path).name,
|
|
472
|
+
'fileType': Path(file_path).suffix.lstrip('.'),
|
|
473
|
+
'status': 'created' if tool_name == 'Write' else 'modified',
|
|
474
|
+
'edits': [],
|
|
475
|
+
'firstChange': timestamp,
|
|
476
|
+
'lastChange': timestamp,
|
|
477
|
+
'linesAdded': 0,
|
|
478
|
+
'linesRemoved': 0,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
file_entry = changes_by_file[file_path]
|
|
482
|
+
file_entry['lastChange'] = timestamp
|
|
483
|
+
|
|
484
|
+
if tool_name == 'Edit':
|
|
485
|
+
old_string = tool_input.get('old_string', '')
|
|
486
|
+
new_string = tool_input.get('new_string', '')
|
|
487
|
+
|
|
488
|
+
lines_added, lines_removed = count_diff_changes(old_string, new_string)
|
|
489
|
+
|
|
490
|
+
actual_output = load_tool_output(tool_output, tool_output_file)
|
|
491
|
+
start_line = parse_line_number(actual_output, new_string)
|
|
492
|
+
|
|
493
|
+
edit = {
|
|
494
|
+
'toolId': tool_id,
|
|
495
|
+
'timestamp': timestamp,
|
|
496
|
+
'type': 'edit',
|
|
497
|
+
'oldString': old_string,
|
|
498
|
+
'newString': new_string,
|
|
499
|
+
'linesAdded': lines_added,
|
|
500
|
+
'linesRemoved': lines_removed,
|
|
501
|
+
'startLine': start_line,
|
|
502
|
+
}
|
|
503
|
+
file_entry['edits'].append(edit)
|
|
504
|
+
file_entry['linesAdded'] += edit['linesAdded']
|
|
505
|
+
file_entry['linesRemoved'] += edit['linesRemoved']
|
|
506
|
+
if len(file_entry['edits']) == 1 and file_entry['status'] == 'created':
|
|
507
|
+
file_entry['status'] = 'modified'
|
|
508
|
+
|
|
509
|
+
elif tool_name == 'Write':
|
|
510
|
+
content = tool_input.get('content', '')
|
|
511
|
+
new_lines = count_lines(content)
|
|
512
|
+
|
|
513
|
+
edit = {
|
|
514
|
+
'toolId': tool_id,
|
|
515
|
+
'timestamp': timestamp,
|
|
516
|
+
'type': 'write',
|
|
517
|
+
'content': content,
|
|
518
|
+
'linesAdded': new_lines,
|
|
519
|
+
'linesRemoved': 0,
|
|
520
|
+
'startLine': 1,
|
|
521
|
+
}
|
|
522
|
+
file_entry['edits'].append(edit)
|
|
523
|
+
file_entry['linesAdded'] += new_lines
|
|
524
|
+
file_entry['status'] = 'created'
|
|
525
|
+
|
|
526
|
+
# Read messages and extract changes
|
|
527
|
+
with open(messages_path, 'r', encoding="utf-8") as f:
|
|
528
|
+
for line in f:
|
|
529
|
+
line = line.strip()
|
|
530
|
+
if not line:
|
|
531
|
+
continue
|
|
532
|
+
try:
|
|
533
|
+
msg = json.loads(line)
|
|
534
|
+
timestamp = msg.get('timestamp', '')
|
|
535
|
+
|
|
536
|
+
if msg.get('role') == 'tool':
|
|
537
|
+
process_tool(
|
|
538
|
+
msg.get('tool_name', ''),
|
|
539
|
+
msg.get('tool_id', ''),
|
|
540
|
+
msg.get('tool_input', {}),
|
|
541
|
+
msg.get('tool_output', ''),
|
|
542
|
+
msg.get('tool_output_file', ''),
|
|
543
|
+
timestamp
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
if msg.get('role') == 'thinking' and msg.get('tools'):
|
|
547
|
+
for tool in msg['tools']:
|
|
548
|
+
process_tool(
|
|
549
|
+
tool.get('toolName', ''),
|
|
550
|
+
tool.get('toolId', ''),
|
|
551
|
+
tool.get('toolInput', {}),
|
|
552
|
+
tool.get('toolOutput', ''),
|
|
553
|
+
tool.get('toolOutputFile', ''),
|
|
554
|
+
timestamp
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
except json.JSONDecodeError:
|
|
558
|
+
continue
|
|
559
|
+
|
|
560
|
+
files = list(changes_by_file.values())
|
|
561
|
+
files.sort(key=lambda f: f['lastChange'], reverse=True)
|
|
562
|
+
|
|
563
|
+
for f in files:
|
|
564
|
+
f['editCount'] = len(f['edits'])
|
|
565
|
+
|
|
566
|
+
summary = {
|
|
567
|
+
'totalFiles': len(files),
|
|
568
|
+
'modified': sum(1 for f in files if f['status'] == 'modified'),
|
|
569
|
+
'created': sum(1 for f in files if f['status'] == 'created'),
|
|
570
|
+
'linesAdded': sum(f['linesAdded'] for f in files),
|
|
571
|
+
'linesRemoved': sum(f['linesRemoved'] for f in files),
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return {"files": files, "summary": summary}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
578
|
+
# Session Reads (Read tool tracking)
|
|
579
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
580
|
+
|
|
581
|
+
@router.get("/api/sessions/{session_id}/read-files")
|
|
582
|
+
async def get_session_read_files(session_id: str, store: SessionStore = Depends(get_session_store)):
|
|
583
|
+
"""Get files Claude opened with the Read tool during this session.
|
|
584
|
+
|
|
585
|
+
Unlike /changes (Edit/Write), reads aren't persisted anywhere queryable,
|
|
586
|
+
so we reconstruct them by scanning messages.jsonl for Read tool calls.
|
|
587
|
+
Returns files most-recently-read first.
|
|
588
|
+
"""
|
|
589
|
+
return await asyncio.to_thread(compute_session_read_files, store, session_id)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def compute_session_read_files(store, session_id: str) -> dict:
|
|
593
|
+
"""Scan messages.jsonl for Read tool calls → unique files, recent first.
|
|
594
|
+
|
|
595
|
+
Returns: {"files": [{filePath, fileName, readCount, firstRead, lastRead}], "summary": {...}}
|
|
596
|
+
"""
|
|
597
|
+
messages_path = store._messages_path(session_id)
|
|
598
|
+
if not messages_path.exists():
|
|
599
|
+
return {"files": [], "summary": {"totalFiles": 0, "totalReads": 0}}
|
|
600
|
+
|
|
601
|
+
reads_by_file = {}
|
|
602
|
+
|
|
603
|
+
def process_tool(tool_name, tool_input, timestamp):
|
|
604
|
+
if tool_name != 'Read':
|
|
605
|
+
return
|
|
606
|
+
file_path = (tool_input or {}).get('file_path', '')
|
|
607
|
+
if not file_path:
|
|
608
|
+
return
|
|
609
|
+
entry = reads_by_file.get(file_path)
|
|
610
|
+
if entry is None:
|
|
611
|
+
reads_by_file[file_path] = {
|
|
612
|
+
'filePath': file_path,
|
|
613
|
+
'fileName': Path(file_path).name,
|
|
614
|
+
'fileType': Path(file_path).suffix.lstrip('.'),
|
|
615
|
+
'readCount': 1,
|
|
616
|
+
'firstRead': timestamp,
|
|
617
|
+
'lastRead': timestamp,
|
|
618
|
+
}
|
|
619
|
+
else:
|
|
620
|
+
entry['readCount'] += 1
|
|
621
|
+
if timestamp:
|
|
622
|
+
entry['lastRead'] = timestamp
|
|
623
|
+
|
|
624
|
+
with open(messages_path, 'r', encoding="utf-8") as f:
|
|
625
|
+
for line in f:
|
|
626
|
+
line = line.strip()
|
|
627
|
+
if not line:
|
|
628
|
+
continue
|
|
629
|
+
try:
|
|
630
|
+
msg = json.loads(line)
|
|
631
|
+
except json.JSONDecodeError:
|
|
632
|
+
continue
|
|
633
|
+
timestamp = msg.get('timestamp', '')
|
|
634
|
+
|
|
635
|
+
if msg.get('role') == 'tool':
|
|
636
|
+
process_tool(msg.get('tool_name', ''), msg.get('tool_input', {}), timestamp)
|
|
637
|
+
|
|
638
|
+
if msg.get('role') == 'thinking' and msg.get('tools'):
|
|
639
|
+
for tool in msg['tools']:
|
|
640
|
+
process_tool(tool.get('toolName', ''), tool.get('toolInput', {}), timestamp)
|
|
641
|
+
|
|
642
|
+
files = list(reads_by_file.values())
|
|
643
|
+
files.sort(key=lambda f: f['lastRead'], reverse=True)
|
|
644
|
+
|
|
645
|
+
summary = {
|
|
646
|
+
'totalFiles': len(files),
|
|
647
|
+
'totalReads': sum(f['readCount'] for f in files),
|
|
648
|
+
}
|
|
649
|
+
return {"files": files, "summary": summary}
|