mosscode 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 (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/usage.py ADDED
@@ -0,0 +1,68 @@
1
+ """Normalize service-reported counters without inventing missing measurements."""
2
+
3
+ from .messages import Usage, UsageTotals
4
+
5
+
6
+ def usage_data(value) -> dict:
7
+ if value is None:
8
+ return {}
9
+ return value if isinstance(value, dict) else value.model_dump()
10
+
11
+
12
+ def counter(data: dict, name: str) -> int | None:
13
+ value = data.get(name)
14
+ return value if type(value) is int and value >= 0 else None
15
+
16
+
17
+ def parse_usage(value, protocol: str) -> Usage:
18
+ data = usage_data(value)
19
+ output = counter(data, "output_tokens" if protocol != "openai-chat-completions" else "completion_tokens")
20
+ if protocol == "anthropic-messages":
21
+ uncached = counter(data, "input_tokens")
22
+ read = counter(data, "cache_read_input_tokens")
23
+ write = counter(data, "cache_creation_input_tokens")
24
+ if data.get("cache_read_input_tokens") is None and data.get("cache_creation_input_tokens") is None:
25
+ total = uncached # Older compatible endpoints may report only total input.
26
+ uncached = None # No reliable decomposition of that reported total.
27
+ else:
28
+ total = uncached + read + write if all(v is not None for v in (uncached, read, write)) else None
29
+ return Usage(total, output, read, write, uncached)
30
+ chat = protocol == "openai-chat-completions"
31
+ total = counter(data, "prompt_tokens" if chat else "input_tokens")
32
+ details = data.get("prompt_tokens_details" if chat else "input_tokens_details") or {}
33
+ if not isinstance(details, dict):
34
+ details = {}
35
+ read = counter(details, "cached_tokens")
36
+ write = counter(details, "cache_write_tokens")
37
+ miss = None
38
+ if chat:
39
+ vendor_read = counter(data, "prompt_cache_hit_tokens")
40
+ if read is None:
41
+ read = vendor_read
42
+ miss = counter(data, "prompt_cache_miss_tokens")
43
+ return Usage(total, output, read, write, miss)
44
+
45
+
46
+ def accumulated(value: int, missing: int, requests: int, unknown: str = "未提供") -> str:
47
+ if missing == requests:
48
+ return unknown
49
+ return ("≥" if missing else "") + str(value)
50
+
51
+
52
+ def cache_summary(total: UsageTotals) -> str:
53
+ read = accumulated(total.cached_input_tokens, total.missing_cached, total.requests)
54
+ text = f"缓存读 {read}"
55
+ if total.missing_cache_write < total.requests:
56
+ text += " / 写 " + accumulated(total.cache_write_tokens, total.missing_cache_write, total.requests)
57
+ return text
58
+
59
+
60
+ def request_usage_text(usage: Usage, iteration: int) -> str:
61
+ def show(value):
62
+ return "未提供" if value is None else str(value)
63
+
64
+ return (
65
+ f"第 {iteration} 次请求 · Token 入 {show(usage.input_tokens)} / 出 {show(usage.output_tokens)}"
66
+ f" · 缓存读 {show(usage.cached_input_tokens)} / 写 {show(usage.cache_write_tokens)}"
67
+ f" · 未缓存输入 {show(usage.uncached_input_tokens)}"
68
+ )
@@ -0,0 +1,148 @@
1
+ """Use Textual's inline compositor with its existing Windows input driver.
2
+
3
+ Textual 8 does not ship a Windows inline driver. Keep its console input and mode
4
+ restoration, but stay in the main buffer and consume cursor-position reports.
5
+ """
6
+
7
+ import asyncio
8
+ import ctypes
9
+ from ctypes import byref, wintypes
10
+
11
+ from textual import constants, events
12
+ from textual._xterm_parser import XTermParser
13
+ from textual.drivers import win32
14
+ from textual.drivers._writer_thread import WriterThread
15
+ from textual.drivers.windows_driver import WindowsDriver
16
+
17
+ KEY_EVENT, WINDOW_BUFFER_SIZE_EVENT = 0x0001, 0x0004
18
+ VK_RETURN, VK_SHIFT = 0x0D, 0x10
19
+ SHIFT_PRESSED = 0x0010
20
+ CTRL_OR_ALT = 0x000F # Right/left Alt and Ctrl.
21
+ # Kitty keyboard protocol encoding, which Textual's parser reads as "shift+enter".
22
+ SHIFT_ENTER = "\x1b[13;2u"
23
+
24
+
25
+ def physical_shift_down() -> bool:
26
+ return bool(ctypes.windll.user32.GetAsyncKeyState(VK_SHIFT) & 0x8000)
27
+
28
+
29
+ def _shift_enter(records, index: int, shift_down) -> bool:
30
+ event = records[index].Event.KeyEvent
31
+ state = event.dwControlKeyState
32
+ if event.wVirtualKeyCode != VK_RETURN:
33
+ # With VT input enabled the console replaces Enter's key-down record by a
34
+ # bare "\r" without key code or modifiers, so Shift+Enter would submit.
35
+ # conhost keeps the modifiers on the key-up record when it follows in the
36
+ # same batch; Windows Terminal leaves them out there too.
37
+ following = records[index + 1] if index + 1 < len(records) else None
38
+ if (
39
+ following is not None
40
+ and following.EventType == KEY_EVENT
41
+ and not following.Event.KeyEvent.bKeyDown
42
+ and following.Event.KeyEvent.wVirtualKeyCode == VK_RETURN
43
+ ):
44
+ state = following.Event.KeyEvent.dwControlKeyState
45
+ # A record may omit Shift; the keyboard state read right after the key press
46
+ # covers that, so either source is enough.
47
+ return (bool(state & SHIFT_PRESSED) or shift_down()) and not state & CTRL_OR_ALT
48
+
49
+
50
+ def key_text(records, shift_down=physical_shift_down) -> str:
51
+ """Text for the parser from one batch of console input records."""
52
+ keys = []
53
+ for index, record in enumerate(records):
54
+ if record.EventType != KEY_EVENT:
55
+ continue
56
+ event = record.Event.KeyEvent
57
+ if not event.bKeyDown or (event.dwControlKeyState and event.wVirtualKeyCode == 0):
58
+ continue
59
+ key = event.uChar.UnicodeChar
60
+ keys.append(SHIFT_ENTER if key == "\r" and _shift_enter(records, index, shift_down) else key)
61
+ # Textual issue 3178: join surrogate pairs typed as two records.
62
+ return "".join(keys).encode("utf-16", "surrogatepass").decode("utf-16")
63
+
64
+
65
+ class EventMonitor(win32.EventMonitor):
66
+ """Textual's console input thread with Shift+Enter kept apart from Enter."""
67
+
68
+ def run(self) -> None:
69
+ exit_requested = self.exit_event.is_set
70
+ parser = XTermParser(debug=constants.DEBUG)
71
+ try:
72
+ read_count = wintypes.DWORD(0)
73
+ handle = win32.GetStdHandle(win32.STD_INPUT_HANDLE)
74
+ input_records = (win32.INPUT_RECORD * 1024)()
75
+ while not exit_requested():
76
+ for event in parser.tick():
77
+ self.process_event(event)
78
+ if win32.wait_for_handles([handle], 100) is None:
79
+ continue
80
+ win32.KERNEL32.ReadConsoleInputW(handle, byref(input_records), 1024, byref(read_count))
81
+ records = input_records[: read_count.value]
82
+ text = key_text(records)
83
+ new_size = None
84
+ for record in records:
85
+ if record.EventType == WINDOW_BUFFER_SIZE_EVENT:
86
+ size = record.Event.WindowBufferSizeEvent.dwSize
87
+ new_size = (size.X, size.Y)
88
+ if text:
89
+ for event in parser.feed(text):
90
+ self.process_event(event)
91
+ if new_size is not None:
92
+ self.on_size_change(*new_size)
93
+ except Exception as error:
94
+ self.app.log.error("EVENT MONITOR ERROR", error)
95
+
96
+
97
+ class WindowsInlineDriver(WindowsDriver):
98
+ @property
99
+ def is_inline(self) -> bool:
100
+ return True
101
+
102
+ def start_application_mode(self) -> None:
103
+ # Same sequence as WindowsDriver, with the input thread replaced.
104
+ loop = asyncio.get_running_loop()
105
+ self._restore_console = win32.enable_application_mode()
106
+ self._writer_thread = WriterThread(self._file)
107
+ self._writer_thread.start()
108
+ self.write("\x1b[?1049h")
109
+ self._enable_mouse_support()
110
+ self.write("\x1b[?25l")
111
+ self.write("\033[?1004h")
112
+ self.write("\x1b[>1u")
113
+ self.flush()
114
+ self._enable_bracketed_paste()
115
+ self._event_thread = EventMonitor(loop, self._app, self.exit_event, self.process_message)
116
+ self._event_thread.start()
117
+
118
+ def write(self, data: str) -> None:
119
+ # WindowsDriver emits these only at startup / shutdown. Do not enter or
120
+ # leave the alternate screen: inline rendering uses relative cursor moves.
121
+ data = data.replace("\x1b[?1049h", "\r\n" * self._app.INLINE_PADDING)
122
+ # On exit Textual has already placed the cursor after retained output,
123
+ # or at the top of a temporary picker. Clear only below that cursor.
124
+ data = data.replace("\x1b[?1049l", "\x1b[J")
125
+ if data:
126
+ # Textual prints the retained screen directly on inline exit. Keep
127
+ # cursor updates synchronous so queued writes cannot race that print.
128
+ self._file.write(data)
129
+
130
+ def flush(self) -> None:
131
+ self._file.flush()
132
+
133
+ def _enable_mouse_support(self) -> None:
134
+ # Inline transcript uses the terminal's scrollback, even if a caller
135
+ # supplied mouse=True or an interrupted older app left tracking enabled.
136
+ self.write("\x1b[?1000l\x1b[?1003l\x1b[?1015l\x1b[?1006l")
137
+ self.flush()
138
+
139
+ def process_message(self, message) -> None:
140
+ if isinstance(message, events.MouseEvent):
141
+ # This interface is keyboard-operated: mouse clicks cannot execute
142
+ # commands or shift focus away from the prompt. Native wheel input
143
+ # belongs to the terminal; ignore any stale mouse reports.
144
+ return
145
+ elif isinstance(message, events.CursorPosition):
146
+ self.cursor_origin = (message.x, message.y)
147
+ else:
148
+ super().process_message(message)
@@ -0,0 +1,29 @@
1
+ """Git worktrees, so a sub-agent can change files without touching the main directory."""
2
+
3
+ from .changes import Changes, count_changes
4
+ from .git import WorktreeError, find_repo, read_head
5
+ from .manager import CleanReport, Created, Entry, Finished, WorktreeChanged, WorktreeManager
6
+ from .meta import WorktreeMeta, load_meta, process_alive
7
+ from .names import EPHEMERAL, branch_of, ephemeral_name, flatten, validate_name
8
+
9
+ __all__ = [
10
+ "Changes",
11
+ "CleanReport",
12
+ "Created",
13
+ "EPHEMERAL",
14
+ "Entry",
15
+ "Finished",
16
+ "WorktreeChanged",
17
+ "WorktreeError",
18
+ "WorktreeManager",
19
+ "WorktreeMeta",
20
+ "branch_of",
21
+ "count_changes",
22
+ "ephemeral_name",
23
+ "find_repo",
24
+ "flatten",
25
+ "load_meta",
26
+ "process_alive",
27
+ "read_head",
28
+ "validate_name",
29
+ ]
@@ -0,0 +1,59 @@
1
+ """Does a worktree hold work that deleting it would lose? Any doubt counts as yes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from . import git
9
+ from .git import WorktreeError
10
+
11
+ FILE_LIMIT = 10
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Changes:
16
+ uncommitted: int = 0
17
+ commits: int = 0
18
+ files: tuple[str, ...] = ()
19
+ error: str = "" # The check failed; uncommitted is then 1.
20
+
21
+ @property
22
+ def any(self) -> bool:
23
+ return self.uncommitted > 0 or self.commits > 0
24
+
25
+ def describe(self) -> str:
26
+ if self.error:
27
+ return f"无法确认是否有改动({self.error}),按有改动处理"
28
+ parts = []
29
+ if self.uncommitted:
30
+ parts.append(f"未提交 {self.uncommitted} 个文件")
31
+ if self.commits:
32
+ parts.append(f"独有提交 {self.commits} 个")
33
+ return ",".join(parts) or "无改动"
34
+
35
+
36
+ def count_changes(path: Path, branch: str, skip=()) -> Changes:
37
+ """skip: links and copied files MossCode put there itself."""
38
+ skip = {item.rstrip("/") for item in skip}
39
+ try:
40
+ status = git.run_git(["status", "--porcelain", "--untracked-files=all"], path)
41
+ if status.returncode != 0:
42
+ return Changes(1, 0, (), (status.stderr or "git status 失败").strip()[:200])
43
+ files = []
44
+ for line in status.stdout.splitlines():
45
+ name = line[3:].split(" -> ")[-1].strip().strip('"').rstrip("/")
46
+ # On Windows git walks into a junction, so the files under a link show up one by one.
47
+ if name and not any(name == item or name.startswith(item + "/") for item in skip):
48
+ files.append(name)
49
+ # With --branches the exclude pattern is relative to refs/heads/.
50
+ commits = git.run_git(
51
+ ["rev-list", "--count", "HEAD", "--not", f"--exclude={branch}", "--branches", "--remotes"], path
52
+ )
53
+ if commits.returncode != 0:
54
+ return Changes(
55
+ max(len(files), 1), 0, tuple(files[:FILE_LIMIT]), (commits.stderr or "git rev-list 失败").strip()[:200]
56
+ )
57
+ return Changes(len(files), int(commits.stdout.strip() or "0"), tuple(files[:FILE_LIMIT]))
58
+ except (WorktreeError, ValueError) as exc:
59
+ return Changes(1, 0, (), str(exc)[:200])
@@ -0,0 +1,125 @@
1
+ """Git plumbing for worktrees: run git without prompts, find the repository, read HEAD without git."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ GIT_TIMEOUT = 120
11
+ EXCLUDE_LINE = "/.mosscode/worktrees/"
12
+
13
+
14
+ class WorktreeError(Exception):
15
+ def __init__(self, message: str, code: str = "worktree_error"):
16
+ super().__init__(message)
17
+ self.code = code
18
+
19
+
20
+ def run_git(args, cwd: Path, *, timeout: float = GIT_TIMEOUT) -> subprocess.CompletedProcess:
21
+ """No terminal prompt, no stdin; a missing git is a WorktreeError."""
22
+ executable = shutil.which("git")
23
+ if executable is None:
24
+ raise WorktreeError("找不到 git,无法使用工作树。", "worktree_unavailable")
25
+ env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "", "LC_ALL": "C"}
26
+ try:
27
+ return subprocess.run(
28
+ [executable, "-c", "core.quotepath=false", *args],
29
+ cwd=str(cwd),
30
+ env=env,
31
+ stdin=subprocess.DEVNULL,
32
+ capture_output=True,
33
+ timeout=timeout,
34
+ encoding="utf-8",
35
+ errors="replace",
36
+ )
37
+ except subprocess.TimeoutExpired:
38
+ raise WorktreeError(f"git {args[0]} 超过 {timeout:g} 秒未完成。") from None
39
+ except OSError as exc:
40
+ raise WorktreeError(f"无法运行 git:{exc}", "worktree_unavailable") from None
41
+
42
+
43
+ def git_text(args, cwd: Path) -> str:
44
+ """stdout of a git command that must succeed."""
45
+ done = run_git(args, cwd)
46
+ if done.returncode != 0:
47
+ raise WorktreeError(f"git {' '.join(args)} 失败:{(done.stderr or done.stdout).strip()[:500]}")
48
+ return done.stdout.strip()
49
+
50
+
51
+ def find_repo(start: Path) -> tuple[Path, Path]:
52
+ """(top level of the work tree, common git directory)."""
53
+ done = run_git(["rev-parse", "--is-bare-repository", "--show-toplevel", "--git-common-dir"], start)
54
+ lines = done.stdout.strip().splitlines()
55
+ if done.returncode != 0 or len(lines) < 3:
56
+ if lines[:1] == ["true"]:
57
+ raise WorktreeError("当前仓库是裸仓库,无法使用工作树。", "worktree_unavailable")
58
+ raise WorktreeError("项目目录不在 git 仓库中,无法使用工作树。", "worktree_unavailable")
59
+ if lines[0] == "true":
60
+ raise WorktreeError("当前仓库是裸仓库,无法使用工作树。", "worktree_unavailable")
61
+ top = Path(lines[1]).resolve()
62
+ common = Path(lines[2])
63
+ common = (common if common.is_absolute() else start / common).resolve()
64
+ if run_git(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], top).returncode != 0:
65
+ raise WorktreeError("仓库还没有任何提交,无法创建工作树。", "worktree_unavailable")
66
+ return top, common
67
+
68
+
69
+ def ensure_excluded(common: Path) -> bool:
70
+ """True when the line was added."""
71
+ path = common / "info" / "exclude"
72
+ try:
73
+ text = path.read_text(encoding="utf-8", errors="replace")
74
+ except FileNotFoundError:
75
+ text = ""
76
+ if any(line.strip() == EXCLUDE_LINE for line in text.splitlines()):
77
+ return False
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ with path.open("a", encoding="utf-8", newline="\n") as stream:
80
+ stream.write(("" if not text or text.endswith("\n") else "\n") + EXCLUDE_LINE + "\n")
81
+ return True
82
+
83
+
84
+ def _read(path: Path) -> str | None:
85
+ try:
86
+ return path.read_text(encoding="utf-8", errors="replace").strip()
87
+ except OSError:
88
+ return None
89
+
90
+
91
+ def _resolve_ref(ref: str, gitdir: Path, common: Path) -> str | None:
92
+ for base in (gitdir, common):
93
+ value = _read(base / ref)
94
+ if value:
95
+ return value
96
+ packed = _read(common / "packed-refs") or ""
97
+ for line in packed.splitlines():
98
+ if line and line[0] not in "#^":
99
+ sha, _, name = line.partition(" ")
100
+ if name.strip() == ref:
101
+ return sha
102
+ return None
103
+
104
+
105
+ def read_head(worktree: Path) -> tuple[str, str] | None:
106
+ """(commit SHA, ref or "") from .git -> gitdir -> HEAD -> loose ref or packed-refs; no git process."""
107
+ pointer = _read(worktree / ".git")
108
+ if not pointer or not pointer.startswith("gitdir:"):
109
+ return None
110
+ gitdir = Path(pointer.split(":", 1)[1].strip())
111
+ gitdir = gitdir if gitdir.is_absolute() else (worktree / gitdir).resolve()
112
+ head = _read(gitdir / "HEAD")
113
+ if not head:
114
+ return None
115
+ relative = _read(gitdir / "commondir")
116
+ common = (gitdir / relative).resolve() if relative else gitdir
117
+ if head.startswith("ref:"):
118
+ ref = head[4:].strip()
119
+ sha = _resolve_ref(ref, gitdir, common)
120
+ return (sha, ref) if sha and _is_sha(sha) else None
121
+ return (head, "") if _is_sha(head) else None
122
+
123
+
124
+ def _is_sha(value: str) -> bool:
125
+ return len(value) in {40, 64} and all(c in "0123456789abcdef" for c in value)