multi-forge 0.2.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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
forge/cli/search.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Search CLI commands for Forge transcript search.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- forge search -q <query>: Search transcripts, output JSON
|
|
5
|
+
- forge search rebuild-index: Full index rebuild (writes three stores)
|
|
6
|
+
- forge search status: Show index statistics
|
|
7
|
+
- forge search clean: Remove orphaned documents
|
|
8
|
+
|
|
9
|
+
Query is passed via -q/--query option to avoid ambiguity with subcommand
|
|
10
|
+
names (Click groups parse positional args before subcommand resolution).
|
|
11
|
+
|
|
12
|
+
Stores are per-project at <forge_root>/.forge/search-index/:
|
|
13
|
+
- documents.json (v2): metadata only
|
|
14
|
+
- bm25_index.json: precomputed BM25 data structures
|
|
15
|
+
- content.json: document content for snippet extraction
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import click
|
|
24
|
+
from rich.console import Console
|
|
25
|
+
|
|
26
|
+
from forge.core.paths import display_path
|
|
27
|
+
from forge.core.state import SchemaVersionError
|
|
28
|
+
from forge.search.bm25_store import BM25IndexData, BM25IndexStore
|
|
29
|
+
from forge.search.content_store import ContentStore
|
|
30
|
+
from forge.search.engine import search_from_index
|
|
31
|
+
from forge.search.exceptions import (
|
|
32
|
+
BM25IndexCorruptedError,
|
|
33
|
+
ContentStoreCorruptedError,
|
|
34
|
+
SearchDocumentStoreCorruptedError,
|
|
35
|
+
)
|
|
36
|
+
from forge.search.store import SearchDocumentStore
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resolve_forge_root() -> Path:
|
|
40
|
+
"""Resolve the current Forge project root, falling back to cwd."""
|
|
41
|
+
from forge.session.artifacts import resolve_forge_root
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
return resolve_forge_root(Path.cwd())
|
|
45
|
+
except Exception:
|
|
46
|
+
return Path.cwd().resolve()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@click.group(
|
|
50
|
+
invoke_without_command=True,
|
|
51
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
52
|
+
)
|
|
53
|
+
@click.option("-q", "--query", type=str, default=None, help="Search query")
|
|
54
|
+
@click.option("--limit", "-n", type=int, default=10, help="Maximum results")
|
|
55
|
+
@click.option(
|
|
56
|
+
"--scope",
|
|
57
|
+
type=click.Choice(["project", "all"]),
|
|
58
|
+
default="project",
|
|
59
|
+
help="Search scope: current project (default) or all indexed projects",
|
|
60
|
+
)
|
|
61
|
+
@click.pass_context
|
|
62
|
+
def search_cmd(ctx: click.Context, query: str | None, limit: int, scope: str) -> None:
|
|
63
|
+
"""Search session transcripts.
|
|
64
|
+
|
|
65
|
+
\b
|
|
66
|
+
Examples:
|
|
67
|
+
forge search -q "timeout config" Search for "timeout config"
|
|
68
|
+
forge search rebuild-index Rebuild the search index
|
|
69
|
+
forge search status Show index statistics
|
|
70
|
+
"""
|
|
71
|
+
if ctx.invoked_subcommand is not None:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
if query is None:
|
|
75
|
+
click.echo(ctx.get_help())
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
_run_search(query, limit=limit, scope=scope)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _run_search(query: str, *, limit: int, scope: str) -> None:
|
|
82
|
+
"""Execute a search and output JSON results."""
|
|
83
|
+
if scope == "all":
|
|
84
|
+
_run_search_all_projects(query, limit=limit)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
project_root = _resolve_forge_root()
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
results = _search_project(project_root, query, limit=limit)
|
|
91
|
+
except (
|
|
92
|
+
SearchDocumentStoreCorruptedError,
|
|
93
|
+
BM25IndexCorruptedError,
|
|
94
|
+
ContentStoreCorruptedError,
|
|
95
|
+
SchemaVersionError,
|
|
96
|
+
) as e:
|
|
97
|
+
output = {
|
|
98
|
+
"query": query,
|
|
99
|
+
"total_results": 0,
|
|
100
|
+
"results": [],
|
|
101
|
+
"error": f"Search index corrupted or outdated: {e}",
|
|
102
|
+
"hint": "Run 'forge search rebuild-index' to rebuild.",
|
|
103
|
+
}
|
|
104
|
+
click.echo(json.dumps(output, indent=2))
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
if results is None:
|
|
108
|
+
hint = "No transcripts indexed for this project. Run 'forge search rebuild-index' or use --scope all."
|
|
109
|
+
try:
|
|
110
|
+
from forge.install.hooks import has_forge_hook
|
|
111
|
+
|
|
112
|
+
if not has_forge_hook(project_root, "Stop"):
|
|
113
|
+
hint += " If hooks are not installed, transcripts are not captured automatically."
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
output = {
|
|
117
|
+
"query": query,
|
|
118
|
+
"total_results": 0,
|
|
119
|
+
"results": [],
|
|
120
|
+
"hint": hint,
|
|
121
|
+
}
|
|
122
|
+
click.echo(json.dumps(output, indent=2))
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
_output_results(query, results)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _run_search_all_projects(query: str, *, limit: int) -> None:
|
|
129
|
+
"""Search across all known project indices."""
|
|
130
|
+
import logging
|
|
131
|
+
|
|
132
|
+
from forge.session.index import IndexStore
|
|
133
|
+
|
|
134
|
+
logger = logging.getLogger(__name__)
|
|
135
|
+
current_root = _resolve_forge_root()
|
|
136
|
+
project_roots = {str(current_root)}
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
index = IndexStore()
|
|
140
|
+
sessions = index.list_sessions()
|
|
141
|
+
project_roots.update((entry.forge_root or entry.worktree_path) for _, entry in sessions)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
all_results: list = []
|
|
146
|
+
searched_any_index = False
|
|
147
|
+
for root in project_roots:
|
|
148
|
+
try:
|
|
149
|
+
results = _search_project(Path(root), query, limit=limit)
|
|
150
|
+
if results is not None:
|
|
151
|
+
searched_any_index = True
|
|
152
|
+
all_results.extend(results)
|
|
153
|
+
except (
|
|
154
|
+
SearchDocumentStoreCorruptedError,
|
|
155
|
+
BM25IndexCorruptedError,
|
|
156
|
+
ContentStoreCorruptedError,
|
|
157
|
+
SchemaVersionError,
|
|
158
|
+
) as e:
|
|
159
|
+
logger.warning("Skipping corrupted search index in %s: %s", root, e)
|
|
160
|
+
continue
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.debug("Skipping project %s: %s", root, e)
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if not all_results:
|
|
166
|
+
if searched_any_index:
|
|
167
|
+
output = {
|
|
168
|
+
"query": query,
|
|
169
|
+
"total_results": 0,
|
|
170
|
+
"results": [],
|
|
171
|
+
}
|
|
172
|
+
click.echo(json.dumps(output, indent=2))
|
|
173
|
+
return
|
|
174
|
+
output = {
|
|
175
|
+
"query": query,
|
|
176
|
+
"total_results": 0,
|
|
177
|
+
"results": [],
|
|
178
|
+
"hint": "No indexed transcripts. Run 'forge search rebuild-index' first.",
|
|
179
|
+
}
|
|
180
|
+
click.echo(json.dumps(output, indent=2))
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# Merge and sort by score descending
|
|
184
|
+
all_results.sort(key=lambda r: r.score, reverse=True)
|
|
185
|
+
_output_results(query, all_results[:limit])
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _search_project(project_root: Path, query: str, *, limit: int):
|
|
189
|
+
"""Search a single project using its persistent BM25 index.
|
|
190
|
+
|
|
191
|
+
Returns list of SearchResult, or None if no index exists.
|
|
192
|
+
Raises on corruption (caller handles).
|
|
193
|
+
"""
|
|
194
|
+
bm25_store = BM25IndexStore(forge_root=project_root)
|
|
195
|
+
bm25_index = bm25_store.read()
|
|
196
|
+
if bm25_index is None:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
doc_store = SearchDocumentStore(forge_root=project_root)
|
|
200
|
+
doc_metas = doc_store.read()
|
|
201
|
+
if not doc_metas and not bm25_index.doc_keys:
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
meta_map = {m.transcript_path: m for m in doc_metas}
|
|
205
|
+
|
|
206
|
+
content_store = ContentStore(forge_root=project_root)
|
|
207
|
+
|
|
208
|
+
return search_from_index(
|
|
209
|
+
query,
|
|
210
|
+
doc_keys=bm25_index.doc_keys,
|
|
211
|
+
term_freqs=bm25_index.term_freqs,
|
|
212
|
+
doc_freqs=bm25_index.doc_freqs,
|
|
213
|
+
doc_lens=bm25_index.doc_lens,
|
|
214
|
+
avgdl=bm25_index.avgdl,
|
|
215
|
+
k1=bm25_index.k1,
|
|
216
|
+
b=bm25_index.b,
|
|
217
|
+
content_loader=content_store.read_keys,
|
|
218
|
+
doc_metadata=meta_map,
|
|
219
|
+
limit=limit,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _output_results(query: str, results: list) -> None:
|
|
224
|
+
"""Format and output search results as JSON."""
|
|
225
|
+
output = {
|
|
226
|
+
"query": query,
|
|
227
|
+
"total_results": len(results),
|
|
228
|
+
"results": [
|
|
229
|
+
{
|
|
230
|
+
"session_name": r.session_name,
|
|
231
|
+
"session_id": r.session_id,
|
|
232
|
+
"score": r.score,
|
|
233
|
+
"snippet": r.snippet,
|
|
234
|
+
"transcript_path": r.transcript_path,
|
|
235
|
+
"metadata": r.metadata,
|
|
236
|
+
}
|
|
237
|
+
for r in results
|
|
238
|
+
],
|
|
239
|
+
}
|
|
240
|
+
click.echo(json.dumps(output, indent=2))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@search_cmd.command("rebuild-index")
|
|
244
|
+
def rebuild_index_cmd() -> None:
|
|
245
|
+
"""Rebuild search index from all transcript artifacts.
|
|
246
|
+
|
|
247
|
+
Scans .forge/artifacts/**/transcripts/*.jsonl in the current project,
|
|
248
|
+
extracts content, and writes to all three per-project stores
|
|
249
|
+
(documents.json, bm25_index.json, content.json).
|
|
250
|
+
This is a full reset — all data for this project is replaced.
|
|
251
|
+
"""
|
|
252
|
+
console = Console()
|
|
253
|
+
|
|
254
|
+
from forge.search.engine import BM25
|
|
255
|
+
from forge.search.extractor import decompose_document, extract_document
|
|
256
|
+
from forge.search.index_state import IndexStateStore
|
|
257
|
+
|
|
258
|
+
project_root = _resolve_forge_root()
|
|
259
|
+
artifacts_dir = project_root / ".forge" / "artifacts"
|
|
260
|
+
|
|
261
|
+
if not artifacts_dir.is_dir():
|
|
262
|
+
console.print("[dim]No artifacts directory found.[/dim]")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
doc_store = SearchDocumentStore(forge_root=project_root)
|
|
266
|
+
bm25_store = BM25IndexStore(forge_root=project_root)
|
|
267
|
+
content_store = ContentStore(forge_root=project_root)
|
|
268
|
+
index_store = IndexStateStore(forge_root=project_root)
|
|
269
|
+
|
|
270
|
+
project_root_str = str(project_root)
|
|
271
|
+
|
|
272
|
+
# Extract all docs
|
|
273
|
+
new_docs = []
|
|
274
|
+
errors = 0
|
|
275
|
+
|
|
276
|
+
for session_dir in sorted(artifacts_dir.iterdir()):
|
|
277
|
+
if not session_dir.is_dir():
|
|
278
|
+
continue
|
|
279
|
+
session_name = session_dir.name
|
|
280
|
+
transcripts_dir = session_dir / "transcripts"
|
|
281
|
+
if not transcripts_dir.is_dir():
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
for jsonl_file in sorted(transcripts_dir.glob("*.jsonl")):
|
|
285
|
+
session_id = jsonl_file.stem
|
|
286
|
+
try:
|
|
287
|
+
doc = extract_document(
|
|
288
|
+
transcript_path=jsonl_file,
|
|
289
|
+
session_name=session_name,
|
|
290
|
+
session_id=session_id,
|
|
291
|
+
worktree_path=project_root_str,
|
|
292
|
+
)
|
|
293
|
+
new_docs.append(doc)
|
|
294
|
+
except Exception as e:
|
|
295
|
+
console.print(f"[yellow]Warning:[/yellow] Failed to extract {jsonl_file.name}: {e}")
|
|
296
|
+
errors += 1
|
|
297
|
+
|
|
298
|
+
# Decompose into three-store components
|
|
299
|
+
metas = []
|
|
300
|
+
content_map = {}
|
|
301
|
+
all_tokens = []
|
|
302
|
+
|
|
303
|
+
for doc in new_docs:
|
|
304
|
+
meta, _, _, content = decompose_document(doc)
|
|
305
|
+
metas.append(meta)
|
|
306
|
+
content_map[doc.transcript_path] = content
|
|
307
|
+
all_tokens.append(doc.tokens if doc.tokens is not None else [])
|
|
308
|
+
|
|
309
|
+
# Build BM25 from all tokens at once (efficient bulk construction)
|
|
310
|
+
bm25 = BM25(all_tokens)
|
|
311
|
+
precomputed = bm25.to_precomputed()
|
|
312
|
+
|
|
313
|
+
bm25_data = BM25IndexData(
|
|
314
|
+
doc_keys=[doc.transcript_path for doc in new_docs],
|
|
315
|
+
doc_lens=precomputed["doc_lens"],
|
|
316
|
+
term_freqs=precomputed["term_freqs"],
|
|
317
|
+
doc_freqs=precomputed["doc_freqs"],
|
|
318
|
+
avgdl=precomputed["avgdl"],
|
|
319
|
+
k1=precomputed["k1"],
|
|
320
|
+
b=precomputed["b"],
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Replace all three stores under locks
|
|
324
|
+
doc_store.replace_all(metas)
|
|
325
|
+
bm25_store.replace_all(bm25_data)
|
|
326
|
+
content_store.replace_all(content_map)
|
|
327
|
+
|
|
328
|
+
# Mark all as indexed
|
|
329
|
+
for doc in new_docs:
|
|
330
|
+
try:
|
|
331
|
+
index_store.mark_indexed(Path(doc.transcript_path))
|
|
332
|
+
except (FileNotFoundError, ValueError):
|
|
333
|
+
pass
|
|
334
|
+
|
|
335
|
+
# Prune stale entries from index state (stores were fully replaced)
|
|
336
|
+
index_store.prune_missing()
|
|
337
|
+
|
|
338
|
+
console.print(f"[green]Indexed {len(new_docs)} transcripts.[/green]")
|
|
339
|
+
if errors:
|
|
340
|
+
console.print(f"[yellow]{errors} files failed extraction.[/yellow]")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@search_cmd.command("clean")
|
|
344
|
+
def clean_cmd() -> None:
|
|
345
|
+
"""Remove orphaned documents whose transcript files no longer exist.
|
|
346
|
+
|
|
347
|
+
Scans all three stores and index state, removing entries that point
|
|
348
|
+
to transcript files that have been deleted or moved.
|
|
349
|
+
"""
|
|
350
|
+
console = Console()
|
|
351
|
+
|
|
352
|
+
from forge.search.index_state import IndexStateStore
|
|
353
|
+
|
|
354
|
+
project_root = _resolve_forge_root()
|
|
355
|
+
doc_store = SearchDocumentStore(forge_root=project_root)
|
|
356
|
+
bm25_store = BM25IndexStore(forge_root=project_root)
|
|
357
|
+
content_store = ContentStore(forge_root=project_root)
|
|
358
|
+
index_store = IndexStateStore(forge_root=project_root)
|
|
359
|
+
|
|
360
|
+
removed_docs = doc_store.prune_missing()
|
|
361
|
+
|
|
362
|
+
# Also remove from BM25 index and content store
|
|
363
|
+
for path in removed_docs:
|
|
364
|
+
bm25_store.remove_document(path)
|
|
365
|
+
content_store.remove(path)
|
|
366
|
+
|
|
367
|
+
removed_index = index_store.prune_missing()
|
|
368
|
+
|
|
369
|
+
if removed_docs or removed_index:
|
|
370
|
+
console.print(
|
|
371
|
+
f"Pruned [cyan]{len(removed_docs)}[/cyan] orphaned documents"
|
|
372
|
+
f" and [cyan]{len(removed_index)}[/cyan] stale index entries."
|
|
373
|
+
)
|
|
374
|
+
else:
|
|
375
|
+
console.print("[dim]No orphaned entries found.[/dim]")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@search_cmd.command("status")
|
|
379
|
+
def status_cmd() -> None:
|
|
380
|
+
"""Show search index statistics."""
|
|
381
|
+
console = Console()
|
|
382
|
+
|
|
383
|
+
from forge.search.index_state import IndexStateStore
|
|
384
|
+
|
|
385
|
+
project_root = _resolve_forge_root()
|
|
386
|
+
doc_store = SearchDocumentStore(forge_root=project_root)
|
|
387
|
+
bm25_store = BM25IndexStore(forge_root=project_root)
|
|
388
|
+
index_store = IndexStateStore(forge_root=project_root)
|
|
389
|
+
index_dir = project_root / ".forge" / "search-index"
|
|
390
|
+
|
|
391
|
+
if not doc_store.exists():
|
|
392
|
+
console.print("Search index: [yellow]not built[/yellow]")
|
|
393
|
+
console.print(f"Index location: [dim]{display_path(index_dir)}[/dim]")
|
|
394
|
+
console.print("\n[dim]Tip: Run 'forge search rebuild-index' to build.[/dim]")
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
documents = doc_store.read()
|
|
398
|
+
state = index_store.read()
|
|
399
|
+
|
|
400
|
+
console.print(f"Index location: [dim]{display_path(index_dir)}[/dim]")
|
|
401
|
+
console.print(f"Documents indexed: [cyan]{len(documents)}[/cyan]")
|
|
402
|
+
console.print(f"Files tracked: [cyan]{len(state.indexed_files)}[/cyan]")
|
|
403
|
+
if state.updated_at:
|
|
404
|
+
console.print(f"Last updated: [dim]{state.updated_at}[/dim]")
|
|
405
|
+
|
|
406
|
+
if documents:
|
|
407
|
+
session_names = {d.session_name for d in documents}
|
|
408
|
+
console.print(f"Sessions: [cyan]{len(session_names)}[/cyan]")
|
|
409
|
+
|
|
410
|
+
# BM25 index stats
|
|
411
|
+
bm25_index = bm25_store.read()
|
|
412
|
+
if bm25_index is not None:
|
|
413
|
+
console.print(
|
|
414
|
+
f"BM25 index: [cyan]{len(bm25_index.doc_keys)}[/cyan] documents, "
|
|
415
|
+
f"[cyan]{len(bm25_index.doc_freqs)}[/cyan] unique terms"
|
|
416
|
+
)
|