gobby 0.2.5__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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- gobby-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Artifact type classifier.
|
|
2
|
+
|
|
3
|
+
Automatically classifies content into artifact types:
|
|
4
|
+
- code: Programming language code blocks
|
|
5
|
+
- file_path: File or directory paths
|
|
6
|
+
- error: Error messages and stack traces
|
|
7
|
+
- command_output: Terminal/shell command output
|
|
8
|
+
- structured_data: JSON, YAML, TOML, XML
|
|
9
|
+
- text: Plain text (default)
|
|
10
|
+
|
|
11
|
+
Also extracts relevant metadata for each type (language, extension, format, etc.)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
__all__ = ["ArtifactType", "ClassificationResult", "classify_artifact"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ArtifactType(str, Enum):
|
|
26
|
+
"""Artifact type enumeration."""
|
|
27
|
+
|
|
28
|
+
CODE = "code"
|
|
29
|
+
FILE_PATH = "file_path"
|
|
30
|
+
ERROR = "error"
|
|
31
|
+
COMMAND_OUTPUT = "command_output"
|
|
32
|
+
STRUCTURED_DATA = "structured_data"
|
|
33
|
+
TEXT = "text"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ClassificationResult:
|
|
38
|
+
"""Result of artifact classification."""
|
|
39
|
+
|
|
40
|
+
artifact_type: ArtifactType
|
|
41
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
"""Convert to dictionary."""
|
|
45
|
+
return {
|
|
46
|
+
"artifact_type": self.artifact_type.value,
|
|
47
|
+
"metadata": self.metadata,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Language detection patterns (more specific patterns first)
|
|
52
|
+
_LANGUAGE_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
53
|
+
# Python
|
|
54
|
+
(
|
|
55
|
+
"python",
|
|
56
|
+
re.compile(
|
|
57
|
+
r"^\s*(def\s+\w+|class\s+\w+|import\s+\w+|from\s+\w+\s+import|async\s+def\s+\w+|@\w+)",
|
|
58
|
+
re.MULTILINE,
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
# TypeScript (must be before JavaScript - has interface/type)
|
|
62
|
+
(
|
|
63
|
+
"typescript",
|
|
64
|
+
re.compile(
|
|
65
|
+
r"^\s*(interface\s+\w+|type\s+\w+\s*=|:\s*(string|number|boolean|any)\b)", re.MULTILINE
|
|
66
|
+
),
|
|
67
|
+
),
|
|
68
|
+
# JavaScript
|
|
69
|
+
(
|
|
70
|
+
"javascript",
|
|
71
|
+
re.compile(
|
|
72
|
+
r"^\s*(function\s+\w+|const\s+\w+\s*=|let\s+\w+\s*=|var\s+\w+\s*=|=>\s*\{)",
|
|
73
|
+
re.MULTILINE,
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
# Rust
|
|
77
|
+
(
|
|
78
|
+
"rust",
|
|
79
|
+
re.compile(
|
|
80
|
+
r"^\s*(fn\s+\w+|impl\s+|struct\s+\w+|enum\s+\w+|use\s+\w+|pub\s+fn)", re.MULTILINE
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
# Go
|
|
84
|
+
("go", re.compile(r"^\s*(func\s+\w+|func\s+\(\w+|package\s+\w+|import\s+\()", re.MULTILINE)),
|
|
85
|
+
# SQL
|
|
86
|
+
(
|
|
87
|
+
"sql",
|
|
88
|
+
re.compile(
|
|
89
|
+
r"^\s*(SELECT\s+|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM|CREATE\s+TABLE|DROP\s+TABLE)",
|
|
90
|
+
re.IGNORECASE | re.MULTILINE,
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
# Shell/Bash
|
|
94
|
+
(
|
|
95
|
+
"bash",
|
|
96
|
+
re.compile(
|
|
97
|
+
r"(^#!/bin/(ba)?sh|^\s*for\s+\w+\s+in\s+|^\s*if\s+\[\[?\s+|^\s*while\s+|echo\s+[\"'])",
|
|
98
|
+
re.MULTILINE,
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
# Markdown code fence pattern
|
|
104
|
+
_CODE_FENCE_PATTERN = re.compile(r"^```(\w*).*?\n(.*?)```", re.DOTALL | re.MULTILINE)
|
|
105
|
+
|
|
106
|
+
# File path patterns
|
|
107
|
+
_UNIX_PATH_PATTERN = re.compile(r"^(/[\w./\-_]+|\.{1,2}/[\w./\-_]+)$")
|
|
108
|
+
_WINDOWS_PATH_PATTERN = re.compile(r"^[A-Za-z]:\\[\w\\/.\-_]+$")
|
|
109
|
+
_RELATIVE_PATH_PATTERN = re.compile(r"^[\w\-_]+/[\w./\-_]+\.\w+$")
|
|
110
|
+
|
|
111
|
+
# Error patterns
|
|
112
|
+
_ERROR_PATTERNS = [
|
|
113
|
+
re.compile(r"^Traceback \(most recent call last\):", re.MULTILINE),
|
|
114
|
+
re.compile(r"^\w+Error:\s+", re.MULTILINE),
|
|
115
|
+
re.compile(r"^TypeError:\s+", re.MULTILINE),
|
|
116
|
+
re.compile(r"^Exception\s+", re.MULTILINE),
|
|
117
|
+
re.compile(r"^Error:\s+", re.MULTILINE),
|
|
118
|
+
re.compile(r"thread\s+'.*'\s+panicked\s+at", re.MULTILINE),
|
|
119
|
+
re.compile(r"^\s+at\s+[\w.]+\([\w.]+:\d+\)$", re.MULTILINE), # JS stack trace line
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
# Command output patterns
|
|
123
|
+
_COMMAND_OUTPUT_PATTERNS = [
|
|
124
|
+
re.compile(r"^On branch\s+\w+", re.MULTILINE), # git status
|
|
125
|
+
re.compile(r"^\$ \w+", re.MULTILINE), # shell prompt
|
|
126
|
+
re.compile(r"^npm\s+(WARN|ERR!?|notice)", re.MULTILINE), # npm
|
|
127
|
+
re.compile(r"^={3,}\s+test session starts\s+={3,}$", re.MULTILINE), # pytest
|
|
128
|
+
re.compile(r"^total\s+\d+\s*$", re.MULTILINE), # ls -l
|
|
129
|
+
re.compile(r"^(d|-)rwx", re.MULTILINE), # ls -l permissions
|
|
130
|
+
re.compile(r"^added\s+\d+\s+packages?", re.MULTILINE), # npm install
|
|
131
|
+
re.compile(r"^collected\s+\d+\s+items?", re.MULTILINE), # pytest
|
|
132
|
+
re.compile(r"^\d+\s+passed", re.MULTILINE), # pytest results
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _detect_language(content: str) -> str | None:
|
|
137
|
+
"""Detect programming language from content."""
|
|
138
|
+
for lang, pattern in _LANGUAGE_PATTERNS:
|
|
139
|
+
if pattern.search(content):
|
|
140
|
+
return lang
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_file_path(content: str) -> tuple[bool, dict[str, Any]]:
|
|
145
|
+
"""Check if content is a file path and extract metadata."""
|
|
146
|
+
content = content.strip()
|
|
147
|
+
|
|
148
|
+
# Don't classify multi-line content as a file path
|
|
149
|
+
if "\n" in content:
|
|
150
|
+
return False, {}
|
|
151
|
+
|
|
152
|
+
metadata: dict[str, Any] = {}
|
|
153
|
+
|
|
154
|
+
# Check patterns
|
|
155
|
+
if _UNIX_PATH_PATTERN.match(content):
|
|
156
|
+
pass
|
|
157
|
+
elif _WINDOWS_PATH_PATTERN.match(content):
|
|
158
|
+
pass
|
|
159
|
+
elif _RELATIVE_PATH_PATTERN.match(content):
|
|
160
|
+
pass
|
|
161
|
+
else:
|
|
162
|
+
return False, {}
|
|
163
|
+
|
|
164
|
+
# Extract filename and extension
|
|
165
|
+
parts = content.replace("\\", "/").split("/")
|
|
166
|
+
filename = parts[-1]
|
|
167
|
+
metadata["filename"] = filename
|
|
168
|
+
|
|
169
|
+
if "." in filename:
|
|
170
|
+
ext = filename.rsplit(".", 1)[-1]
|
|
171
|
+
metadata["extension"] = ext
|
|
172
|
+
else:
|
|
173
|
+
metadata["extension"] = None
|
|
174
|
+
|
|
175
|
+
return True, metadata
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _is_error(content: str) -> bool:
|
|
179
|
+
"""Check if content is an error message or stack trace."""
|
|
180
|
+
for pattern in _ERROR_PATTERNS:
|
|
181
|
+
if pattern.search(content):
|
|
182
|
+
return True
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_command_output(content: str) -> bool:
|
|
187
|
+
"""Check if content is command output."""
|
|
188
|
+
for pattern in _COMMAND_OUTPUT_PATTERNS:
|
|
189
|
+
if pattern.search(content):
|
|
190
|
+
return True
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_json(content: str) -> bool:
|
|
195
|
+
"""Check if content is valid JSON."""
|
|
196
|
+
content = content.strip()
|
|
197
|
+
if not (content.startswith("{") or content.startswith("[")):
|
|
198
|
+
return False
|
|
199
|
+
try:
|
|
200
|
+
json.loads(content)
|
|
201
|
+
return True
|
|
202
|
+
except (json.JSONDecodeError, ValueError):
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _is_yaml(content: str) -> bool:
|
|
207
|
+
"""Check if content looks like YAML (simple heuristic)."""
|
|
208
|
+
content = content.strip()
|
|
209
|
+
lines = content.split("\n")
|
|
210
|
+
|
|
211
|
+
# YAML typically has key: value patterns
|
|
212
|
+
# Must have actual values after the colon (not just colons in prose)
|
|
213
|
+
yaml_kv_pattern = re.compile(r"^\s*[\w\-_]+:\s*\S")
|
|
214
|
+
yaml_list_with_kv_pattern = re.compile(r"^\s*-\s+[\w\-_]+:\s*")
|
|
215
|
+
|
|
216
|
+
yaml_kv_lines = 0
|
|
217
|
+
total_non_empty = 0
|
|
218
|
+
for line in lines:
|
|
219
|
+
line = line.strip()
|
|
220
|
+
if not line or line.startswith("#"):
|
|
221
|
+
continue
|
|
222
|
+
total_non_empty += 1
|
|
223
|
+
# Count lines with key: value pattern (not just list items)
|
|
224
|
+
if yaml_kv_pattern.match(line) or yaml_list_with_kv_pattern.match(line):
|
|
225
|
+
yaml_kv_lines += 1
|
|
226
|
+
|
|
227
|
+
# Need at least 2 key-value lines and they should be significant portion
|
|
228
|
+
return yaml_kv_lines >= 2 and (yaml_kv_lines / max(total_non_empty, 1)) > 0.3
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _is_toml(content: str) -> bool:
|
|
232
|
+
"""Check if content looks like TOML."""
|
|
233
|
+
content = content.strip()
|
|
234
|
+
|
|
235
|
+
# TOML has [section] headers and key = value
|
|
236
|
+
section_pattern = re.compile(r"^\s*\[[\w.\-]+\]\s*$", re.MULTILINE)
|
|
237
|
+
kv_pattern = re.compile(r"^\s*[\w\-]+\s*=\s*", re.MULTILINE)
|
|
238
|
+
|
|
239
|
+
has_section = section_pattern.search(content) is not None
|
|
240
|
+
has_kv = kv_pattern.search(content) is not None
|
|
241
|
+
|
|
242
|
+
return has_section and has_kv
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _is_xml(content: str) -> bool:
|
|
246
|
+
"""Check if content looks like XML."""
|
|
247
|
+
content = content.strip()
|
|
248
|
+
|
|
249
|
+
# XML starts with <?xml or <tag>
|
|
250
|
+
if content.startswith("<?xml"):
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
# Check for matching opening/closing tags
|
|
254
|
+
tag_pattern = re.compile(r"^<(\w+)[^>]*>.*</\1>", re.DOTALL)
|
|
255
|
+
return tag_pattern.match(content) is not None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _is_code_block(content: str) -> tuple[bool, dict[str, Any]]:
|
|
259
|
+
"""Check if content is a markdown code block and extract language."""
|
|
260
|
+
match = _CODE_FENCE_PATTERN.match(content.strip())
|
|
261
|
+
if match:
|
|
262
|
+
lang = match.group(1).lower() if match.group(1) else None
|
|
263
|
+
return True, {"language": lang} if lang else {}
|
|
264
|
+
return False, {}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def classify_artifact(content: str) -> ClassificationResult:
|
|
268
|
+
"""
|
|
269
|
+
Classify content into an artifact type with metadata.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
content: The content to classify
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
ClassificationResult with artifact_type and extracted metadata
|
|
276
|
+
"""
|
|
277
|
+
if not content or not content.strip():
|
|
278
|
+
return ClassificationResult(artifact_type=ArtifactType.TEXT, metadata={})
|
|
279
|
+
|
|
280
|
+
# Check for markdown code fence first
|
|
281
|
+
is_code_fence, fence_metadata = _is_code_block(content)
|
|
282
|
+
if is_code_fence:
|
|
283
|
+
metadata = fence_metadata.copy()
|
|
284
|
+
# If no language in fence, try to detect from content
|
|
285
|
+
if "language" not in metadata or not metadata["language"]:
|
|
286
|
+
inner_content = _CODE_FENCE_PATTERN.match(content.strip())
|
|
287
|
+
if inner_content:
|
|
288
|
+
detected_lang = _detect_language(inner_content.group(2))
|
|
289
|
+
if detected_lang:
|
|
290
|
+
metadata["language"] = detected_lang
|
|
291
|
+
return ClassificationResult(artifact_type=ArtifactType.CODE, metadata=metadata)
|
|
292
|
+
|
|
293
|
+
# Check for file path (single line only)
|
|
294
|
+
is_path, path_metadata = _is_file_path(content)
|
|
295
|
+
if is_path:
|
|
296
|
+
return ClassificationResult(artifact_type=ArtifactType.FILE_PATH, metadata=path_metadata)
|
|
297
|
+
|
|
298
|
+
# Check for error messages/stack traces
|
|
299
|
+
if _is_error(content):
|
|
300
|
+
metadata = {}
|
|
301
|
+
# Try to extract error type
|
|
302
|
+
error_match = re.search(r"^(\w+Error):", content, re.MULTILINE)
|
|
303
|
+
if error_match:
|
|
304
|
+
metadata["error"] = error_match.group(1)
|
|
305
|
+
return ClassificationResult(artifact_type=ArtifactType.ERROR, metadata=metadata)
|
|
306
|
+
|
|
307
|
+
# Check for code patterns BEFORE structured data
|
|
308
|
+
# (TypeScript interfaces look like YAML otherwise)
|
|
309
|
+
detected_lang = _detect_language(content)
|
|
310
|
+
if detected_lang:
|
|
311
|
+
return ClassificationResult(
|
|
312
|
+
artifact_type=ArtifactType.CODE, metadata={"language": detected_lang}
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Check for structured data formats
|
|
316
|
+
if _is_json(content):
|
|
317
|
+
return ClassificationResult(
|
|
318
|
+
artifact_type=ArtifactType.STRUCTURED_DATA, metadata={"format": "json"}
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if _is_xml(content):
|
|
322
|
+
return ClassificationResult(
|
|
323
|
+
artifact_type=ArtifactType.STRUCTURED_DATA, metadata={"format": "xml"}
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
if _is_toml(content):
|
|
327
|
+
return ClassificationResult(
|
|
328
|
+
artifact_type=ArtifactType.STRUCTURED_DATA, metadata={"format": "toml"}
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if _is_yaml(content):
|
|
332
|
+
return ClassificationResult(
|
|
333
|
+
artifact_type=ArtifactType.STRUCTURED_DATA, metadata={"format": "yaml"}
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Check for command output
|
|
337
|
+
if _is_command_output(content):
|
|
338
|
+
return ClassificationResult(artifact_type=ArtifactType.COMMAND_OUTPUT, metadata={})
|
|
339
|
+
|
|
340
|
+
# Default to text
|
|
341
|
+
return ClassificationResult(artifact_type=ArtifactType.TEXT, metadata={})
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session artifacts storage module.
|
|
3
|
+
|
|
4
|
+
Stores code snippets, diffs, errors, and other artifacts from sessions
|
|
5
|
+
with optional FTS5 full-text search support.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import sqlite3
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from gobby.storage.database import DatabaseProtocol
|
|
17
|
+
from gobby.utils.id import generate_prefixed_id
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Artifact:
|
|
24
|
+
"""A session artifact representing code, diff, error, or other content."""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
session_id: str
|
|
28
|
+
artifact_type: str
|
|
29
|
+
content: str
|
|
30
|
+
created_at: str
|
|
31
|
+
metadata: dict[str, Any] | None = None
|
|
32
|
+
source_file: str | None = None
|
|
33
|
+
line_start: int | None = None
|
|
34
|
+
line_end: int | None = None
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_row(cls, row: sqlite3.Row) -> "Artifact":
|
|
38
|
+
"""Create an Artifact from a database row."""
|
|
39
|
+
metadata_json = row["metadata_json"]
|
|
40
|
+
metadata = json.loads(metadata_json) if metadata_json else None
|
|
41
|
+
|
|
42
|
+
return cls(
|
|
43
|
+
id=row["id"],
|
|
44
|
+
session_id=row["session_id"],
|
|
45
|
+
artifact_type=row["artifact_type"],
|
|
46
|
+
content=row["content"],
|
|
47
|
+
created_at=row["created_at"],
|
|
48
|
+
metadata=metadata,
|
|
49
|
+
source_file=row["source_file"],
|
|
50
|
+
line_start=row["line_start"],
|
|
51
|
+
line_end=row["line_end"],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
"""Convert artifact to dictionary for serialization."""
|
|
56
|
+
return {
|
|
57
|
+
"id": self.id,
|
|
58
|
+
"session_id": self.session_id,
|
|
59
|
+
"artifact_type": self.artifact_type,
|
|
60
|
+
"content": self.content,
|
|
61
|
+
"created_at": self.created_at,
|
|
62
|
+
"metadata": self.metadata,
|
|
63
|
+
"source_file": self.source_file,
|
|
64
|
+
"line_start": self.line_start,
|
|
65
|
+
"line_end": self.line_end,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LocalArtifactManager:
|
|
70
|
+
"""Manages session artifacts in local SQLite database."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, db: DatabaseProtocol):
|
|
73
|
+
self.db = db
|
|
74
|
+
self._change_listeners: list[Callable[[], Any]] = []
|
|
75
|
+
|
|
76
|
+
def add_change_listener(self, listener: Callable[[], Any]) -> None:
|
|
77
|
+
"""Add a change listener that will be called on create/delete."""
|
|
78
|
+
self._change_listeners.append(listener)
|
|
79
|
+
|
|
80
|
+
def _notify_listeners(self) -> None:
|
|
81
|
+
"""Notify all change listeners."""
|
|
82
|
+
for listener in self._change_listeners:
|
|
83
|
+
try:
|
|
84
|
+
listener()
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.error(f"Error in artifact change listener: {e}")
|
|
87
|
+
|
|
88
|
+
def create_artifact(
|
|
89
|
+
self,
|
|
90
|
+
session_id: str,
|
|
91
|
+
artifact_type: str,
|
|
92
|
+
content: str,
|
|
93
|
+
metadata: dict[str, Any] | None = None,
|
|
94
|
+
source_file: str | None = None,
|
|
95
|
+
line_start: int | None = None,
|
|
96
|
+
line_end: int | None = None,
|
|
97
|
+
) -> Artifact:
|
|
98
|
+
"""Create a new artifact.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
session_id: ID of the session this artifact belongs to
|
|
102
|
+
artifact_type: Type of artifact (code, diff, error, etc.)
|
|
103
|
+
content: The artifact content
|
|
104
|
+
metadata: Optional metadata dict
|
|
105
|
+
source_file: Optional source file path
|
|
106
|
+
line_start: Optional starting line number
|
|
107
|
+
line_end: Optional ending line number
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The created Artifact
|
|
111
|
+
"""
|
|
112
|
+
now = datetime.now(UTC).isoformat()
|
|
113
|
+
artifact_id = generate_prefixed_id("art", content[:50] + session_id)
|
|
114
|
+
|
|
115
|
+
metadata_json = json.dumps(metadata) if metadata else None
|
|
116
|
+
|
|
117
|
+
with self.db.transaction() as conn:
|
|
118
|
+
conn.execute(
|
|
119
|
+
"""
|
|
120
|
+
INSERT INTO session_artifacts (
|
|
121
|
+
id, session_id, artifact_type, content, metadata_json,
|
|
122
|
+
source_file, line_start, line_end, created_at
|
|
123
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
124
|
+
""",
|
|
125
|
+
(
|
|
126
|
+
artifact_id,
|
|
127
|
+
session_id,
|
|
128
|
+
artifact_type,
|
|
129
|
+
content,
|
|
130
|
+
metadata_json,
|
|
131
|
+
source_file,
|
|
132
|
+
line_start,
|
|
133
|
+
line_end,
|
|
134
|
+
now,
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
# Also insert into FTS5 table for full-text search
|
|
138
|
+
conn.execute(
|
|
139
|
+
"INSERT INTO session_artifacts_fts (id, content) VALUES (?, ?)",
|
|
140
|
+
(artifact_id, content),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
self._notify_listeners()
|
|
144
|
+
return self.get_artifact(artifact_id) # type: ignore[return-value]
|
|
145
|
+
|
|
146
|
+
def get_artifact(self, artifact_id: str) -> Artifact | None:
|
|
147
|
+
"""Get an artifact by ID.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
artifact_id: The artifact ID
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The Artifact if found, None otherwise
|
|
154
|
+
"""
|
|
155
|
+
row = self.db.fetchone("SELECT * FROM session_artifacts WHERE id = ?", (artifact_id,))
|
|
156
|
+
if not row:
|
|
157
|
+
return None
|
|
158
|
+
return Artifact.from_row(row)
|
|
159
|
+
|
|
160
|
+
def list_artifacts(
|
|
161
|
+
self,
|
|
162
|
+
session_id: str | None = None,
|
|
163
|
+
artifact_type: str | None = None,
|
|
164
|
+
limit: int = 100,
|
|
165
|
+
offset: int = 0,
|
|
166
|
+
) -> list[Artifact]:
|
|
167
|
+
"""List artifacts with optional filters.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
session_id: Filter by session ID
|
|
171
|
+
artifact_type: Filter by artifact type
|
|
172
|
+
limit: Maximum number of results
|
|
173
|
+
offset: Offset for pagination
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
List of matching Artifacts
|
|
177
|
+
"""
|
|
178
|
+
query = "SELECT * FROM session_artifacts WHERE 1=1"
|
|
179
|
+
params: list[Any] = []
|
|
180
|
+
|
|
181
|
+
if session_id:
|
|
182
|
+
query += " AND session_id = ?"
|
|
183
|
+
params.append(session_id)
|
|
184
|
+
|
|
185
|
+
if artifact_type:
|
|
186
|
+
query += " AND artifact_type = ?"
|
|
187
|
+
params.append(artifact_type)
|
|
188
|
+
|
|
189
|
+
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
|
190
|
+
params.extend([limit, offset])
|
|
191
|
+
|
|
192
|
+
rows = self.db.fetchall(query, tuple(params))
|
|
193
|
+
return [Artifact.from_row(row) for row in rows]
|
|
194
|
+
|
|
195
|
+
def delete_artifact(self, artifact_id: str) -> bool:
|
|
196
|
+
"""Delete an artifact by ID.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
artifact_id: The artifact ID to delete
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
True if deleted, False if not found
|
|
203
|
+
"""
|
|
204
|
+
with self.db.transaction() as conn:
|
|
205
|
+
cursor = conn.execute("DELETE FROM session_artifacts WHERE id = ?", (artifact_id,))
|
|
206
|
+
if cursor.rowcount == 0:
|
|
207
|
+
return False
|
|
208
|
+
# Also delete from FTS5 table
|
|
209
|
+
conn.execute("DELETE FROM session_artifacts_fts WHERE id = ?", (artifact_id,))
|
|
210
|
+
|
|
211
|
+
self._notify_listeners()
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
def search_artifacts(
|
|
215
|
+
self,
|
|
216
|
+
query_text: str,
|
|
217
|
+
session_id: str | None = None,
|
|
218
|
+
artifact_type: str | None = None,
|
|
219
|
+
limit: int = 50,
|
|
220
|
+
) -> list[Artifact]:
|
|
221
|
+
"""Search artifacts by content using FTS5 full-text search.
|
|
222
|
+
|
|
223
|
+
Uses FTS5 MATCH query on session_artifacts_fts with bm25 ranking.
|
|
224
|
+
Can optionally filter by session_id and/or artifact_type.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
query_text: The search query text
|
|
228
|
+
session_id: Optional session ID filter
|
|
229
|
+
artifact_type: Optional artifact type filter
|
|
230
|
+
limit: Maximum number of results (default: 50)
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
List of matching Artifacts ordered by relevance (bm25 ranking)
|
|
234
|
+
"""
|
|
235
|
+
# Empty query returns empty results
|
|
236
|
+
if not query_text or not query_text.strip():
|
|
237
|
+
return []
|
|
238
|
+
|
|
239
|
+
# Escape FTS5 special characters and build query
|
|
240
|
+
# Split into words and add prefix matching for each term
|
|
241
|
+
words = query_text.strip().split()
|
|
242
|
+
if not words:
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
# Build FTS5 query: each word becomes a prefix search term
|
|
246
|
+
# e.g., "calculate total" -> "calculate* total*"
|
|
247
|
+
fts_terms = []
|
|
248
|
+
for word in words:
|
|
249
|
+
# Remove FTS5 special chars that would break syntax: * ^ " ( ) AND OR NOT
|
|
250
|
+
# Keep only alphanumeric and safe punctuation
|
|
251
|
+
sanitized = ""
|
|
252
|
+
for char in word:
|
|
253
|
+
if char.isalnum() or char in "-_":
|
|
254
|
+
sanitized += char
|
|
255
|
+
if sanitized:
|
|
256
|
+
fts_terms.append(f"{sanitized}*")
|
|
257
|
+
|
|
258
|
+
if not fts_terms:
|
|
259
|
+
return []
|
|
260
|
+
|
|
261
|
+
fts_query = " ".join(fts_terms)
|
|
262
|
+
|
|
263
|
+
# Use FTS5 MATCH query with JOIN to main table
|
|
264
|
+
# Order by bm25() for relevance ranking (lower bm25 = more relevant)
|
|
265
|
+
sql = """
|
|
266
|
+
SELECT sa.*
|
|
267
|
+
FROM session_artifacts sa
|
|
268
|
+
INNER JOIN session_artifacts_fts fts ON sa.id = fts.id
|
|
269
|
+
WHERE fts.content MATCH ?
|
|
270
|
+
"""
|
|
271
|
+
params: list[Any] = [fts_query]
|
|
272
|
+
|
|
273
|
+
if session_id:
|
|
274
|
+
sql += " AND sa.session_id = ?"
|
|
275
|
+
params.append(session_id)
|
|
276
|
+
|
|
277
|
+
if artifact_type:
|
|
278
|
+
sql += " AND sa.artifact_type = ?"
|
|
279
|
+
params.append(artifact_type)
|
|
280
|
+
|
|
281
|
+
sql += " ORDER BY bm25(session_artifacts_fts) LIMIT ?"
|
|
282
|
+
params.append(limit)
|
|
283
|
+
|
|
284
|
+
rows = self.db.fetchall(sql, tuple(params))
|
|
285
|
+
return [Artifact.from_row(row) for row in rows]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Task compaction logic."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from gobby.storage.tasks import LocalTaskManager
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TaskCompactor:
|
|
10
|
+
"""Handles compaction of old closed tasks."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, task_manager: LocalTaskManager) -> None:
|
|
13
|
+
self.task_manager = task_manager
|
|
14
|
+
|
|
15
|
+
def find_candidates(self, days_closed: int = 30) -> list[dict[str, Any]]:
|
|
16
|
+
"""
|
|
17
|
+
Find tasks that have been closed for longer than the specified days
|
|
18
|
+
and haven't been compacted yet.
|
|
19
|
+
"""
|
|
20
|
+
cutoff = datetime.now(UTC) - timedelta(days=days_closed)
|
|
21
|
+
cutoff_str = cutoff.isoformat()
|
|
22
|
+
|
|
23
|
+
# Query directly since we need custom filtering not exposed by list_tasks
|
|
24
|
+
sql = """
|
|
25
|
+
SELECT * FROM tasks
|
|
26
|
+
WHERE status = 'closed'
|
|
27
|
+
AND updated_at < ?
|
|
28
|
+
AND compacted_at IS NULL
|
|
29
|
+
ORDER BY updated_at ASC
|
|
30
|
+
"""
|
|
31
|
+
rows = self.task_manager.db.fetchall(sql, (cutoff_str,))
|
|
32
|
+
return [dict(row) for row in rows]
|
|
33
|
+
|
|
34
|
+
def compact_task(self, task_id: str, summary: str) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Compact a task by replacing its description with a summary.
|
|
37
|
+
"""
|
|
38
|
+
# Update database directly to set compacted_at
|
|
39
|
+
now = datetime.now(UTC).isoformat()
|
|
40
|
+
|
|
41
|
+
# We preserve the title but replace description with summary
|
|
42
|
+
# and mark it as compacted.
|
|
43
|
+
sql = """
|
|
44
|
+
UPDATE tasks
|
|
45
|
+
SET description = ?,
|
|
46
|
+
summary = ?,
|
|
47
|
+
compacted_at = ?,
|
|
48
|
+
updated_at = ?
|
|
49
|
+
WHERE id = ?
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
self.task_manager.db.execute(sql, (summary, summary, now, now, task_id))
|
|
53
|
+
self.task_manager._notify_listeners()
|
|
54
|
+
|
|
55
|
+
def get_stats(self) -> dict[str, Any]:
|
|
56
|
+
"""Get compaction statistics."""
|
|
57
|
+
sql_total = "SELECT COUNT(*) as c FROM tasks WHERE status = 'closed'"
|
|
58
|
+
sql_compacted = "SELECT COUNT(*) as c FROM tasks WHERE compacted_at IS NOT NULL"
|
|
59
|
+
|
|
60
|
+
total = (self.task_manager.db.fetchone(sql_total) or {"c": 0})["c"]
|
|
61
|
+
compacted = (self.task_manager.db.fetchone(sql_compacted) or {"c": 0})["c"]
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
"total_closed": total,
|
|
65
|
+
"compacted": compacted,
|
|
66
|
+
"rate": round(compacted / total * 100, 1) if total > 0 else 0,
|
|
67
|
+
}
|