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.
Files changed (174) hide show
  1. git_repokit-0.9.10.dist-info/METADATA +422 -0
  2. git_repokit-0.9.10.dist-info/RECORD +174 -0
  3. git_repokit-0.9.10.dist-info/WHEEL +5 -0
  4. git_repokit-0.9.10.dist-info/entry_points.txt +2 -0
  5. git_repokit-0.9.10.dist-info/licenses/LICENSE +9 -0
  6. git_repokit-0.9.10.dist-info/top_level.txt +1 -0
  7. repokit/__init__.py +8 -0
  8. repokit/__main__.py +12 -0
  9. repokit/__version__.py +4 -0
  10. repokit/_vendor/__init__.py +0 -0
  11. repokit/_vendor/ailib/__init__.py +1 -0
  12. repokit/_vendor/ailib/analyzer.py +177 -0
  13. repokit/_vendor/ailib/backend.py +95 -0
  14. repokit/_vendor/ailib/cache.py +97 -0
  15. repokit/_vendor/ailib/parsers.py +52 -0
  16. repokit/_vendor/ailib/transports/__init__.py +16 -0
  17. repokit/_vendor/ailib/transports/cli.py +312 -0
  18. repokit/_vendor/ailib/transports/openai_compat.py +383 -0
  19. repokit/_vendor/ailib/transports/prompt_file.py +41 -0
  20. repokit/_vendor/ailib/types.py +224 -0
  21. repokit/auth_integration.py +244 -0
  22. repokit/badges.py +183 -0
  23. repokit/branch_utils.py +364 -0
  24. repokit/cli.py +2997 -0
  25. repokit/common_kit.py +202 -0
  26. repokit/config.py +686 -0
  27. repokit/defaults.py +220 -0
  28. repokit/directory_analyzer.py +1244 -0
  29. repokit/directory_profiles.py +423 -0
  30. repokit/git_config.py +370 -0
  31. repokit/github_create.py +142 -0
  32. repokit/github_setup.py +248 -0
  33. repokit/guardrails.py +707 -0
  34. repokit/history_cleaner.py +449 -0
  35. repokit/history_protection.py +434 -0
  36. repokit/hooks/pre-commit-guardrails +122 -0
  37. repokit/hooks/pre-commit-template +28 -0
  38. repokit/layers.py +510 -0
  39. repokit/licenses/0bsd.txt +14 -0
  40. repokit/licenses/LICENSES.md +27 -0
  41. repokit/licenses/__init__.py +142 -0
  42. repokit/licenses/agpl-3.0.txt +661 -0
  43. repokit/licenses/all-rights-reserved.txt +10 -0
  44. repokit/licenses/apache-2.0.txt +201 -0
  45. repokit/licenses/bsd-2-clause.txt +24 -0
  46. repokit/licenses/bsd-3-clause.txt +28 -0
  47. repokit/licenses/bsl-1.0.txt +23 -0
  48. repokit/licenses/cc-by-4.0.txt +395 -0
  49. repokit/licenses/cc-by-sa-4.0.txt +427 -0
  50. repokit/licenses/cc0-1.0.txt +121 -0
  51. repokit/licenses/epl-2.0.txt +277 -0
  52. repokit/licenses/gpl-2.0.txt +339 -0
  53. repokit/licenses/gpl-3.0.txt +674 -0
  54. repokit/licenses/isc.txt +15 -0
  55. repokit/licenses/lgpl-2.1.txt +504 -0
  56. repokit/licenses/lgpl-3.0.txt +165 -0
  57. repokit/licenses/mit.txt +21 -0
  58. repokit/licenses/mpl-2.0.txt +373 -0
  59. repokit/licenses/unlicense.txt +24 -0
  60. repokit/readme_blocks.py +112 -0
  61. repokit/remote_integration.py +627 -0
  62. repokit/repo_manager.py +2796 -0
  63. repokit/template_engine.py +391 -0
  64. repokit/templates/ai/claude/CLAUDE-nested.md.template +25 -0
  65. repokit/templates/ai/claude/CLAUDE.md.template +98 -0
  66. repokit/templates/ai/claude/instructions/step1_context_rebuilder.md +33 -0
  67. repokit/templates/ai/claude/instructions/step2_dev_workflow_process.md +67 -0
  68. repokit/templates/ai/claude/instructions/step3_context_bridge.md +52 -0
  69. repokit/templates/common/CHANGELOG.md.template +15 -0
  70. repokit/templates/common/CONTRIBUTING.md.template +31 -0
  71. repokit/templates/common/README.md.template +30 -0
  72. repokit/templates/common/gitattributes.template +17 -0
  73. repokit/templates/common/gitignore.template +141 -0
  74. repokit/templates/extras/FUNDING.yml.template +13 -0
  75. repokit/templates/extras/ROADMAP.md.template +17 -0
  76. repokit/templates/extras/dependabot.yml.template +22 -0
  77. repokit/templates/extras/one-offs-README.md.template +28 -0
  78. repokit/templates/extras/platform-support.md.template +11 -0
  79. repokit/templates/extras/pull_request_template.md.template +25 -0
  80. repokit/templates/extras/release-template.md.template +34 -0
  81. repokit/templates/extras/settings.json.template +11 -0
  82. repokit/templates/extras/stale.yml.template +14 -0
  83. repokit/templates/github/CODEOWNERS.template +11 -0
  84. repokit/templates/github/ISSUE_TEMPLATE/bug-report.md.template +29 -0
  85. repokit/templates/github/ISSUE_TEMPLATE/feature-request.md.template +19 -0
  86. repokit/templates/github/issues/notes.md.template +18 -0
  87. repokit/templates/github/issues/roadmap.md.template +15 -0
  88. repokit/templates/hooks/pre-commit.template +64 -0
  89. repokit/templates/layers/archetypes/any/claude-plugin/.claude-plugin/marketplace.json +20 -0
  90. repokit/templates/layers/archetypes/any/claude-plugin/plugin/.claude-plugin/plugin.json +15 -0
  91. repokit/templates/layers/archetypes/any/claude-plugin/plugin/README.md +28 -0
  92. repokit/templates/layers/archetypes/c-cpp/cmake-app/.github/workflows/ci-c-cpp.yml +24 -0
  93. repokit/templates/layers/archetypes/c-cpp/cmake-app/.repokit-common.toml.rkappend +4 -0
  94. repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/launch.json +29 -0
  95. repokit/templates/layers/archetypes/c-cpp/cmake-app/.vscode/tasks.json +26 -0
  96. repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakeLists.txt +47 -0
  97. repokit/templates/layers/archetypes/c-cpp/cmake-app/CMakePresets.json +23 -0
  98. repokit/templates/layers/archetypes/c-cpp/cmake-app/cmake/GenerateStamp.cmake +45 -0
  99. repokit/templates/layers/archetypes/c-cpp/cmake-app/src/main.cpp +27 -0
  100. repokit/templates/layers/archetypes/c-cpp/cmake-app/tests/test_version.cpp +20 -0
  101. repokit/templates/layers/archetypes/c-cpp/cmake-app/vcpkg.json +5 -0
  102. repokit/templates/layers/archetypes/c-cpp/minimal/.vscode/launch.json +27 -0
  103. repokit/templates/layers/archetypes/c-cpp/minimal/Makefile +10 -0
  104. repokit/templates/layers/archetypes/c-cpp/minimal/build.bat +7 -0
  105. repokit/templates/layers/archetypes/c-cpp/minimal/src/__RK_SNAKE__.c +26 -0
  106. repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/launch.json +17 -0
  107. repokit/templates/layers/archetypes/c-cpp/msbuild/.vscode/tasks.json +20 -0
  108. repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.sln +19 -0
  109. repokit/templates/layers/archetypes/c-cpp/msbuild/__RK_PROJECT__.vcxproj +41 -0
  110. repokit/templates/layers/archetypes/c-cpp/msbuild/src/main.cpp +26 -0
  111. repokit/templates/layers/archetypes/js-ts/claude-plugin/.repokit-common.toml +28 -0
  112. repokit/templates/layers/archetypes/js-ts/claude-plugin/.vscode/launch.json +34 -0
  113. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/hooks.json +15 -0
  114. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/hooks/scripts/session-start.mjs +23 -0
  115. repokit/templates/layers/archetypes/js-ts/claude-plugin/plugin/tests/manifest.test.js +30 -0
  116. repokit/templates/layers/archetypes/js-ts/node/.repokit-common.toml.rkappend +6 -0
  117. repokit/templates/layers/archetypes/js-ts/node/package.json +13 -0
  118. repokit/templates/layers/archetypes/js-ts/node-package/.github/workflows/release.yml +39 -0
  119. repokit/templates/layers/archetypes/js-ts/node-package/package.json +32 -0
  120. repokit/templates/layers/archetypes/js-ts/node-package/src/index.ts +13 -0
  121. repokit/templates/layers/archetypes/js-ts/node-package/tests/index.test.ts +11 -0
  122. repokit/templates/layers/archetypes/js-ts/script/.vscode/launch.json +34 -0
  123. repokit/templates/layers/archetypes/js-ts/script/__RK_PROJECT__.js +37 -0
  124. repokit/templates/layers/archetypes/js-ts/script/tests/version.test.js +26 -0
  125. repokit/templates/layers/archetypes/js-ts/ts/package.json +18 -0
  126. repokit/templates/layers/archetypes/js-ts/ts/tsconfig.json +15 -0
  127. repokit/templates/layers/archetypes/python/claude-plugin/.github/workflows/ci-python.yml +44 -0
  128. repokit/templates/layers/archetypes/python/claude-plugin/.repokit-common.toml +21 -0
  129. repokit/templates/layers/archetypes/python/claude-plugin/.vscode/launch.json +33 -0
  130. repokit/templates/layers/archetypes/python/claude-plugin/VERSION +1 -0
  131. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/hooks.json +15 -0
  132. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/run-hook.mjs +42 -0
  133. repokit/templates/layers/archetypes/python/claude-plugin/plugin/hooks/scripts/session_start.py +31 -0
  134. repokit/templates/layers/archetypes/python/claude-plugin/plugin/tests/test_manifest.py +42 -0
  135. repokit/templates/layers/archetypes/python/pypi/.github/workflows/release.yml +63 -0
  136. repokit/templates/layers/archetypes/python/pypi/.vscode/launch.json +43 -0
  137. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__init__.py +5 -0
  138. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/__main__.py +6 -0
  139. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/_version.py +96 -0
  140. repokit/templates/layers/archetypes/python/pypi/__RK_SNAKE__/cli.py +18 -0
  141. repokit/templates/layers/archetypes/python/pypi/pyproject.toml +71 -0
  142. repokit/templates/layers/archetypes/python/pypi/tests/conftest.py +11 -0
  143. repokit/templates/layers/archetypes/python/pypi/tests/test_version.py +50 -0
  144. repokit/templates/layers/archetypes/python/script/.github/workflows/ci-python.yml +43 -0
  145. repokit/templates/layers/archetypes/python/script/.repokit-common.toml +14 -0
  146. repokit/templates/layers/archetypes/python/script/.vscode/launch.json +33 -0
  147. repokit/templates/layers/archetypes/python/script/VERSION +1 -0
  148. repokit/templates/layers/archetypes/python/script/__RK_PROJECT__.py +40 -0
  149. repokit/templates/layers/archetypes/python/script/tests/test_version.py +25 -0
  150. repokit/templates/layers/archetypes/python/setuppy/requirements.txt +8 -0
  151. repokit/templates/layers/archetypes/python/setuppy/setup.py +23 -0
  152. repokit/templates/layers/languages/c-cpp/.gitignore.rkappend +20 -0
  153. repokit/templates/layers/languages/c-cpp/.repokit-common.toml +7 -0
  154. repokit/templates/layers/languages/c-cpp/include/__RK_SNAKE__/version.h +20 -0
  155. repokit/templates/layers/languages/c-cpp/src/app.rc +25 -0
  156. repokit/templates/layers/languages/js-ts/.editorconfig +15 -0
  157. repokit/templates/layers/languages/js-ts/.github/workflows/ci-js-ts.yml +41 -0
  158. repokit/templates/layers/languages/js-ts/.gitignore.rkappend +13 -0
  159. repokit/templates/layers/languages/js-ts/.repokit-common.toml +19 -0
  160. repokit/templates/layers/languages/js-ts/.vscode/launch.json +24 -0
  161. repokit/templates/layers/languages/js-ts/VERSION +1 -0
  162. repokit/templates/layers/languages/python/.github/workflows/ci-python.yml +76 -0
  163. repokit/templates/layers/languages/python/.gitignore.rkappend +15 -0
  164. repokit/templates/layers/languages/python/.vscode/launch.json +33 -0
  165. repokit/templates/layers/manifest.toml +302 -0
  166. repokit/templates/vault/CLAUDE.md +29 -0
  167. repokit/templates/vault/claude/_maps/MOC-project.md +15 -0
  168. repokit/templates/vault/claude/_oracle/backlinks.md +3 -0
  169. repokit/templates/vault/claude/_oracle/concepts.md +12 -0
  170. repokit/templates/vault/claude/_oracle/manifest.md +10 -0
  171. repokit/templates/vault/claude/_todo/ACTIVE-WORK.md +18 -0
  172. repokit/templates/vault/claude/_todo/OPEN-LOOPS.md +14 -0
  173. repokit/utils.py +316 -0
  174. repokit/vault.py +248 -0
repokit/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ RepoKit: Repository Template Generator
4
+
5
+ Main entry point for the package.
6
+ """
7
+
8
+ import sys
9
+ from .cli import main
10
+
11
+ if __name__ == "__main__":
12
+ sys.exit(main())
repokit/__version__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Version information for RepoKit."""
2
+
3
+ __version__ = "0.9.10"
4
+ __version_info__ = tuple(int(part) for part in __version__.split("."))
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
+ """