zall 0.1.0__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 (173) hide show
  1. zall/__init__.py +8 -0
  2. zall/__main__.py +3 -0
  3. zall/_util/__init__.py +18 -0
  4. zall/_util/backoff.py +46 -0
  5. zall/_util/file.py +118 -0
  6. zall/_util/file_state.py +123 -0
  7. zall/_util/hash_utils.py +110 -0
  8. zall/_util/jsonl.py +85 -0
  9. zall/_util/logging.py +130 -0
  10. zall/_util/model_registry.py +342 -0
  11. zall/_util/path.py +73 -0
  12. zall/_util/string.py +98 -0
  13. zall/_util/term.py +150 -0
  14. zall/_util/tokens.py +60 -0
  15. zall/_util/toml.py +231 -0
  16. zall/_util/win32.py +91 -0
  17. zall/adapters/__init__.py +26 -0
  18. zall/adapters/anthropic.py +460 -0
  19. zall/adapters/base.py +432 -0
  20. zall/adapters/chaos.py +128 -0
  21. zall/adapters/gemini.py +363 -0
  22. zall/adapters/ollama.py +315 -0
  23. zall/adapters/openai_compat.py +581 -0
  24. zall/adapters/scripted.py +165 -0
  25. zall/cli/__init__.py +8 -0
  26. zall/cli/app.py +211 -0
  27. zall/cli/commands/__init__.py +197 -0
  28. zall/cli/commands/_common.py +1240 -0
  29. zall/cli/commands/config.py +345 -0
  30. zall/cli/commands/eval.py +219 -0
  31. zall/cli/commands/files.py +251 -0
  32. zall/cli/commands/git.py +211 -0
  33. zall/cli/commands/model.py +944 -0
  34. zall/cli/commands/reload.py +97 -0
  35. zall/cli/commands/science.py +358 -0
  36. zall/cli/commands/session.py +465 -0
  37. zall/cli/commands/suggest.py +512 -0
  38. zall/cli/commands/system.py +772 -0
  39. zall/cli/commands/v040.py +427 -0
  40. zall/cli/config.py +508 -0
  41. zall/cli/config_layers.py +290 -0
  42. zall/cli/diff_render.py +428 -0
  43. zall/cli/environment.py +782 -0
  44. zall/cli/file_complete.py +236 -0
  45. zall/cli/init_wizard.py +284 -0
  46. zall/cli/judge.py +64 -0
  47. zall/cli/orchestrator.py +674 -0
  48. zall/cli/paste_fold.py +112 -0
  49. zall/cli/prompt.py +366 -0
  50. zall/cli/render.py +1747 -0
  51. zall/cli/repl_ui.py +595 -0
  52. zall/cli/replay.py +318 -0
  53. zall/cli/responder.py +529 -0
  54. zall/cli/select.py +80 -0
  55. zall/cli/session.py +755 -0
  56. zall/cli/syntax_theme.py +102 -0
  57. zall/cli/theme.py +318 -0
  58. zall/cli/tui/__init__.py +36 -0
  59. zall/cli/tui/app.py +1590 -0
  60. zall/cli/tui/tui_responder.py +93 -0
  61. zall/cli/tui/widgets.py +1358 -0
  62. zall/cli/update.py +299 -0
  63. zall/codegraph/__init__.py +919 -0
  64. zall/core/__init__.py +178 -0
  65. zall/core/accountability.py +317 -0
  66. zall/core/action.py +66 -0
  67. zall/core/agent.py +563 -0
  68. zall/core/builder.py +445 -0
  69. zall/core/chat_state.py +687 -0
  70. zall/core/checkpoint.py +432 -0
  71. zall/core/compactor.py +478 -0
  72. zall/core/context.py +115 -0
  73. zall/core/context_manager.py +256 -0
  74. zall/core/coordinator.py +176 -0
  75. zall/core/eval/__init__.py +362 -0
  76. zall/core/events.py +159 -0
  77. zall/core/evidence.py +69 -0
  78. zall/core/executor.py +463 -0
  79. zall/core/experience_store.py +335 -0
  80. zall/core/experiment.py +99 -0
  81. zall/core/extension.py +248 -0
  82. zall/core/gate.py +349 -0
  83. zall/core/goal.py +419 -0
  84. zall/core/hallucination.py +48 -0
  85. zall/core/hypothesis.py +203 -0
  86. zall/core/judge/__init__.py +460 -0
  87. zall/core/lifecycle.py +262 -0
  88. zall/core/loop.py +1809 -0
  89. zall/core/loop_checkpoint.py +152 -0
  90. zall/core/loop_config.py +99 -0
  91. zall/core/loop_errors.py +8 -0
  92. zall/core/loop_events.py +45 -0
  93. zall/core/loop_model_call.py +217 -0
  94. zall/core/loop_perception.py +126 -0
  95. zall/core/memory.py +306 -0
  96. zall/core/model.py +240 -0
  97. zall/core/open_ended.py +180 -0
  98. zall/core/perception/__init__.py +47 -0
  99. zall/core/perception/coding_sensors.py +463 -0
  100. zall/core/perception/coding_world_model.py +377 -0
  101. zall/core/perception/engine.py +300 -0
  102. zall/core/perception/sensor.py +275 -0
  103. zall/core/perception/world_model.py +197 -0
  104. zall/core/plan_mode.py +165 -0
  105. zall/core/plugin_loader.py +351 -0
  106. zall/core/policies.py +102 -0
  107. zall/core/process_anchor/__init__.py +30 -0
  108. zall/core/process_anchor/client.py +268 -0
  109. zall/core/process_anchor/protocol.py +124 -0
  110. zall/core/process_anchor/server.py +340 -0
  111. zall/core/prompt_template.py +182 -0
  112. zall/core/proof_gate.py +464 -0
  113. zall/core/provenance.py +115 -0
  114. zall/core/red_blue.py +236 -0
  115. zall/core/refiner.py +320 -0
  116. zall/core/safety.py +346 -0
  117. zall/core/sandbox.py +253 -0
  118. zall/core/sandbox_verifier.py +109 -0
  119. zall/core/self_improve.py +244 -0
  120. zall/core/tool.py +443 -0
  121. zall/core/tool_kind.py +98 -0
  122. zall/core/toolset.py +242 -0
  123. zall/core/verifiability.py +579 -0
  124. zall/eval/__init__.py +15 -0
  125. zall/eval/metrics.py +348 -0
  126. zall/extensions/__init__.py +1 -0
  127. zall/extensions/auto_learn.py +733 -0
  128. zall/extensions/science/__init__.py +14 -0
  129. zall/extensions/science/store.py +197 -0
  130. zall/extensions/usage_tracker.py +91 -0
  131. zall/lsp/__init__.py +847 -0
  132. zall/mcp/__init__.py +22 -0
  133. zall/mcp/client.py +270 -0
  134. zall/mcp/config.py +191 -0
  135. zall/mcp/tool.py +129 -0
  136. zall/plugin/__init__.py +583 -0
  137. zall/safety/__init__.py +10 -0
  138. zall/safety/config.py +231 -0
  139. zall/safety/rules_file.py +578 -0
  140. zall/safety/sensitive.py +90 -0
  141. zall/sandbox/__init__.py +630 -0
  142. zall/skills/__init__.py +11 -0
  143. zall/skills/loader.py +202 -0
  144. zall/tools/__init__.py +11 -0
  145. zall/tools/_bash_semantics.py +256 -0
  146. zall/tools/_diff.py +24 -0
  147. zall/tools/apply_patch.py +298 -0
  148. zall/tools/bash.py +761 -0
  149. zall/tools/batch_edit.py +331 -0
  150. zall/tools/code_understanding.py +258 -0
  151. zall/tools/codegraph.py +350 -0
  152. zall/tools/edit_file.py +205 -0
  153. zall/tools/git_protect.py +189 -0
  154. zall/tools/glob.py +138 -0
  155. zall/tools/grep.py +346 -0
  156. zall/tools/list_dir.py +156 -0
  157. zall/tools/lsp_diagnostics.py +329 -0
  158. zall/tools/project_analysis.py +221 -0
  159. zall/tools/pty_executor.py +253 -0
  160. zall/tools/read_file.py +278 -0
  161. zall/tools/read_image.py +316 -0
  162. zall/tools/science.py +506 -0
  163. zall/tools/search.py +474 -0
  164. zall/tools/spawn_subagent.py +731 -0
  165. zall/tools/todo.py +142 -0
  166. zall/tools/web_fetch.py +502 -0
  167. zall/tools/write_file.py +150 -0
  168. zall-0.1.0.dist-info/METADATA +470 -0
  169. zall-0.1.0.dist-info/RECORD +173 -0
  170. zall-0.1.0.dist-info/WHEEL +5 -0
  171. zall-0.1.0.dist-info/entry_points.txt +2 -0
  172. zall-0.1.0.dist-info/licenses/LICENSE +21 -0
  173. zall-0.1.0.dist-info/top_level.txt +1 -0
zall/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """zall — model-agnostic engineering agent.
2
+
3
+ Model-agnostic engineering agent. This package MUST NOT import any model SDK;
4
+ Model adapters reside in `zall.adapters`. See IMPL.md IPR-3.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["__version__"]
zall/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from zall.cli import main
2
+
3
+ raise SystemExit(main())
zall/_util/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """zall._util — 内部共享toolfunction库。
2
+
3
+ 本包为 zall 内部使用的工具函数, 不构成 public API。
4
+ 各模块消除重复代码用 (B22/B23/B24 等)。
5
+ """
6
+
7
+ from zall._util.file import is_binary, read_text_file
8
+ from zall._util.path import NOISE_DIRS, is_noise, skip_noise_dirs
9
+ from zall._util.string import unquote
10
+
11
+ __all__ = [
12
+ "NOISE_DIRS",
13
+ "is_binary",
14
+ "is_noise",
15
+ "read_text_file",
16
+ "skip_noise_dirs",
17
+ "unquote",
18
+ ]
zall/_util/backoff.py ADDED
@@ -0,0 +1,46 @@
1
+ """zall._util.backoff — 指数抖动退避 (单一真相源)。
2
+
3
+ G13 (kimi wait_exponential_jitter 对标):
4
+ 此前三处消费点 (core/loop · cli/repl_ui · cli/tui/app) 各自硬编码
5
+ `delay = attempt * 2` (2s/4s/6s) — 线性退避对慢端点体感僵硬, 且固定值
6
+ 在多客户端同时重试时产生同步惊群 (thundering herd)。
7
+
8
+ 本版: base = min(initial * 2**(attempt-1), maximum), 再乘均匀抖动
9
+ [1 - jitter/2, 1 + jitter/2) — 期望值恰为 base (好推理), 打散重试相位。
10
+ 默认 initial=2, maximum=8, jitter=0.5 → attempt 1/2/3 ≈ [1.5,2.5)/[3,5)/[6,10)s。
11
+
12
+ IPR constraints:
13
+ IPR-0: tests/test_backoff_invariants.py (含反例)
14
+ IPR-3: 纯 stdlib
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import random
20
+ from collections.abc import Callable
21
+
22
+ DEFAULT_INITIAL = 2.0
23
+ DEFAULT_MAXIMUM = 8.0
24
+ DEFAULT_JITTER = 0.5
25
+
26
+
27
+ def backoff_delay(
28
+ attempt: int,
29
+ *,
30
+ initial: float = DEFAULT_INITIAL,
31
+ maximum: float = DEFAULT_MAXIMUM,
32
+ jitter: float = DEFAULT_JITTER,
33
+ rng: Callable[[], float] = random.random,
34
+ ) -> float:
35
+ """第 attempt 次 (1-based) 重试的等待秒数: 指数 + 封顶 + 均匀抖动。
36
+
37
+ 抖动区间以 base 为中心对称 ([base*(1-j/2), base*(1+j/2))),
38
+ 期望值 = base; jitter=0 时退化为确定性指数退避。
39
+ attempt < 1 按 1 处理 (防御)。
40
+ """
41
+ attempt = max(attempt, 1)
42
+ base = min(initial * (2.0 ** (attempt - 1)), maximum)
43
+ if jitter <= 0:
44
+ return base
45
+ factor = 1.0 - jitter / 2.0 + rng() * jitter
46
+ return base * factor
zall/_util/file.py ADDED
@@ -0,0 +1,118 @@
1
+ """zall._util.file — File operations共享toolfunction。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import locale
6
+ from pathlib import Path
7
+
8
+
9
+ def _preferred_encoding() -> str:
10
+ """Get the system's preferred encoding (Windows Chinese is GBK/CP936, do not hardcode UTF-8)."""
11
+ try:
12
+ enc = locale.getpreferredencoding(False)
13
+ if enc:
14
+ return enc
15
+ except (ValueError, LookupError):
16
+ pass
17
+ return "utf-8" # fallback
18
+
19
+
20
+ def detect_text_encoding(path: Path) -> str:
21
+ """Detect text file encoding: try UTF-8 first, fallback to system preferred encoding.
22
+
23
+ Most modern code/text files are UTF-8. On Chinese Windows, the system default is GBK,
24
+ but opening a UTF-8 file with GBK produces mojibake. This function tries UTF-8 first
25
+ (strict), and verifies via round-trip to avoid GBK text accidentally passing UTF-8
26
+ validation (e.g., GBK bytes that happen to form valid UTF-8 sequences).
27
+
28
+ Note: the returned encoding should be used WITHOUT errors='replace', because
29
+ replacement characters (U+FFFD) cannot be encoded in the target encoding
30
+ (e.g., GBK) when the content is sent to the API pipeline.
31
+ """
32
+ raw = b"" # 预初始化: 防 open 失败(如文件不存在)时下方 GBK 回退分支引用未绑定的 raw
33
+ try:
34
+ with open(path, "rb") as f:
35
+ raw = f.read(8192)
36
+ # Strict UTF-8 decode
37
+ decoded = raw.decode("utf-8")
38
+ # Round-trip verification: re-encode must match original bytes exactly
39
+ # This catches GBK text that happens to be valid UTF-8 (different characters)
40
+ if decoded.encode("utf-8") == raw:
41
+ return "utf-8"
42
+ except (UnicodeDecodeError, OSError):
43
+ pass
44
+
45
+ # Try system encoding (GBK/CP936 on Chinese Windows)
46
+ sys_enc = _preferred_encoding()
47
+ if sys_enc.lower() != "utf-8":
48
+ try:
49
+ raw.decode(sys_enc)
50
+ return sys_enc
51
+ except (UnicodeDecodeError, LookupError):
52
+ pass
53
+
54
+ return "utf-8" # best-effort fallback
55
+
56
+
57
+ def is_binary(path: Path) -> bool:
58
+ """检测二进制file: 读前 8KB, check空字节。
59
+
60
+ 失败时返回 True (fail-safe: 二进制文件不读).
61
+ B23: 统一 grep.py 的 _is_binary 和 read_file.py 的内联检测。
62
+ """
63
+ try:
64
+ with open(path, "rb") as f:
65
+ chunk = f.read(8192)
66
+ return b"\0" in chunk
67
+ except OSError:
68
+ return True
69
+
70
+
71
+ def read_text_file(path: Path, encoding: str | None = None) -> str:
72
+ """read文本file, 统一exceptionhandle。
73
+
74
+ B22: 统一 edit_file.py 和 batch_edit.py 的文件读取逻辑。
75
+ 自动解析相对路径, 检查存在性/类型, 返回文件内容。
76
+ 抛出 OSError 时调用方自行处理。
77
+ 编码默认自动检测: 先尝试 UTF-8, 失败回退系统编码。
78
+ """
79
+ # 始终解析相对路径 (修复: 原仅在 encoding=None 时解析, 导致不一致行为)
80
+ if not path.is_absolute():
81
+ path = Path.cwd() / path
82
+ if encoding is None:
83
+ encoding = detect_text_encoding(path)
84
+ if not path.exists():
85
+ raise FileNotFoundError(f"file not found: {path}")
86
+ if not path.is_file():
87
+ raise IsADirectoryError(f"not a file: {path}")
88
+ return path.read_text(encoding=encoding)
89
+
90
+
91
+ def atomic_write(path: Path, content: str, encoding: str | None = None) -> Path:
92
+ """原子writefile — 使用唯一临时file名, 避免 concurrent write竞态。
93
+
94
+ v2 fix: 旧实现 path.with_suffix(suffix + ".zall_tmp") 在两个 agent
95
+ 实例同时编辑同一文件时会产生相同临时文件路径, 导致竞态。
96
+ 新实现使用 uuid 生成唯一文件名, 保证并发安全。
97
+ 编码默认 UTF-8 (现代标准, 与 detect_text_encoding 的优先尝试一致)。
98
+
99
+ Returns: 临时文件路径 (已被 os.replace 移走, 不再存在)
100
+ Raises: OSError on write/replace failure (临时文件已清理)
101
+ """
102
+ import os
103
+ import uuid
104
+ if encoding is None:
105
+ encoding = "utf-8"
106
+ tmp_name = f".zall_tmp_{uuid.uuid4().hex[:8]}"
107
+ tmp = path.parent / tmp_name
108
+ try:
109
+ tmp.write_text(content, encoding=encoding)
110
+ os.replace(str(tmp), str(path))
111
+ except OSError:
112
+ try:
113
+ if tmp.exists():
114
+ tmp.unlink()
115
+ except Exception:
116
+ pass
117
+ raise
118
+ return tmp
@@ -0,0 +1,123 @@
1
+ """File state cache for tracking read file contents and modification state.
2
+
3
+ 缓存每个文件的最后读取时间和 mtime, 支持:
4
+ - is_file_unchanged(path) — 文件是否在上次读取后未被修改
5
+ - get_cached_mtime(path) — 获取缓存的 mtime
6
+ - mark_file_read(path) — 标记文件已读取
7
+ - invalidate(path) — 使缓存失效
8
+
9
+ 用途: 避免 agent 重复读取未修改的文件, 节省 API token。
10
+ 当 agent 调用 read_file 后, 文件状态被缓存; 下次读取前先检查 mtime,
11
+ 如果未变化则提示 agent "file unchanged, no need to re-read"。
12
+
13
+ IPR constraints:
14
+ IPR-3: stdlib only, no model SDK
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ class FileStateCache:
26
+ """filestatecache — 跟踪file mtime + read时间。
27
+
28
+ 线程安全: 使用 RLock 保护内部 dict。
29
+ 缓存上限: 1000 条 (LRU 淘汰), 防长会话无界增长。
30
+ """
31
+
32
+ __test__ = False
33
+
34
+ _MAX_ENTRIES = 1000
35
+
36
+ def __init__(self) -> None:
37
+ self._lock = threading.RLock()
38
+ # key: str(path), value: {"mtime": float, "read_at": float, "size": int}
39
+ self._cache: dict[str, dict[str, Any]] = {}
40
+
41
+ def mark_file_read(self, path: Path | str) -> None:
42
+ """标记file已read, cache其 mtime 和 size。"""
43
+ key = str(path)
44
+ try:
45
+ stat = Path(path).stat()
46
+ mtime = stat.st_mtime
47
+ size = stat.st_size
48
+ except OSError:
49
+ return
50
+ with self._lock:
51
+ # LRU 淘汰
52
+ if len(self._cache) >= self._MAX_ENTRIES:
53
+ # 淘汰最旧的条目
54
+ oldest_key = min(self._cache, key=lambda k: self._cache[k].get("read_at", 0))
55
+ self._cache.pop(oldest_key, None)
56
+ self._cache[key] = {"mtime": mtime, "read_at": time.time(), "size": size}
57
+
58
+ def is_file_unchanged(self, path: Path | str) -> bool:
59
+ """checkfile是否在上次read后未被修改。
60
+
61
+ Returns:
62
+ True — 文件未修改 (可以跳过重复读取)
63
+ False — 文件已修改或未被缓存过 (需要重新读取)
64
+ """
65
+ key = str(path)
66
+ with self._lock:
67
+ cached = self._cache.get(key)
68
+ if cached is None:
69
+ return False
70
+ try:
71
+ current_mtime = Path(path).stat().st_mtime
72
+ except OSError:
73
+ return False
74
+ return bool(cached["mtime"] == current_mtime)
75
+
76
+ def get_cached_mtime(self, path: Path | str) -> float | None:
77
+ """获取cache的 mtime (不读盘)。"""
78
+ key = str(path)
79
+ with self._lock:
80
+ entry = self._cache.get(key)
81
+ return entry["mtime"] if entry else None
82
+
83
+ def invalidate(self, path: Path | str | None = None) -> None:
84
+ """使cache失效。path=None 时清除全部cache。"""
85
+ with self._lock:
86
+ if path is None:
87
+ self._cache.clear()
88
+ else:
89
+ self._cache.pop(str(path), None)
90
+
91
+ def get_changed_files(self) -> list[str]:
92
+ """return自上次read以来被修改的filelist (供 /doctor 用)。"""
93
+ changed = []
94
+ with self._lock:
95
+ for key, entry in self._cache.items():
96
+ try:
97
+ current_mtime = Path(key).stat().st_mtime
98
+ if current_mtime != entry["mtime"]:
99
+ changed.append(key)
100
+ except OSError:
101
+ changed.append(key) # 文件被删除也算变更
102
+ return changed
103
+
104
+ @property
105
+ def size(self) -> int:
106
+ """当前cache条目数。"""
107
+ with self._lock:
108
+ return len(self._cache)
109
+
110
+
111
+ # 全局singleton (process级)
112
+ _global_cache: FileStateCache | None = None
113
+ _global_lock = threading.Lock()
114
+
115
+
116
+ def get_file_state_cache() -> FileStateCache:
117
+ """获取全局 FileStateCache singleton。"""
118
+ global _global_cache
119
+ if _global_cache is None:
120
+ with _global_lock:
121
+ if _global_cache is None:
122
+ _global_cache = FileStateCache()
123
+ return _global_cache
@@ -0,0 +1,110 @@
1
+ """zall._util.hash_utils - 真实文件 SHA-256 哈希工具 (E3 Science Kit 诚实化).
2
+
3
+ Part D (真溯源): ScienceProvenance 的四个 hash 字段此前是硬编码占位符
4
+ ("sha256:cli-manual" / "sha256:agent"), 无任何代码对真实文件算哈希。本模块
5
+ 提供真实文件哈希能力, 使实验溯源从"声明"变为"可验证事实"。
6
+
7
+ 对应:
8
+ docs/E3_SCIENCE_KIT.md §2.4 ScienceProvenance - protocol/data/code/env 完整血缘
9
+ MASTER.md §10 I-10 负结果平等 (真实溯源同样适用于负结果)
10
+
11
+ IPR constraints:
12
+ IPR-3: stdlib only (hashlib/pathlib), no model SDK. _util 是 stdlib-only 区。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import os
19
+ from pathlib import Path
20
+
21
+ # 哈希前缀, 与 ScienceProvenance 字段约定一致 (如 "sha256:<hex>")
22
+ _HASH_PREFIX = "sha256:"
23
+ _CHUNK_SIZE = 1 << 16 # 64 KiB 读块, 大文件不爆内存
24
+
25
+
26
+ def hash_file(path: str | Path) -> str:
27
+ """计算单个文件的 SHA-256, 返回 "sha256:<hex>" 格式。
28
+
29
+ 文件不存在或不可读时抛 OSError (调用方决定降级策略)。
30
+ """
31
+ p = Path(path)
32
+ h = hashlib.sha256()
33
+ with p.open("rb") as f:
34
+ while True:
35
+ chunk = f.read(_CHUNK_SIZE)
36
+ if not chunk:
37
+ break
38
+ h.update(chunk)
39
+ return _HASH_PREFIX + h.hexdigest()
40
+
41
+
42
+ def hash_bytes(data: bytes) -> str:
43
+ """计算字节内容的 SHA-256, 返回 "sha256:<hex>" 格式。"""
44
+ return _HASH_PREFIX + hashlib.sha256(data).hexdigest()
45
+
46
+
47
+ def hash_files(paths: list[str | Path]) -> str:
48
+ """计算多个文件的聚合 SHA-256 (顺序无关: 先排序再聚合)。
49
+
50
+ 聚合策略: 对每个文件取 (相对名, sha256) 后排序, 再对拼接串算总哈希。
51
+ 顺序无关保证: 同一组文件无论传入顺序如何, 聚合哈希一致。
52
+ 返回 "sha256:<hex>" 格式。
53
+ """
54
+ entries: list[tuple[str, str]] = []
55
+ for path in paths:
56
+ p = Path(path)
57
+ digest = hash_file(p).removeprefix(_HASH_PREFIX)
58
+ entries.append((p.name, digest))
59
+ entries.sort(key=lambda e: e[0])
60
+ joined = "\n".join(f"{name}\t{digest}" for name, digest in entries)
61
+ return _HASH_PREFIX + hashlib.sha256(joined.encode("utf-8")).hexdigest()
62
+
63
+
64
+ def hash_dir(path: str | Path, *, pattern: str = "*") -> str:
65
+ """计算目录下所有匹配文件的聚合 SHA-256 (环境快照用)。
66
+
67
+ 递归遍历目录, 对匹配 pattern 的文件聚合哈希 (排序保证顺序无关)。
68
+ 用于 ScienceProvenance.environment_hash (如 requirements.txt + python version)。
69
+ 返回 "sha256:<hex>" 格式。空目录返回对空串的哈希。
70
+ """
71
+ import fnmatch
72
+
73
+ p = Path(path)
74
+ if not p.is_dir():
75
+ return hash_bytes(b"")
76
+ entries: list[tuple[str, str]] = []
77
+ for root, _dirs, files in os.walk(p):
78
+ for fn in files:
79
+ if not fnmatch.fnmatch(fn, pattern) and pattern != "*":
80
+ continue
81
+ fp = Path(root) / fn
82
+ try:
83
+ digest = hash_file(fp).removeprefix(_HASH_PREFIX)
84
+ # 用相对路径作名, 保证目录迁移后哈希稳定
85
+ rel = fp.relative_to(p).as_posix()
86
+ entries.append((rel, digest))
87
+ except OSError:
88
+ continue # 跳过不可读文件 (如权限/锁), 不让环境快照失败
89
+ entries.sort(key=lambda e: e[0])
90
+ joined = "\n".join(f"{name}\t{digest}" for name, digest in entries)
91
+ return _HASH_PREFIX + hashlib.sha256(joined.encode("utf-8")).hexdigest()
92
+
93
+
94
+ def environment_hash(extra: str = "") -> str:
95
+ """计算运行环境快照哈希 (python 版本 + 关键库版本 + 可选附加串)。
96
+
97
+ 用于 ScienceProvenance.environment_hash: 捕获 (python 版本, sys.platform,
98
+ 可选的 requirements 摘要), 使实验环境可溯源。
99
+ 返回 "sha256:<hex>" 格式。
100
+ """
101
+ import sys
102
+ parts = [
103
+ f"python={sys.version.split()[0]}",
104
+ f"platform={sys.platform}",
105
+ f"executable={sys.executable}",
106
+ ]
107
+ if extra:
108
+ parts.append(extra)
109
+ joined = "\n".join(parts)
110
+ return _HASH_PREFIX + hashlib.sha256(joined.encode("utf-8")).hexdigest()
zall/_util/jsonl.py ADDED
@@ -0,0 +1,85 @@
1
+ """zall._util.jsonl — JSONL 版本头与坏行容错读取 (单一真相源)。
2
+
3
+ G12 (kimi context.jsonl `_` 特殊行 / wire.jsonl 首行 protocol_version 对标):
4
+ timeline.jsonl 首行写 metadata 版本头 {"type":"metadata","version":1,...} —
5
+ 无版本头的旧文件视为 legacy 正常读 (向后兼容);
6
+ 读取循环坏行 skip 不再一坏全弃 (此前 _load_timeline_events 任一行
7
+ JSONDecodeError 会整个返回 None, 丢掉全部可读事件)。
8
+
9
+ IPR constraints:
10
+ IPR-0: tests/test_jsonl_header_invariants.py (含反例)
11
+ IPR-3: 纯 stdlib
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ METADATA_TYPE = "metadata"
21
+ TIMELINE_VERSION = 1
22
+
23
+
24
+ def make_metadata(**fields: Any) -> dict[str, Any]:
25
+ """构造版本头记录 (首行)。fields 覆盖默认键之外的补充信息。"""
26
+ rec: dict[str, Any] = {"type": METADATA_TYPE, "version": TIMELINE_VERSION}
27
+ rec.update(fields)
28
+ return rec
29
+
30
+
31
+ def is_metadata(record: dict[str, Any]) -> bool:
32
+ return isinstance(record, dict) and record.get("type") == METADATA_TYPE
33
+
34
+
35
+ def read_jsonl(
36
+ path: str | Path,
37
+ *,
38
+ skip_metadata: bool = True,
39
+ ) -> list[dict[str, Any]] | None:
40
+ """读取 JSONL: 文件不存在/不可读 → None; 坏行 skip (不一坏全弃)。
41
+
42
+ skip_metadata=True 时过滤版本头行 — 消费方拿到的纯是数据记录,
43
+ legacy 文件 (无版本头) 行为完全一致。
44
+ """
45
+ p = Path(path)
46
+ if not p.exists():
47
+ return None
48
+ try:
49
+ text = p.read_text(encoding="utf-8")
50
+ except OSError:
51
+ return None
52
+ records: list[dict[str, Any]] = []
53
+ for line in text.splitlines():
54
+ line = line.strip()
55
+ if not line:
56
+ continue
57
+ try:
58
+ rec = json.loads(line)
59
+ except json.JSONDecodeError:
60
+ continue # 坏行 skip: 保住其余可读记录
61
+ if not isinstance(rec, dict):
62
+ continue
63
+ if skip_metadata and is_metadata(rec):
64
+ continue
65
+ records.append(rec)
66
+ return records
67
+
68
+
69
+ def read_metadata(path: str | Path) -> dict[str, Any] | None:
70
+ """读首行版本头; 无版本头 (legacy) / 文件不存在 → None。"""
71
+ p = Path(path)
72
+ if not p.exists():
73
+ return None
74
+ try:
75
+ with open(p, encoding="utf-8") as f:
76
+ first = f.readline().strip()
77
+ except OSError:
78
+ return None
79
+ if not first:
80
+ return None
81
+ try:
82
+ rec = json.loads(first)
83
+ except json.JSONDecodeError:
84
+ return None
85
+ return rec if is_metadata(rec) else None
zall/_util/logging.py ADDED
@@ -0,0 +1,130 @@
1
+ """zall._util.logging — Unified logging configuration (IPR-0: self-falsifiable errors).
2
+
3
+ Inspired by Grok Build's xai-tracing crate: structured, contextual logging that
4
+ makes non-fatal errors observable without changing control flow.
5
+
6
+ Design:
7
+ - Single entry point: get_zall_logger(__name__) → standard library logger
8
+ - ZALL_LOG_LEVEL env var controls verbosity (default: WARNING)
9
+ - IPR-0 invariant: observer/renderer errors are swallowed but LOGGED
10
+ (silent pass → violates falsifiability; logged pass → observable)
11
+
12
+ Usage:
13
+ from zall._util.logging import get_zall_logger
14
+ logger = get_zall_logger(__name__)
15
+ ...
16
+ except Exception as e:
17
+ logger.warning("operation failed: %s", e) # observable, non-fatal
18
+
19
+ Corresponds to:
20
+ §0 PR-0: self-falsifiability — silent errors are unfalsifiable
21
+ IPR-0 Self-falsifiability code form — errors must be observable
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import os
28
+ import sys
29
+ from typing import TextIO
30
+
31
+ # ── Defaults ──
32
+
33
+ _DEFAULT_LEVEL = logging.WARNING
34
+ _ENV_VAR = "ZALL_LOG_LEVEL"
35
+
36
+ # Level name → int mapping for env var parsing
37
+ _LEVEL_NAMES: dict[str, int] = {
38
+ "DEBUG": logging.DEBUG,
39
+ "INFO": logging.INFO,
40
+ "WARNING": logging.WARNING,
41
+ "ERROR": logging.ERROR,
42
+ "CRITICAL": logging.CRITICAL,
43
+ }
44
+
45
+
46
+ def _resolve_level() -> int:
47
+ """Resolve log level from environment or default."""
48
+ raw = os.environ.get(_ENV_VAR, "").strip().upper()
49
+ if raw in _LEVEL_NAMES:
50
+ return _LEVEL_NAMES[raw]
51
+ if raw and raw.isdigit():
52
+ return int(raw)
53
+ return _DEFAULT_LEVEL
54
+
55
+
56
+ # ── Formatter ──
57
+
58
+ _FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
59
+ _FORMAT_DATE = "%H:%M:%S"
60
+
61
+
62
+ def _make_handler(stream: TextIO = sys.stderr) -> logging.Handler:
63
+ """Create a stderr handler with the standard format."""
64
+ handler = logging.StreamHandler(stream)
65
+ handler.setFormatter(logging.Formatter(_FORMAT, _FORMAT_DATE))
66
+ return handler
67
+
68
+
69
+ # ── Global state (lazy-initialized) ──
70
+
71
+ _initialized = False
72
+
73
+
74
+ def setup_logging(
75
+ *,
76
+ level: int | None = None,
77
+ stream: TextIO | None = None,
78
+ force: bool = False,
79
+ ) -> None:
80
+ """Configure the root zall logger.
81
+
82
+ Called once at process start (from main/REPL). Safe to call multiple
83
+ times — subsequent calls are no-ops unless *force=True*.
84
+
85
+ Args:
86
+ level: Log level (default: from ZALL_LOG_LEVEL env, or WARNING).
87
+ stream: Output stream (default: sys.stderr).
88
+ force: Reconfigure even if already initialized.
89
+ """
90
+ global _initialized
91
+ if _initialized and not force:
92
+ return
93
+
94
+ root = logging.getLogger("zall")
95
+ root.setLevel(level if level is not None else _resolve_level())
96
+
97
+ # Remove existing handlers to avoid duplicates on force re-init
98
+ if force:
99
+ root.handlers.clear()
100
+
101
+ if not root.handlers:
102
+ root.addHandler(_make_handler(stream or sys.stderr))
103
+
104
+ _initialized = True
105
+
106
+
107
+ def get_zall_logger(name: str) -> logging.Logger:
108
+ """Get a zall-namespaced logger.
109
+
110
+ Usage:
111
+ logger = get_zall_logger(__name__)
112
+ logger.warning("non-fatal issue: %s", detail)
113
+
114
+ The logger name is prefixed with 'zall.' to keep it under the
115
+ zall root logger's level configuration.
116
+ """
117
+ # Strip leading 'zall.' if already present to avoid 'zall.zall...'
118
+ if name.startswith("zall."):
119
+ logger_name = name
120
+ elif name.startswith("zall"):
121
+ logger_name = f"zall.{name}"
122
+ else:
123
+ logger_name = f"zall.{name}"
124
+
125
+ return logging.getLogger(logger_name)
126
+
127
+
128
+ def get_log_level() -> int:
129
+ """Return the current effective log level for the zall root logger."""
130
+ return logging.getLogger("zall").level