Repository navigation
Expand file tree
/
Copy pathkimicli.py
More file actions
5237 lines (4689 loc) · 242 KB
/
Copy pathkimicli.py
File metadata and controls
5237 lines (4689 loc) · 242 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
r"""
kimicli.py - Kimi K3 workbench (v3)
Design rules, in priority order:
1. YOUR CODEBASE IS NEVER TOUCHED BY THE MODEL.
Kimi has no tools and no filesystem access. Every change it proposes is
staged under .kimi_out/<session>/proposed/ ONLY. Nothing reaches the repo
until you run --apply-fixes yourself.
2. SMALL, EXACT EDITS - NOT WHOLE FILES.
Kimi sends '### EDIT:' search/replace blocks for existing files (the same
old/new string-replacement semantics as Kimi's own StrReplaceFile tool),
and '### FILE:' only for new files. An edit applies only where its SEARCH
text occurs EXACTLY ONCE in the file as it is on disk NOW; anything else
rejects the whole batch. Line endings, BOM and every untouched byte are
preserved exactly.
3. ALL OR NOTHING, VERIFIED, UNDOABLE.
Pass 1 resolves every block against the live tree and runs the checks
(syntax, undefined names, cross-file imports, paths). Pass 2 backs up and
VERIFIES every original, writes the manifest BEFORE touching the repo,
writes each file atomically and verifies it, and rolls everything back
automatically if any write fails. --undo restores byte-for-byte, is
all-or-nothing, can be re-run safely, and never destroys anything.
4. NOTHING IS DROPPED SILENTLY.
Every block-shaped thing in a reply is either parsed or reported, with its
reply line number. A cut-off reply is detected. A failure produces a
ready-made repair prompt for a cheap cached --resume.
5. NOTHING IS LOST TO THE TERMINAL; NO SURPRISE BILLS.
Every token is appended to transcript.md as it arrives; each turn's reply
is also saved on its own. Streaming, no SDK auto-retries, a preflight that
prints the cost before sending, a spend cap, and a running ledger.
Verified against the Kimi platform docs on 2026-08-05:
- context caching is automatic; prefix must be byte-identical -> codebase first
- usage.cached_tokens is TOP-LEVEL in usage (not prompt_tokens_details)
- prompt_cache_key improves hit rate across separate invocations
- 504 after 900s on long non-streaming requests -> stream=True always
- reasoning_effort is top-level: low | high | max (default max)
- temperature/top_p/n/penalties are FIXED on K3 -> never sent
- max_completion_tokens (max_tokens is deprecated)
- K3 uses Preserved Thinking -> keep reasoning_content in history
Quick start:
python kimicli.py --selftest # prove apply/undo on THIS machine
python kimicli.py --prompt task.md --request-code
python kimicli.py --apply-fixes last --dry-run
python kimicli.py --apply-fixes last
python kimicli.py --undo last
python kimicli.py --history
"""
from __future__ import annotations
import argparse
import ast
import bisect
import contextlib
import difflib
import fnmatch
import hashlib
import io
import json
import logging
import os
import re
import shutil
import subprocess
import symtable
import sys
import tempfile
import time
import unicodedata
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
# --------------------------------------------------------------------------
# Console: make Windows cp1252 consoles stop killing the run on a stray glyph.
# (Same root fix as AA's apply_unicode_safe_logging.)
for _stream_name in ("stdout", "stderr"):
_stream = getattr(sys, _stream_name, None)
try:
_stream.reconfigure(encoding="utf-8", errors="backslashreplace") # type: ignore[union-attr]
except Exception:
pass
try:
from dotenv import load_dotenv
except ImportError: # dotenv is optional
def load_dotenv(*_a: Any, **_k: Any) -> bool: # type: ignore[misc]
return False
# ==========================================================================
# Configuration
# ==========================================================================
PROJECT_ROOT = Path(os.getenv("KIMI_PROJECT_ROOT", Path(__file__).parent)).resolve()
load_dotenv()
_alt_env = PROJECT_ROOT / "packages" / "auto_apply" / ".env"
if _alt_env.exists():
load_dotenv(dotenv_path=_alt_env, override=True)
API_KEY = os.getenv("MOONSHOT_API_KEY") or os.getenv("KIMI_API_KEY") or ""
BASE_URL = os.getenv("KIMI_BASE_URL", "https://api.moonshot.ai/v1")
MODEL = os.getenv("KIMI_MODEL", "kimi-k3")
DEFAULT_CODEBASE = Path(os.getenv("KIMI_CODEBASE", r"D:\Downloads\AA-kimi.txt"))
# AA's engineering philosophy rides along with EVERY call (Nick's standing rule,
# 2026-10-01): every design and every change is judged against it, so the model
# must always have it - not only when someone remembers to --attach it.
# The architecture bible and the master TODO ride along the same way. Each of
# the three has a default path, an environment override, a --<name> PATH flag
# and a --no-<name> way out; an empty environment variable means the default.
DEFAULT_PHILOSOPHY = Path(os.getenv("KIMI_PHILOSOPHY") or
PROJECT_ROOT / "packages" / "auto_apply" / "docs" / "ENGINEERING_PHILOSOPHY.md")
DEFAULT_BIBLE = Path(os.getenv("KIMI_BIBLE") or
PROJECT_ROOT / "packages" / "auto_apply" / "docs" / "AA_ARCHITECTURE_BIBLE.md")
DEFAULT_TODO = Path(os.getenv("KIMI_TODO") or PROJECT_ROOT / "AA_MASTER_TODO.md")
OUT_DIR = PROJECT_ROOT / ".kimi_out" # sessions, transcripts, staged files
BACKUP_DIR = PROJECT_ROOT / ".kimi_backups" # backups + manifests
LEDGER = OUT_DIR / "ledger.jsonl"
# Kimi K3 pricing, USD per 1M tokens (docs/pricing/chat-k3)
PRICE_IN_CACHED = 0.30
PRICE_IN_FRESH = 3.00
PRICE_OUT = 15.00
CONTEXT_WINDOW = 1_048_576
# Your organisation's tokens-per-minute ceiling, from
# platform.kimi.ai/console/limits. Override with KIMI_TPM in .env when the
# tier changes. The preflight warns only when a request exceeds THIS, not a
# hardcoded guess at someone else's tier.
ACCOUNT_TPM = int(os.getenv("KIMI_TPM", "3000000"))
DEFAULT_MAX_COMPLETION = 131_072 # K3's own default
REQUEST_TIMEOUT_S = float(os.getenv("KIMI_TIMEOUT", "2400"))
# Files the model is never allowed to touch, even with --apply-fixes.
PROTECTED = (
".git", ".venv", "venv", "node_modules", ".kimi_out", ".kimi_backups",
".ds_backups", "__pycache__", ".env", "kimicli.py", "callapi.py",
# Retired work is never overwritten by the model: the whole point of
# docs/old_retired_files/ is that what lands there cannot be lost.
"old_retired_files", "retire.py",
# Personal data. dev_data/ holds the profile database, screenshots and
# logs; none of it is source and none of it is the model's to rewrite.
"dev_data",
)
MANIFEST_VERSION = 3 # .kimi_backups/manifest_<id>.json format
logger = logging.getLogger("kimi")
def setup_logging(logfile: Optional[Path] = None, verbose: bool = False) -> None:
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
logger.handlers.clear()
con = logging.StreamHandler(sys.stdout)
con.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%H:%M:%S"))
logger.addHandler(con)
if logfile:
logfile.parent.mkdir(parents=True, exist_ok=True)
fh = logging.FileHandler(logfile, encoding="utf-8")
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(fh)
# ==========================================================================
# Small utilities
# ==========================================================================
def sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()
#: Chars per token, measured against the tokenizer on this repository:
#: item 2 est 55,533 (@3.6) exact 40,521 -> 4.93
#: item 4a est 69,665 (@3.6) exact 51,621 -> 4.86
#: item 5 #1 est 44,684 (@4.9) exact 48,771 -> 4.49
#: full dump est 850,840 (@4.9) exact 914,512 -> 4.56
#: item 5 #2 est 46,782 (@4.9) exact 51,412 -> 4.46
#: The original 3.6 erred high by about a third. 4.9, chosen from the first
#: two samples, then erred LOW by 7-9% on the next three. Five samples in,
#: the ratio is not drifting — it is content-dependent, clustering near 4.9
#: for one attachment mix and near 4.5 for another, so no single constant is
#: better than roughly +/-5% on both.
#:
#: The constant therefore does not chase the mean. est = chars / K, so a
#: LARGER K under-counts tokens, and under-counting is the dangerous reading:
#: EST_WINDOW_SAFETY below exists because the window guard fails only after
#: the dump has been built and sent. 4.45 sits just below the lowest ratio
#: measured, so the estimate is >= the exact count on every sample so far and
#: the safety margin compounds on top of that rather than rescuing it.
#: The preflight prints estimate-vs-exact on every call; add samples here.
EST_CHARS_PER_TOKEN = 4.45
#: The estimate also backs the context-window guard when --no-count is set,
#: and a LOW reading is the dangerous one there: the request fails after the
#: dump has been built and sent. A calibrated estimator is as likely to sit
#: under the truth as over it, so the guard applies this margin explicitly
#: rather than depending on the estimator being quietly pessimistic.
EST_WINDOW_SAFETY = 1.35
def est_tokens(text: str) -> int:
"""Local estimate, calibrated (see EST_CHARS_PER_TOKEN).
Used for the cost table and the spend guard. The context-window check
scales this by EST_WINDOW_SAFETY when no exact count is available.
"""
return int(len(text) / EST_CHARS_PER_TOKEN) if text else 0
def money(x: float) -> str:
return f"${x:,.4f}" if x < 1 else f"${x:,.2f}"
def cost_of(prompt: int, cached: int, completion: int) -> float:
fresh = max(prompt - cached, 0)
return (cached * PRICE_IN_CACHED + fresh * PRICE_IN_FRESH + completion * PRICE_OUT) / 1e6
def _attach_label(p: Path) -> str:
"""The name an attachment is announced under: its repo-relative path.
The attachment name is the ONLY place the model learns where a file lives.
Announcing the bare basename means a model asked to emit
``### EDIT: <full path>`` blocks has to guess each directory. On item 4a it
guessed ``tests/pins/`` for a file in ``tests/architecture/`` and the whole
batch was correctly rejected — six good files thrown away over a path the
model was never told. Falls back to the basename for a file outside the
project root, where no relative path exists.
"""
try:
return p.resolve().relative_to(PROJECT_ROOT).as_posix()
except (ValueError, OSError):
return p.name
def read_text(p: Path) -> str:
return p.read_text(encoding="utf-8", errors="replace")
def atomic_write(path: Path, content: str, newline: str = "\n") -> None:
"""Write via a temp file in the same directory, fsync, then replace. A crash
mid-write can never leave a half-written file."""
path.parent.mkdir(parents=True, exist_ok=True)
data = content.replace("\n", newline) if newline != "\n" else content
write_bytes_atomic(path, data.encode("utf-8"))
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def confirm(question: str, default_no: bool = True) -> bool:
suffix = " [y/N] " if default_no else " [Y/n] "
try:
ans = input(question + suffix).strip().lower()
except (EOFError, KeyboardInterrupt):
print()
return False
if not ans:
return not default_no
return ans in ("y", "yes")
# ==========================================================================
# Repo dump: load, optionally filter out directories you don't want to pay for
# ==========================================================================
# repo2txt-style section headers. Tolerant: if none match we leave the dump
# alone rather than mangling it silently.
SECTION_PATTERNS = [
re.compile(r"(?m)^-{3,}\s*\nFile:\s*(?P<path>.+?)\s*\n-{3,}\s*$"),
re.compile(r"(?m)^={3,}\s*\nFILE:\s*(?P<path>.+?)\s*\n={3,}\s*$"),
re.compile(r"(?m)^#{1,3}\s*File:\s*(?P<path>.+?)\s*$"),
re.compile(r"(?m)^/{2,}\s*File:\s*(?P<path>.+?)\s*$"),
]
def split_dump(dump: str) -> Tuple[List[Tuple[str, str]], str]:
"""Return ([(path, section_text_including_header)], pattern_name).
Empty list means: format not recognised, do not filter."""
best: List[Any] = []
best_pat = ""
for pat in SECTION_PATTERNS:
hits = list(pat.finditer(dump))
if len(hits) > len(best):
best, best_pat = hits, pat.pattern[:24]
if len(best) < 2: # one match is more likely a coincidence than a format
return [], ""
out: List[Tuple[str, str]] = []
for i, m in enumerate(best):
end = best[i + 1].start() if i + 1 < len(best) else len(dump)
out.append((m.group("path").strip(), dump[m.start():end]))
return out, best_pat
def filter_dump(dump: str, excludes: Sequence[str]) -> Tuple[str, str]:
"""Drop sections whose path matches any exclude prefix/substring.
Returns (new_dump, human_report)."""
if not excludes:
return dump, ""
sections, _ = split_dump(dump)
if not sections:
return dump, (" ! codebase filter skipped: could not recognise the dump's file\n"
" headers, so nothing was removed (the dump is sent unchanged).")
kept, dropped, dropped_chars = [], 0, 0
for path, body in sections:
norm = path.replace("\\", "/").lstrip("./")
if any(ex.replace("\\", "/").strip("/") in norm for ex in excludes):
dropped += 1
dropped_chars += len(body)
continue
kept.append(body)
preamble = dump[:dump.find(sections[0][1])] if sections else ""
new_dump = preamble + "".join(kept)
report = (f" - filtered out {dropped} of {len(sections)} files "
f"(~{est_tokens('x' * dropped_chars):,} tokens, "
f"~{money(est_tokens('x' * dropped_chars) / 1e6 * PRICE_IN_FRESH)} per uncached call)\n"
f" keep the same --exclude flags every run: changing them changes the "
f"prefix, which costs you the cache once.")
return new_dump, report
# ==========================================================================
# Session store: transcript, message history, staged files, ledger
# ==========================================================================
@dataclass
class Usage:
prompt: int = 0
cached: int = 0
completion: int = 0
calls: int = 0
def add(self, u: Dict[str, Any]) -> None:
self.prompt += int(u.get("prompt_tokens") or 0)
self.completion += int(u.get("completion_tokens") or 0)
# Kimi returns cached_tokens at the top level of usage. Older/other
# gateways nest it; accept both so the number is never silently zero.
cached = u.get("cached_tokens")
if cached is None:
cached = (u.get("prompt_tokens_details") or {}).get("cached_tokens")
self.cached += int(cached or 0)
self.calls += 1
@property
def cost(self) -> float:
return cost_of(self.prompt, self.cached, self.completion)
class Session:
"""One conversation. Everything about it lives in .kimi_out/<id>/."""
def __init__(self, session_id: Optional[str] = None):
self.id = session_id or f"{time.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
self.dir = OUT_DIR / self.id
self.dir.mkdir(parents=True, exist_ok=True)
(self.dir / "proposed").mkdir(exist_ok=True)
self.transcript = self.dir / "transcript.md"
self.thinking = self.dir / "thinking.md"
self.messages_path = self.dir / "messages.json"
self.usage = Usage()
self.turn = 0
self.messages: List[Dict[str, Any]] = []
# The cache key this session was opened with. Persisted so that
# --resume sends the SAME key as turn 1: a different key is a
# different cache bucket, and the prefix you already paid for misses.
self.cache_key: Optional[str] = None
self.printed_stage_summary = False
self.load_error: Optional[str] = None
self._load()
# -- persistence -------------------------------------------------
def _load(self) -> None:
if self.messages_path.exists():
try:
blob = json.loads(read_text(self.messages_path))
self.messages = blob["messages"]
self.turn = blob.get("turn", 0)
self.cache_key = blob.get("cache_key")
u = blob.get("usage", {})
self.usage = Usage(**{k: u.get(k, 0) for k in ("prompt", "cached", "completion", "calls")})
except Exception as exc:
# Recorded, not just logged: resuming a session that failed to
# load would send the new prompt with NO codebase and NO history.
self.load_error = f"{type(exc).__name__}: {exc}"
logger.warning("could not reload session state: %s", exc)
@staticmethod
def has_turns(d: Path) -> bool:
"""True when the directory holds a session that completed at least one
call. --estimate-only, a declined preflight or a call that failed
before streaming used to leave an empty directory that 'last' then
picked over the real session."""
p = d / "messages.json"
if not p.is_file():
return False
try:
return int(json.loads(read_text(p)).get("turn", 0)) >= 1
except (ValueError, TypeError, AttributeError, OSError):
return False
def save(self) -> None:
blob = {
"session": self.id,
"turn": self.turn,
"cache_key": self.cache_key,
"usage": self.usage.__dict__,
"messages": self.messages,
}
atomic_write(self.messages_path, json.dumps(blob, indent=1, ensure_ascii=False))
# -- transcript --------------------------------------------------
def banner(self, kind: str, extra: str = "", also_thinking: bool = False) -> None:
"""Turn separator in transcript.md. also_thinking=True writes the same
separator to thinking.md, so each call's reasoning is findable by turn
instead of running together with the call before it."""
line = f"\n\n{'=' * 78}\n== TURN {self.turn} - {kind}{(' - ' + extra) if extra else ''}\n{'=' * 78}\n\n"
self._append(self.transcript, line)
if also_thinking:
self._append(self.thinking, line)
def write(self, text: str, to_thinking: bool = False) -> None:
self._append(self.thinking if to_thinking else self.transcript, text)
@staticmethod
def _append(path: Path, text: str) -> None:
with open(path, "a", encoding="utf-8", newline="\n") as fh:
fh.write(text)
fh.flush()
def save_reply(self, turn: int, text: str) -> Path:
"""Each turn's answer on its own, so reply line numbers mean something
and --apply-fixes <file> can re-read it."""
p = self.dir / f"reply_turn{turn}.md"
atomic_write(p, text)
return p
# -- staged changes ------------------------------------------------
@staticmethod
def _safe_rel(rel_path: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._/\\-]", "_", rel_path).replace("\\", "/")
# Drop '..' and drive letters: staging must never escape the session dir.
parts = [seg for seg in cleaned.split("/") if seg not in ("", ".", "..") and ":" not in seg]
return "/".join(parts) or "unnamed"
def stage(self, rel_path: str, content: str, turn: int) -> Path:
dest = self.dir / "proposed" / f"turn{turn}" / self._safe_rel(rel_path)
atomic_write(dest, content)
return dest
def stage_edits(self, rel_path: str, edits: List["EditBlock"], turn: int) -> Path:
dest = self.dir / "proposed" / f"turn{turn}" / (self._safe_rel(rel_path) + ".edits.json")
atomic_write(dest, json.dumps({"path": rel_path, "edits": [e.to_json() for e in edits]},
indent=1, ensure_ascii=False))
return dest
def rows(self) -> List[Dict[str, Any]]:
index = self.dir / "proposals.json"
if not index.exists():
return []
try:
return json.loads(read_text(index))
except ValueError:
logger.warning("proposals.json is damaged in %s", self.dir)
return []
def append_rows(self, rows: List[Dict[str, Any]]) -> None:
atomic_write(self.dir / "proposals.json", json.dumps(self.rows() + rows, indent=1))
def read_artifact(self, row: Dict[str, Any]) -> Any:
"""FILE row -> text; EDIT row -> List[EditBlock]; None if missing."""
p = self.dir / row["staged"]
if not p.exists():
return None
try:
text = p.read_bytes().decode("utf-8")
except UnicodeDecodeError:
return None
if row.get("kind", "file") == "edit":
try:
return [EditBlock.from_json(d) for d in json.loads(text)["edits"]]
except (ValueError, KeyError, TypeError):
return None
return text
def write_parse_report(self, turn: int, report: Dict[str, Any]) -> None:
atomic_write(self.dir / f"parse_turn{turn}.json", json.dumps(report, indent=1))
def parse_report(self, turn: int) -> Optional[Dict[str, Any]]:
p = self.dir / f"parse_turn{turn}.json"
if not p.exists():
return None
try:
return json.loads(read_text(p))
except ValueError:
return None
def turns_with_reports(self) -> List[int]:
out = []
for p in self.dir.glob("parse_turn*.json"):
m = re.match(r"parse_turn(\d+)\.json$", p.name)
if m:
out.append(int(m.group(1)))
return sorted(out)
def ledger_entry(self, u: Dict[str, Any], effort: str, note: str = "", estimated: bool = False) -> None:
cached = u.get("cached_tokens") or (u.get("prompt_tokens_details") or {}).get("cached_tokens") or 0
row = {
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
"session": self.id,
"turn": self.turn,
"effort": effort,
"prompt": u.get("prompt_tokens", 0),
"cached": cached,
"completion": u.get("completion_tokens", 0),
"cost_usd": round(cost_of(u.get("prompt_tokens", 0), cached, u.get("completion_tokens", 0)), 5),
"note": note,
}
if estimated:
row["estimated"] = True
LEDGER.parent.mkdir(parents=True, exist_ok=True)
with open(LEDGER, "a", encoding="utf-8") as fh:
fh.write(json.dumps(row) + "\n")
def resolve_session(token: str) -> Optional[Session]:
"""'last' -> newest session that completed a call; otherwise an explicit id."""
if token == "last":
if not OUT_DIR.exists():
return None
# Newest first, stopping at the first real one: messages.json carries
# the whole dump, so reading every session's would be slow.
for d in sorted((d for d in OUT_DIR.iterdir() if d.is_dir()),
key=lambda d: d.name, reverse=True):
if Session.has_turns(d):
return Session(d.name)
return None
if not re.match(r"^[A-Za-z0-9_.-]+$", token):
return None
return Session(token) if (OUT_DIR / token).is_dir() else None
# ==========================================================================
# Response parsing: '### EDIT:' search/replace blocks and '### FILE:' blocks
# ==========================================================================
#
# The parser is a line-by-line state machine, not a regex over the whole
# reply. That is what lets it (a) never lose a block silently, (b) treat a
# ``` line INSIDE an edit as content rather than a terminator, and (c) say
# exactly which line of the reply was malformed.
#
# Every block-shaped thing it sees is either parsed or reported. There is no
# third outcome.
HEADER_RE = re.compile(
r"^[ ]{0,3}#{2,4}[ \t]*(?P<kind>FILE|EDIT|PATCH)[ \t]*:[ \t]*(?P<path>.+?)[ \t]*$", re.I)
END_RE = re.compile(r"^[ ]{0,3}#{2,4}[ \t]*END[ \t]+(?:OF[ \t]+)?CHANGES[ \t]*#*[ \t]*$", re.I)
OTHER_HEADER_RE = re.compile(
r"^[ ]{0,3}#{2,4}[ \t]*(?P<kind>[A-Za-z][A-Za-z _-]{1,24}?)[ \t]*:[ \t]*(?P<rest>\S.*)$")
FENCE_RE = re.compile(r"^(?P<indent>[ ]{0,3})(?P<fence>`{3,}|~{3,})(?P<info>.*)$")
SEARCH_RE = re.compile(r"^<{4,9}[ \t]*(?:SEARCH)?[ \t]*$", re.I)
DIVIDER_RE = re.compile(r"^={4,9}[ \t]*$")
REPLACE_RE = re.compile(r"^>{4,9}[ \t]*(?:REPLACE)?[ \t]*$", re.I)
# Something that is TRYING to be a marker but is not one we accept (indented,
# 3 characters, trailing junk). Reported, never guessed at.
NEAR_MARKER_RE = re.compile(r"^[ \t]*(?:<{4,}|>{4,}|<{3}[ \t]*SEARCH\b|>{3}[ \t]*REPLACE\b)", re.I)
UDIFF_RE = re.compile(r"^(?:diff --git |--- a/|\+\+\+ b/|@@ -\d+(?:,\d+)? \+\d+)")
PATHLIKE_RE = re.compile(r"^(?:[\w.@+-]+[/\\])*[\w.@+-]+\.[A-Za-z0-9_]{1,10}$")
# Block types a model might invent that mean "change the repo". Seeing one is
# an error, because silently ignoring it is how half a change gets applied.
CHANGE_INTENT_WORDS = {
"create", "update", "modify", "replace", "diff", "delete", "remove", "rename",
"move", "new file", "newfile", "append", "insert", "write",
}
# Placeholder text that stands in for code. Inside a FILE body or a REPLACE
# side it means "the model summarised instead of writing", and applying it
# would delete real code.
ELISION = [
re.compile(r"(?im)^\s*(?:#|//|/\*|<!--|--)?\s*\.{3,}\s*\(?\s*(?:rest|remainder|remaining|the rest|unchanged|existing|same)\b"),
re.compile(r"(?im)\b(?:rest|remainder) of (?:the )?(?:file|function|class|code)\s+(?:is\s+)?(?:unchanged|the same|omitted|as before)"),
re.compile(r"(?im)^\s*(?:#|//|/\*|<!--|--)?\s*(?:\.{3,}|\u2026)\s*(?:existing|previous|unchanged|other)\s+(?:code|methods|imports|content)"),
re.compile(r"(?im)\bomitted for brevity\b"),
re.compile(r"(?im)^\s*(?:#|//)\s*\.{3,}\s*$"),
]
LOOSE_HEADER_RE = re.compile(r"^[ \t>*_#`]*(?:FILE|EDIT|PATCH)[*_`]*[ \t]*:[ \t]*\S", re.I)
# A change header with its colon missing ("### FILE pkg/x.py"). Every other
# header regex requires the colon, so without this the header matched nothing
# and its fenced body was skipped as prose - a new file silently dropped while
# the dry run said "PASS 1 clean" (T-1, measured on 4a62556).
NOCOLON_HEADER_RE = re.compile(
r"^[ ]{0,3}#{2,4}[ \t]*(?P<kind>FILE|EDIT|PATCH|CREATE|UPDATE|MODIFY|DELETE|REMOVE|RENAME|MOVE)"
r"[ \t]+(?P<rest>\S.*)$", re.I)
# Change intents that carry no body by nature: a bare header IS the request.
BODILESS_INTENT_WORDS = {"delete", "remove", "rename", "move"}
MARKDOWNISH = {".md", ".markdown", ".rst", ".txt", ".mdx"}
@dataclass
class EditBlock:
search: str # literal text, LF line endings, ends with '\n' unless empty
replace: str # literal text, LF line endings, ends with '\n' unless empty
line: int # 1-based line of the SEARCH marker in the reply
def to_json(self) -> Dict[str, Any]:
return {"search": self.search, "replace": self.replace, "line": self.line}
@staticmethod
def from_json(d: Dict[str, Any]) -> "EditBlock":
return EditBlock(search=d["search"], replace=d["replace"], line=int(d.get("line", 0)))
@dataclass
class ParsedBlock:
kind: str # "edit" | "file"
path: str # as the model wrote it (cleaned of wrappers)
line: int # reply line of the header (or of the block)
edit: Optional[EditBlock] = None
content: Optional[str] = None
style: str = "header" # "header" | "aider"
@dataclass
class ParseProblem:
line: int
message: str
path: Optional[str] = None # raw path the problem belongs to, when known
severity: str = "error" # "error" blocks applying; "warning" does not
@dataclass
class ParseResult:
blocks: List[ParsedBlock] = field(default_factory=list)
problems: List[ParseProblem] = field(default_factory=list)
end_marker: bool = False
blocks_after_end: int = 0
announced: List[Tuple[int, str, str]] = field(default_factory=list) # (line, path, kind)
@property
def errors(self) -> List[ParseProblem]:
return [p for p in self.problems if p.severity == "error"]
@property
def warnings(self) -> List[ParseProblem]:
return [p for p in self.problems if p.severity != "error"]
# The up-front file list. Bullets, numbers, and markdown TABLE rows
# ("| `pkg/x.py` | EDIT | why |") - a table used to match nothing, so an
# announced-but-never-emitted file in a table went unreported.
ANNOUNCE_RE = re.compile(
r"^[ \t]*(?:[-*+|]|\d+[.)])?[ \t]*[`*]*(?P<path>[\w.@+-]+(?:[/\\][\w.@+-]+)*\.[A-Za-z0-9_]{1,10})[`*]*"
r"[ \t]*(?:[-\u2013\u2014:|>(\[]|\u2192)+[ \t]*[`*]*(?P<kind>EDIT|FILE)\b", re.I)
def _join_lines(lines: List[str]) -> str:
return ("\n".join(lines) + "\n") if lines else ""
def _clean_header_path(raw: str) -> str:
"""'`a/b.py` (adds X)' -> 'a/b.py'. Only strips a trailing description
when it is unmistakably one; a path is never guessed."""
s = raw.strip()
m = re.match(r"^(`+|\*\*|\*|\"|')(.+?)\1(.*)$", s)
if m:
s = m.group(2) + m.group(3)
s = s.strip().rstrip(":").strip()
if " " in s:
first, rest = s.split(" ", 1)
if rest.lstrip()[:1] in ("(", "-", "\u2014", "\u2013", "#", "[") and PATHLIKE_RE.match(first.strip("`*")):
s = first
return s.strip("`*").strip()
def parse_changes(text: str) -> ParseResult:
"""Parse a model reply into change blocks. Never drops anything silently."""
res = ParseResult()
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
n = len(lines)
i = 0
section_path: Optional[str] = None # set while inside an EDIT section
section_style = "header"
section_blocks = 0
section_line = 0
section_kind = "EDIT" # the header word the model wrote
def close_section() -> None:
nonlocal section_path, section_blocks
if section_path is not None and section_blocks == 0:
res.problems.append(ParseProblem(
section_line, f"'### {section_kind}: {section_path}' has no SEARCH/REPLACE block under it",
path=section_path))
section_path = None
section_blocks = 0
def after_end() -> bool:
return res.end_marker
def parse_edit(start: int, path: str, style: str) -> int:
"""lines[start] is a SEARCH marker. Returns the index after the block."""
j = start + 1
search: List[str] = []
while j < n:
ln = lines[j]
if DIVIDER_RE.match(ln):
break
if SEARCH_RE.match(ln):
res.problems.append(ParseProblem(
j + 1, "a second SEARCH marker appeared before the '=======' divider "
"(the previous block was never finished)", path=path))
return j
if REPLACE_RE.match(ln):
res.problems.append(ParseProblem(
j + 1, "REPLACE marker found before the '=======' divider", path=path))
return j + 1
search.append(ln)
j += 1
if j >= n:
res.problems.append(ParseProblem(
start + 1, "edit block never reached its '=======' divider - the reply was "
"cut off here (truncated output)", path=path))
return n
j += 1
replace: List[str] = []
while j < n:
ln = lines[j]
if REPLACE_RE.match(ln):
break
if DIVIDER_RE.match(ln):
res.problems.append(ParseProblem(
j + 1, "a second '=======' divider inside one block - ambiguous. This "
"happens when the file itself contains a '=======' line; that region "
"must be sent as a FILE block instead", path=path))
k = j + 1
while k < n and not REPLACE_RE.match(lines[k]):
k += 1
return min(k + 1, n)
if SEARCH_RE.match(ln):
res.problems.append(ParseProblem(
j + 1, "a new SEARCH marker appeared before '>>>>>>> REPLACE' closed the "
"previous block", path=path))
return j
replace.append(ln)
j += 1
if j >= n:
res.problems.append(ParseProblem(
start + 1, "edit block never reached '>>>>>>> REPLACE' - the reply was cut off "
"here (truncated output)", path=path))
return n
blk = EditBlock(search=_join_lines(search), replace=_join_lines(replace), line=start + 1)
if after_end():
res.blocks_after_end += 1
else:
res.blocks.append(ParsedBlock("edit", path, start + 1, edit=blk, style=style))
return j + 1
def parse_file(hdr: int, path: str) -> int:
j = hdr + 1
while j < n and not lines[j].strip() and j - hdr <= 3:
j += 1
m = FENCE_RE.match(lines[j]) if j < n else None
if not m:
# The commonest shape of this failure is a body that IS there with
# only its opening fence missing. When the span up to the next change
# header (or END) holds exactly one fence line, say so precisely: the
# fix is one inserted line, which is free, and a repair turn is not.
k, fences = hdr + 1, []
while k < n and not (HEADER_RE.match(lines[k]) or END_RE.match(lines[k])):
if FENCE_RE.match(lines[k]):
fences.append(k)
k += 1
hint = ""
if len(fences) == 1 and fences[0] > hdr + 1:
hint = (f" - its body looks present but unfenced (reply lines {hdr + 2}-"
f"{fences[0]}, closed by the fence on line {fences[0] + 1}): the "
f"OPENING fence is missing. Free fix: in a copy of the reply, insert "
f"a line like ```python directly under line {hdr + 1}, then run "
f"--apply-fixes on that copy")
res.problems.append(ParseProblem(
hdr + 1, f"'### FILE: {path}' is not followed by a fenced code block" + hint,
path=path))
return hdr + 1
ch, width = m.group("fence")[0], len(m.group("fence"))
j += 1
body: List[str] = []
closed = False
while j < n:
fm = FENCE_RE.match(lines[j])
if (fm and fm.group("fence")[0] == ch and len(fm.group("fence")) >= width
and not fm.group("info").strip()):
closed = True
break
body.append(lines[j])
j += 1
if not closed:
res.problems.append(ParseProblem(
hdr + 1, f"FILE block for {path} has no closing fence - the reply was cut off "
f"inside it (truncated output). Nothing from it is staged.", path=path))
return n
# Early-close check: a markdown file that contains its own ``` lines,
# fenced with only ```, closes at the file's first inner fence. The
# rest of the file then trails the block as "prose" with more fences.
k = j + 1
while k < n and not (HEADER_RE.match(lines[k]) or END_RE.match(lines[k])
or SEARCH_RE.match(lines[k])):
fm = FENCE_RE.match(lines[k])
if fm and fm.group("fence")[0] == ch:
suffix = Path(path).suffix.lower()
res.problems.append(ParseProblem(
hdr + 1,
f"FILE block for {path} may have been cut short at a ``` line inside the "
f"file (more fences follow it at reply line {k + 1}). A file that contains "
f"``` must be fenced with a LONGER run, e.g. ````",
path=path, severity="error" if suffix in MARKDOWNISH else "warning"))
break
k += 1
if after_end():
res.blocks_after_end += 1
else:
res.blocks.append(ParsedBlock("file", path, hdr + 1, content=_join_lines(body)))
return j + 1
def aider_path_before(idx: int) -> Optional[str]:
"""Aider style: the path on its own line just above the block
(optionally with a fence line in between). Exact path tokens only."""
k = idx - 1
while k >= 0 and not lines[k].strip():
k -= 1
if k >= 0 and FENCE_RE.match(lines[k]):
k -= 1
while k >= 0 and not lines[k].strip():
k -= 1
if k < 0:
return None
cand = lines[k].strip().strip("`*").strip()
return cand if PATHLIKE_RE.match(cand) else None
while i < n:
ln = lines[i]
hm = HEADER_RE.match(ln)
if hm:
close_section()
kind = hm.group("kind").lower()
path = _clean_header_path(hm.group("path"))
if kind == "file":
i = parse_file(i, path)
continue
section_path, section_style, section_blocks, section_line = path, "header", 0, i + 1
section_kind = kind.upper()
i += 1
continue
if END_RE.match(ln):
close_section()
res.end_marker = True
i += 1
continue
if SEARCH_RE.match(ln):
owner: Optional[str] = section_path
style = section_style
if owner is None:
owner = aider_path_before(i)
style = "aider"
if owner is None:
res.problems.append(ParseProblem(
i + 1, "SEARCH/REPLACE block with no '### EDIT: <path>' header above it - "
"cannot tell which file it belongs to"))
# consume it so its content is not re-scanned as top-level text
j = i + 1
while j < n and not REPLACE_RE.match(lines[j]):
j += 1
i = j + 1
continue
section_path, section_style, section_blocks, section_line = owner, "aider", 0, i + 1
i = parse_edit(i, owner, style)
section_blocks += 1
continue
if section_path is not None:
stripped = ln.strip()
if (not stripped or FENCE_RE.match(ln)
or stripped.strip("`*").strip() == section_path):
# A fence here is usually an EDIT's wrapping - but a unified diff
# opened right after an EDIT block also starts with one, and the
# top-level udiff check below never sees a fence this branch ate.
if (FENCE_RE.match(ln) and i + 1 < n and UDIFF_RE.match(lines[i + 1])
and not after_end()):
res.problems.append(ParseProblem(
i + 1, "the reply contains a unified diff; kimicli does not apply diffs. "
"If it was meant as a change, ask for EDIT blocks", severity="warning"))
i += 1
continue
if NEAR_MARKER_RE.match(ln) or DIVIDER_RE.match(stripped):
res.problems.append(ParseProblem(
i + 1, f"malformed edit marker {stripped[:40]!r} - markers must start at "
f"column 0 and be exactly '<<<<<<< SEARCH', '=======', "
f"'>>>>>>> REPLACE'", path=section_path))
i += 1
continue
close_section() # prose ends an EDIT section
continue
if NEAR_MARKER_RE.match(ln):
res.problems.append(ParseProblem(
i + 1, f"malformed edit marker {ln.strip()[:40]!r} outside any EDIT section"))
i += 1
continue
if LOOSE_HEADER_RE.match(ln) and not after_end():
nxt = [x for x in lines[i + 1:i + 4] if x.strip()][:1]
if nxt and (FENCE_RE.match(nxt[0]) or SEARCH_RE.match(nxt[0])):
res.problems.append(ParseProblem(
i + 1, f"change header not recognised: {ln.strip()[:60]!r} - it must be "
f"'### FILE: <path>' or '### EDIT: <path>' on its own line"))
i += 1
continue
nm = NOCOLON_HEADER_RE.match(ln)
if nm and not after_end():
word = nm.group("kind").lower()
first = nm.group("rest").split()[0].strip("`*")
nxt = [x for x in lines[i + 1:i + 4] if x.strip()][:1]
block_follows = bool(nxt) and bool(FENCE_RE.match(nxt[0]) or SEARCH_RE.match(nxt[0]))
if PATHLIKE_RE.match(first) and (block_follows or word in BODILESS_INTENT_WORDS):
res.problems.append(ParseProblem(
i + 1, f"change header not recognised: {ln.strip()[:60]!r} - the colon is "
f"missing; it must be '### FILE: <path>' or '### EDIT: <path>'"
+ (" (and kimicli never deletes, renames or moves files - do that "
"by hand with retire.py)" if word in BODILESS_INTENT_WORDS else ""),
path=first))
i += 1
continue
om = OTHER_HEADER_RE.match(ln)
if om:
word = om.group("kind").strip().lower()
nxt = [x for x in lines[i + 1:i + 4] if x.strip()][:2]
block_shaped = any(FENCE_RE.match(x) or SEARCH_RE.match(x) for x in nxt)
first_tok = (om.group("rest").split() or [""])[0].strip("`* ")
bodiless = (word in BODILESS_INTENT_WORDS and bool(PATHLIKE_RE.match(first_tok)))
if (block_shaped or bodiless) and not after_end() and (
word in CHANGE_INTENT_WORDS or PATHLIKE_RE.match(om.group("rest").strip("`* "))):
hint = ("kimicli never deletes, renames or moves files - do that step by hand "
"(retire.py), then continue" if word in ("delete", "remove", "rename", "move")
else "only '### EDIT:' and '### FILE:' blocks are applied")
res.problems.append(ParseProblem(
i + 1, f"unsupported block type '### {om.group('kind').strip()}:' - {hint}",
path=om.group("rest").strip("`* ")))
i += 1
continue
fm = FENCE_RE.match(ln)
if fm and i + 1 < n and UDIFF_RE.match(lines[i + 1]) and not after_end():
res.problems.append(ParseProblem(
i + 1, "the reply contains a unified diff; kimicli does not apply diffs. If it "
"was meant as a change, ask for EDIT blocks", severity="warning"))
i += 1
close_section()
first = min((b.line for b in res.blocks), default=n + 1)
for k in range(0, min(first - 1, n)):
am = ANNOUNCE_RE.match(lines[k])
if am and not HEADER_RE.match(lines[k]):
res.announced.append((k + 1, am.group("path"), am.group("kind").lower()))
if res.blocks_after_end:
res.problems.append(ParseProblem(
0, f"{res.blocks_after_end} change block(s) after '### END CHANGES' were NOT staged "
f"(that part of a reply is for discussion, e.g. BETTER IDEA code)", severity="warning"))
return res
# ==========================================================================
# Paths: normalisation, safety, and a one-walk index of the repo
# ==========================================================================
class PathRefused(Exception):
pass
_WIN_RESERVED = re.compile(r"^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[0-9\u00b9\u00b2\u00b3]|"
r"LPT[0-9\u00b9\u00b2\u00b3])(?:\..*)?$", re.I)
_SHORT_NAME = re.compile(r"~\d")
_BAD_WIN_CHARS = set('<>|?*"')