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.
- zall/__init__.py +8 -0
- zall/__main__.py +3 -0
- zall/_util/__init__.py +18 -0
- zall/_util/backoff.py +46 -0
- zall/_util/file.py +118 -0
- zall/_util/file_state.py +123 -0
- zall/_util/hash_utils.py +110 -0
- zall/_util/jsonl.py +85 -0
- zall/_util/logging.py +130 -0
- zall/_util/model_registry.py +342 -0
- zall/_util/path.py +73 -0
- zall/_util/string.py +98 -0
- zall/_util/term.py +150 -0
- zall/_util/tokens.py +60 -0
- zall/_util/toml.py +231 -0
- zall/_util/win32.py +91 -0
- zall/adapters/__init__.py +26 -0
- zall/adapters/anthropic.py +460 -0
- zall/adapters/base.py +432 -0
- zall/adapters/chaos.py +128 -0
- zall/adapters/gemini.py +363 -0
- zall/adapters/ollama.py +315 -0
- zall/adapters/openai_compat.py +581 -0
- zall/adapters/scripted.py +165 -0
- zall/cli/__init__.py +8 -0
- zall/cli/app.py +211 -0
- zall/cli/commands/__init__.py +197 -0
- zall/cli/commands/_common.py +1240 -0
- zall/cli/commands/config.py +345 -0
- zall/cli/commands/eval.py +219 -0
- zall/cli/commands/files.py +251 -0
- zall/cli/commands/git.py +211 -0
- zall/cli/commands/model.py +944 -0
- zall/cli/commands/reload.py +97 -0
- zall/cli/commands/science.py +358 -0
- zall/cli/commands/session.py +465 -0
- zall/cli/commands/suggest.py +512 -0
- zall/cli/commands/system.py +772 -0
- zall/cli/commands/v040.py +427 -0
- zall/cli/config.py +508 -0
- zall/cli/config_layers.py +290 -0
- zall/cli/diff_render.py +428 -0
- zall/cli/environment.py +782 -0
- zall/cli/file_complete.py +236 -0
- zall/cli/init_wizard.py +284 -0
- zall/cli/judge.py +64 -0
- zall/cli/orchestrator.py +674 -0
- zall/cli/paste_fold.py +112 -0
- zall/cli/prompt.py +366 -0
- zall/cli/render.py +1747 -0
- zall/cli/repl_ui.py +595 -0
- zall/cli/replay.py +318 -0
- zall/cli/responder.py +529 -0
- zall/cli/select.py +80 -0
- zall/cli/session.py +755 -0
- zall/cli/syntax_theme.py +102 -0
- zall/cli/theme.py +318 -0
- zall/cli/tui/__init__.py +36 -0
- zall/cli/tui/app.py +1590 -0
- zall/cli/tui/tui_responder.py +93 -0
- zall/cli/tui/widgets.py +1358 -0
- zall/cli/update.py +299 -0
- zall/codegraph/__init__.py +919 -0
- zall/core/__init__.py +178 -0
- zall/core/accountability.py +317 -0
- zall/core/action.py +66 -0
- zall/core/agent.py +563 -0
- zall/core/builder.py +445 -0
- zall/core/chat_state.py +687 -0
- zall/core/checkpoint.py +432 -0
- zall/core/compactor.py +478 -0
- zall/core/context.py +115 -0
- zall/core/context_manager.py +256 -0
- zall/core/coordinator.py +176 -0
- zall/core/eval/__init__.py +362 -0
- zall/core/events.py +159 -0
- zall/core/evidence.py +69 -0
- zall/core/executor.py +463 -0
- zall/core/experience_store.py +335 -0
- zall/core/experiment.py +99 -0
- zall/core/extension.py +248 -0
- zall/core/gate.py +349 -0
- zall/core/goal.py +419 -0
- zall/core/hallucination.py +48 -0
- zall/core/hypothesis.py +203 -0
- zall/core/judge/__init__.py +460 -0
- zall/core/lifecycle.py +262 -0
- zall/core/loop.py +1809 -0
- zall/core/loop_checkpoint.py +152 -0
- zall/core/loop_config.py +99 -0
- zall/core/loop_errors.py +8 -0
- zall/core/loop_events.py +45 -0
- zall/core/loop_model_call.py +217 -0
- zall/core/loop_perception.py +126 -0
- zall/core/memory.py +306 -0
- zall/core/model.py +240 -0
- zall/core/open_ended.py +180 -0
- zall/core/perception/__init__.py +47 -0
- zall/core/perception/coding_sensors.py +463 -0
- zall/core/perception/coding_world_model.py +377 -0
- zall/core/perception/engine.py +300 -0
- zall/core/perception/sensor.py +275 -0
- zall/core/perception/world_model.py +197 -0
- zall/core/plan_mode.py +165 -0
- zall/core/plugin_loader.py +351 -0
- zall/core/policies.py +102 -0
- zall/core/process_anchor/__init__.py +30 -0
- zall/core/process_anchor/client.py +268 -0
- zall/core/process_anchor/protocol.py +124 -0
- zall/core/process_anchor/server.py +340 -0
- zall/core/prompt_template.py +182 -0
- zall/core/proof_gate.py +464 -0
- zall/core/provenance.py +115 -0
- zall/core/red_blue.py +236 -0
- zall/core/refiner.py +320 -0
- zall/core/safety.py +346 -0
- zall/core/sandbox.py +253 -0
- zall/core/sandbox_verifier.py +109 -0
- zall/core/self_improve.py +244 -0
- zall/core/tool.py +443 -0
- zall/core/tool_kind.py +98 -0
- zall/core/toolset.py +242 -0
- zall/core/verifiability.py +579 -0
- zall/eval/__init__.py +15 -0
- zall/eval/metrics.py +348 -0
- zall/extensions/__init__.py +1 -0
- zall/extensions/auto_learn.py +733 -0
- zall/extensions/science/__init__.py +14 -0
- zall/extensions/science/store.py +197 -0
- zall/extensions/usage_tracker.py +91 -0
- zall/lsp/__init__.py +847 -0
- zall/mcp/__init__.py +22 -0
- zall/mcp/client.py +270 -0
- zall/mcp/config.py +191 -0
- zall/mcp/tool.py +129 -0
- zall/plugin/__init__.py +583 -0
- zall/safety/__init__.py +10 -0
- zall/safety/config.py +231 -0
- zall/safety/rules_file.py +578 -0
- zall/safety/sensitive.py +90 -0
- zall/sandbox/__init__.py +630 -0
- zall/skills/__init__.py +11 -0
- zall/skills/loader.py +202 -0
- zall/tools/__init__.py +11 -0
- zall/tools/_bash_semantics.py +256 -0
- zall/tools/_diff.py +24 -0
- zall/tools/apply_patch.py +298 -0
- zall/tools/bash.py +761 -0
- zall/tools/batch_edit.py +331 -0
- zall/tools/code_understanding.py +258 -0
- zall/tools/codegraph.py +350 -0
- zall/tools/edit_file.py +205 -0
- zall/tools/git_protect.py +189 -0
- zall/tools/glob.py +138 -0
- zall/tools/grep.py +346 -0
- zall/tools/list_dir.py +156 -0
- zall/tools/lsp_diagnostics.py +329 -0
- zall/tools/project_analysis.py +221 -0
- zall/tools/pty_executor.py +253 -0
- zall/tools/read_file.py +278 -0
- zall/tools/read_image.py +316 -0
- zall/tools/science.py +506 -0
- zall/tools/search.py +474 -0
- zall/tools/spawn_subagent.py +731 -0
- zall/tools/todo.py +142 -0
- zall/tools/web_fetch.py +502 -0
- zall/tools/write_file.py +150 -0
- zall-0.1.0.dist-info/METADATA +470 -0
- zall-0.1.0.dist-info/RECORD +173 -0
- zall-0.1.0.dist-info/WHEEL +5 -0
- zall-0.1.0.dist-info/entry_points.txt +2 -0
- zall-0.1.0.dist-info/licenses/LICENSE +21 -0
- zall-0.1.0.dist-info/top_level.txt +1 -0
zall/__init__.py
ADDED
zall/__main__.py
ADDED
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
|
zall/_util/file_state.py
ADDED
|
@@ -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
|
zall/_util/hash_utils.py
ADDED
|
@@ -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
|