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,879 @@
|
|
|
1
|
+
"""Session Storage V2: directory-based, per-project.
|
|
2
|
+
|
|
3
|
+
The core class lives here; the public-API facade is in session_store.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional, Iterator
|
|
15
|
+
|
|
16
|
+
from painapple_code import paths
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("painapple-code.store")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SessionStoreV2:
|
|
22
|
+
"""
|
|
23
|
+
Directory-based session storage.
|
|
24
|
+
|
|
25
|
+
Each session is a directory containing:
|
|
26
|
+
- meta.json: Small metadata file (id, name, cwd, cost, timestamps)
|
|
27
|
+
- messages.jsonl: Append-only message log (one JSON per line)
|
|
28
|
+
- tools/: Directory for tool output files
|
|
29
|
+
|
|
30
|
+
Storage location: ~/.painapple-code/projects/{project-hash}/sessions/
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Tool output size threshold - outputs larger than this go to separate files
|
|
34
|
+
TOOL_OUTPUT_THRESHOLD = 500 # chars
|
|
35
|
+
|
|
36
|
+
def __init__(self, project_path: str):
|
|
37
|
+
"""
|
|
38
|
+
Initialize session store.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
project_path: Project directory (cwd). Sessions are stored
|
|
42
|
+
in ~/.painapple-code/projects/{hash}/sessions/.
|
|
43
|
+
"""
|
|
44
|
+
self.project_path = project_path
|
|
45
|
+
paths.ensure_project_dir(project_path)
|
|
46
|
+
self.base_dir = paths.get_sessions_dir(project_path)
|
|
47
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def generate_id() -> str:
|
|
51
|
+
"""Generate a short unique session ID."""
|
|
52
|
+
return secrets.token_urlsafe(8) # ~11 chars, URL-safe
|
|
53
|
+
|
|
54
|
+
def _session_dir(self, session_id: str) -> Path:
|
|
55
|
+
"""Get the directory path for a session."""
|
|
56
|
+
return self.base_dir / session_id
|
|
57
|
+
|
|
58
|
+
def _meta_path(self, session_id: str) -> Path:
|
|
59
|
+
"""Get the meta.json path for a session."""
|
|
60
|
+
return self._session_dir(session_id) / "meta.json"
|
|
61
|
+
|
|
62
|
+
def _messages_path(self, session_id: str) -> Path:
|
|
63
|
+
"""Get the messages.jsonl path for a session."""
|
|
64
|
+
return self._session_dir(session_id) / "messages.jsonl"
|
|
65
|
+
|
|
66
|
+
def _raw_log_path(self, session_id: str) -> Path:
|
|
67
|
+
"""Get the raw.jsonl path for a session (raw Claude output log)."""
|
|
68
|
+
return self._session_dir(session_id) / "raw.jsonl"
|
|
69
|
+
|
|
70
|
+
def _tools_dir(self, session_id: str) -> Path:
|
|
71
|
+
"""Get the tools/ directory path for a session."""
|
|
72
|
+
return self._session_dir(session_id) / "tools"
|
|
73
|
+
|
|
74
|
+
def _uploads_dir(self, session_id: str) -> Path:
|
|
75
|
+
"""Get the uploads/ directory path for a session."""
|
|
76
|
+
return self._session_dir(session_id) / "uploads"
|
|
77
|
+
|
|
78
|
+
def _stash_path(self, session_id: str) -> Path:
|
|
79
|
+
"""Get the stash.json path for a session."""
|
|
80
|
+
return self._session_dir(session_id) / "stash.json"
|
|
81
|
+
|
|
82
|
+
def _tool_filename(self, tool_name: str, tool_id: str) -> str:
|
|
83
|
+
"""Generate filename for a tool output file."""
|
|
84
|
+
# Strip 'toolu_' prefix if present
|
|
85
|
+
suffix = tool_id.replace("toolu_", "") if tool_id.startswith("toolu_") else tool_id
|
|
86
|
+
# Sanitize tool name (remove any problematic chars)
|
|
87
|
+
safe_name = "".join(c for c in tool_name if c.isalnum() or c in "-_")
|
|
88
|
+
return f"{safe_name}_{suffix}.txt"
|
|
89
|
+
|
|
90
|
+
# ─────────────────────────────────────────────────────────────────
|
|
91
|
+
# Session CRUD
|
|
92
|
+
# ─────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def create_pending(self, cwd: str, name: str = None) -> dict:
|
|
95
|
+
"""
|
|
96
|
+
Create session metadata WITHOUT persisting to disk.
|
|
97
|
+
Use persist() to actually write to disk when first message arrives.
|
|
98
|
+
This avoids cluttering history with empty sessions.
|
|
99
|
+
"""
|
|
100
|
+
session_id = self.generate_id()
|
|
101
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
102
|
+
|
|
103
|
+
meta = {
|
|
104
|
+
"id": session_id,
|
|
105
|
+
"name": name or (Path(cwd).name if cwd else "") or "New Session",
|
|
106
|
+
"cwd": cwd,
|
|
107
|
+
"provider": None, # None → resolved to the box-wide default at launch
|
|
108
|
+
"provider_session_id": None,
|
|
109
|
+
"model": None,
|
|
110
|
+
"total_cost": 0,
|
|
111
|
+
"message_count": 0,
|
|
112
|
+
"slash_commands": [],
|
|
113
|
+
"created_at": now,
|
|
114
|
+
"last_activity": now,
|
|
115
|
+
"_pending": True, # Flag indicating not yet persisted
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Include project hash for reverse lookup (new storage format)
|
|
119
|
+
if self.project_path:
|
|
120
|
+
meta["project_hash"] = paths.get_project_hash(self.project_path)
|
|
121
|
+
|
|
122
|
+
return meta
|
|
123
|
+
|
|
124
|
+
def persist(self, meta: dict) -> dict:
|
|
125
|
+
"""
|
|
126
|
+
Persist a pending session to disk.
|
|
127
|
+
Call this when first message arrives or Claude sends init.
|
|
128
|
+
Returns the meta dict with _pending flag removed.
|
|
129
|
+
"""
|
|
130
|
+
if not meta.get("_pending"):
|
|
131
|
+
return meta # Already persisted
|
|
132
|
+
|
|
133
|
+
session_id = meta["id"]
|
|
134
|
+
session_dir = self._session_dir(session_id)
|
|
135
|
+
|
|
136
|
+
# Create directory structure
|
|
137
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
self._tools_dir(session_id).mkdir(exist_ok=True)
|
|
139
|
+
|
|
140
|
+
# Remove pending flag and write
|
|
141
|
+
meta = {k: v for k, v in meta.items() if k != "_pending"}
|
|
142
|
+
self._write_meta(session_id, meta)
|
|
143
|
+
|
|
144
|
+
# Create empty log files
|
|
145
|
+
self._messages_path(session_id).touch()
|
|
146
|
+
self._raw_log_path(session_id).touch()
|
|
147
|
+
|
|
148
|
+
logger.info(f"Persisted session directory: {session_id}")
|
|
149
|
+
return meta
|
|
150
|
+
|
|
151
|
+
def create(self, cwd: str, name: str = None) -> dict:
|
|
152
|
+
"""Create a new session with directory structure (immediate persistence)."""
|
|
153
|
+
session_id = self.generate_id()
|
|
154
|
+
session_dir = self._session_dir(session_id)
|
|
155
|
+
|
|
156
|
+
# Create directory structure
|
|
157
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
self._tools_dir(session_id).mkdir(exist_ok=True)
|
|
159
|
+
|
|
160
|
+
# Initialize metadata
|
|
161
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
162
|
+
meta = {
|
|
163
|
+
"id": session_id,
|
|
164
|
+
"name": name or (Path(cwd).name if cwd else "") or "New Session",
|
|
165
|
+
"cwd": cwd,
|
|
166
|
+
"provider_session_id": None,
|
|
167
|
+
"model": None,
|
|
168
|
+
"total_cost": 0,
|
|
169
|
+
"message_count": 0,
|
|
170
|
+
"slash_commands": [], # Claude slash commands for autocomplete
|
|
171
|
+
"created_at": now,
|
|
172
|
+
"last_activity": now,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Include project hash for reverse lookup (new storage format)
|
|
176
|
+
if self.project_path:
|
|
177
|
+
meta["project_hash"] = paths.get_project_hash(self.project_path)
|
|
178
|
+
|
|
179
|
+
# Write meta.json
|
|
180
|
+
self._write_meta(session_id, meta)
|
|
181
|
+
|
|
182
|
+
# Create empty messages.jsonl and raw.jsonl
|
|
183
|
+
self._messages_path(session_id).touch()
|
|
184
|
+
self._raw_log_path(session_id).touch()
|
|
185
|
+
|
|
186
|
+
logger.info(f"Created session directory: {session_id}")
|
|
187
|
+
return meta
|
|
188
|
+
|
|
189
|
+
def exists(self, session_id: str) -> bool:
|
|
190
|
+
"""Check if a session exists."""
|
|
191
|
+
return self._meta_path(session_id).exists()
|
|
192
|
+
|
|
193
|
+
def load_meta(self, session_id: str) -> Optional[dict]:
|
|
194
|
+
"""Load only session metadata (fast, for listings).
|
|
195
|
+
|
|
196
|
+
If meta.json is empty/corrupt (e.g. from disk-full), attempts to
|
|
197
|
+
reconstruct minimal metadata from raw.jsonl and re-persist it.
|
|
198
|
+
"""
|
|
199
|
+
meta_path = self._meta_path(session_id)
|
|
200
|
+
if not meta_path.exists():
|
|
201
|
+
return None
|
|
202
|
+
try:
|
|
203
|
+
text = meta_path.read_text(encoding="utf-8")
|
|
204
|
+
if not text.strip():
|
|
205
|
+
raise ValueError("empty meta.json")
|
|
206
|
+
meta = json.loads(text)
|
|
207
|
+
# Back-compat: the provider conversation-id key was claude_session_id
|
|
208
|
+
# before the provider-neutral rename. Normalize on read so existing
|
|
209
|
+
# sessions still resume; the old key is dropped on the next write.
|
|
210
|
+
if "claude_session_id" in meta and "provider_session_id" not in meta:
|
|
211
|
+
meta["provider_session_id"] = meta.pop("claude_session_id")
|
|
212
|
+
return meta
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.warning(f"Corrupt meta for {session_id}: {e}, attempting recovery")
|
|
215
|
+
meta = self._recover_meta(session_id)
|
|
216
|
+
if meta:
|
|
217
|
+
self._write_meta(session_id, meta)
|
|
218
|
+
logger.info(f"Recovered meta for {session_id} from raw.jsonl")
|
|
219
|
+
return meta
|
|
220
|
+
|
|
221
|
+
def load_full(self, session_id: str) -> Optional[dict]:
|
|
222
|
+
"""Load full session data including all messages (for restore)."""
|
|
223
|
+
meta = self.load_meta(session_id)
|
|
224
|
+
if not meta:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
# Load messages from JSONL
|
|
228
|
+
messages = list(self._read_messages(session_id))
|
|
229
|
+
|
|
230
|
+
# Hydrate tool outputs from files
|
|
231
|
+
for msg in messages:
|
|
232
|
+
self._hydrate_tool_output(session_id, msg)
|
|
233
|
+
|
|
234
|
+
return {**meta, "messages": messages}
|
|
235
|
+
|
|
236
|
+
def _write_meta(self, session_id: str, meta: dict):
|
|
237
|
+
"""Write metadata to meta.json atomically (temp file + rename).
|
|
238
|
+
|
|
239
|
+
Avoids data loss on disk-full: writes to temp file first, then
|
|
240
|
+
atomic rename. If write fails, the original meta.json is untouched.
|
|
241
|
+
"""
|
|
242
|
+
meta_path = self._meta_path(session_id)
|
|
243
|
+
tmp_path = None
|
|
244
|
+
try:
|
|
245
|
+
data = json.dumps(meta, indent=2).encode()
|
|
246
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
247
|
+
dir=meta_path.parent, prefix=".meta_", suffix=".tmp"
|
|
248
|
+
)
|
|
249
|
+
try:
|
|
250
|
+
os.write(fd, data)
|
|
251
|
+
os.fsync(fd)
|
|
252
|
+
finally:
|
|
253
|
+
os.close(fd)
|
|
254
|
+
os.replace(tmp_path, meta_path)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.error(f"Failed to write meta for {session_id}: {e}")
|
|
257
|
+
if tmp_path:
|
|
258
|
+
try:
|
|
259
|
+
os.unlink(tmp_path)
|
|
260
|
+
except OSError:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
def _recover_meta(self, session_id: str) -> Optional[dict]:
|
|
264
|
+
"""Reconstruct minimal meta from raw.jsonl when meta.json is corrupt."""
|
|
265
|
+
raw_path = self._raw_log_path(session_id)
|
|
266
|
+
if not raw_path.exists() or raw_path.stat().st_size == 0:
|
|
267
|
+
logger.warning(f"Cannot recover {session_id}: no raw.jsonl")
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
cwd = None
|
|
272
|
+
provider_session_id = None
|
|
273
|
+
model = None
|
|
274
|
+
first_ts = None
|
|
275
|
+
last_ts = None
|
|
276
|
+
msg_count = 0
|
|
277
|
+
|
|
278
|
+
with open(raw_path, encoding="utf-8") as f:
|
|
279
|
+
for line in f:
|
|
280
|
+
line = line.strip()
|
|
281
|
+
if not line:
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
entry = json.loads(line)
|
|
285
|
+
except json.JSONDecodeError:
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
ts = entry.get("ts")
|
|
289
|
+
if ts and not first_ts:
|
|
290
|
+
first_ts = ts
|
|
291
|
+
if ts:
|
|
292
|
+
last_ts = ts
|
|
293
|
+
|
|
294
|
+
# Extract init data
|
|
295
|
+
if entry.get("subtype") == "init" and entry.get("data"):
|
|
296
|
+
try:
|
|
297
|
+
init = json.loads(entry["data"])
|
|
298
|
+
cwd = cwd or init.get("cwd")
|
|
299
|
+
provider_session_id = provider_session_id or init.get("session_id")
|
|
300
|
+
model = model or init.get("model")
|
|
301
|
+
except json.JSONDecodeError:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
# Count user messages
|
|
305
|
+
if entry.get("dir") == "in" and entry.get("type") == "user":
|
|
306
|
+
msg_count += 1
|
|
307
|
+
|
|
308
|
+
if not cwd:
|
|
309
|
+
logger.warning(f"Cannot recover {session_id}: no cwd in raw.jsonl")
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
313
|
+
meta = {
|
|
314
|
+
"id": session_id,
|
|
315
|
+
"name": (Path(cwd).name if cwd else "") or "Recovered Session",
|
|
316
|
+
"cwd": cwd,
|
|
317
|
+
"provider_session_id": provider_session_id,
|
|
318
|
+
"model": model,
|
|
319
|
+
"total_cost": 0,
|
|
320
|
+
"message_count": msg_count,
|
|
321
|
+
"slash_commands": [],
|
|
322
|
+
"created_at": first_ts or now,
|
|
323
|
+
"last_activity": last_ts or now,
|
|
324
|
+
"_recovered": True,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if self.project_path:
|
|
328
|
+
meta["project_hash"] = paths.get_project_hash(self.project_path)
|
|
329
|
+
|
|
330
|
+
return meta
|
|
331
|
+
|
|
332
|
+
except Exception as e:
|
|
333
|
+
logger.error(f"Recovery failed for {session_id}: {e}")
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
def update_meta(self, session_id: str, **kwargs):
|
|
337
|
+
"""Update specific metadata fields (allows adding new fields)."""
|
|
338
|
+
meta = self.load_meta(session_id)
|
|
339
|
+
if meta:
|
|
340
|
+
# Allow known fields to be added/updated
|
|
341
|
+
allowed_fields = {
|
|
342
|
+
"name", "description", "cwd", "provider_session_id", "model",
|
|
343
|
+
"total_cost", "message_count", "slash_commands", "max_thinking_tokens",
|
|
344
|
+
"token_profile", "preferred_model", "permission_level",
|
|
345
|
+
"effort_level", "provider",
|
|
346
|
+
}
|
|
347
|
+
for key, value in kwargs.items():
|
|
348
|
+
if key in meta or key in allowed_fields:
|
|
349
|
+
meta[key] = value
|
|
350
|
+
meta["last_activity"] = datetime.now(timezone.utc).isoformat()
|
|
351
|
+
self._write_meta(session_id, meta)
|
|
352
|
+
|
|
353
|
+
def delete(self, session_id: str) -> bool:
|
|
354
|
+
"""Delete a session and all its data."""
|
|
355
|
+
session_dir = self._session_dir(session_id)
|
|
356
|
+
if session_dir.exists():
|
|
357
|
+
shutil.rmtree(session_dir)
|
|
358
|
+
logger.info(f"Deleted session: {session_id}")
|
|
359
|
+
return True
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
def list_all(self) -> list[dict]:
|
|
363
|
+
"""List all sessions (metadata only, sorted by last_activity)."""
|
|
364
|
+
sessions = []
|
|
365
|
+
|
|
366
|
+
for entry in self.base_dir.iterdir():
|
|
367
|
+
if entry.is_dir() and (entry / "meta.json").exists():
|
|
368
|
+
meta = self.load_meta(entry.name)
|
|
369
|
+
if meta:
|
|
370
|
+
sessions.append(meta)
|
|
371
|
+
|
|
372
|
+
# Sort by last_activity descending
|
|
373
|
+
sessions.sort(key=lambda s: s.get("last_activity", ""), reverse=True)
|
|
374
|
+
return sessions
|
|
375
|
+
|
|
376
|
+
# ─────────────────────────────────────────────────────────────────
|
|
377
|
+
# Message Storage (append-only JSONL)
|
|
378
|
+
# ─────────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
def _read_messages(self, session_id: str) -> Iterator[dict]:
|
|
381
|
+
"""Read all messages from JSONL file."""
|
|
382
|
+
messages_path = self._messages_path(session_id)
|
|
383
|
+
if not messages_path.exists():
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
with open(messages_path, 'r', encoding="utf-8") as f:
|
|
388
|
+
for line_num, line in enumerate(f, 1):
|
|
389
|
+
line = line.strip()
|
|
390
|
+
if line:
|
|
391
|
+
try:
|
|
392
|
+
yield json.loads(line)
|
|
393
|
+
except json.JSONDecodeError as e:
|
|
394
|
+
logger.warning(f"Skipping invalid JSON at line {line_num} in {session_id}: {e}")
|
|
395
|
+
except Exception as e:
|
|
396
|
+
logger.error(f"Failed to read messages for {session_id}: {e}")
|
|
397
|
+
|
|
398
|
+
def _append_message(self, session_id: str, message: dict) -> int:
|
|
399
|
+
"""Append a single message to JSONL file. Returns new message count."""
|
|
400
|
+
messages_path = self._messages_path(session_id)
|
|
401
|
+
try:
|
|
402
|
+
with open(messages_path, 'a', encoding="utf-8") as f:
|
|
403
|
+
f.write(json.dumps(message) + '\n')
|
|
404
|
+
|
|
405
|
+
# Update message count in meta
|
|
406
|
+
meta = self.load_meta(session_id)
|
|
407
|
+
if meta:
|
|
408
|
+
new_count = meta.get("message_count", 0) + 1
|
|
409
|
+
meta["message_count"] = new_count
|
|
410
|
+
meta["last_activity"] = datetime.now(timezone.utc).isoformat()
|
|
411
|
+
self._write_meta(session_id, meta)
|
|
412
|
+
return new_count
|
|
413
|
+
|
|
414
|
+
except Exception as e:
|
|
415
|
+
logger.error(f"Failed to append message to {session_id}: {e}")
|
|
416
|
+
return -1
|
|
417
|
+
|
|
418
|
+
def add_message(self, session_id: str, message: dict) -> int:
|
|
419
|
+
"""
|
|
420
|
+
Add a message to the session.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Line number (1-indexed) of the added message, or -1 on failure
|
|
424
|
+
"""
|
|
425
|
+
# Add timestamp if not present
|
|
426
|
+
if "timestamp" not in message:
|
|
427
|
+
message["timestamp"] = datetime.now(timezone.utc).isoformat()
|
|
428
|
+
|
|
429
|
+
return self._append_message(session_id, message)
|
|
430
|
+
|
|
431
|
+
# ─────────────────────────────────────────────────────────────────
|
|
432
|
+
# Raw Output Logging (append-only, captures everything from Claude)
|
|
433
|
+
# ─────────────────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
def log_raw(self, session_id: str, direction: str, data: str, parsed: dict = None):
|
|
436
|
+
"""
|
|
437
|
+
Log raw Claude I/O to raw.jsonl for debugging/audit.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
session_id: Session ID
|
|
441
|
+
direction: "in" (to Claude), "out" (from Claude), or "event" (lifecycle events)
|
|
442
|
+
data: Raw string data
|
|
443
|
+
parsed: Parsed JSON if available (for type extraction)
|
|
444
|
+
"""
|
|
445
|
+
raw_path = self._raw_log_path(session_id)
|
|
446
|
+
if not raw_path.exists():
|
|
447
|
+
return # Session doesn't exist or not initialized
|
|
448
|
+
|
|
449
|
+
entry = {
|
|
450
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
451
|
+
"dir": direction,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
# Add type info if parsed
|
|
455
|
+
if parsed and isinstance(parsed, dict):
|
|
456
|
+
entry["type"] = parsed.get("type", "unknown")
|
|
457
|
+
if parsed.get("subtype"):
|
|
458
|
+
entry["subtype"] = parsed["subtype"]
|
|
459
|
+
|
|
460
|
+
# Handle different directions
|
|
461
|
+
if direction == "out":
|
|
462
|
+
# Claude output - include raw data (truncated if huge)
|
|
463
|
+
if len(data) > 10000:
|
|
464
|
+
entry["data"] = data[:10000] + f"...[truncated, total {len(data)} chars]"
|
|
465
|
+
entry["truncated"] = True
|
|
466
|
+
else:
|
|
467
|
+
entry["data"] = data
|
|
468
|
+
elif direction == "in":
|
|
469
|
+
# Input to Claude - just log size (content in messages.jsonl)
|
|
470
|
+
entry["size"] = len(data)
|
|
471
|
+
elif direction == "event":
|
|
472
|
+
# Lifecycle events (process start/stop, etc.)
|
|
473
|
+
entry["event"] = data
|
|
474
|
+
else:
|
|
475
|
+
entry["data"] = data
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
with open(raw_path, 'a', encoding="utf-8") as f:
|
|
479
|
+
f.write(json.dumps(entry) + '\n')
|
|
480
|
+
except Exception as e:
|
|
481
|
+
logger.error(f"Failed to write raw log for {session_id}: {e}")
|
|
482
|
+
|
|
483
|
+
def log_raw_error(self, session_id: str, error: str, context: str = None):
|
|
484
|
+
"""Log an error/anomaly to raw.jsonl."""
|
|
485
|
+
raw_path = self._raw_log_path(session_id)
|
|
486
|
+
if not raw_path.exists():
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
entry = {
|
|
490
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
491
|
+
"dir": "error",
|
|
492
|
+
"error": error,
|
|
493
|
+
}
|
|
494
|
+
if context:
|
|
495
|
+
entry["context"] = context[:1000] # Truncate context
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
with open(raw_path, 'a', encoding="utf-8") as f:
|
|
499
|
+
f.write(json.dumps(entry) + '\n')
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.error(f"Failed to write raw error log for {session_id}: {e}")
|
|
502
|
+
|
|
503
|
+
# ─────────────────────────────────────────────────────────────────
|
|
504
|
+
# Tool Output Storage (separate files for large outputs)
|
|
505
|
+
# ─────────────────────────────────────────────────────────────────
|
|
506
|
+
|
|
507
|
+
def _save_tool_output(self, session_id: str, tool_name: str, tool_id: str, output: str) -> str:
|
|
508
|
+
"""
|
|
509
|
+
Save tool output to a separate file.
|
|
510
|
+
Returns the filename for reference in the message.
|
|
511
|
+
"""
|
|
512
|
+
tools_dir = self._tools_dir(session_id)
|
|
513
|
+
tools_dir.mkdir(exist_ok=True)
|
|
514
|
+
|
|
515
|
+
filename = self._tool_filename(tool_name, tool_id)
|
|
516
|
+
filepath = tools_dir / filename
|
|
517
|
+
|
|
518
|
+
try:
|
|
519
|
+
filepath.write_text(output, encoding="utf-8")
|
|
520
|
+
logger.debug(f"Saved tool output: {filename} ({len(output)} chars)")
|
|
521
|
+
except Exception as e:
|
|
522
|
+
logger.error(f"Failed to save tool output {filename}: {e}")
|
|
523
|
+
|
|
524
|
+
return filename
|
|
525
|
+
|
|
526
|
+
def _load_tool_output(self, session_id: str, filename: str) -> Optional[str]:
|
|
527
|
+
"""Load tool output from file."""
|
|
528
|
+
filepath = self._tools_dir(session_id) / filename
|
|
529
|
+
if filepath.exists():
|
|
530
|
+
try:
|
|
531
|
+
return filepath.read_text(encoding="utf-8")
|
|
532
|
+
except Exception as e:
|
|
533
|
+
logger.error(f"Failed to load tool output {filename}: {e}")
|
|
534
|
+
return None
|
|
535
|
+
|
|
536
|
+
def _hydrate_tool_output(self, session_id: str, message: dict):
|
|
537
|
+
"""
|
|
538
|
+
Hydrate tool output from file if stored externally.
|
|
539
|
+
Modifies message in-place.
|
|
540
|
+
"""
|
|
541
|
+
# Handle regular tool messages
|
|
542
|
+
if message.get("role") == "tool" and message.get("tool_output_file"):
|
|
543
|
+
output = self._load_tool_output(session_id, message["tool_output_file"])
|
|
544
|
+
if output is not None:
|
|
545
|
+
message["tool_output"] = output
|
|
546
|
+
|
|
547
|
+
# Handle thinking messages with nested tools
|
|
548
|
+
if message.get("role") == "thinking" and message.get("tools"):
|
|
549
|
+
for tool in message["tools"]:
|
|
550
|
+
if tool.get("toolOutputFile"):
|
|
551
|
+
output = self._load_tool_output(session_id, tool["toolOutputFile"])
|
|
552
|
+
if output is not None:
|
|
553
|
+
tool["toolOutput"] = output
|
|
554
|
+
|
|
555
|
+
def add_tool_message(self, session_id: str, tool_name: str, tool_id: str, tool_input: dict):
|
|
556
|
+
"""Add a tool invocation message."""
|
|
557
|
+
message = {
|
|
558
|
+
"role": "tool",
|
|
559
|
+
"tool_name": tool_name,
|
|
560
|
+
"tool_id": tool_id,
|
|
561
|
+
"tool_input": tool_input,
|
|
562
|
+
"tool_completed": False,
|
|
563
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
564
|
+
}
|
|
565
|
+
self._append_message(session_id, message)
|
|
566
|
+
|
|
567
|
+
def update_tool_result(self, session_id: str, tool_id: str, output: str, error: str = "", start_line: int = None):
|
|
568
|
+
"""
|
|
569
|
+
Update a tool message with its result.
|
|
570
|
+
Large outputs are stored in separate files.
|
|
571
|
+
"""
|
|
572
|
+
# Read all messages to find and update the tool
|
|
573
|
+
messages = list(self._read_messages(session_id))
|
|
574
|
+
updated = False
|
|
575
|
+
|
|
576
|
+
for msg in reversed(messages):
|
|
577
|
+
if msg.get("role") == "tool" and msg.get("tool_id") == tool_id:
|
|
578
|
+
msg["tool_completed"] = True
|
|
579
|
+
msg["tool_error"] = error
|
|
580
|
+
|
|
581
|
+
# Store startLine for Edit tools (from structuredPatch)
|
|
582
|
+
if start_line:
|
|
583
|
+
msg["startLine"] = start_line
|
|
584
|
+
|
|
585
|
+
# Store large outputs in separate file
|
|
586
|
+
if len(output) > self.TOOL_OUTPUT_THRESHOLD:
|
|
587
|
+
filename = self._save_tool_output(
|
|
588
|
+
session_id,
|
|
589
|
+
msg.get("tool_name", "Unknown"),
|
|
590
|
+
tool_id,
|
|
591
|
+
output
|
|
592
|
+
)
|
|
593
|
+
msg["tool_output_file"] = filename
|
|
594
|
+
msg["tool_output"] = f"[Stored in {filename}]"
|
|
595
|
+
else:
|
|
596
|
+
msg["tool_output"] = output
|
|
597
|
+
|
|
598
|
+
updated = True
|
|
599
|
+
break
|
|
600
|
+
|
|
601
|
+
if updated:
|
|
602
|
+
# Rewrite messages file (necessary for updates)
|
|
603
|
+
self._rewrite_messages(session_id, messages)
|
|
604
|
+
|
|
605
|
+
def _rewrite_messages(self, session_id: str, messages: list):
|
|
606
|
+
"""Rewrite all messages (used for updates/imports)."""
|
|
607
|
+
messages_path = self._messages_path(session_id)
|
|
608
|
+
try:
|
|
609
|
+
with open(messages_path, 'w', encoding="utf-8") as f:
|
|
610
|
+
for msg in messages:
|
|
611
|
+
f.write(json.dumps(msg) + '\n')
|
|
612
|
+
|
|
613
|
+
# Update message count in meta
|
|
614
|
+
self.update_meta(session_id, message_count=len(messages))
|
|
615
|
+
except Exception as e:
|
|
616
|
+
logger.error(f"Failed to rewrite messages for {session_id}: {e}")
|
|
617
|
+
|
|
618
|
+
# ─────────────────────────────────────────────────────────────────
|
|
619
|
+
# Thinking Message Support
|
|
620
|
+
# ─────────────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
def add_thinking_message(self, session_id: str, content: str, timestamp: str = None) -> int:
|
|
623
|
+
"""Add a thinking message. Returns the message index."""
|
|
624
|
+
messages_path = self._messages_path(session_id)
|
|
625
|
+
|
|
626
|
+
# Count existing messages to get index
|
|
627
|
+
msg_count = sum(1 for _ in self._read_messages(session_id))
|
|
628
|
+
|
|
629
|
+
message = {
|
|
630
|
+
"role": "thinking",
|
|
631
|
+
"content": content,
|
|
632
|
+
"tools": [],
|
|
633
|
+
"timestamp": timestamp or (datetime.now(timezone.utc).isoformat()),
|
|
634
|
+
}
|
|
635
|
+
self._append_message(session_id, message)
|
|
636
|
+
return msg_count
|
|
637
|
+
|
|
638
|
+
def add_tool_to_thinking(self, session_id: str, thinking_index: int, tool_data: dict):
|
|
639
|
+
"""Add a tool to an existing thinking message."""
|
|
640
|
+
messages = list(self._read_messages(session_id))
|
|
641
|
+
|
|
642
|
+
if 0 <= thinking_index < len(messages):
|
|
643
|
+
msg = messages[thinking_index]
|
|
644
|
+
if msg.get("role") == "thinking":
|
|
645
|
+
msg["tools"].append({
|
|
646
|
+
"toolName": tool_data.get("name"),
|
|
647
|
+
"toolId": tool_data.get("id"),
|
|
648
|
+
"toolInput": tool_data.get("input"),
|
|
649
|
+
"toolOutput": None,
|
|
650
|
+
"toolCompleted": False,
|
|
651
|
+
})
|
|
652
|
+
self._rewrite_messages(session_id, messages)
|
|
653
|
+
|
|
654
|
+
def update_thinking_tool_result(self, session_id: str, tool_id: str, output: str, start_line: int = None):
|
|
655
|
+
"""Update a tool result within a thinking message."""
|
|
656
|
+
messages = list(self._read_messages(session_id))
|
|
657
|
+
updated = False
|
|
658
|
+
|
|
659
|
+
for msg in reversed(messages):
|
|
660
|
+
if msg.get("role") == "thinking" and msg.get("tools"):
|
|
661
|
+
for tool in msg["tools"]:
|
|
662
|
+
if tool.get("toolId") == tool_id:
|
|
663
|
+
tool["toolCompleted"] = True
|
|
664
|
+
|
|
665
|
+
# Store large outputs in separate file
|
|
666
|
+
if len(output) > self.TOOL_OUTPUT_THRESHOLD:
|
|
667
|
+
filename = self._save_tool_output(
|
|
668
|
+
session_id,
|
|
669
|
+
tool.get("toolName", "Unknown"),
|
|
670
|
+
tool_id,
|
|
671
|
+
output
|
|
672
|
+
)
|
|
673
|
+
tool["toolOutputFile"] = filename
|
|
674
|
+
tool["toolOutput"] = f"[Stored in {filename}]"
|
|
675
|
+
else:
|
|
676
|
+
tool["toolOutput"] = output
|
|
677
|
+
|
|
678
|
+
# Store startLine for Edit tools (from structuredPatch)
|
|
679
|
+
if start_line:
|
|
680
|
+
tool["startLine"] = start_line
|
|
681
|
+
|
|
682
|
+
updated = True
|
|
683
|
+
break
|
|
684
|
+
if updated:
|
|
685
|
+
break
|
|
686
|
+
|
|
687
|
+
if updated:
|
|
688
|
+
self._rewrite_messages(session_id, messages)
|
|
689
|
+
|
|
690
|
+
# ─────────────────────────────────────────────────────────────────
|
|
691
|
+
# Clear / Reset
|
|
692
|
+
# ─────────────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
def clear_conversation(self, session_id: str):
|
|
695
|
+
"""Clear conversation history and reset for fresh start."""
|
|
696
|
+
# Clear messages file
|
|
697
|
+
messages_path = self._messages_path(session_id)
|
|
698
|
+
if messages_path.exists():
|
|
699
|
+
messages_path.write_text("", encoding="utf-8")
|
|
700
|
+
|
|
701
|
+
# Clear tools directory
|
|
702
|
+
tools_dir = self._tools_dir(session_id)
|
|
703
|
+
if tools_dir.exists():
|
|
704
|
+
shutil.rmtree(tools_dir)
|
|
705
|
+
tools_dir.mkdir()
|
|
706
|
+
|
|
707
|
+
# Reset metadata
|
|
708
|
+
self.update_meta(
|
|
709
|
+
session_id,
|
|
710
|
+
provider_session_id=None,
|
|
711
|
+
message_count=0,
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
logger.info(f"Cleared conversation for {session_id}")
|
|
715
|
+
|
|
716
|
+
# ─────────────────────────────────────────────────────────────────
|
|
717
|
+
# Project-level Command Storage
|
|
718
|
+
# ─────────────────────────────────────────────────────────────────
|
|
719
|
+
# Slash commands are per-project (CWD), not per-session.
|
|
720
|
+
# This allows new sessions to show commands before Claude starts.
|
|
721
|
+
|
|
722
|
+
def _project_data_path(self, cwd: str) -> Path:
|
|
723
|
+
"""Get the project-data.json path for storing per-project data."""
|
|
724
|
+
if self.project_path:
|
|
725
|
+
# New: store in project's data directory
|
|
726
|
+
return paths.get_project_dir(cwd) / "project-data.json"
|
|
727
|
+
else:
|
|
728
|
+
# Legacy: store in sessions directory
|
|
729
|
+
return self.base_dir / "projects.json"
|
|
730
|
+
|
|
731
|
+
def _load_project_data(self, cwd: str) -> dict:
|
|
732
|
+
"""Load project data."""
|
|
733
|
+
path = self._project_data_path(cwd)
|
|
734
|
+
if path.exists():
|
|
735
|
+
try:
|
|
736
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
737
|
+
except Exception as e:
|
|
738
|
+
logger.error(f"Failed to load project data: {e}")
|
|
739
|
+
return {}
|
|
740
|
+
|
|
741
|
+
def _save_project_data(self, cwd: str, data: dict):
|
|
742
|
+
"""Save project data."""
|
|
743
|
+
path = self._project_data_path(cwd)
|
|
744
|
+
try:
|
|
745
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
746
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
747
|
+
except Exception as e:
|
|
748
|
+
logger.error(f"Failed to save project data: {e}")
|
|
749
|
+
|
|
750
|
+
def save_project_commands(self, cwd: str, commands: list):
|
|
751
|
+
"""Save slash commands for a project (CWD)."""
|
|
752
|
+
data = self._load_project_data(cwd)
|
|
753
|
+
data["slash_commands"] = commands
|
|
754
|
+
data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
755
|
+
self._save_project_data(cwd, data)
|
|
756
|
+
logger.debug(f"Saved {len(commands)} commands for project {cwd}")
|
|
757
|
+
|
|
758
|
+
def get_project_commands(self, cwd: str) -> list:
|
|
759
|
+
"""Get slash commands for a project (CWD)."""
|
|
760
|
+
data = self._load_project_data(cwd)
|
|
761
|
+
return data.get("slash_commands", [])
|
|
762
|
+
|
|
763
|
+
# ─────────────────────────────────────────────────────────────────
|
|
764
|
+
# Stash (context references)
|
|
765
|
+
# ─────────────────────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
def get_stash(self, session_id: str) -> list:
|
|
768
|
+
"""Get all stash items for a session."""
|
|
769
|
+
stash_path = self._stash_path(session_id)
|
|
770
|
+
if not stash_path.exists():
|
|
771
|
+
return []
|
|
772
|
+
try:
|
|
773
|
+
data = json.loads(stash_path.read_text(encoding="utf-8"))
|
|
774
|
+
return data.get("items", [])
|
|
775
|
+
except Exception as e:
|
|
776
|
+
logger.warning(f"Error reading stash for {session_id}: {e}")
|
|
777
|
+
return []
|
|
778
|
+
|
|
779
|
+
def _save_stash(self, session_id: str, items: list) -> bool:
|
|
780
|
+
"""Save stash items for a session. Returns True on success."""
|
|
781
|
+
stash_path = self._stash_path(session_id)
|
|
782
|
+
try:
|
|
783
|
+
# Ensure session directory exists (may not if session is pending or different user)
|
|
784
|
+
stash_path.parent.mkdir(parents=True, exist_ok=True)
|
|
785
|
+
stash_path.write_text(json.dumps({"items": items}, indent=2), encoding="utf-8")
|
|
786
|
+
return True
|
|
787
|
+
except Exception as e:
|
|
788
|
+
logger.error(f"Error saving stash for {session_id}: {e}")
|
|
789
|
+
return False
|
|
790
|
+
|
|
791
|
+
def add_stash_item(self, session_id: str, item: dict) -> bool:
|
|
792
|
+
"""Add an item to the session stash. Returns True on success."""
|
|
793
|
+
items = self.get_stash(session_id)
|
|
794
|
+
items.insert(0, item) # Newest first
|
|
795
|
+
return self._save_stash(session_id, items)
|
|
796
|
+
|
|
797
|
+
def remove_stash_item(self, session_id: str, item_id: str) -> bool:
|
|
798
|
+
"""Remove an item from the session stash. Returns True on success."""
|
|
799
|
+
items = self.get_stash(session_id)
|
|
800
|
+
items = [i for i in items if i.get("id") != item_id]
|
|
801
|
+
return self._save_stash(session_id, items)
|
|
802
|
+
|
|
803
|
+
def update_stash_item(self, session_id: str, item_id: str, updates: dict) -> tuple[bool, bool]:
|
|
804
|
+
"""Update a stash item (toggle enabled, edit note, etc).
|
|
805
|
+
|
|
806
|
+
Returns (found, saved) tuple:
|
|
807
|
+
- found: whether the item was found
|
|
808
|
+
- saved: whether the save succeeded (only meaningful if found)
|
|
809
|
+
"""
|
|
810
|
+
items = self.get_stash(session_id)
|
|
811
|
+
for item in items:
|
|
812
|
+
if item.get("id") == item_id:
|
|
813
|
+
item.update(updates)
|
|
814
|
+
saved = self._save_stash(session_id, items)
|
|
815
|
+
return (True, saved)
|
|
816
|
+
return (False, False)
|
|
817
|
+
|
|
818
|
+
def clear_stash(self, session_id: str) -> bool:
|
|
819
|
+
"""Clear all items from a session stash."""
|
|
820
|
+
stash_path = self._stash_path(session_id)
|
|
821
|
+
if stash_path.exists():
|
|
822
|
+
stash_path.unlink()
|
|
823
|
+
return True
|
|
824
|
+
|
|
825
|
+
# Sent-history cap per session — trimmed oldest-first on every mark-sent
|
|
826
|
+
# so stash.json can't grow unbounded
|
|
827
|
+
STASH_HISTORY_LIMIT = 50
|
|
828
|
+
|
|
829
|
+
def mark_stash_sent(self, session_id: str, item_ids: list,
|
|
830
|
+
message_id: str = None, sent_at: str = None,
|
|
831
|
+
sent_session_id: str = None) -> tuple[int, bool]:
|
|
832
|
+
"""Mark stash items as sent (history) instead of deleting them.
|
|
833
|
+
|
|
834
|
+
Sets status/sentAt/sentWithMessageId/sentInSessionId and disables
|
|
835
|
+
the items, then trims sent history beyond STASH_HISTORY_LIMIT
|
|
836
|
+
(oldest sentAt dropped first).
|
|
837
|
+
|
|
838
|
+
Returns (marked_count, saved) tuple.
|
|
839
|
+
"""
|
|
840
|
+
items = self.get_stash(session_id)
|
|
841
|
+
ids = set(item_ids or [])
|
|
842
|
+
now = sent_at or (datetime.now(timezone.utc).isoformat())
|
|
843
|
+
|
|
844
|
+
marked = 0
|
|
845
|
+
for item in items:
|
|
846
|
+
if item.get("id") in ids:
|
|
847
|
+
item["status"] = "sent"
|
|
848
|
+
item["enabled"] = False
|
|
849
|
+
item["sentAt"] = now
|
|
850
|
+
item["sentWithMessageId"] = message_id
|
|
851
|
+
item["sentInSessionId"] = sent_session_id
|
|
852
|
+
marked += 1
|
|
853
|
+
|
|
854
|
+
if marked == 0:
|
|
855
|
+
return (0, True)
|
|
856
|
+
|
|
857
|
+
# Trim oldest sent entries beyond the cap
|
|
858
|
+
sent = [i for i in items if i.get("status") == "sent"]
|
|
859
|
+
if len(sent) > self.STASH_HISTORY_LIMIT:
|
|
860
|
+
sent.sort(key=lambda i: i.get("sentAt") or "", reverse=True)
|
|
861
|
+
keep_ids = {i.get("id") for i in sent[:self.STASH_HISTORY_LIMIT]}
|
|
862
|
+
items = [i for i in items
|
|
863
|
+
if i.get("status") != "sent" or i.get("id") in keep_ids]
|
|
864
|
+
|
|
865
|
+
return (marked, self._save_stash(session_id, items))
|
|
866
|
+
|
|
867
|
+
def clear_stash_scope(self, session_id: str, scope: str = "all") -> bool:
|
|
868
|
+
"""Clear stash items by scope: 'pending', 'history', or 'all'."""
|
|
869
|
+
if scope == "all":
|
|
870
|
+
return self.clear_stash(session_id)
|
|
871
|
+
|
|
872
|
+
items = self.get_stash(session_id)
|
|
873
|
+
if scope == "pending":
|
|
874
|
+
items = [i for i in items if i.get("status") == "sent"]
|
|
875
|
+
elif scope == "history":
|
|
876
|
+
items = [i for i in items if i.get("status") != "sent"]
|
|
877
|
+
else:
|
|
878
|
+
return False
|
|
879
|
+
return self._save_stash(session_id, items)
|