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,984 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prompt Explorer - Extract and search user prompts across all sessions
|
|
3
|
+
|
|
4
|
+
Provides efficient access to user prompts with rich metadata:
|
|
5
|
+
- Content and timestamp
|
|
6
|
+
- Session context (name, project)
|
|
7
|
+
- Response preview
|
|
8
|
+
- Word/character counts
|
|
9
|
+
|
|
10
|
+
Features:
|
|
11
|
+
- Full-text search across prompts
|
|
12
|
+
- Filter by project, date range, session
|
|
13
|
+
- Recent prompts for quick access (Ctrl+R)
|
|
14
|
+
- Prompt statistics and patterns
|
|
15
|
+
|
|
16
|
+
Search Syntax:
|
|
17
|
+
- `word1 word2` → AND (both required)
|
|
18
|
+
- `"exact phrase"` → Exact phrase match
|
|
19
|
+
- `-exclude` → Exclude term
|
|
20
|
+
- `word1 OR word2` → Either term
|
|
21
|
+
- `in:response` → Search in Claude's responses
|
|
22
|
+
- `project:name` → Filter by project name (partial match)
|
|
23
|
+
- `after:2026-01-15` → After date
|
|
24
|
+
- `before:2026-01-10`→ Before date
|
|
25
|
+
- `long:` → Long prompts (>500 chars)
|
|
26
|
+
- `short:` → Short prompts (<100 chars)
|
|
27
|
+
- `has:image` → Prompts with images
|
|
28
|
+
- `session:id` → Filter by session
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import re
|
|
34
|
+
from datetime import datetime, timedelta
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Optional, List, Dict, Tuple, Set
|
|
37
|
+
from dataclasses import dataclass, asdict, field
|
|
38
|
+
|
|
39
|
+
from painapple_code import paths
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger("painapple-code.prompts")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
45
|
+
# QUERY PARSER - Advanced search syntax
|
|
46
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ParsedQuery:
|
|
50
|
+
"""Parsed search query with structured components"""
|
|
51
|
+
# Terms that MUST be present
|
|
52
|
+
required_terms: List[str] = field(default_factory=list)
|
|
53
|
+
# Terms that MUST NOT be present
|
|
54
|
+
excluded_terms: List[str] = field(default_factory=list)
|
|
55
|
+
# Groups of terms where at least one must match (OR groups)
|
|
56
|
+
or_groups: List[List[str]] = field(default_factory=list)
|
|
57
|
+
# Exact phrases that must appear
|
|
58
|
+
exact_phrases: List[str] = field(default_factory=list)
|
|
59
|
+
# Search in responses too
|
|
60
|
+
search_response: bool = False
|
|
61
|
+
# Project filter (partial match)
|
|
62
|
+
project_filter: Optional[str] = None
|
|
63
|
+
# Session filter
|
|
64
|
+
session_filter: Optional[str] = None
|
|
65
|
+
# Date filters
|
|
66
|
+
after_date: Optional[datetime] = None
|
|
67
|
+
before_date: Optional[datetime] = None
|
|
68
|
+
# Length filters
|
|
69
|
+
min_length: Optional[int] = None
|
|
70
|
+
max_length: Optional[int] = None
|
|
71
|
+
# Has image filter
|
|
72
|
+
has_image: Optional[bool] = None
|
|
73
|
+
# Has stash filter
|
|
74
|
+
has_stash: Optional[bool] = None
|
|
75
|
+
# Favorites filter
|
|
76
|
+
favorites_only: bool = False
|
|
77
|
+
# Original query for display
|
|
78
|
+
original_query: str = ""
|
|
79
|
+
# Active filters for UI display
|
|
80
|
+
active_filters: List[Dict] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
def is_empty(self) -> bool:
|
|
83
|
+
"""Check if query has any search criteria"""
|
|
84
|
+
return (
|
|
85
|
+
not self.required_terms and
|
|
86
|
+
not self.excluded_terms and
|
|
87
|
+
not self.or_groups and
|
|
88
|
+
not self.exact_phrases and
|
|
89
|
+
not self.search_response and
|
|
90
|
+
not self.project_filter and
|
|
91
|
+
not self.session_filter and
|
|
92
|
+
not self.after_date and
|
|
93
|
+
not self.before_date and
|
|
94
|
+
not self.min_length and
|
|
95
|
+
not self.max_length and
|
|
96
|
+
self.has_image is None and
|
|
97
|
+
self.has_stash is None and
|
|
98
|
+
not self.favorites_only
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class QueryParser:
|
|
103
|
+
"""
|
|
104
|
+
Parse search queries into structured components.
|
|
105
|
+
|
|
106
|
+
Supports:
|
|
107
|
+
- Basic terms (AND by default): `word1 word2`
|
|
108
|
+
- Exact phrases: `"exact phrase"`
|
|
109
|
+
- Exclusion: `-word` or `NOT word`
|
|
110
|
+
- OR groups: `word1 OR word2`
|
|
111
|
+
- Field filters: `in:response`, `project:name`, `after:date`
|
|
112
|
+
- Special filters: `long:`, `short:`, `has:image`
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
# Regex patterns
|
|
116
|
+
QUOTED_PHRASE = re.compile(r'"([^"]+)"')
|
|
117
|
+
FILTER_PATTERN = re.compile(r'(\w+):(\S+)')
|
|
118
|
+
|
|
119
|
+
def parse(self, query: str) -> ParsedQuery:
|
|
120
|
+
"""Parse a search query string into structured components"""
|
|
121
|
+
result = ParsedQuery(original_query=query)
|
|
122
|
+
|
|
123
|
+
if not query or not query.strip():
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
# Extract quoted phrases first
|
|
127
|
+
phrases = self.QUOTED_PHRASE.findall(query)
|
|
128
|
+
for phrase in phrases:
|
|
129
|
+
result.exact_phrases.append(phrase)
|
|
130
|
+
result.active_filters.append({
|
|
131
|
+
"type": "phrase",
|
|
132
|
+
"label": f'"{phrase}"',
|
|
133
|
+
"value": phrase
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
# Remove quoted phrases from query
|
|
137
|
+
query = self.QUOTED_PHRASE.sub('', query)
|
|
138
|
+
|
|
139
|
+
# Extract field filters
|
|
140
|
+
filters = self.FILTER_PATTERN.findall(query)
|
|
141
|
+
for key, value in filters:
|
|
142
|
+
self._apply_filter(result, key.lower(), value)
|
|
143
|
+
|
|
144
|
+
# Remove filters from query
|
|
145
|
+
query = self.FILTER_PATTERN.sub('', query)
|
|
146
|
+
|
|
147
|
+
# Handle special keywords
|
|
148
|
+
query = self._handle_special_keywords(query, result)
|
|
149
|
+
|
|
150
|
+
# Split remaining query into tokens
|
|
151
|
+
tokens = query.split()
|
|
152
|
+
|
|
153
|
+
i = 0
|
|
154
|
+
while i < len(tokens):
|
|
155
|
+
token = tokens[i]
|
|
156
|
+
|
|
157
|
+
# Handle exclusion (-term or NOT term)
|
|
158
|
+
if token.startswith('-') and len(token) > 1:
|
|
159
|
+
result.excluded_terms.append(token[1:].lower())
|
|
160
|
+
result.active_filters.append({
|
|
161
|
+
"type": "exclude",
|
|
162
|
+
"label": f"-{token[1:]}",
|
|
163
|
+
"value": token[1:]
|
|
164
|
+
})
|
|
165
|
+
elif token.upper() == 'NOT' and i + 1 < len(tokens):
|
|
166
|
+
result.excluded_terms.append(tokens[i + 1].lower())
|
|
167
|
+
result.active_filters.append({
|
|
168
|
+
"type": "exclude",
|
|
169
|
+
"label": f"-{tokens[i + 1]}",
|
|
170
|
+
"value": tokens[i + 1]
|
|
171
|
+
})
|
|
172
|
+
i += 1
|
|
173
|
+
# Handle OR
|
|
174
|
+
elif token.upper() == 'OR' and i > 0 and i + 1 < len(tokens):
|
|
175
|
+
# Look back for the OR group start
|
|
176
|
+
prev_term = None
|
|
177
|
+
if result.required_terms:
|
|
178
|
+
prev_term = result.required_terms.pop()
|
|
179
|
+
# Remove from active filters too
|
|
180
|
+
result.active_filters = [
|
|
181
|
+
f for f in result.active_filters
|
|
182
|
+
if not (f.get("type") == "term" and f.get("value") == prev_term)
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
# Collect OR group
|
|
186
|
+
or_group = []
|
|
187
|
+
if prev_term:
|
|
188
|
+
or_group.append(prev_term.lower())
|
|
189
|
+
|
|
190
|
+
# Add next term
|
|
191
|
+
i += 1
|
|
192
|
+
if i < len(tokens):
|
|
193
|
+
next_term = tokens[i]
|
|
194
|
+
if not next_term.startswith('-') and next_term.upper() not in ('OR', 'NOT'):
|
|
195
|
+
or_group.append(next_term.lower())
|
|
196
|
+
|
|
197
|
+
# Continue collecting OR terms
|
|
198
|
+
while i + 2 < len(tokens) and tokens[i + 1].upper() == 'OR':
|
|
199
|
+
or_group.append(tokens[i + 2].lower())
|
|
200
|
+
i += 2
|
|
201
|
+
|
|
202
|
+
if or_group:
|
|
203
|
+
result.or_groups.append(or_group)
|
|
204
|
+
result.active_filters.append({
|
|
205
|
+
"type": "or",
|
|
206
|
+
"label": " OR ".join(or_group),
|
|
207
|
+
"value": or_group
|
|
208
|
+
})
|
|
209
|
+
# Regular term
|
|
210
|
+
elif token and not token.startswith(':'):
|
|
211
|
+
result.required_terms.append(token.lower())
|
|
212
|
+
result.active_filters.append({
|
|
213
|
+
"type": "term",
|
|
214
|
+
"label": token,
|
|
215
|
+
"value": token.lower()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
i += 1
|
|
219
|
+
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
def _apply_filter(self, result: ParsedQuery, key: str, value: str):
|
|
223
|
+
"""Apply a field:value filter"""
|
|
224
|
+
if key == 'in' and value.lower() == 'response':
|
|
225
|
+
result.search_response = True
|
|
226
|
+
result.active_filters.append({
|
|
227
|
+
"type": "field",
|
|
228
|
+
"label": "in:response",
|
|
229
|
+
"value": "response"
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
elif key == 'project':
|
|
233
|
+
result.project_filter = value
|
|
234
|
+
result.active_filters.append({
|
|
235
|
+
"type": "project",
|
|
236
|
+
"label": f"project:{value}",
|
|
237
|
+
"value": value
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
elif key == 'session':
|
|
241
|
+
result.session_filter = value
|
|
242
|
+
result.active_filters.append({
|
|
243
|
+
"type": "session",
|
|
244
|
+
"label": f"session:{value}",
|
|
245
|
+
"value": value
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
elif key in ('after', 'since', 'from'):
|
|
249
|
+
dt = self._parse_date_value(value)
|
|
250
|
+
if dt:
|
|
251
|
+
result.after_date = dt
|
|
252
|
+
result.active_filters.append({
|
|
253
|
+
"type": "date",
|
|
254
|
+
"label": f"after:{value}",
|
|
255
|
+
"value": value
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
elif key in ('before', 'until', 'to'):
|
|
259
|
+
dt = self._parse_date_value(value)
|
|
260
|
+
if dt:
|
|
261
|
+
result.before_date = dt
|
|
262
|
+
result.active_filters.append({
|
|
263
|
+
"type": "date",
|
|
264
|
+
"label": f"before:{value}",
|
|
265
|
+
"value": value
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
elif key == 'has' and value.lower() in ('image', 'images', 'img'):
|
|
269
|
+
result.has_image = True
|
|
270
|
+
result.active_filters.append({
|
|
271
|
+
"type": "field",
|
|
272
|
+
"label": "has:image",
|
|
273
|
+
"value": "image"
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
elif key == 'has' and value.lower() in ('stash', 'refs', 'context', 'comments'):
|
|
277
|
+
result.has_stash = True
|
|
278
|
+
result.active_filters.append({
|
|
279
|
+
"type": "field",
|
|
280
|
+
"label": "has:stash",
|
|
281
|
+
"value": "stash"
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
elif key in ('chars', 'length', 'len'):
|
|
285
|
+
# Handle chars>N, chars<N, chars:N
|
|
286
|
+
if value.startswith('>'):
|
|
287
|
+
try:
|
|
288
|
+
result.min_length = int(value[1:])
|
|
289
|
+
result.active_filters.append({
|
|
290
|
+
"type": "length",
|
|
291
|
+
"label": f"chars>{value[1:]}",
|
|
292
|
+
"value": result.min_length
|
|
293
|
+
})
|
|
294
|
+
except ValueError:
|
|
295
|
+
pass
|
|
296
|
+
elif value.startswith('<'):
|
|
297
|
+
try:
|
|
298
|
+
result.max_length = int(value[1:])
|
|
299
|
+
result.active_filters.append({
|
|
300
|
+
"type": "length",
|
|
301
|
+
"label": f"chars<{value[1:]}",
|
|
302
|
+
"value": result.max_length
|
|
303
|
+
})
|
|
304
|
+
except ValueError:
|
|
305
|
+
pass
|
|
306
|
+
|
|
307
|
+
def _handle_special_keywords(self, query: str, result: ParsedQuery) -> str:
|
|
308
|
+
"""Handle special keywords like long:, short:"""
|
|
309
|
+
from datetime import timezone
|
|
310
|
+
now = datetime.now(timezone.utc).replace(tzinfo=None) # Naive UTC
|
|
311
|
+
|
|
312
|
+
# long: → chars > 500
|
|
313
|
+
if 'long:' in query.lower():
|
|
314
|
+
result.min_length = 500
|
|
315
|
+
result.active_filters.append({
|
|
316
|
+
"type": "length",
|
|
317
|
+
"label": "long (>500 chars)",
|
|
318
|
+
"value": 500
|
|
319
|
+
})
|
|
320
|
+
query = re.sub(r'\blong:\s*', '', query, flags=re.IGNORECASE)
|
|
321
|
+
|
|
322
|
+
# short: → chars < 100
|
|
323
|
+
if 'short:' in query.lower():
|
|
324
|
+
result.max_length = 100
|
|
325
|
+
result.active_filters.append({
|
|
326
|
+
"type": "length",
|
|
327
|
+
"label": "short (<100 chars)",
|
|
328
|
+
"value": 100
|
|
329
|
+
})
|
|
330
|
+
query = re.sub(r'\bshort:\s*', '', query, flags=re.IGNORECASE)
|
|
331
|
+
|
|
332
|
+
# today: → after today
|
|
333
|
+
if 'today:' in query.lower():
|
|
334
|
+
result.after_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
335
|
+
result.active_filters.append({
|
|
336
|
+
"type": "date",
|
|
337
|
+
"label": "today",
|
|
338
|
+
"value": "today"
|
|
339
|
+
})
|
|
340
|
+
query = re.sub(r'\btoday:\s*', '', query, flags=re.IGNORECASE)
|
|
341
|
+
|
|
342
|
+
# yesterday: → specific day
|
|
343
|
+
if 'yesterday:' in query.lower():
|
|
344
|
+
yesterday = now - timedelta(days=1)
|
|
345
|
+
result.after_date = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
346
|
+
result.before_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
347
|
+
result.active_filters.append({
|
|
348
|
+
"type": "date",
|
|
349
|
+
"label": "yesterday",
|
|
350
|
+
"value": "yesterday"
|
|
351
|
+
})
|
|
352
|
+
query = re.sub(r'\byesterday:\s*', '', query, flags=re.IGNORECASE)
|
|
353
|
+
|
|
354
|
+
# week: → last 7 days
|
|
355
|
+
if 'week:' in query.lower():
|
|
356
|
+
result.after_date = now - timedelta(days=7)
|
|
357
|
+
result.active_filters.append({
|
|
358
|
+
"type": "date",
|
|
359
|
+
"label": "this week",
|
|
360
|
+
"value": "week"
|
|
361
|
+
})
|
|
362
|
+
query = re.sub(r'\bweek:\s*', '', query, flags=re.IGNORECASE)
|
|
363
|
+
|
|
364
|
+
# fav: or starred: or favorites: → favorites only
|
|
365
|
+
fav_patterns = [r'\bfav:\s*', r'\bstarred:\s*', r'\bfavorites:\s*', r'\bfavourites:\s*']
|
|
366
|
+
for pattern in fav_patterns:
|
|
367
|
+
if re.search(pattern, query, re.IGNORECASE):
|
|
368
|
+
result.favorites_only = True
|
|
369
|
+
result.active_filters.append({
|
|
370
|
+
"type": "favorites",
|
|
371
|
+
"label": "favorites",
|
|
372
|
+
"value": True
|
|
373
|
+
})
|
|
374
|
+
query = re.sub(pattern, '', query, flags=re.IGNORECASE)
|
|
375
|
+
break # Only add filter once
|
|
376
|
+
|
|
377
|
+
return query
|
|
378
|
+
|
|
379
|
+
def _parse_date_value(self, value: str) -> Optional[datetime]:
|
|
380
|
+
"""Parse date value from filter - returns naive UTC datetime"""
|
|
381
|
+
from datetime import timezone
|
|
382
|
+
now = datetime.now(timezone.utc).replace(tzinfo=None) # Naive UTC
|
|
383
|
+
|
|
384
|
+
# Handle relative dates
|
|
385
|
+
if value.lower() == 'today':
|
|
386
|
+
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
387
|
+
elif value.lower() == 'yesterday':
|
|
388
|
+
return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
389
|
+
elif value.lower() == 'week':
|
|
390
|
+
return now - timedelta(days=7)
|
|
391
|
+
elif value.lower() == 'month':
|
|
392
|
+
return now - timedelta(days=30)
|
|
393
|
+
|
|
394
|
+
# Handle YYYY-MM-DD (treat as UTC start of day)
|
|
395
|
+
try:
|
|
396
|
+
return datetime.strptime(value, "%Y-%m-%d")
|
|
397
|
+
except ValueError:
|
|
398
|
+
pass
|
|
399
|
+
|
|
400
|
+
# Handle days ago: 3d, 7d
|
|
401
|
+
match = re.match(r'^(\d+)d$', value)
|
|
402
|
+
if match:
|
|
403
|
+
days = int(match.group(1))
|
|
404
|
+
return now - timedelta(days=days)
|
|
405
|
+
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# Singleton query parser
|
|
410
|
+
_query_parser = QueryParser()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@dataclass
|
|
414
|
+
class Prompt:
|
|
415
|
+
"""A user prompt with metadata"""
|
|
416
|
+
id: str # Unique ID: {session_id}:{line_number}
|
|
417
|
+
content: str # Prompt text
|
|
418
|
+
timestamp: str # ISO timestamp
|
|
419
|
+
session_id: str # Session ID
|
|
420
|
+
session_name: str # Session name/description
|
|
421
|
+
project_path: str # Project CWD
|
|
422
|
+
project_hash: str # Project hash
|
|
423
|
+
response_preview: str # First ~150 chars of Claude's response
|
|
424
|
+
word_count: int # Word count
|
|
425
|
+
char_count: int # Character count
|
|
426
|
+
has_images: bool # Whether prompt included images
|
|
427
|
+
line_number: int # Line number in messages.jsonl
|
|
428
|
+
is_favorite: bool = False # Whether prompt is favorited
|
|
429
|
+
stash_refs: Optional[list] = None # Stash items attached to this prompt
|
|
430
|
+
|
|
431
|
+
def to_dict(self):
|
|
432
|
+
d = asdict(self)
|
|
433
|
+
# Only include stash_refs when present to keep responses lean
|
|
434
|
+
if not d.get('stash_refs'):
|
|
435
|
+
d.pop('stash_refs', None)
|
|
436
|
+
return d
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class PromptExtractor:
|
|
440
|
+
"""
|
|
441
|
+
Extracts and indexes user prompts from session logs.
|
|
442
|
+
|
|
443
|
+
Reads messages.jsonl files directly for efficiency.
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
def __init__(self):
|
|
447
|
+
self._cache = {} # session_id -> list of prompts
|
|
448
|
+
self._cache_timestamps = {} # session_id -> last_activity
|
|
449
|
+
|
|
450
|
+
def get_all_prompts(
|
|
451
|
+
self,
|
|
452
|
+
project_hash: str = None,
|
|
453
|
+
since: str = None,
|
|
454
|
+
until: str = None,
|
|
455
|
+
search_query: str = None,
|
|
456
|
+
limit: int = 100,
|
|
457
|
+
offset: int = 0,
|
|
458
|
+
) -> Tuple[List[Prompt], int, Optional[ParsedQuery]]:
|
|
459
|
+
"""
|
|
460
|
+
Get all prompts with filtering and pagination.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
project_hash: Filter by project
|
|
464
|
+
since: Start date (ISO or YYYY-MM-DD)
|
|
465
|
+
until: End date (ISO or YYYY-MM-DD)
|
|
466
|
+
search_query: Full-text search with advanced syntax
|
|
467
|
+
limit: Max prompts to return
|
|
468
|
+
offset: Skip N prompts
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Tuple of (prompts, total_count, parsed_query)
|
|
472
|
+
"""
|
|
473
|
+
# Parse the search query
|
|
474
|
+
parsed_query = None
|
|
475
|
+
if search_query:
|
|
476
|
+
parsed_query = _query_parser.parse(search_query)
|
|
477
|
+
|
|
478
|
+
# Merge explicit date params with query-parsed dates
|
|
479
|
+
since_dt = self._parse_date(since) if since else None
|
|
480
|
+
until_dt = self._parse_date(until) if until else None
|
|
481
|
+
|
|
482
|
+
if parsed_query:
|
|
483
|
+
if parsed_query.after_date and not since_dt:
|
|
484
|
+
since_dt = parsed_query.after_date
|
|
485
|
+
if parsed_query.before_date and not until_dt:
|
|
486
|
+
until_dt = parsed_query.before_date
|
|
487
|
+
|
|
488
|
+
# Merge project filter
|
|
489
|
+
effective_project = project_hash
|
|
490
|
+
if parsed_query and parsed_query.project_filter:
|
|
491
|
+
effective_project = parsed_query.project_filter
|
|
492
|
+
|
|
493
|
+
# Load favorites for marking
|
|
494
|
+
favorite_ids = set(paths.get_all_prompt_favorites().keys())
|
|
495
|
+
|
|
496
|
+
# Collect all prompts from all sessions
|
|
497
|
+
all_prompts = []
|
|
498
|
+
|
|
499
|
+
# Iterate all projects
|
|
500
|
+
projects_dir = paths.DATA_HOME / "projects"
|
|
501
|
+
if not projects_dir.exists():
|
|
502
|
+
return [], 0, parsed_query
|
|
503
|
+
|
|
504
|
+
for project_dir in projects_dir.iterdir():
|
|
505
|
+
if not project_dir.is_dir():
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
hash_name = project_dir.name
|
|
509
|
+
|
|
510
|
+
# Get project path for display
|
|
511
|
+
path_file = project_dir / "path"
|
|
512
|
+
project_path = path_file.read_text(encoding="utf-8").strip() if path_file.exists() else ""
|
|
513
|
+
|
|
514
|
+
# Filter by project hash OR project path (partial match)
|
|
515
|
+
if effective_project:
|
|
516
|
+
hash_match = hash_name == effective_project or hash_name.startswith(effective_project)
|
|
517
|
+
path_match = effective_project.lower() in project_path.lower()
|
|
518
|
+
if not (hash_match or path_match):
|
|
519
|
+
continue
|
|
520
|
+
|
|
521
|
+
sessions_dir = project_dir / "sessions"
|
|
522
|
+
if not sessions_dir.exists():
|
|
523
|
+
continue
|
|
524
|
+
|
|
525
|
+
for session_dir in sessions_dir.iterdir():
|
|
526
|
+
if not session_dir.is_dir():
|
|
527
|
+
continue
|
|
528
|
+
|
|
529
|
+
session_id = session_dir.name
|
|
530
|
+
|
|
531
|
+
# Filter by session if specified
|
|
532
|
+
if parsed_query and parsed_query.session_filter:
|
|
533
|
+
if not session_id.startswith(parsed_query.session_filter):
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
# Load session meta
|
|
537
|
+
meta_file = session_dir / "meta.json"
|
|
538
|
+
if not meta_file.exists():
|
|
539
|
+
continue
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
543
|
+
except (ValueError, OSError) as exc:
|
|
544
|
+
logger.debug("prompt search: skipping %s with unreadable meta: %s", session_id, exc)
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
session_name = meta.get("name") or meta.get("description") or ""
|
|
548
|
+
|
|
549
|
+
# Extract prompts from this session
|
|
550
|
+
prompts = self._extract_prompts_from_session(
|
|
551
|
+
session_dir=session_dir,
|
|
552
|
+
session_id=session_id,
|
|
553
|
+
session_name=session_name,
|
|
554
|
+
project_path=project_path,
|
|
555
|
+
project_hash=hash_name,
|
|
556
|
+
since_dt=since_dt,
|
|
557
|
+
until_dt=until_dt,
|
|
558
|
+
parsed_query=parsed_query,
|
|
559
|
+
favorite_ids=favorite_ids,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
all_prompts.extend(prompts)
|
|
563
|
+
|
|
564
|
+
# Filter by favorites if requested
|
|
565
|
+
if parsed_query and parsed_query.favorites_only:
|
|
566
|
+
all_prompts = [p for p in all_prompts if p.is_favorite]
|
|
567
|
+
|
|
568
|
+
# Sort by timestamp (newest first)
|
|
569
|
+
all_prompts.sort(key=lambda p: p.timestamp, reverse=True)
|
|
570
|
+
|
|
571
|
+
total = len(all_prompts)
|
|
572
|
+
|
|
573
|
+
# Apply pagination
|
|
574
|
+
paginated = all_prompts[offset:offset + limit]
|
|
575
|
+
|
|
576
|
+
return paginated, total, parsed_query
|
|
577
|
+
|
|
578
|
+
def get_recent_prompts(
|
|
579
|
+
self,
|
|
580
|
+
limit: int = 20,
|
|
581
|
+
project_hash: str = None,
|
|
582
|
+
session_id: str = None,
|
|
583
|
+
) -> List[Prompt]:
|
|
584
|
+
"""
|
|
585
|
+
Get most recent prompts (optimized for Ctrl+R quick access).
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
limit: Max prompts
|
|
589
|
+
project_hash: Filter by project
|
|
590
|
+
session_id: Filter by specific session
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
List of recent prompts
|
|
594
|
+
"""
|
|
595
|
+
prompts, _, _ = self.get_all_prompts(
|
|
596
|
+
project_hash=project_hash,
|
|
597
|
+
limit=limit,
|
|
598
|
+
offset=0,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# Filter by session if specified
|
|
602
|
+
if session_id:
|
|
603
|
+
prompts = [p for p in prompts if p.session_id == session_id]
|
|
604
|
+
|
|
605
|
+
return prompts[:limit]
|
|
606
|
+
|
|
607
|
+
def get_frequent_prompts(
|
|
608
|
+
self,
|
|
609
|
+
limit: int = 10,
|
|
610
|
+
min_similarity: float = 0.8,
|
|
611
|
+
) -> List[Dict]:
|
|
612
|
+
"""
|
|
613
|
+
Get frequently used prompts (similar prompts grouped).
|
|
614
|
+
|
|
615
|
+
Returns prompts that appear multiple times with slight variations.
|
|
616
|
+
Uses simple word-based similarity.
|
|
617
|
+
"""
|
|
618
|
+
prompts, _, _ = self.get_all_prompts(limit=500) # Analyze recent prompts
|
|
619
|
+
|
|
620
|
+
# Group similar prompts
|
|
621
|
+
groups = []
|
|
622
|
+
used = set()
|
|
623
|
+
|
|
624
|
+
for i, prompt in enumerate(prompts):
|
|
625
|
+
if i in used:
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
similar = [prompt]
|
|
629
|
+
words1 = set(prompt.content.lower().split())
|
|
630
|
+
|
|
631
|
+
for j, other in enumerate(prompts[i+1:], i+1):
|
|
632
|
+
if j in used:
|
|
633
|
+
continue
|
|
634
|
+
|
|
635
|
+
words2 = set(other.content.lower().split())
|
|
636
|
+
|
|
637
|
+
# Jaccard similarity
|
|
638
|
+
if words1 and words2:
|
|
639
|
+
intersection = len(words1 & words2)
|
|
640
|
+
union = len(words1 | words2)
|
|
641
|
+
similarity = intersection / union if union > 0 else 0
|
|
642
|
+
|
|
643
|
+
if similarity >= min_similarity:
|
|
644
|
+
similar.append(other)
|
|
645
|
+
used.add(j)
|
|
646
|
+
|
|
647
|
+
if len(similar) >= 2:
|
|
648
|
+
# Return the shortest (most canonical) version
|
|
649
|
+
canonical = min(similar, key=lambda p: len(p.content))
|
|
650
|
+
groups.append({
|
|
651
|
+
"prompt": canonical.to_dict(),
|
|
652
|
+
"count": len(similar),
|
|
653
|
+
"variations": [p.to_dict() for p in similar[:5]] # Limit variations
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
# Sort by count
|
|
657
|
+
groups.sort(key=lambda g: g["count"], reverse=True)
|
|
658
|
+
return groups[:limit]
|
|
659
|
+
|
|
660
|
+
def get_prompt_stats(self, project_hash: str = None) -> Dict:
|
|
661
|
+
"""
|
|
662
|
+
Get statistics about prompts.
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
Dict with counts, averages, time distribution, etc.
|
|
666
|
+
"""
|
|
667
|
+
prompts, total, _ = self.get_all_prompts(
|
|
668
|
+
project_hash=project_hash,
|
|
669
|
+
limit=10000, # Get all for stats
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
if not prompts:
|
|
673
|
+
return {
|
|
674
|
+
"total_prompts": 0,
|
|
675
|
+
"avg_word_count": 0,
|
|
676
|
+
"avg_char_count": 0,
|
|
677
|
+
"prompts_with_images": 0,
|
|
678
|
+
"unique_sessions": 0,
|
|
679
|
+
"unique_projects": 0,
|
|
680
|
+
"by_day": {},
|
|
681
|
+
"by_hour": {},
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
# Calculate stats
|
|
685
|
+
total_words = sum(p.word_count for p in prompts)
|
|
686
|
+
total_chars = sum(p.char_count for p in prompts)
|
|
687
|
+
with_images = sum(1 for p in prompts if p.has_images)
|
|
688
|
+
with_stash = sum(1 for p in prompts if p.stash_refs)
|
|
689
|
+
unique_sessions = len(set(p.session_id for p in prompts))
|
|
690
|
+
unique_projects = len(set(p.project_hash for p in prompts))
|
|
691
|
+
|
|
692
|
+
# Distribution by day of week
|
|
693
|
+
by_day = {}
|
|
694
|
+
by_hour = {}
|
|
695
|
+
for p in prompts:
|
|
696
|
+
try:
|
|
697
|
+
dt = datetime.fromisoformat(p.timestamp.replace("Z", "+00:00"))
|
|
698
|
+
day_name = dt.strftime("%A")
|
|
699
|
+
hour = dt.hour
|
|
700
|
+
|
|
701
|
+
by_day[day_name] = by_day.get(day_name, 0) + 1
|
|
702
|
+
by_hour[hour] = by_hour.get(hour, 0) + 1
|
|
703
|
+
except (ValueError, AttributeError) as exc:
|
|
704
|
+
logger.debug("prompt stats: skipping malformed timestamp %r: %s", p.timestamp, exc)
|
|
705
|
+
|
|
706
|
+
return {
|
|
707
|
+
"total_prompts": total,
|
|
708
|
+
"avg_word_count": round(total_words / len(prompts), 1) if prompts else 0,
|
|
709
|
+
"avg_char_count": round(total_chars / len(prompts), 1) if prompts else 0,
|
|
710
|
+
"prompts_with_images": with_images,
|
|
711
|
+
"prompts_with_stash": with_stash,
|
|
712
|
+
"unique_sessions": unique_sessions,
|
|
713
|
+
"unique_projects": unique_projects,
|
|
714
|
+
"by_day": by_day,
|
|
715
|
+
"by_hour": by_hour,
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
def search_prompts(
|
|
719
|
+
self,
|
|
720
|
+
query: str,
|
|
721
|
+
project_hash: str = None,
|
|
722
|
+
limit: int = 50,
|
|
723
|
+
) -> Tuple[List[Prompt], Optional[ParsedQuery]]:
|
|
724
|
+
"""
|
|
725
|
+
Search prompts by content with advanced query syntax.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
query: Search string with advanced syntax
|
|
729
|
+
project_hash: Filter by project
|
|
730
|
+
limit: Max results
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
Tuple of (matching prompts, parsed query)
|
|
734
|
+
"""
|
|
735
|
+
prompts, _, parsed = self.get_all_prompts(
|
|
736
|
+
project_hash=project_hash,
|
|
737
|
+
search_query=query,
|
|
738
|
+
limit=limit,
|
|
739
|
+
)
|
|
740
|
+
return prompts, parsed
|
|
741
|
+
|
|
742
|
+
def get_full_response(self, prompt_id: str) -> Optional[str]:
|
|
743
|
+
"""Get the full Claude response for a given prompt_id.
|
|
744
|
+
|
|
745
|
+
prompt_id format: '{session_id}:{line_number}'
|
|
746
|
+
|
|
747
|
+
Returns concatenated text of all string-content assistant messages
|
|
748
|
+
between this user message and the next user message in messages.jsonl.
|
|
749
|
+
Returns None if the session/prompt cannot be found.
|
|
750
|
+
"""
|
|
751
|
+
try:
|
|
752
|
+
session_id, line_str = prompt_id.rsplit(":", 1)
|
|
753
|
+
line_number = int(line_str)
|
|
754
|
+
except (ValueError, AttributeError):
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
projects_dir = paths.DATA_HOME / "projects"
|
|
758
|
+
if not projects_dir.exists():
|
|
759
|
+
return None
|
|
760
|
+
|
|
761
|
+
messages_file = None
|
|
762
|
+
for project_dir in projects_dir.iterdir():
|
|
763
|
+
if not project_dir.is_dir():
|
|
764
|
+
continue
|
|
765
|
+
candidate = project_dir / "sessions" / session_id / "messages.jsonl"
|
|
766
|
+
if candidate.exists():
|
|
767
|
+
messages_file = candidate
|
|
768
|
+
break
|
|
769
|
+
|
|
770
|
+
if not messages_file:
|
|
771
|
+
return None
|
|
772
|
+
|
|
773
|
+
parts: List[str] = []
|
|
774
|
+
try:
|
|
775
|
+
with open(messages_file, "r", encoding="utf-8") as f:
|
|
776
|
+
for ln, line in enumerate(f, 1):
|
|
777
|
+
if ln <= line_number:
|
|
778
|
+
continue
|
|
779
|
+
line = line.strip()
|
|
780
|
+
if not line:
|
|
781
|
+
continue
|
|
782
|
+
try:
|
|
783
|
+
msg = json.loads(line)
|
|
784
|
+
except ValueError:
|
|
785
|
+
continue
|
|
786
|
+
role = msg.get("role")
|
|
787
|
+
if role == "user":
|
|
788
|
+
break
|
|
789
|
+
if role == "assistant":
|
|
790
|
+
content = msg.get("content", "")
|
|
791
|
+
if isinstance(content, str) and content:
|
|
792
|
+
parts.append(content)
|
|
793
|
+
except OSError as exc:
|
|
794
|
+
logger.debug("get_full_response: %s unreadable: %s", messages_file, exc)
|
|
795
|
+
return None
|
|
796
|
+
|
|
797
|
+
return "\n\n".join(parts) if parts else None
|
|
798
|
+
|
|
799
|
+
def _extract_prompts_from_session(
|
|
800
|
+
self,
|
|
801
|
+
session_dir: Path,
|
|
802
|
+
session_id: str,
|
|
803
|
+
session_name: str,
|
|
804
|
+
project_path: str,
|
|
805
|
+
project_hash: str,
|
|
806
|
+
since_dt: datetime = None,
|
|
807
|
+
until_dt: datetime = None,
|
|
808
|
+
parsed_query: ParsedQuery = None,
|
|
809
|
+
favorite_ids: Set[str] = None,
|
|
810
|
+
) -> List[Prompt]:
|
|
811
|
+
"""Extract user prompts from a session's messages.jsonl"""
|
|
812
|
+
|
|
813
|
+
messages_file = session_dir / "messages.jsonl"
|
|
814
|
+
if not messages_file.exists():
|
|
815
|
+
return []
|
|
816
|
+
|
|
817
|
+
prompts = []
|
|
818
|
+
|
|
819
|
+
try:
|
|
820
|
+
with open(messages_file, 'r', encoding="utf-8") as f:
|
|
821
|
+
lines = f.readlines()
|
|
822
|
+
|
|
823
|
+
# First pass: collect user messages
|
|
824
|
+
user_messages = []
|
|
825
|
+
for line_num, line in enumerate(lines, 1):
|
|
826
|
+
line = line.strip()
|
|
827
|
+
if not line:
|
|
828
|
+
continue
|
|
829
|
+
|
|
830
|
+
try:
|
|
831
|
+
msg = json.loads(line)
|
|
832
|
+
except ValueError:
|
|
833
|
+
continue
|
|
834
|
+
|
|
835
|
+
role = msg.get("role")
|
|
836
|
+
if role == "user":
|
|
837
|
+
user_messages.append((line_num, msg))
|
|
838
|
+
elif role == "assistant" and user_messages:
|
|
839
|
+
# Attach response preview to last user message
|
|
840
|
+
last_line, last_msg = user_messages[-1]
|
|
841
|
+
content = msg.get("content", "")
|
|
842
|
+
if isinstance(content, str) and content:
|
|
843
|
+
last_msg["_response_preview"] = content[:150] + ("..." if len(content) > 150 else "")
|
|
844
|
+
|
|
845
|
+
# Second pass: convert to Prompt objects
|
|
846
|
+
for line_num, msg in user_messages:
|
|
847
|
+
content = msg.get("content", "")
|
|
848
|
+
if not isinstance(content, str):
|
|
849
|
+
continue
|
|
850
|
+
|
|
851
|
+
timestamp = msg.get("timestamp", "")
|
|
852
|
+
response_preview = msg.get("_response_preview", "")
|
|
853
|
+
has_images = msg.get("has_images", False) or msg.get("image_count", 0) > 0
|
|
854
|
+
stash_refs = msg.get("stashRefs") or None
|
|
855
|
+
char_count = len(content)
|
|
856
|
+
|
|
857
|
+
# Apply date filters
|
|
858
|
+
if since_dt or until_dt:
|
|
859
|
+
try:
|
|
860
|
+
# Parse timestamp and convert to naive UTC for comparison
|
|
861
|
+
msg_dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
|
862
|
+
msg_dt_naive = msg_dt.replace(tzinfo=None) # Strip timezone
|
|
863
|
+
if since_dt and msg_dt_naive < since_dt:
|
|
864
|
+
continue
|
|
865
|
+
if until_dt and msg_dt_naive > until_dt:
|
|
866
|
+
continue
|
|
867
|
+
except (ValueError, AttributeError):
|
|
868
|
+
pass
|
|
869
|
+
|
|
870
|
+
# Apply parsed query filters
|
|
871
|
+
if parsed_query and not self._matches_query(
|
|
872
|
+
content, response_preview, char_count, has_images, bool(stash_refs), parsed_query
|
|
873
|
+
):
|
|
874
|
+
continue
|
|
875
|
+
|
|
876
|
+
# Create Prompt object
|
|
877
|
+
prompt_id = f"{session_id}:{line_num}"
|
|
878
|
+
prompt = Prompt(
|
|
879
|
+
id=prompt_id,
|
|
880
|
+
content=content,
|
|
881
|
+
timestamp=timestamp,
|
|
882
|
+
session_id=session_id,
|
|
883
|
+
session_name=session_name,
|
|
884
|
+
project_path=project_path,
|
|
885
|
+
project_hash=project_hash,
|
|
886
|
+
response_preview=response_preview,
|
|
887
|
+
word_count=len(content.split()),
|
|
888
|
+
char_count=char_count,
|
|
889
|
+
has_images=has_images,
|
|
890
|
+
line_number=line_num,
|
|
891
|
+
is_favorite=prompt_id in (favorite_ids or set()),
|
|
892
|
+
stash_refs=stash_refs,
|
|
893
|
+
)
|
|
894
|
+
prompts.append(prompt)
|
|
895
|
+
|
|
896
|
+
except Exception as e:
|
|
897
|
+
logger.error(f"Error extracting prompts from {session_id}: {e}")
|
|
898
|
+
|
|
899
|
+
return prompts
|
|
900
|
+
|
|
901
|
+
def _matches_query(
|
|
902
|
+
self,
|
|
903
|
+
content: str,
|
|
904
|
+
response: str,
|
|
905
|
+
char_count: int,
|
|
906
|
+
has_images: bool,
|
|
907
|
+
has_stash: bool,
|
|
908
|
+
query: ParsedQuery,
|
|
909
|
+
) -> bool:
|
|
910
|
+
"""Check if content matches the parsed query"""
|
|
911
|
+
|
|
912
|
+
if query.is_empty():
|
|
913
|
+
return True
|
|
914
|
+
|
|
915
|
+
content_lower = content.lower()
|
|
916
|
+
response_lower = response.lower() if response else ""
|
|
917
|
+
|
|
918
|
+
# Combine search targets based on in:response flag
|
|
919
|
+
search_text = content_lower
|
|
920
|
+
if query.search_response:
|
|
921
|
+
search_text = content_lower + " " + response_lower
|
|
922
|
+
|
|
923
|
+
# Check required terms (AND)
|
|
924
|
+
for term in query.required_terms:
|
|
925
|
+
if term not in search_text:
|
|
926
|
+
return False
|
|
927
|
+
|
|
928
|
+
# Check excluded terms (NOT)
|
|
929
|
+
for term in query.excluded_terms:
|
|
930
|
+
if term in search_text:
|
|
931
|
+
return False
|
|
932
|
+
|
|
933
|
+
# Check OR groups (at least one must match)
|
|
934
|
+
for or_group in query.or_groups:
|
|
935
|
+
if not any(term in search_text for term in or_group):
|
|
936
|
+
return False
|
|
937
|
+
|
|
938
|
+
# Check exact phrases
|
|
939
|
+
for phrase in query.exact_phrases:
|
|
940
|
+
if phrase.lower() not in search_text:
|
|
941
|
+
return False
|
|
942
|
+
|
|
943
|
+
# Check length filters
|
|
944
|
+
if query.min_length is not None and char_count < query.min_length:
|
|
945
|
+
return False
|
|
946
|
+
if query.max_length is not None and char_count > query.max_length:
|
|
947
|
+
return False
|
|
948
|
+
|
|
949
|
+
# Check has_image filter
|
|
950
|
+
if query.has_image is not None:
|
|
951
|
+
if query.has_image and not has_images:
|
|
952
|
+
return False
|
|
953
|
+
|
|
954
|
+
# Check has_stash filter
|
|
955
|
+
if query.has_stash is not None:
|
|
956
|
+
if query.has_stash and not has_stash:
|
|
957
|
+
return False
|
|
958
|
+
|
|
959
|
+
return True
|
|
960
|
+
|
|
961
|
+
def _parse_date(self, date_str: str) -> Optional[datetime]:
|
|
962
|
+
"""Parse date string (ISO or YYYY-MM-DD)"""
|
|
963
|
+
if not date_str:
|
|
964
|
+
return None
|
|
965
|
+
try:
|
|
966
|
+
# Try ISO format first
|
|
967
|
+
if "T" in date_str:
|
|
968
|
+
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
|
969
|
+
# Try YYYY-MM-DD
|
|
970
|
+
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=None)
|
|
971
|
+
except ValueError:
|
|
972
|
+
return None
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
# Singleton instance
|
|
976
|
+
_extractor = None
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def get_prompt_extractor() -> PromptExtractor:
|
|
980
|
+
"""Get or create the singleton prompt extractor"""
|
|
981
|
+
global _extractor
|
|
982
|
+
if _extractor is None:
|
|
983
|
+
_extractor = PromptExtractor()
|
|
984
|
+
return _extractor
|