karni-cli 0.3.1__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.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""History providers: what was already asked and decided about a file.
|
|
2
|
+
|
|
3
|
+
``karni context`` shows what a project contains *now*. A **history provider**
|
|
4
|
+
reads a prompt-history store kept alongside the project (Gigai Chronicle's
|
|
5
|
+
``.chronicle/``, for example) and returns a compact brief of what was asked and
|
|
6
|
+
decided about one file, so an agent does not redo a decision that was already
|
|
7
|
+
made and reverted.
|
|
8
|
+
|
|
9
|
+
Provider, not dependency: Karni works identically with no provider installed
|
|
10
|
+
and no store present. Nothing here imports a history tool; with no store
|
|
11
|
+
detected, ``karni context --with-history`` prints the plain inventory and
|
|
12
|
+
``karni rules`` omits the history rule.
|
|
13
|
+
|
|
14
|
+
Stdlib-only and Django-free like the rest of :mod:`karni.agent`: the CLI runs
|
|
15
|
+
this in its own environment (karni's copy when importable, else the copy
|
|
16
|
+
bundled into karni-cli, which still carries the built-in provider).
|
|
17
|
+
|
|
18
|
+
The protocol (duck-typed — subclass nothing, import nothing from Karni):
|
|
19
|
+
|
|
20
|
+
* ``name: str`` — stable identifier, shown in the ``HISTORY:`` heading.
|
|
21
|
+
* ``detect(project_root: Path) -> bool`` — is a store present for this project?
|
|
22
|
+
* ``context(project_root: Path, path: str) -> str | None`` — brief for one file;
|
|
23
|
+
``path`` is relative to ``project_root``, POSIX separators. ``None`` (or an
|
|
24
|
+
empty string) means nothing to say.
|
|
25
|
+
* optional ``project_context(project_root: Path) -> str | None`` — a brief when
|
|
26
|
+
no file is named (``karni context --with-history`` with no path).
|
|
27
|
+
* optional ``full_command(project_root: Path, path: str | None) -> str | None``
|
|
28
|
+
— the shell command that prints the untruncated brief; named in the line
|
|
29
|
+
that ends a truncated brief.
|
|
30
|
+
|
|
31
|
+
Providers are discovered from the ``karni.history_providers`` entry-point
|
|
32
|
+
group. Karni registers its built-in Gigai Chronicle provider there too, and
|
|
33
|
+
falls back to it when karni's own metadata is not installed (the bundled
|
|
34
|
+
copy); an entry point with the same name replaces the built-in one.
|
|
35
|
+
|
|
36
|
+
Providers must not print and should not raise: Karni discards anything they
|
|
37
|
+
write to ``sys.stdout``/``sys.stderr`` and treats an exception as "no brief".
|
|
38
|
+
The ~300-token cap is enforced here, never left to providers.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import contextlib
|
|
44
|
+
import importlib
|
|
45
|
+
import io
|
|
46
|
+
import math
|
|
47
|
+
import os
|
|
48
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
49
|
+
from dataclasses import dataclass
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any, Protocol, runtime_checkable
|
|
52
|
+
|
|
53
|
+
#: Entry-point group a third-party package registers its provider under.
|
|
54
|
+
ENTRY_POINT_GROUP = "karni.history_providers"
|
|
55
|
+
|
|
56
|
+
#: Upper bound (approximate tokens) of everything ``--with-history`` appends.
|
|
57
|
+
#: With ``karni context``'s inventory (under ~200 tokens for a typical
|
|
58
|
+
#: project) the combined output stays within a few hundred tokens.
|
|
59
|
+
HISTORY_TOKEN_CAP = 300
|
|
60
|
+
|
|
61
|
+
#: The approximation the ``karni rules`` budget test uses: ~4 characters/token.
|
|
62
|
+
CHARS_PER_TOKEN = 4
|
|
63
|
+
|
|
64
|
+
#: First word of every block ``--with-history`` appends.
|
|
65
|
+
HEADING = "HISTORY:"
|
|
66
|
+
|
|
67
|
+
#: ``Rule.when`` value that is active when any provider detects a store.
|
|
68
|
+
CONDITION = "history"
|
|
69
|
+
|
|
70
|
+
#: Escape hatch: a comma-separated list that replaces condition detection for
|
|
71
|
+
#: ``karni rules`` (``KARNI_AGENT_CONDITIONS=`` = never emit conditional rules).
|
|
72
|
+
#: The CLI also uses it to hand its own detection to the project interpreter.
|
|
73
|
+
CONDITIONS_ENV = "KARNI_AGENT_CONDITIONS"
|
|
74
|
+
|
|
75
|
+
#: Built-in providers as ``(name, module in this package, attribute)``. Loaded
|
|
76
|
+
#: relative to this package, so the copy bundled into karni-cli finds them
|
|
77
|
+
#: even when karni itself (and its entry-point metadata) is not installed.
|
|
78
|
+
BUILTIN: tuple[tuple[str, str, str], ...] = (
|
|
79
|
+
("gigai-chronicle", "history_chronicle", "ChronicleProvider"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@runtime_checkable
|
|
84
|
+
class HistoryProvider(Protocol):
|
|
85
|
+
"""The required part of the protocol (see the module docstring)."""
|
|
86
|
+
|
|
87
|
+
name: str
|
|
88
|
+
|
|
89
|
+
def detect(self, project_root: Path) -> bool: ...
|
|
90
|
+
|
|
91
|
+
def context(self, project_root: Path, path: str) -> str | None: ...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class Discovered:
|
|
96
|
+
"""Every usable provider, plus why any registered one was skipped."""
|
|
97
|
+
|
|
98
|
+
providers: tuple[HistoryProvider, ...]
|
|
99
|
+
errors: tuple[str, ...] = ()
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def names(self) -> list[str]:
|
|
103
|
+
return [provider.name for provider in self.providers]
|
|
104
|
+
|
|
105
|
+
def third_party(self) -> tuple[HistoryProvider, ...]:
|
|
106
|
+
builtin = {name for name, _module, _attr in BUILTIN}
|
|
107
|
+
return tuple(p for p in self.providers if p.name not in builtin)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# discovery
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _looks_like_provider(obj: object) -> bool:
|
|
114
|
+
return (
|
|
115
|
+
isinstance(getattr(obj, "name", None), str)
|
|
116
|
+
and bool(getattr(obj, "name", ""))
|
|
117
|
+
and callable(getattr(obj, "detect", None))
|
|
118
|
+
and callable(getattr(obj, "context", None))
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _instantiate(obj: Any) -> HistoryProvider | None:
|
|
123
|
+
"""An entry point may name a class, a factory, or a ready instance."""
|
|
124
|
+
if isinstance(obj, type) or (callable(obj) and not _looks_like_provider(obj)):
|
|
125
|
+
obj = obj()
|
|
126
|
+
return obj if _looks_like_provider(obj) else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _entry_points() -> list[Any]:
|
|
130
|
+
try:
|
|
131
|
+
from importlib.metadata import entry_points
|
|
132
|
+
|
|
133
|
+
return list(entry_points(group=ENTRY_POINT_GROUP))
|
|
134
|
+
except Exception: # noqa: BLE001 - broken metadata must never break the CLI
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _builtin(module: str, attribute: str) -> Any:
|
|
139
|
+
package = __name__.rsplit(".", 1)[0]
|
|
140
|
+
return getattr(importlib.import_module(f"{package}.{module}"), attribute)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _karnis_own(entry: Any) -> bool:
|
|
144
|
+
"""Karni's registration of its built-ins (it loses to anyone else's)."""
|
|
145
|
+
return str(entry.value).startswith("karni.agent.")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def discover() -> Discovered:
|
|
149
|
+
"""Entry-point providers, then any built-in whose name is not taken, by name.
|
|
150
|
+
|
|
151
|
+
A third-party entry point wins over Karni's own registration of the same
|
|
152
|
+
name, so registering ``gigai-chronicle`` replaces the built-in provider.
|
|
153
|
+
"""
|
|
154
|
+
found: dict[str, HistoryProvider] = {}
|
|
155
|
+
seen_values: dict[str, str] = {}
|
|
156
|
+
errors: list[str] = []
|
|
157
|
+
for entry in sorted(_entry_points(), key=_karnis_own): # stable: third parties first
|
|
158
|
+
label = f"{entry.name} ({entry.value})"
|
|
159
|
+
if entry.name in seen_values:
|
|
160
|
+
if seen_values[entry.name] != entry.value and not _karnis_own(entry):
|
|
161
|
+
errors.append(f"{label}: name already registered by {seen_values[entry.name]}")
|
|
162
|
+
continue
|
|
163
|
+
seen_values[entry.name] = entry.value
|
|
164
|
+
try:
|
|
165
|
+
provider = _instantiate(entry.load())
|
|
166
|
+
except Exception as exc: # noqa: BLE001 - a broken plugin is reported, not raised
|
|
167
|
+
errors.append(f"{label}: {type(exc).__name__}: {exc}")
|
|
168
|
+
continue
|
|
169
|
+
if provider is None:
|
|
170
|
+
errors.append(f"{label}: not a history provider (needs name, detect, context)")
|
|
171
|
+
continue
|
|
172
|
+
found.setdefault(provider.name, provider)
|
|
173
|
+
for name, module, attribute in BUILTIN:
|
|
174
|
+
if name in found:
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
provider = _instantiate(_builtin(module, attribute))
|
|
178
|
+
except Exception as exc: # noqa: BLE001
|
|
179
|
+
errors.append(f"{name} (built-in): {type(exc).__name__}: {exc}")
|
|
180
|
+
continue
|
|
181
|
+
if provider is not None:
|
|
182
|
+
found[provider.name] = provider
|
|
183
|
+
return Discovered(tuple(found[name] for name in sorted(found)), tuple(errors))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# calling providers safely
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@contextlib.contextmanager
|
|
190
|
+
def _silenced() -> Iterator[None]:
|
|
191
|
+
"""Whatever a provider prints must never reach Karni's machine-readable output."""
|
|
192
|
+
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
193
|
+
yield
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _safe(call: Callable[[], Any]) -> Any:
|
|
197
|
+
try:
|
|
198
|
+
with _silenced():
|
|
199
|
+
return call()
|
|
200
|
+
except Exception: # noqa: BLE001 - a provider failure is "no brief", never an error
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _providers(providers: Iterable[HistoryProvider] | None) -> Iterable[HistoryProvider]:
|
|
205
|
+
return discover().providers if providers is None else providers
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def detected(
|
|
209
|
+
project_root: str | Path, providers: Iterable[HistoryProvider] | None = None
|
|
210
|
+
) -> list[HistoryProvider]:
|
|
211
|
+
"""The providers that find a store for ``project_root`` (cheap: no briefs built)."""
|
|
212
|
+
root = Path(project_root)
|
|
213
|
+
|
|
214
|
+
def finds_store(provider: HistoryProvider) -> bool:
|
|
215
|
+
return bool(_safe(lambda: provider.detect(root)))
|
|
216
|
+
|
|
217
|
+
return [provider for provider in _providers(providers) if finds_store(provider)]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def store_detected(
|
|
221
|
+
project_root: str | Path, providers: Iterable[HistoryProvider] | None = None
|
|
222
|
+
) -> bool:
|
|
223
|
+
return bool(detected(project_root, providers))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def active_conditions(
|
|
227
|
+
root: str | Path | None, providers: Iterable[HistoryProvider] | None = None
|
|
228
|
+
) -> frozenset[str]:
|
|
229
|
+
"""Conditions a :class:`~karni.agent.rules.Rule` ``when`` may name, for ``root``.
|
|
230
|
+
|
|
231
|
+
``KARNI_AGENT_CONDITIONS`` (comma-separated, possibly empty) replaces
|
|
232
|
+
detection when set. Without a project there is no store, so none apply.
|
|
233
|
+
"""
|
|
234
|
+
forced = os.environ.get(CONDITIONS_ENV)
|
|
235
|
+
if forced is not None:
|
|
236
|
+
return frozenset(c.strip() for c in forced.split(",") if c.strip())
|
|
237
|
+
if root is None:
|
|
238
|
+
return frozenset()
|
|
239
|
+
return frozenset({CONDITION}) if store_detected(root, providers) else frozenset()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# composition and the cap
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def estimate_tokens(text: str) -> int:
|
|
246
|
+
"""Approximate tokens: ``ceil(len / CHARS_PER_TOKEN)`` (the rules-budget measure)."""
|
|
247
|
+
return math.ceil(len(text) / CHARS_PER_TOKEN)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cap_lines(lines: Sequence[str], max_tokens: int, last_line: str) -> list[str]:
|
|
251
|
+
"""``lines`` unchanged when they fit in ``max_tokens``; otherwise the longest
|
|
252
|
+
prefix of whole lines that fits together with ``last_line``, then ``last_line``.
|
|
253
|
+
|
|
254
|
+
The first line (a heading) is always kept. When not even the next line fits
|
|
255
|
+
whole, it is cut at a character boundary with ``…`` so the brief is never
|
|
256
|
+
reduced to its heading alone.
|
|
257
|
+
"""
|
|
258
|
+
if estimate_tokens("\n".join(lines)) <= max_tokens:
|
|
259
|
+
return list(lines)
|
|
260
|
+
kept: list[str] = list(lines[:1])
|
|
261
|
+
for line in lines[1:]:
|
|
262
|
+
if estimate_tokens("\n".join([*kept, line, last_line])) > max_tokens:
|
|
263
|
+
break
|
|
264
|
+
kept.append(line)
|
|
265
|
+
if len(kept) == 1 and len(lines) > 1:
|
|
266
|
+
room = max_tokens * CHARS_PER_TOKEN - len("\n".join([*kept, last_line])) - 2
|
|
267
|
+
if room > 0:
|
|
268
|
+
kept.append(lines[1][:room] + "…")
|
|
269
|
+
return [*kept, last_line]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _brief(provider: HistoryProvider, root: Path, path: str | None) -> list[str]:
|
|
273
|
+
if path is None:
|
|
274
|
+
project_context = getattr(provider, "project_context", None)
|
|
275
|
+
if not callable(project_context):
|
|
276
|
+
return []
|
|
277
|
+
brief = _safe(lambda: project_context(root))
|
|
278
|
+
else:
|
|
279
|
+
brief = _safe(lambda: provider.context(root, path))
|
|
280
|
+
if not isinstance(brief, str):
|
|
281
|
+
return []
|
|
282
|
+
return [line.rstrip() for line in brief.splitlines() if line.strip()]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _truncation_line(
|
|
286
|
+
provider: HistoryProvider, root: Path, path: str | None, max_tokens: int
|
|
287
|
+
) -> str:
|
|
288
|
+
full_command = getattr(provider, "full_command", None)
|
|
289
|
+
command = _safe(lambda: full_command(root, path)) if callable(full_command) else None
|
|
290
|
+
if isinstance(command, str) and command.strip():
|
|
291
|
+
return f"… truncated at ~{max_tokens} tokens; full brief: {command.strip()}"
|
|
292
|
+
return f"… truncated at ~{max_tokens} tokens; {provider.name} holds the full brief"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def compose(
|
|
296
|
+
project_root: str | Path,
|
|
297
|
+
path: str | None = None,
|
|
298
|
+
*,
|
|
299
|
+
providers: Iterable[HistoryProvider] | None = None,
|
|
300
|
+
max_tokens: int = HISTORY_TOKEN_CAP,
|
|
301
|
+
) -> str | None:
|
|
302
|
+
"""The block ``karni context --with-history [path]`` appends, or ``None``.
|
|
303
|
+
|
|
304
|
+
One ``HISTORY: <path or "project"> (<provider>)`` heading per provider that
|
|
305
|
+
detects a store and has something to say, followed by its brief (blank
|
|
306
|
+
lines dropped). All blocks together stay within ``max_tokens`` (estimated
|
|
307
|
+
with :func:`estimate_tokens`), truncated at line boundaries with a final
|
|
308
|
+
line saying how to get the full brief. ``None`` when no store is detected or
|
|
309
|
+
no provider had anything — the caller then prints nothing at all.
|
|
310
|
+
"""
|
|
311
|
+
root = Path(project_root)
|
|
312
|
+
target = path if path is not None else "project"
|
|
313
|
+
out: list[str] = []
|
|
314
|
+
for provider in detected(root, providers):
|
|
315
|
+
brief = _brief(provider, root, path)
|
|
316
|
+
if not brief:
|
|
317
|
+
continue
|
|
318
|
+
lines = [f"{HEADING} {target} ({provider.name})", *brief]
|
|
319
|
+
last = _truncation_line(provider, root, path, max_tokens)
|
|
320
|
+
# ceil((a + 1 + b) / 4) <= ceil((a + 1) / 4) + ceil(b / 4): the sum stays capped.
|
|
321
|
+
remaining = max_tokens - (estimate_tokens("\n".join(out) + "\n") if out else 0)
|
|
322
|
+
if out and estimate_tokens("\n".join([lines[0], last])) >= remaining:
|
|
323
|
+
break # no room left for another provider's brief
|
|
324
|
+
capped = cap_lines(lines, remaining, last)
|
|
325
|
+
out += capped
|
|
326
|
+
if capped != lines:
|
|
327
|
+
break # truncated: the budget is spent
|
|
328
|
+
return "\n".join(out) if out else None
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Built-in history provider for Gigai Chronicle (MIT, local-only).
|
|
2
|
+
|
|
3
|
+
Karni never imports Chronicle and never depends on it. This module only looks
|
|
4
|
+
for a ``.chronicle/`` directory and, when one exists and ``chronicle`` is on
|
|
5
|
+
PATH, shells out to it. Every failure means "no brief": no store, no binary,
|
|
6
|
+
exit 3 (not a chronicle project) or any other non-zero exit, a timeout, output
|
|
7
|
+
that is not the documented JSON, or an honestly empty pack. The provider then
|
|
8
|
+
returns ``None``; it never raises and never prints, and Chronicle's stderr is
|
|
9
|
+
discarded.
|
|
10
|
+
|
|
11
|
+
Chronicle's CLI contract used here (``--json`` envelope ``apiVersion`` 1)::
|
|
12
|
+
|
|
13
|
+
chronicle context <repo-relative file> --json --limit N
|
|
14
|
+
{"apiVersion": 1, "command": "context", "file": str, "empty": bool,
|
|
15
|
+
"prompts": [{"eventId", "ts", "session", "text", "churn"}],
|
|
16
|
+
"knowledge": [{"kind", "text", "eventId", "session", "ts", "role", "confidence"}],
|
|
17
|
+
"markdown": str}
|
|
18
|
+
chronicle knowledge --type decision --json
|
|
19
|
+
{"apiVersion": 1, "command": "knowledge", "count": int, "items": [...]}
|
|
20
|
+
|
|
21
|
+
Exit codes: 0 ok, 1 failure, 2 usage, 3 not a chronicle project.
|
|
22
|
+
|
|
23
|
+
Escape hatch: register your own instance under the ``karni.history_providers``
|
|
24
|
+
entry-point name ``gigai-chronicle`` (for example
|
|
25
|
+
``ChronicleProvider(timeout=15.0)``); it replaces this default.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import shlex
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
#: The store directory Chronicle walks up to find (its ``findChronicleDir``).
|
|
40
|
+
STORE_DIR = ".chronicle"
|
|
41
|
+
#: Chronicle's "not a chronicle project" exit status.
|
|
42
|
+
EXIT_NOT_A_PROJECT = 3
|
|
43
|
+
API_VERSION = 1
|
|
44
|
+
|
|
45
|
+
#: Chronicle's prompt-list section; moved last so a capped brief keeps the
|
|
46
|
+
#: decisions / failed approaches, which matter most before an edit.
|
|
47
|
+
_PROMPTS_SECTION = "## what shaped"
|
|
48
|
+
_SPACES = re.compile(r"[ \t]{2,}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ChronicleProvider:
|
|
52
|
+
name = "gigai-chronicle"
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self, executable: str = "chronicle", *, timeout: float = 5.0, limit: int = 5
|
|
56
|
+
) -> None:
|
|
57
|
+
self.executable = executable
|
|
58
|
+
#: Seconds before Chronicle is abandoned (the brief is then empty).
|
|
59
|
+
self.timeout = timeout
|
|
60
|
+
#: Max shaping prompts requested (Chronicle's ``--limit``; its default is 8).
|
|
61
|
+
self.limit = limit
|
|
62
|
+
|
|
63
|
+
# protocol
|
|
64
|
+
|
|
65
|
+
def store(self, project_root: Path) -> Path | None:
|
|
66
|
+
"""The nearest ``.chronicle/`` at or above ``project_root`` (Chronicle's walk-up)."""
|
|
67
|
+
current = Path(project_root).resolve()
|
|
68
|
+
for candidate in (current, *current.parents):
|
|
69
|
+
if (candidate / STORE_DIR).is_dir():
|
|
70
|
+
return candidate / STORE_DIR
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def detect(self, project_root: Path) -> bool:
|
|
74
|
+
return self.store(project_root) is not None
|
|
75
|
+
|
|
76
|
+
def context(self, project_root: Path, path: str) -> str | None:
|
|
77
|
+
store = self.store(project_root)
|
|
78
|
+
if store is None:
|
|
79
|
+
return None
|
|
80
|
+
repo = store.parent
|
|
81
|
+
try:
|
|
82
|
+
absolute = Path(os.path.normpath(Path(project_root).resolve() / path))
|
|
83
|
+
target = absolute.relative_to(repo)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
if target == Path("."):
|
|
87
|
+
return None
|
|
88
|
+
payload = self._run(
|
|
89
|
+
repo, ["context", target.as_posix(), "--json", "--limit", str(self.limit)], "context"
|
|
90
|
+
)
|
|
91
|
+
if payload is None or payload.get("empty") is True:
|
|
92
|
+
return None
|
|
93
|
+
markdown = payload.get("markdown")
|
|
94
|
+
brief = (
|
|
95
|
+
brief_from_markdown(markdown)
|
|
96
|
+
if isinstance(markdown, str)
|
|
97
|
+
else brief_from_pack(payload.get("prompts"), payload.get("knowledge"))
|
|
98
|
+
)
|
|
99
|
+
return brief or None
|
|
100
|
+
|
|
101
|
+
def project_context(self, project_root: Path) -> str | None:
|
|
102
|
+
"""The project's recorded decisions, newest first (no file named)."""
|
|
103
|
+
store = self.store(project_root)
|
|
104
|
+
if store is None:
|
|
105
|
+
return None
|
|
106
|
+
args = ["knowledge", "--type", "decision", "--json"]
|
|
107
|
+
payload = self._run(store.parent, args, "knowledge")
|
|
108
|
+
if payload is None:
|
|
109
|
+
return None
|
|
110
|
+
return brief_from_decisions(payload.get("items")) or None
|
|
111
|
+
|
|
112
|
+
def full_command(self, project_root: Path, path: str | None) -> str:
|
|
113
|
+
if path is None:
|
|
114
|
+
return "chronicle knowledge --type decision"
|
|
115
|
+
return f"chronicle context {shlex.quote(path)}"
|
|
116
|
+
|
|
117
|
+
# the one place Chronicle runs
|
|
118
|
+
|
|
119
|
+
def _run(self, repo: Path, args: list[str], command: str) -> dict[str, Any] | None:
|
|
120
|
+
executable = shutil.which(self.executable)
|
|
121
|
+
if executable is None:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
proc = subprocess.run(
|
|
125
|
+
[executable, *args],
|
|
126
|
+
cwd=repo,
|
|
127
|
+
stdin=subprocess.DEVNULL,
|
|
128
|
+
stdout=subprocess.PIPE,
|
|
129
|
+
stderr=subprocess.DEVNULL,
|
|
130
|
+
text=True,
|
|
131
|
+
encoding="utf-8",
|
|
132
|
+
errors="replace",
|
|
133
|
+
timeout=self.timeout,
|
|
134
|
+
check=False,
|
|
135
|
+
env={**os.environ, "NO_COLOR": "1"},
|
|
136
|
+
)
|
|
137
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
138
|
+
return None
|
|
139
|
+
if proc.returncode != 0: # 3 = not a chronicle project; 1/2 = failure/usage
|
|
140
|
+
return None
|
|
141
|
+
return parse_envelope(proc.stdout, command)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# pure helpers (tested without a binary)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_envelope(stdout: str, command: str) -> dict[str, Any] | None:
|
|
148
|
+
"""The ``--json`` payload for ``command``, or ``None`` if it is not one.
|
|
149
|
+
|
|
150
|
+
Chronicle prints exactly one JSON line; the last non-empty line is tried
|
|
151
|
+
too, in case anything precedes it.
|
|
152
|
+
"""
|
|
153
|
+
lines = [line for line in stdout.splitlines() if line.strip()]
|
|
154
|
+
for candidate in (stdout, lines[-1] if lines else ""):
|
|
155
|
+
try:
|
|
156
|
+
payload = json.loads(candidate)
|
|
157
|
+
except ValueError:
|
|
158
|
+
continue
|
|
159
|
+
if (
|
|
160
|
+
isinstance(payload, dict)
|
|
161
|
+
and payload.get("apiVersion") == API_VERSION
|
|
162
|
+
and payload.get("command") == command
|
|
163
|
+
):
|
|
164
|
+
return payload
|
|
165
|
+
return None
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def brief_from_markdown(markdown: str) -> str:
|
|
170
|
+
"""Chronicle's paste-ready brief, compacted for an agent.
|
|
171
|
+
|
|
172
|
+
Drops the title, the italic boilerplate paragraphs, horizontal rules and
|
|
173
|
+
blank lines; collapses runs of spaces; and moves the "What shaped this
|
|
174
|
+
file" prompt list after the decision/memory sections. Unknown structure is
|
|
175
|
+
kept as-is, so a future Chronicle format degrades to "longer", not "wrong".
|
|
176
|
+
"""
|
|
177
|
+
kept: list[str] = []
|
|
178
|
+
in_italic = False
|
|
179
|
+
for raw in markdown.splitlines():
|
|
180
|
+
text = raw.strip()
|
|
181
|
+
if in_italic:
|
|
182
|
+
in_italic = bool(text) and not text.endswith("_")
|
|
183
|
+
continue
|
|
184
|
+
if not text or text == "---" or text.startswith("# "):
|
|
185
|
+
continue
|
|
186
|
+
if text.startswith("_") and not text.startswith("__"):
|
|
187
|
+
in_italic = not (len(text) > 1 and text.endswith("_"))
|
|
188
|
+
continue
|
|
189
|
+
kept.append(_SPACES.sub(" ", raw.rstrip()))
|
|
190
|
+
preamble: list[str] = []
|
|
191
|
+
sections: list[list[str]] = []
|
|
192
|
+
for line in kept:
|
|
193
|
+
if line.startswith("## "):
|
|
194
|
+
sections.append([line])
|
|
195
|
+
elif sections:
|
|
196
|
+
sections[-1].append(line)
|
|
197
|
+
else:
|
|
198
|
+
preamble.append(line)
|
|
199
|
+
prompts = [s for s in sections if s[0].lower().startswith(_PROMPTS_SECTION)]
|
|
200
|
+
others = [s for s in sections if not s[0].lower().startswith(_PROMPTS_SECTION)]
|
|
201
|
+
ordered = preamble + [line for section in (*others, *prompts) for line in section]
|
|
202
|
+
return "\n".join(ordered)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _when(ts: object) -> str:
|
|
206
|
+
return str(ts)[:10] if isinstance(ts, str) else ""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def brief_from_pack(prompts: object, knowledge: object) -> str:
|
|
210
|
+
"""A brief from the structured pack, for a payload without ``markdown``."""
|
|
211
|
+
lines: list[str] = []
|
|
212
|
+
items = [k for k in knowledge if isinstance(k, dict)] if isinstance(knowledge, list) else []
|
|
213
|
+
if items:
|
|
214
|
+
lines.append("## Decisions & TODOs")
|
|
215
|
+
for item in items:
|
|
216
|
+
if isinstance(item.get("text"), str) and item["text"].strip():
|
|
217
|
+
lines.append(f"- [{item.get('kind', 'note')}] {item['text'].strip()}")
|
|
218
|
+
shaped = [p for p in prompts if isinstance(p, dict)] if isinstance(prompts, list) else []
|
|
219
|
+
if shaped:
|
|
220
|
+
lines.append("## What shaped this file")
|
|
221
|
+
for i, prompt in enumerate(shaped, start=1):
|
|
222
|
+
text = prompt.get("text")
|
|
223
|
+
quoted = '"' + " ".join(text.split()) + '"' if isinstance(text, str) else "(no text)"
|
|
224
|
+
lines.append(f"{i}. {_when(prompt.get('ts'))} {prompt.get('churn', '')} — {quoted}")
|
|
225
|
+
return "\n".join(_SPACES.sub(" ", line) for line in lines)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def brief_from_decisions(items: object) -> str:
|
|
229
|
+
"""``chronicle knowledge`` decisions as a newest-first list."""
|
|
230
|
+
decisions = [
|
|
231
|
+
item
|
|
232
|
+
for item in (items if isinstance(items, list) else [])
|
|
233
|
+
if isinstance(item, dict)
|
|
234
|
+
and item.get("kind", "decision") == "decision"
|
|
235
|
+
and isinstance(item.get("text"), str)
|
|
236
|
+
and item["text"].strip()
|
|
237
|
+
]
|
|
238
|
+
if not decisions:
|
|
239
|
+
return ""
|
|
240
|
+
decisions.sort(key=lambda item: str(item.get("ts") or ""), reverse=True)
|
|
241
|
+
lines = ["Recorded decisions (newest first):"]
|
|
242
|
+
for item in decisions:
|
|
243
|
+
when = _when(item.get("ts"))
|
|
244
|
+
text = " ".join(item["text"].split())
|
|
245
|
+
lines.append(f"- {when} {text}" if when else f"- {text}")
|
|
246
|
+
return "\n".join(lines)
|