git-repokit 0.9.10__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.
- git_repokit-0.9.10.dist-info/METADATA +422 -0
- git_repokit-0.9.10.dist-info/RECORD +174 -0
- git_repokit-0.9.10.dist-info/WHEEL +5 -0
- git_repokit-0.9.10.dist-info/entry_points.txt +2 -0
- git_repokit-0.9.10.dist-info/licenses/LICENSE +9 -0
- git_repokit-0.9.10.dist-info/top_level.txt +1 -0
- repokit/__init__.py +8 -0
- repokit/__main__.py +12 -0
- repokit/__version__.py +4 -0
- repokit/_vendor/__init__.py +0 -0
- repokit/_vendor/ailib/__init__.py +1 -0
- repokit/_vendor/ailib/analyzer.py +177 -0
- repokit/_vendor/ailib/backend.py +95 -0
- repokit/_vendor/ailib/cache.py +97 -0
- repokit/_vendor/ailib/parsers.py +52 -0
- repokit/_vendor/ailib/transports/__init__.py +16 -0
- repokit/_vendor/ailib/transports/cli.py +312 -0
- repokit/_vendor/ailib/transports/openai_compat.py +383 -0
- repokit/_vendor/ailib/transports/prompt_file.py +41 -0
- repokit/_vendor/ailib/types.py +224 -0
- repokit/auth_integration.py +244 -0
- repokit/badges.py +183 -0
- repokit/branch_utils.py +364 -0
- repokit/cli.py +2997 -0
- repokit/common_kit.py +202 -0
- repokit/config.py +686 -0
- repokit/defaults.py +220 -0
- repokit/directory_analyzer.py +1244 -0
- repokit/directory_profiles.py +423 -0
- repokit/git_config.py +370 -0
- repokit/github_create.py +142 -0
- repokit/github_setup.py +248 -0
- repokit/guardrails.py +707 -0
- repokit/history_cleaner.py +449 -0
- repokit/history_protection.py +434 -0
- repokit/hooks/pre-commit-guardrails +122 -0
- repokit/hooks/pre-commit-template +28 -0
- repokit/layers.py +510 -0
- repokit/licenses/0bsd.txt +14 -0
- repokit/licenses/LICENSES.md +27 -0
- repokit/licenses/__init__.py +142 -0
- repokit/licenses/agpl-3.0.txt +661 -0
- repokit/licenses/all-rights-reserved.txt +10 -0
- repokit/licenses/apache-2.0.txt +201 -0
- repokit/licenses/bsd-2-clause.txt +24 -0
- repokit/licenses/bsd-3-clause.txt +28 -0
- repokit/licenses/bsl-1.0.txt +23 -0
- repokit/licenses/cc-by-4.0.txt +395 -0
- repokit/licenses/cc-by-sa-4.0.txt +427 -0
- repokit/licenses/cc0-1.0.txt +121 -0
- repokit/licenses/epl-2.0.txt +277 -0
- repokit/licenses/gpl-2.0.txt +339 -0
- repokit/licenses/gpl-3.0.txt +674 -0
- repokit/licenses/isc.txt +15 -0
- repokit/licenses/lgpl-2.1.txt +504 -0
- repokit/licenses/lgpl-3.0.txt +165 -0
- repokit/licenses/mit.txt +21 -0
- repokit/licenses/mpl-2.0.txt +373 -0
- repokit/licenses/unlicense.txt +24 -0
- repokit/readme_blocks.py +112 -0
- repokit/remote_integration.py +627 -0
- repokit/repo_manager.py +2796 -0
- repokit/template_engine.py +391 -0
- repokit/templates/ai/claude/CLAUDE-nested.md.template +25 -0
- repokit/templates/ai/claude/CLAUDE.md.template +98 -0
- repokit/templates/ai/claude/instructions/step1_context_rebuilder.md +33 -0
- repokit/templates/ai/claude/instructions/step2_dev_workflow_process.md +67 -0
- repokit/templates/ai/claude/instructions/step3_context_bridge.md +52 -0
- repokit/templates/common/CHANGELOG.md.template +15 -0
- repokit/templates/common/CONTRIBUTING.md.template +31 -0
- repokit/templates/common/README.md.template +30 -0
- repokit/templates/common/gitattributes.template +17 -0
- repokit/templates/common/gitignore.template +141 -0
- repokit/templates/extras/FUNDING.yml.template +13 -0
- repokit/templates/extras/ROADMAP.md.template +17 -0
- repokit/templates/extras/dependabot.yml.template +22 -0
- repokit/templates/extras/one-offs-README.md.template +28 -0
- repokit/templates/extras/platform-support.md.template +11 -0
- repokit/templates/extras/pull_request_template.md.template +25 -0
- repokit/templates/extras/release-template.md.template +34 -0
- repokit/templates/extras/settings.json.template +11 -0
- repokit/templates/extras/stale.yml.template +14 -0
- repokit/templates/github/CODEOWNERS.template +11 -0
- repokit/templates/github/ISSUE_TEMPLATE/bug-report.md.template +29 -0
- repokit/templates/github/ISSUE_TEMPLATE/feature-request.md.template +19 -0
- repokit/templates/github/issues/notes.md.template +18 -0
- repokit/templates/github/issues/roadmap.md.template +15 -0
- repokit/templates/hooks/pre-commit.template +64 -0
- repokit/templates/layers/archetypes/any/claude-plugin/.claude-plugin/marketplace.json +20 -0
- repokit/templates/layers/archetypes/any/claude-plugin/plugin/.claude-plugin/plugin.json +15 -0
- repokit/templates/layers/archetypes/any/claude-plugin/plugin/README.md +28 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.github/workflows/ci-c-cpp.yml +24 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.repokit-common.toml.rkappend +4 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/launch.json +29 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/tasks.json +26 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakeLists.txt +47 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakePresets.json +23 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/cmake/GenerateStamp.cmake +45 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/src/main.cpp +27 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/tests/test_version.cpp +20 -0
- repokit/templates/layers/archetypes/c-cpp/cmake-app/vcpkg.json +5 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/.vscode/launch.json +27 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/Makefile +10 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/build.bat +7 -0
- repokit/templates/layers/archetypes/c-cpp/minimal/src/__RK_SNAKE__.c +26 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/launch.json +17 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/tasks.json +20 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.sln +19 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.vcxproj +41 -0
- repokit/templates/layers/archetypes/c-cpp/msbuild/src/main.cpp +26 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/.repokit-common.toml +28 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/.vscode/launch.json +34 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/hooks.json +15 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/scripts/session-start.mjs +23 -0
- repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/tests/manifest.test.js +30 -0
- repokit/templates/layers/archetypes/js-ts/node/.repokit-common.toml.rkappend +6 -0
- repokit/templates/layers/archetypes/js-ts/node/package.json +13 -0
- repokit/templates/layers/archetypes/js-ts/node-package/.github/workflows/release.yml +39 -0
- repokit/templates/layers/archetypes/js-ts/node-package/package.json +32 -0
- repokit/templates/layers/archetypes/js-ts/node-package/src/index.ts +13 -0
- repokit/templates/layers/archetypes/js-ts/node-package/tests/index.test.ts +11 -0
- repokit/templates/layers/archetypes/js-ts/script/.vscode/launch.json +34 -0
- repokit/templates/layers/archetypes/js-ts/script/__RK_PROJECT__.js +37 -0
- repokit/templates/layers/archetypes/js-ts/script/tests/version.test.js +26 -0
- repokit/templates/layers/archetypes/js-ts/ts/package.json +18 -0
- repokit/templates/layers/archetypes/js-ts/ts/tsconfig.json +15 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.github/workflows/ci-python.yml +44 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.repokit-common.toml +21 -0
- repokit/templates/layers/archetypes/python/claude-plugin/.vscode/launch.json +33 -0
- repokit/templates/layers/archetypes/python/claude-plugin/VERSION +1 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/hooks.json +15 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/run-hook.mjs +42 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/session_start.py +31 -0
- repokit/templates/layers/archetypes/python/claude-plugin/plugin/tests/test_manifest.py +42 -0
- repokit/templates/layers/archetypes/python/pypi/.github/workflows/release.yml +63 -0
- repokit/templates/layers/archetypes/python/pypi/.vscode/launch.json +43 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__init__.py +5 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__main__.py +6 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/_version.py +96 -0
- repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/cli.py +18 -0
- repokit/templates/layers/archetypes/python/pypi/pyproject.toml +71 -0
- repokit/templates/layers/archetypes/python/pypi/tests/conftest.py +11 -0
- repokit/templates/layers/archetypes/python/pypi/tests/test_version.py +50 -0
- repokit/templates/layers/archetypes/python/script/.github/workflows/ci-python.yml +43 -0
- repokit/templates/layers/archetypes/python/script/.repokit-common.toml +14 -0
- repokit/templates/layers/archetypes/python/script/.vscode/launch.json +33 -0
- repokit/templates/layers/archetypes/python/script/VERSION +1 -0
- repokit/templates/layers/archetypes/python/script/__RK_PROJECT__.py +40 -0
- repokit/templates/layers/archetypes/python/script/tests/test_version.py +25 -0
- repokit/templates/layers/archetypes/python/setuppy/requirements.txt +8 -0
- repokit/templates/layers/archetypes/python/setuppy/setup.py +23 -0
- repokit/templates/layers/languages/c-cpp/.gitignore.rkappend +20 -0
- repokit/templates/layers/languages/c-cpp/.repokit-common.toml +7 -0
- repokit/templates/layers/languages/c-cpp/include/__RK_SNAKE__/version.h +20 -0
- repokit/templates/layers/languages/c-cpp/src/app.rc +25 -0
- repokit/templates/layers/languages/js-ts/.editorconfig +15 -0
- repokit/templates/layers/languages/js-ts/.github/workflows/ci-js-ts.yml +41 -0
- repokit/templates/layers/languages/js-ts/.gitignore.rkappend +13 -0
- repokit/templates/layers/languages/js-ts/.repokit-common.toml +19 -0
- repokit/templates/layers/languages/js-ts/.vscode/launch.json +24 -0
- repokit/templates/layers/languages/js-ts/VERSION +1 -0
- repokit/templates/layers/languages/python/.github/workflows/ci-python.yml +76 -0
- repokit/templates/layers/languages/python/.gitignore.rkappend +15 -0
- repokit/templates/layers/languages/python/.vscode/launch.json +33 -0
- repokit/templates/layers/manifest.toml +302 -0
- repokit/templates/vault/CLAUDE.md +29 -0
- repokit/templates/vault/claude/_maps/MOC-project.md +15 -0
- repokit/templates/vault/claude/_oracle/backlinks.md +3 -0
- repokit/templates/vault/claude/_oracle/concepts.md +12 -0
- repokit/templates/vault/claude/_oracle/manifest.md +10 -0
- repokit/templates/vault/claude/_todo/ACTIVE-WORK.md +18 -0
- repokit/templates/vault/claude/_todo/OPEN-LOOPS.md +14 -0
- repokit/utils.py +316 -0
- repokit/vault.py +248 -0
repokit/__main__.py
ADDED
repokit/__version__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AI analysis pipeline for wtf-windows diagnostic tools."""
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Shared AI analysis pipeline for wtf-windows diagnostic tools.
|
|
2
|
+
|
|
3
|
+
Orchestrates prompt building, backend invocation, and response parsing.
|
|
4
|
+
Each tool supplies its own domain-specific functions:
|
|
5
|
+
|
|
6
|
+
from wtf_windows.lib.ai.analyzer import analyze
|
|
7
|
+
|
|
8
|
+
result = analyze(
|
|
9
|
+
results=investigation_data,
|
|
10
|
+
fingerprint_fn=my_stable_fields, # tool-specific cache fingerprint
|
|
11
|
+
prompt_path=Path("prompts/diagnose.md"),
|
|
12
|
+
cache_dir=Path.home() / ".wtf-locked" / "cache",
|
|
13
|
+
tool_name="locked",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
The analyzer handles caching, backend dispatch, and response parsing.
|
|
17
|
+
Tools handle what makes their events semantically unique.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from .backend import build as _build
|
|
29
|
+
from .cache import run as _run
|
|
30
|
+
from .parsers import sections as _sections
|
|
31
|
+
from .types import Request as _Request, Spec as _Spec
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# The three names the ORIGINAL front door knew, as specs over the object
|
|
35
|
+
# model. This is the shim's table, not a registry: a consumer that wants
|
|
36
|
+
# its own names keeps them on its own side and builds specs itself. The argv
|
|
37
|
+
# is what the original backends ran; the prompt-only directory is the
|
|
38
|
+
# original default.
|
|
39
|
+
#
|
|
40
|
+
# COMPAT(remove-after: wtf locked migrates to build/run)
|
|
41
|
+
_COMPAT_PRESETS = {
|
|
42
|
+
"claude": _Spec("cli", name="claude",
|
|
43
|
+
command=("claude", "--output-format", "text", "-p", "-"),
|
|
44
|
+
env_unset=("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"),
|
|
45
|
+
candidates=("~/.local/bin/claude.exe", "~/.local/bin/claude"), on_prem=False),
|
|
46
|
+
"codex": _Spec("cli", name="codex",
|
|
47
|
+
command=("codex", "exec", "--skip-git-repo-check", "-"),
|
|
48
|
+
candidates=("%APPDATA%/npm/codex.cmd",
|
|
49
|
+
"%LOCALAPPDATA%/Microsoft/WinGet/Links/codex.cmd"), on_prem=False),
|
|
50
|
+
"prompt-only": _Spec("prompt-file", name="prompt-only",
|
|
51
|
+
endpoint=str(Path.home() / ".wtf-windows" / "ai")),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def check_available(backend_name="claude"):
|
|
55
|
+
"""Whether one of the original three backends is ready, by name.
|
|
56
|
+
|
|
57
|
+
A thin wrapper: the answer is `build(spec).probe().ok` for the spec the
|
|
58
|
+
name maps to. A consumer on the object model asks its backend directly.
|
|
59
|
+
|
|
60
|
+
COMPAT(remove-after: wtf locked migrates to build/run)
|
|
61
|
+
"""
|
|
62
|
+
spec = _COMPAT_PRESETS.get(backend_name)
|
|
63
|
+
if spec is None:
|
|
64
|
+
return False
|
|
65
|
+
return _build(spec).probe().ok
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_prompt(results, prompt_path, clean_fn=None):
|
|
69
|
+
"""Build the AI analysis prompt from investigation results.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
results: dict from the tool's investigation
|
|
73
|
+
prompt_path: Path to the tool's prompt template (.md file)
|
|
74
|
+
clean_fn: Optional function to clean results before embedding in prompt
|
|
75
|
+
(e.g., removing large raw_output fields to save tokens)
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
str: The complete prompt text
|
|
79
|
+
"""
|
|
80
|
+
template = prompt_path.read_text(encoding="utf-8")
|
|
81
|
+
|
|
82
|
+
evidence = clean_fn(results) if clean_fn else results
|
|
83
|
+
evidence_json = json.dumps(evidence, indent=2, default=str)
|
|
84
|
+
|
|
85
|
+
# Replace template placeholders
|
|
86
|
+
prompt = template.replace("{evidence_json}", evidence_json)
|
|
87
|
+
|
|
88
|
+
# Optional dump section (for tools that include crash dump analysis)
|
|
89
|
+
dump_section = ""
|
|
90
|
+
raw_output = (results.get("dump_analysis") or {}).get("raw_output")
|
|
91
|
+
if raw_output:
|
|
92
|
+
lines = raw_output.splitlines()
|
|
93
|
+
if len(lines) > 200:
|
|
94
|
+
raw_output = "\n".join(lines[-200:])
|
|
95
|
+
raw_output = f"[...truncated to last 200 lines...]\n{raw_output}"
|
|
96
|
+
dump_section = (
|
|
97
|
+
"\n## Crash Dump Analysis (kd.exe !analyze -v output)\n\n"
|
|
98
|
+
f"```\n{raw_output}\n```\n"
|
|
99
|
+
)
|
|
100
|
+
prompt = prompt.replace("{dump_section}", dump_section)
|
|
101
|
+
|
|
102
|
+
return prompt
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def analyze(
|
|
106
|
+
results,
|
|
107
|
+
fingerprint_fn,
|
|
108
|
+
prompt_path,
|
|
109
|
+
cache_dir,
|
|
110
|
+
tool_name,
|
|
111
|
+
backend_name="claude",
|
|
112
|
+
clean_fn=None,
|
|
113
|
+
response_sections=None,
|
|
114
|
+
verbose=False,
|
|
115
|
+
timeout=120,
|
|
116
|
+
refresh=False,
|
|
117
|
+
):
|
|
118
|
+
"""Run AI analysis on investigation results.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
results: dict from the tool's investigation
|
|
122
|
+
fingerprint_fn: Callable[[dict], dict] -- extracts stable fields for
|
|
123
|
+
cache key. Each tool defines what makes its events unique.
|
|
124
|
+
prompt_path: Path to the tool's prompt template
|
|
125
|
+
cache_dir: Path to the tool's cache directory
|
|
126
|
+
tool_name: Tool identifier (included in cache key)
|
|
127
|
+
backend_name: which AI backend to use (claude, codex, prompt-only)
|
|
128
|
+
clean_fn: Optional callable to clean results before prompt building
|
|
129
|
+
response_sections: Optional list of (key, regex_label) for parsing.
|
|
130
|
+
Defaults to What Happened / Why / What To Do / Confidence.
|
|
131
|
+
verbose: stream output in real-time
|
|
132
|
+
timeout: seconds before timing out
|
|
133
|
+
refresh: bypass cache (re-run analysis)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
dict with keys:
|
|
137
|
+
success: bool
|
|
138
|
+
raw_response: str
|
|
139
|
+
sections: dict (parsed sections)
|
|
140
|
+
error: str or None
|
|
141
|
+
cached: bool (if from cache)
|
|
142
|
+
cached_at: float (if from cache)
|
|
143
|
+
|
|
144
|
+
This is the ORIGINAL front door, kept with its signature and its result
|
|
145
|
+
shape for the one live caller that still uses it (wtf-windows'
|
|
146
|
+
`wtf locked --ai`, tools/core/locked/locked.py). It is a thin wrapper:
|
|
147
|
+
the prompt comes from `build_prompt`, the backend from the three-name
|
|
148
|
+
table above, the answer from `cache.run` over `backend.build`, the
|
|
149
|
+
sections from `parsers.sections`. No logic of its own. The migration is
|
|
150
|
+
one call: `run(build(spec), Request(prompt), cache_dir=..., ...)`.
|
|
151
|
+
|
|
152
|
+
COMPAT(remove-after: wtf locked migrates to build/run)
|
|
153
|
+
"""
|
|
154
|
+
spec = _COMPAT_PRESETS.get(backend_name)
|
|
155
|
+
if spec is None:
|
|
156
|
+
return {"success": False, "raw_response": "", "sections": {},
|
|
157
|
+
"error": f"Unknown AI backend: {backend_name!r}. Available: {', '.join(_COMPAT_PRESETS)}"}
|
|
158
|
+
backend = _build(spec)
|
|
159
|
+
ready = backend.probe()
|
|
160
|
+
if not ready.ok:
|
|
161
|
+
return {"success": False, "raw_response": "", "sections": {},
|
|
162
|
+
"error": f"AI backend '{backend_name}' is not available ({ready.reason})"}
|
|
163
|
+
prompt = build_prompt(results, prompt_path, clean_fn)
|
|
164
|
+
req = _Request(prompt=prompt, timeout=timeout, stream_to=sys.stdout if verbose else None)
|
|
165
|
+
resp = _run(backend, req, cache_dir=Path(cache_dir), fingerprint_extra=fingerprint_fn(results),
|
|
166
|
+
refresh=refresh, tool=tool_name)
|
|
167
|
+
if resp.status == "deferred":
|
|
168
|
+
return {"success": False, "raw_response": "", "sections": {},
|
|
169
|
+
"error": f"Prompt saved to: {resp.artifact}"}
|
|
170
|
+
if not resp.ok:
|
|
171
|
+
return {"success": False, "raw_response": resp.error, "sections": {}, "error": resp.error}
|
|
172
|
+
result = {"success": True, "raw_response": resp.text,
|
|
173
|
+
"sections": _sections(resp.text, response_sections), "error": None}
|
|
174
|
+
if resp.cached:
|
|
175
|
+
result["cached"] = True
|
|
176
|
+
result["cached_at"] = resp.cached_at
|
|
177
|
+
return result
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The AI object: a `Spec` bound to a `Transport`.
|
|
2
|
+
|
|
3
|
+
A backend holds its spec and delegates to a transport. It keeps no module
|
|
4
|
+
state, so two backends built from two specs can live in one process -- for
|
|
5
|
+
a caller that asks several models in one run, or one that lives longer than
|
|
6
|
+
a single call -- and neither can leak into the other.
|
|
7
|
+
|
|
8
|
+
Transports are registered by name. The three shipped here are lazily
|
|
9
|
+
imported the first time a backend is built; a consumer (or a test) may
|
|
10
|
+
register its own with `register_transport`.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Protocol, runtime_checkable
|
|
15
|
+
|
|
16
|
+
from .types import Readiness, Request, Response, Spec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class Transport(Protocol):
|
|
21
|
+
"""One per WAY OF REACHING a model. Stateless: everything comes from the
|
|
22
|
+
spec it is handed, so the same transport object serves every backend
|
|
23
|
+
built over it."""
|
|
24
|
+
|
|
25
|
+
def probe(self, spec: Spec) -> Readiness: ...
|
|
26
|
+
def invoke(self, spec: Spec, req: Request) -> Response: ...
|
|
27
|
+
def capabilities(self, spec: Spec) -> frozenset[str]: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_TRANSPORTS: dict[str, Transport] = {}
|
|
31
|
+
_BUILTIN = ("cli", "openai_compat", "prompt-file")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_transport(name: str, transport: Transport | None) -> None:
|
|
35
|
+
"""Add a transport under `name`, or remove it with None."""
|
|
36
|
+
if transport is None:
|
|
37
|
+
_TRANSPORTS.pop(name, None)
|
|
38
|
+
else:
|
|
39
|
+
_TRANSPORTS[name] = transport
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ensure_builtins() -> None:
|
|
43
|
+
if all(n in _TRANSPORTS for n in _BUILTIN):
|
|
44
|
+
return
|
|
45
|
+
from .transports import cli, openai_compat, prompt_file
|
|
46
|
+
_TRANSPORTS.setdefault("cli", cli.SubprocessCli())
|
|
47
|
+
# "openai_compat": the OpenAI-compatible wire format every local and
|
|
48
|
+
# hosted server speaks (LM Studio, Ollama, OpenRouter, OpenAI itself).
|
|
49
|
+
# Named for the protocol, not the company, so a preset called "openai"
|
|
50
|
+
# (the hosted API) is one of several riding it and never read as the
|
|
51
|
+
# transport itself.
|
|
52
|
+
_TRANSPORTS.setdefault("openai_compat", openai_compat.OpenAICompatible())
|
|
53
|
+
_TRANSPORTS.setdefault("prompt-file", prompt_file.PromptFile())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def transport_names() -> list[str]:
|
|
57
|
+
_ensure_builtins()
|
|
58
|
+
return sorted(_TRANSPORTS)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def transport_for(name: str) -> Transport:
|
|
62
|
+
_ensure_builtins()
|
|
63
|
+
try:
|
|
64
|
+
return _TRANSPORTS[name]
|
|
65
|
+
except KeyError:
|
|
66
|
+
raise ValueError(f"unknown transport {name!r} -- one of: {', '.join(sorted(_TRANSPORTS))}") from None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Backend:
|
|
70
|
+
"""The object a caller talks to. Build one with `build(spec)`."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, spec: Spec):
|
|
73
|
+
self.spec = spec
|
|
74
|
+
self._transport = transport_for(spec.transport)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def identity(self) -> str:
|
|
78
|
+
return self.spec.identity()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def capabilities(self) -> frozenset[str]:
|
|
82
|
+
return frozenset(self._transport.capabilities(self.spec))
|
|
83
|
+
|
|
84
|
+
def probe(self) -> Readiness:
|
|
85
|
+
return self._transport.probe(self.spec)
|
|
86
|
+
|
|
87
|
+
def invoke(self, req: Request) -> Response:
|
|
88
|
+
return self._transport.invoke(self.spec, req)
|
|
89
|
+
|
|
90
|
+
def __repr__(self) -> str:
|
|
91
|
+
return f"Backend({self.spec.name or self.spec.transport}: {self.identity})"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build(spec: Spec) -> Backend:
|
|
95
|
+
return Backend(spec)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""`run()` -- ask a backend through a cache.
|
|
2
|
+
|
|
3
|
+
The key is the backend's own identity plus the request's fingerprint plus
|
|
4
|
+
whatever facts the caller adds (a merge tool adds the three sides and its
|
|
5
|
+
rules; a diagnostic tool adds the event's stable fields), so the caller
|
|
6
|
+
never learns which backend facts matter for which transport. A hit
|
|
7
|
+
reproduces the response the backend gave, marked cached. Nothing is written
|
|
8
|
+
for a deferred or failed answer. A refresh skips the read and still writes.
|
|
9
|
+
|
|
10
|
+
A write also sweeps entries older than the TTL from the cache directory
|
|
11
|
+
under ANY file name: an entry that is never looked up again would otherwise
|
|
12
|
+
never expire.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .backend import Backend
|
|
23
|
+
from .types import Request, Response
|
|
24
|
+
|
|
25
|
+
CACHE_TTL_SECONDS = 24 * 60 * 60
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _now() -> float: # a seam for tests
|
|
29
|
+
return time.time()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cache_key(backend: Backend, req: Request, extra: dict[str, Any] | None, tool: str) -> str:
|
|
33
|
+
payload = json.dumps({"identity": backend.identity, "request": req.fingerprint(),
|
|
34
|
+
"extra": extra or {}, "tool": tool}, sort_keys=True, default=str)
|
|
35
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _path(cache_dir: Path, tool: str, backend: Backend, key: str) -> Path:
|
|
39
|
+
return Path(cache_dir) / f"{tool}_{backend.spec.name or backend.spec.transport}_{key}.json"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read(path: Path) -> dict | None:
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
if _now() - float(data.get("cached_at", 0)) > CACHE_TTL_SECONDS:
|
|
46
|
+
return None
|
|
47
|
+
resp = data.get("response")
|
|
48
|
+
if not isinstance(resp, dict) or not resp.get("text"):
|
|
49
|
+
return None
|
|
50
|
+
resp["cached_at"] = float(data["cached_at"])
|
|
51
|
+
return resp
|
|
52
|
+
except (OSError, ValueError, TypeError, KeyError):
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _write(path: Path, backend: Backend, resp: Response) -> None:
|
|
57
|
+
try:
|
|
58
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
body = {"cached_at": _now(), "identity": backend.identity,
|
|
60
|
+
"response": {"text": resp.text, "model_used": resp.model_used,
|
|
61
|
+
"honoured": list(resp.honoured), "elapsed": resp.elapsed}}
|
|
62
|
+
path.write_text(json.dumps(body, indent=1, default=str), encoding="utf-8")
|
|
63
|
+
except OSError:
|
|
64
|
+
pass # a cache miss next time; not fatal
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _sweep(cache_dir: Path) -> None:
|
|
68
|
+
cutoff = _now() - CACHE_TTL_SECONDS
|
|
69
|
+
try:
|
|
70
|
+
for p in Path(cache_dir).glob("*.json"):
|
|
71
|
+
try:
|
|
72
|
+
if float(json.loads(p.read_text(encoding="utf-8")).get("cached_at", 0)) < cutoff:
|
|
73
|
+
p.unlink()
|
|
74
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
75
|
+
continue
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run(backend: Backend, req: Request, *, cache_dir: Path | str,
|
|
81
|
+
fingerprint_extra: dict[str, Any] | None = None, refresh: bool = False,
|
|
82
|
+
tool: str = "ai") -> Response:
|
|
83
|
+
"""Ask `backend` for `req`, through the cache under `cache_dir`."""
|
|
84
|
+
cache_dir = Path(cache_dir)
|
|
85
|
+
key = cache_key(backend, req, fingerprint_extra, tool)
|
|
86
|
+
path = _path(cache_dir, tool, backend, key)
|
|
87
|
+
if not refresh:
|
|
88
|
+
hit = _read(path)
|
|
89
|
+
if hit:
|
|
90
|
+
return Response("answered", text=hit["text"], model_used=hit.get("model_used", ""),
|
|
91
|
+
honoured=tuple(hit.get("honoured", ())), elapsed=float(hit.get("elapsed", 0)),
|
|
92
|
+
cached=True, cached_at=hit["cached_at"], key=key)
|
|
93
|
+
resp = backend.invoke(req).with_(key=key)
|
|
94
|
+
if resp.status == "answered":
|
|
95
|
+
_write(path, backend, resp)
|
|
96
|
+
_sweep(cache_dir)
|
|
97
|
+
return resp
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Readers a caller chooses from. The library never interprets an answer; it
|
|
2
|
+
offers two ways to read one and the caller picks the one its prompt asked
|
|
3
|
+
for. A merge tool wants one fenced JSON object; a diagnostic tool wants
|
|
4
|
+
labelled prose sections. Neither shape is this library's.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
_FENCE = re.compile(r"```json\s*\n(.*?)\n\s*```", re.DOTALL)
|
|
13
|
+
|
|
14
|
+
#: The labelled-prose shape the original diagnostic tools ask for.
|
|
15
|
+
DEFAULT_SECTIONS: list[tuple[str, str]] = [
|
|
16
|
+
("what_happened", r"What Happened:"),
|
|
17
|
+
("why", r"Why:"),
|
|
18
|
+
("what_to_do", r"What To Do:"),
|
|
19
|
+
("confidence", r"Confidence:"),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def json_block(text: str) -> Any | None:
|
|
24
|
+
"""The LAST fenced ```json block in `text`, parsed; None when there is
|
|
25
|
+
none or it does not parse. The last one, because a model that thinks
|
|
26
|
+
aloud tends to draft and then answer."""
|
|
27
|
+
blocks = _FENCE.findall(text or "")
|
|
28
|
+
if not blocks:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(blocks[-1])
|
|
32
|
+
except ValueError:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sections(text: str, labels: list[tuple[str, str]] | None = None) -> dict[str, str]:
|
|
37
|
+
"""Split labelled prose into a dict by the caller's labels -- each a
|
|
38
|
+
(key, regex) pair, in the order they appear. When nothing matches, the
|
|
39
|
+
whole text comes back under "raw", so a caller always has something to
|
|
40
|
+
show."""
|
|
41
|
+
labels = labels or DEFAULT_SECTIONS
|
|
42
|
+
out: dict[str, str] = {}
|
|
43
|
+
for i, (key, pattern) in enumerate(labels):
|
|
44
|
+
rest = [p for _, p in labels[i + 1:]]
|
|
45
|
+
regex = (rf"{pattern}\s*(.*?)(?=(?:{'|'.join(rest)})|\Z)" if rest
|
|
46
|
+
else rf"{pattern}\s*(.*)")
|
|
47
|
+
m = re.search(regex, text or "", re.DOTALL)
|
|
48
|
+
if m:
|
|
49
|
+
out[key] = m.group(1).strip()
|
|
50
|
+
if not out:
|
|
51
|
+
out["raw"] = (text or "").strip()
|
|
52
|
+
return out
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""One transport per WAY OF REACHING a model.
|
|
2
|
+
|
|
3
|
+
`cli` -- an executable, argv from a template, the prompt on stdin
|
|
4
|
+
(claude, codex, and any CLI-shaped agent)
|
|
5
|
+
`openai` -- an OpenAI-compatible HTTP server: LM Studio and Ollama on
|
|
6
|
+
this machine, OpenAI, OpenRouter and the rest over the wire;
|
|
7
|
+
local versus remote is the endpoint and whether a credential
|
|
8
|
+
is named
|
|
9
|
+
`prompt-file` -- not a model: writes the request where a person can carry it,
|
|
10
|
+
and answers "deferred"
|
|
11
|
+
|
|
12
|
+
Transports are stateless. Everything comes from the `Spec` they are handed,
|
|
13
|
+
so one transport object serves every backend built over it and two backends
|
|
14
|
+
cannot leak into each other. The protocol they implement is
|
|
15
|
+
`backend.Transport`; they are registered in `backend._TRANSPORTS` by name.
|
|
16
|
+
"""
|