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.
Files changed (145) hide show
  1. karni_cli/__init__.py +3 -0
  2. karni_cli/_agent/__init__.py +82 -0
  3. karni_cli/_agent/__main__.py +44 -0
  4. karni_cli/_agent/check.py +658 -0
  5. karni_cli/_agent/history.py +328 -0
  6. karni_cli/_agent/history_chronicle.py +246 -0
  7. karni_cli/_agent/overrides.py +194 -0
  8. karni_cli/_agent/rules.py +673 -0
  9. karni_cli/_changelog/0.2.0.toml +304 -0
  10. karni_cli/_changelog/0.3.0.toml +25 -0
  11. karni_cli/_changelog/__init__.py +248 -0
  12. karni_cli/_errorcodes/__init__.py +1171 -0
  13. karni_cli/_errorcodes/__main__.py +34 -0
  14. karni_cli/_errorcodes/links.py +217 -0
  15. karni_cli/_templates/project/cookiecutter.json +8 -0
  16. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
  17. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
  18. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
  19. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
  20. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
  21. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
  22. karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
  23. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
  24. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
  25. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
  26. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
  27. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
  28. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
  29. karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
  30. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
  31. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
  32. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
  33. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
  34. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
  35. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
  36. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
  37. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
  38. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
  39. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
  40. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
  41. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
  42. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
  43. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
  44. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
  45. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
  46. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
  47. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
  48. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
  49. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
  50. karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
  51. karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
  52. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
  53. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
  54. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
  55. karni_cli/adapters.py +373 -0
  56. karni_cli/agent_adapters.toml +160 -0
  57. karni_cli/agent_data.py +260 -0
  58. karni_cli/brand.py +55 -0
  59. karni_cli/clidoc.py +254 -0
  60. karni_cli/commands/__init__.py +0 -0
  61. karni_cli/commands/add.py +144 -0
  62. karni_cli/commands/beat.py +36 -0
  63. karni_cli/commands/check.py +101 -0
  64. karni_cli/commands/config.py +81 -0
  65. karni_cli/commands/context.py +171 -0
  66. karni_cli/commands/createsuperuser.py +28 -0
  67. karni_cli/commands/doctor.py +136 -0
  68. karni_cli/commands/explain.py +60 -0
  69. karni_cli/commands/make.py +55 -0
  70. karni_cli/commands/manage_passthrough.py +28 -0
  71. karni_cli/commands/mcp.py +20 -0
  72. karni_cli/commands/migrate.py +23 -0
  73. karni_cli/commands/new.py +106 -0
  74. karni_cli/commands/routes.py +20 -0
  75. karni_cli/commands/rules.py +139 -0
  76. karni_cli/commands/run_tests.py +38 -0
  77. karni_cli/commands/schedule.py +40 -0
  78. karni_cli/commands/seed.py +34 -0
  79. karni_cli/commands/serve.py +50 -0
  80. karni_cli/commands/shell.py +28 -0
  81. karni_cli/commands/sync_roles.py +32 -0
  82. karni_cli/commands/types.py +72 -0
  83. karni_cli/commands/upgrade.py +231 -0
  84. karni_cli/commands/worker.py +63 -0
  85. karni_cli/config.py +142 -0
  86. karni_cli/environment.py +29 -0
  87. karni_cli/errorcodes.py +377 -0
  88. karni_cli/errordoc.py +227 -0
  89. karni_cli/generators/__init__.py +67 -0
  90. karni_cli/generators/apitest.py +206 -0
  91. karni_cli/generators/app.py +103 -0
  92. karni_cli/generators/command.py +98 -0
  93. karni_cli/generators/component.py +115 -0
  94. karni_cli/generators/core.py +319 -0
  95. karni_cli/generators/event.py +57 -0
  96. karni_cli/generators/factory.py +88 -0
  97. karni_cli/generators/fields.py +343 -0
  98. karni_cli/generators/introspect.py +263 -0
  99. karni_cli/generators/listener.py +168 -0
  100. karni_cli/generators/middleware.py +95 -0
  101. karni_cli/generators/migration.py +82 -0
  102. karni_cli/generators/model.py +235 -0
  103. karni_cli/generators/module.py +255 -0
  104. karni_cli/generators/naming.py +100 -0
  105. karni_cli/generators/page.py +373 -0
  106. karni_cli/generators/pysource.py +304 -0
  107. karni_cli/generators/schedule.py +174 -0
  108. karni_cli/generators/seeder.py +86 -0
  109. karni_cli/generators/serializer.py +165 -0
  110. karni_cli/generators/service.py +68 -0
  111. karni_cli/generators/task.py +84 -0
  112. karni_cli/generators/viewset.py +179 -0
  113. karni_cli/history.py +59 -0
  114. karni_cli/local_state.py +73 -0
  115. karni_cli/main.py +104 -0
  116. karni_cli/mcp_server.py +103 -0
  117. karni_cli/project.py +248 -0
  118. karni_cli/rules_render.py +86 -0
  119. karni_cli/scaffold.py +197 -0
  120. karni_cli/settings_edit.py +128 -0
  121. karni_cli/tips.py +53 -0
  122. karni_cli/typegen.py +101 -0
  123. karni_cli/upgrades/__init__.py +28 -0
  124. karni_cli/upgrades/changelog.py +123 -0
  125. karni_cli/upgrades/codemod.py +125 -0
  126. karni_cli/upgrades/engine.py +224 -0
  127. karni_cli/upgrades/legacy_agent_files.py +138 -0
  128. karni_cli/upgrades/python.py +670 -0
  129. karni_cli/upgrades/transforms/rename-export.cjs +96 -0
  130. karni_cli/upgrades/typescript.py +160 -0
  131. karni_cli/upgrades/v0_2/__init__.py +1 -0
  132. karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
  133. karni_cli/upgrades/v1_0/__init__.py +5 -0
  134. karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
  135. karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
  136. karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
  137. karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
  138. karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
  139. karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
  140. karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
  141. karni_cli-0.3.1.dist-info/METADATA +191 -0
  142. karni_cli-0.3.1.dist-info/RECORD +145 -0
  143. karni_cli-0.3.1.dist-info/WHEEL +4 -0
  144. karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
  145. 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)