zettcode-engine 0.3.4__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.
- zettcode/_compat.py +35 -0
- zettcode/budget.py +24 -0
- zettcode/config.py +1088 -0
- zettcode/config_edit.py +457 -0
- zettcode/engine/__init__.py +39 -0
- zettcode/engine/agent/__init__.py +81 -0
- zettcode/engine/agent/base.py +60 -0
- zettcode/engine/agent/context.py +166 -0
- zettcode/engine/agent/exports.py +73 -0
- zettcode/engine/agent/interaction.py +54 -0
- zettcode/engine/agent/sessions.py +218 -0
- zettcode/engine/agent/settings.py +269 -0
- zettcode/engine/agent/turns.py +308 -0
- zettcode/engine/core.py +819 -0
- zettcode/engine/dialogue/__init__.py +6 -0
- zettcode/engine/dialogue/ask.py +113 -0
- zettcode/engine/dialogue/side.py +197 -0
- zettcode/engine/dialogue/title.py +80 -0
- zettcode/engine/export/__init__.py +421 -0
- zettcode/engine/export/trace/script.js +18 -0
- zettcode/engine/export/trace/style.css +136 -0
- zettcode/engine/export/trace/template.html +31 -0
- zettcode/engine/extensions/__init__.py +8 -0
- zettcode/engine/extensions/capabilities.py +68 -0
- zettcode/engine/extensions/compaction.py +259 -0
- zettcode/engine/extensions/context.py +521 -0
- zettcode/engine/extensions/files.py +101 -0
- zettcode/engine/extensions/guidance.py +17 -0
- zettcode/engine/extensions/harness.py +208 -0
- zettcode/engine/extensions/harness_switch.py +18 -0
- zettcode/engine/extensions/mcp_notice.py +49 -0
- zettcode/engine/extensions/relay.py +93 -0
- zettcode/engine/extensions/subagents.py +391 -0
- zettcode/engine/extensions/tool_output.py +153 -0
- zettcode/engine/extensions/truncation.py +156 -0
- zettcode/engine/extensions/usage.py +304 -0
- zettcode/engine/harness_paths.py +74 -0
- zettcode/engine/permission/__init__.py +197 -0
- zettcode/engine/permission/approval.py +66 -0
- zettcode/engine/permission/readonly.py +435 -0
- zettcode/engine/permission/sandbox.py +140 -0
- zettcode/engine/pipeline.py +191 -0
- zettcode/engine/plugins/__init__.py +72 -0
- zettcode/engine/plugins/commands.py +152 -0
- zettcode/engine/plugins/context.py +251 -0
- zettcode/engine/plugins/extension.py +183 -0
- zettcode/engine/plugins/groups.py +74 -0
- zettcode/engine/plugins/loader.py +367 -0
- zettcode/engine/plugins/mixins.py +156 -0
- zettcode/engine/plugins/plugin.py +190 -0
- zettcode/engine/plugins/services.py +68 -0
- zettcode/engine/plugins/settings.py +117 -0
- zettcode/engine/plugins/state.py +64 -0
- zettcode/engine/plugins/switches.py +553 -0
- zettcode/engine/py.typed +0 -0
- zettcode/engine/server_tools.py +179 -0
- zettcode/engine/storage/__init__.py +51 -0
- zettcode/engine/storage/layout.py +161 -0
- zettcode/engine/storage/ledger.py +659 -0
- zettcode/engine/storage/locks.py +182 -0
- zettcode/engine/storage/metadata.py +278 -0
- zettcode/engine/storage/records.py +373 -0
- zettcode/engine/storage/store.py +1056 -0
- zettcode/engine/tools/__init__.py +7 -0
- zettcode/engine/tools/boundary.py +56 -0
- zettcode/engine/tools/files.py +262 -0
- zettcode/engine/tools/images.py +32 -0
- zettcode/engine/tools/output.py +81 -0
- zettcode/engine/tools/shell.py +181 -0
- zettcode/engine/tools/workspace.py +271 -0
- zettcode/error_text.py +217 -0
- zettcode/filelock.py +125 -0
- zettcode/logging_setup.py +196 -0
- zettcode/mention.py +124 -0
- zettcode/modes.py +18 -0
- zettcode/paths.py +113 -0
- zettcode/plugin_tui.py +133 -0
- zettcode/processes.py +126 -0
- zettcode/prompt.py +94 -0
- zettcode/providers.py +215 -0
- zettcode/py.typed +0 -0
- zettcode/registry.py +63 -0
- zettcode/relay.py +267 -0
- zettcode/statusline_parts.py +84 -0
- zettcode/version.py +11 -0
- zettcode/wire.py +30 -0
- zettcode/wording.py +11 -0
- zettcode_engine-0.3.4.dist-info/METADATA +23 -0
- zettcode_engine-0.3.4.dist-info/RECORD +90 -0
- zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Process-wide setup for the standard-library loggers.
|
|
2
|
+
|
|
3
|
+
Every component uses ordinary ``logging`` calls::
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("zettcode.terminal")
|
|
6
|
+
logger.info("turn.start", extra={"session": session})
|
|
7
|
+
logger.exception("turn.failed")
|
|
8
|
+
|
|
9
|
+
``setup`` adds one rotating JSON-lines handler under ``~/.zettcode/log``. The
|
|
10
|
+
terminal's stderr capture stays in ``tui.log``; structured events go to
|
|
11
|
+
``terminal.jsonl`` so rotation never renames the file fd 2 writes to.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from logging.handlers import RotatingFileHandler
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
DEFAULT_MAX_BYTES = 8 * 1024 * 1024
|
|
25
|
+
DEFAULT_BACKUPS = 3
|
|
26
|
+
MAX_MESSAGE = 2_000
|
|
27
|
+
MAX_TRACEBACK = 20_000
|
|
28
|
+
MAX_RECORD_CHARS = 64_000
|
|
29
|
+
DEFAULT_DIR = Path.home() / ".zettcode" / "log"
|
|
30
|
+
|
|
31
|
+
_LEVELS = {"debug": logging.DEBUG, "info": logging.INFO, "warning": logging.WARNING, "error": logging.ERROR}
|
|
32
|
+
_RESERVED = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"name",
|
|
35
|
+
"msg",
|
|
36
|
+
"args",
|
|
37
|
+
"levelname",
|
|
38
|
+
"levelno",
|
|
39
|
+
"pathname",
|
|
40
|
+
"filename",
|
|
41
|
+
"module",
|
|
42
|
+
"exc_info",
|
|
43
|
+
"exc_text",
|
|
44
|
+
"stack_info",
|
|
45
|
+
"lineno",
|
|
46
|
+
"funcName",
|
|
47
|
+
"created",
|
|
48
|
+
"msecs",
|
|
49
|
+
"relativeCreated",
|
|
50
|
+
"thread",
|
|
51
|
+
"threadName",
|
|
52
|
+
"processName",
|
|
53
|
+
"process",
|
|
54
|
+
"message",
|
|
55
|
+
"asctime",
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _env_positive(name: str, default: int) -> int:
|
|
61
|
+
try:
|
|
62
|
+
value = int(os.getenv(name, ""))
|
|
63
|
+
except ValueError:
|
|
64
|
+
return default
|
|
65
|
+
return value if value > 0 else default
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _env_non_negative(name: str, default: int) -> int:
|
|
69
|
+
try:
|
|
70
|
+
value = int(os.getenv(name, ""))
|
|
71
|
+
except ValueError:
|
|
72
|
+
return default
|
|
73
|
+
return value if value >= 0 else default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def level() -> int:
|
|
77
|
+
"""Return the configured threshold; an unknown value falls back to info."""
|
|
78
|
+
return _LEVELS.get(os.getenv("ZETTCODE_LOG_LEVEL", "info").strip().lower(), logging.INFO)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def log_path(component: str) -> Path:
|
|
82
|
+
"""Return the JSONL file for one component."""
|
|
83
|
+
return DEFAULT_DIR / f"{component}.jsonl"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _OwnerRotatingHandler(RotatingFileHandler):
|
|
87
|
+
"""Rotating handler that creates its file owner-only, not with the umask."""
|
|
88
|
+
|
|
89
|
+
def _open(self) -> Any:
|
|
90
|
+
stream = super()._open()
|
|
91
|
+
if os.name != "nt":
|
|
92
|
+
try:
|
|
93
|
+
os.chmod(self.baseFilename, 0o600)
|
|
94
|
+
except OSError:
|
|
95
|
+
pass
|
|
96
|
+
return stream
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class JsonFormatter(logging.Formatter):
|
|
100
|
+
"""One JSON object per record, including the caller's ``extra`` fields."""
|
|
101
|
+
|
|
102
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
103
|
+
payload: dict[str, Any] = {
|
|
104
|
+
"time": datetime.fromtimestamp(record.created, timezone.utc).isoformat(timespec="milliseconds"),
|
|
105
|
+
"level": record.levelname.lower(),
|
|
106
|
+
"logger": record.name,
|
|
107
|
+
"event": record.getMessage()[:MAX_MESSAGE],
|
|
108
|
+
"pid": record.process,
|
|
109
|
+
}
|
|
110
|
+
for key, value in record.__dict__.items():
|
|
111
|
+
if key not in _RESERVED and not key.startswith("_"):
|
|
112
|
+
payload[key] = value
|
|
113
|
+
if record.exc_info is not None:
|
|
114
|
+
formatted = self.formatException(record.exc_info)
|
|
115
|
+
if len(formatted) > MAX_TRACEBACK:
|
|
116
|
+
marker = "\n<traceback truncated>\n"
|
|
117
|
+
keep = MAX_TRACEBACK - len(marker)
|
|
118
|
+
head, tail = keep // 2, keep - keep // 2
|
|
119
|
+
formatted = formatted[:head] + marker + formatted[-tail:]
|
|
120
|
+
payload["traceback"] = formatted
|
|
121
|
+
line = json.dumps(payload, ensure_ascii=False, default=str)
|
|
122
|
+
if len(line) > MAX_RECORD_CHARS:
|
|
123
|
+
line = json.dumps(
|
|
124
|
+
{
|
|
125
|
+
"time": payload["time"],
|
|
126
|
+
"level": payload["level"],
|
|
127
|
+
"logger": payload["logger"],
|
|
128
|
+
"event": payload["event"],
|
|
129
|
+
"pid": payload["pid"],
|
|
130
|
+
"truncated": True,
|
|
131
|
+
"original_chars": len(line),
|
|
132
|
+
},
|
|
133
|
+
ensure_ascii=False,
|
|
134
|
+
)
|
|
135
|
+
return line
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ensure_log_parent(path: Path) -> None:
|
|
139
|
+
"""Create the folder a log file lives in, owner-only when this call made it."""
|
|
140
|
+
parent = path.parent
|
|
141
|
+
created = not parent.exists()
|
|
142
|
+
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
143
|
+
if created and os.name != "nt":
|
|
144
|
+
try:
|
|
145
|
+
os.chmod(parent, 0o700)
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def setup(component: str, *, path: Path | None = None) -> logging.Logger:
|
|
151
|
+
"""Configure and return ``zettcode.<component>``; safe to call repeatedly."""
|
|
152
|
+
logger = logging.getLogger(f"zettcode.{component}")
|
|
153
|
+
logging.raiseExceptions = False
|
|
154
|
+
logger.setLevel(level())
|
|
155
|
+
logger.propagate = False
|
|
156
|
+
logger.handlers.clear()
|
|
157
|
+
target = path or log_path(component)
|
|
158
|
+
try:
|
|
159
|
+
ensure_log_parent(target)
|
|
160
|
+
handler: logging.Handler = _OwnerRotatingHandler(
|
|
161
|
+
target,
|
|
162
|
+
maxBytes=_env_positive("ZETTCODE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES),
|
|
163
|
+
backupCount=_env_non_negative("ZETTCODE_LOG_BACKUPS", DEFAULT_BACKUPS),
|
|
164
|
+
encoding="utf-8",
|
|
165
|
+
delay=True,
|
|
166
|
+
)
|
|
167
|
+
except OSError:
|
|
168
|
+
handler = logging.NullHandler()
|
|
169
|
+
handler.setLevel(logging.DEBUG)
|
|
170
|
+
handler.setFormatter(JsonFormatter())
|
|
171
|
+
logger.addHandler(handler)
|
|
172
|
+
if not isinstance(handler, logging.NullHandler) and os.name != "nt":
|
|
173
|
+
try:
|
|
174
|
+
os.chmod(target, 0o600)
|
|
175
|
+
except OSError:
|
|
176
|
+
pass
|
|
177
|
+
return logger
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def rotate_text(path: Path, *, max_bytes: int | None = None, backups: int | None = None) -> None:
|
|
181
|
+
"""Rotate a text log at startup, before a raw stderr fd opens it."""
|
|
182
|
+
limit = max_bytes if max_bytes is not None else _env_positive("ZETTCODE_LOG_MAX_BYTES", DEFAULT_MAX_BYTES)
|
|
183
|
+
count = backups if backups is not None else _env_non_negative("ZETTCODE_LOG_BACKUPS", DEFAULT_BACKUPS)
|
|
184
|
+
try:
|
|
185
|
+
if not path.is_file() or path.stat().st_size <= limit:
|
|
186
|
+
return
|
|
187
|
+
if count <= 0:
|
|
188
|
+
path.unlink(missing_ok=True)
|
|
189
|
+
return
|
|
190
|
+
for index in range(count - 1, 0, -1):
|
|
191
|
+
source = path.with_name(f"{path.name}.{index}")
|
|
192
|
+
if source.exists():
|
|
193
|
+
source.replace(path.with_name(f"{path.name}.{index + 1}"))
|
|
194
|
+
path.replace(path.with_name(f"{path.name}.1"))
|
|
195
|
+
except OSError:
|
|
196
|
+
pass
|
zettcode/mention.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""What an ``@`` reference is, and the shape a provider of them has.
|
|
2
|
+
|
|
3
|
+
The runtime resolves references against providers a plugin hands it (:class:`zettcode.engine.plugins.PluginContext`),
|
|
4
|
+
so the type is the engine's: a plugin that offers resources implements this, and the engine reads it without knowing
|
|
5
|
+
whether the resources are files, skills or other sessions. The implementations and the registry over them are
|
|
6
|
+
:mod:`zettcode.mentions`, which ships with the SDK.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from zettcode.registry import Provider, Registry
|
|
17
|
+
|
|
18
|
+
#: One mention token: an ``@`` at a word boundary, then a name. A name is what a
|
|
19
|
+
#: skill is called (lowercase words) or where a file lives (``src/app.py``), so it
|
|
20
|
+
#: holds path punctuation as well; it starts with a letter, a digit, or an
|
|
21
|
+
#: underscore, which is what keeps ``@/`` and a trailing ``@`` out. The lookbehind
|
|
22
|
+
#: keeps an email address or a second ``@`` from matching.
|
|
23
|
+
MENTION = re.compile(r"(?<![\w@])@([A-Za-z0-9_][A-Za-z0-9_./-]*)")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class Mention:
|
|
28
|
+
"""One resource a draft can reference.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
token: Text as typed, e.g. ``@code-review``.
|
|
32
|
+
kind: Provider that owns the resource, e.g. ``"skill"``.
|
|
33
|
+
name: Resource name without the ``@``.
|
|
34
|
+
description: One line shown by the completion menu.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
token: str
|
|
38
|
+
kind: str
|
|
39
|
+
name: str
|
|
40
|
+
description: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MentionProvider(Provider[Mention], ABC):
|
|
44
|
+
"""One kind of ``@`` resource: what it offers and what it injects."""
|
|
45
|
+
|
|
46
|
+
#: Short name shown in the completion menu's type column, e.g. ``"skill"``.
|
|
47
|
+
kind: str
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def candidates(self, query: str) -> Sequence[Mention]:
|
|
51
|
+
"""Return the resources whose name matches ``query`` (without the ``@``)."""
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def items(self) -> Sequence[Mention]:
|
|
55
|
+
"""Return every resource this provider offers."""
|
|
56
|
+
return self.candidates("")
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def expand(self, name: str) -> str | None:
|
|
60
|
+
"""Return the text one resource injects, or ``None`` when it is unknown."""
|
|
61
|
+
|
|
62
|
+
def rename(self, name: str) -> str | None:
|
|
63
|
+
"""Return the words the model reads in place of ``@name``, or ``None`` to leave the token as typed.
|
|
64
|
+
|
|
65
|
+
Most references are meaningful to the model as they are (a path, a skill's name). One that is not —
|
|
66
|
+
the name of another session — is replaced, so the model is not left holding a word it must guess at.
|
|
67
|
+
"""
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MentionRegistry(Registry[Mention]):
|
|
72
|
+
"""The providers a draft is resolved against, in registration order."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, providers: Sequence[MentionProvider] = ()) -> None:
|
|
75
|
+
"""Keep the providers; the first to claim a name wins a duplicate."""
|
|
76
|
+
super().__init__(providers)
|
|
77
|
+
|
|
78
|
+
def candidates(self, query: str) -> tuple[Mention, ...]:
|
|
79
|
+
"""Return every resource whose name matches ``query`` (which has no ``@``).
|
|
80
|
+
|
|
81
|
+
Each provider is asked for the query rather than handed every item it has:
|
|
82
|
+
a draft is completed on every keystroke, and the file provider holds
|
|
83
|
+
thousands of names it would otherwise build a ``Mention`` for each time.
|
|
84
|
+
An earlier provider still wins a duplicate name.
|
|
85
|
+
"""
|
|
86
|
+
seen: set[str] = set()
|
|
87
|
+
merged: list[Mention] = []
|
|
88
|
+
for provider in self.mention_providers:
|
|
89
|
+
for mention in provider.candidates(query):
|
|
90
|
+
if mention.name in seen:
|
|
91
|
+
continue
|
|
92
|
+
seen.add(mention.name)
|
|
93
|
+
merged.append(mention)
|
|
94
|
+
return tuple(merged)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def mention_providers(self) -> tuple[MentionProvider, ...]:
|
|
98
|
+
"""Return the providers as the ``@``-specific type that can expand tokens."""
|
|
99
|
+
return tuple(provider for provider in self.providers if isinstance(provider, MentionProvider))
|
|
100
|
+
|
|
101
|
+
def expand_token(self, token: str) -> str | None:
|
|
102
|
+
"""Resolve one typed ``@`` token into the text it contributes, if any.
|
|
103
|
+
|
|
104
|
+
The shell gets the tokens from the composer's parts rather than scanning
|
|
105
|
+
the draft, so this is handed ``"@name"`` exactly as it was written.
|
|
106
|
+
"""
|
|
107
|
+
name = token[1:] if token.startswith("@") else token
|
|
108
|
+
for provider in self.mention_providers:
|
|
109
|
+
block = provider.expand(name)
|
|
110
|
+
if block is not None:
|
|
111
|
+
return block
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def rename_token(self, token: str) -> str | None:
|
|
115
|
+
"""Return the words the model reads in place of one typed token, from the provider that owns it.
|
|
116
|
+
|
|
117
|
+
The owner is the first provider that knows the name, which is the one :meth:`expand_token` asks:
|
|
118
|
+
a session and a file with one name are the session.
|
|
119
|
+
"""
|
|
120
|
+
name = token[1:] if token.startswith("@") else token
|
|
121
|
+
for provider in self.mention_providers:
|
|
122
|
+
if provider.expand(name) is not None:
|
|
123
|
+
return provider.rename(name)
|
|
124
|
+
return None
|
zettcode/modes.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""How a message asks to be handled by a recipient that is busy with a request.
|
|
2
|
+
|
|
3
|
+
A leaf with no imports, so the broker, the SDK, the server and the shell can name the two modes without one of them
|
|
4
|
+
depending on another. The broker's protocol re-exports these names, because that is where they are checked; an
|
|
5
|
+
unknown value is refused there, and a missing one means :data:`MODE_STEER`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
#: Hand the message to the request that is running, so it changes course.
|
|
11
|
+
MODE_STEER = "steer"
|
|
12
|
+
|
|
13
|
+
#: Wait until the request that is running ends.
|
|
14
|
+
MODE_FOLLOWUP = "followup"
|
|
15
|
+
|
|
16
|
+
MODES = (MODE_STEER, MODE_FOLLOWUP)
|
|
17
|
+
|
|
18
|
+
__all__ = ["MODE_FOLLOWUP", "MODE_STEER", "MODES"]
|
zettcode/paths.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Where ZettCode keeps its own files.
|
|
2
|
+
|
|
3
|
+
Constants only, and deliberately importable without the settings module: the
|
|
4
|
+
shell takes the terminal and points its diagnostics at the log before it has
|
|
5
|
+
read the config, so the paths cannot live behind that import.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shlex
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
#: The conventional config file, read when the user has created one.
|
|
15
|
+
CONFIG_FILE = Path.home() / ".zettcode" / "config.toml"
|
|
16
|
+
|
|
17
|
+
#: Session storage is an application default, not a model setting.
|
|
18
|
+
DEFAULT_STORE = Path.home() / ".zettcode" / "sessions"
|
|
19
|
+
|
|
20
|
+
#: Plugins the reader switched off with ``/plugins disable``; read at the next start. Kept beside the store so the
|
|
21
|
+
#: reader's config file stays theirs, and a name here is an entry-point name, the same one ``[plugins] disable`` takes.
|
|
22
|
+
DEFAULT_PLUGINS_FILE = DEFAULT_STORE.parent / "plugins.json"
|
|
23
|
+
|
|
24
|
+
#: A plugin that is a single ``.py`` file lives here instead of being installed into site-packages: one file, one
|
|
25
|
+
#: module, loaded at the next start. ``$ZETTCODE_PLUGINS_DIR`` moves it, the way ``$ZETTCODE_CONFIG`` moves the config.
|
|
26
|
+
DEFAULT_PLUGINS_DIR = DEFAULT_STORE.parent / "plugins"
|
|
27
|
+
PLUGINS_DIR_VARIABLE = "ZETTCODE_PLUGINS_DIR"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def plugins_dir() -> Path:
|
|
31
|
+
"""Return the folder single-file plugins live in: ``$ZETTCODE_PLUGINS_DIR`` or ``~/.zettcode/plugins``."""
|
|
32
|
+
override = os.getenv(PLUGINS_DIR_VARIABLE)
|
|
33
|
+
return Path(override).expanduser() if override else DEFAULT_PLUGINS_DIR
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: MCP servers are ZettCode's own file, so they are configured independently of
|
|
37
|
+
#: whatever other zett tools read.
|
|
38
|
+
DEFAULT_MCP_CONFIG = Path.home() / ".zettcode" / "mcp.json"
|
|
39
|
+
|
|
40
|
+
#: Where the just-in-time harness keeps the tools a model wrote, one folder per session. Beside the sessions, not in
|
|
41
|
+
#: them: it is the model's own code, and clearing or moving the session store should not take it along.
|
|
42
|
+
DEFAULT_HARNESS_DIR = Path.home() / ".zettcode" / "harness"
|
|
43
|
+
|
|
44
|
+
#: What the background release check leaves behind: the newest version it saw
|
|
45
|
+
#: and the version the reader asked not to hear about again.
|
|
46
|
+
DEFAULT_UPDATE_FILE = Path.home() / ".zettcode" / "update.json"
|
|
47
|
+
|
|
48
|
+
#: Where the reader's own palettes live: one ``<name>.toml`` each, picked by name with ``/theme <name>``.
|
|
49
|
+
DEFAULT_THEMES_DIR = Path.home() / ".zettcode" / "themes"
|
|
50
|
+
|
|
51
|
+
#: The one palette file that is laid over whatever theme is in force.
|
|
52
|
+
DEFAULT_THEME_FILE = Path.home() / ".zettcode" / "theme.toml"
|
|
53
|
+
|
|
54
|
+
#: Where tiktoken keeps the vocabulary it downloads once. Deliberately not the
|
|
55
|
+
#: OS temp directory, which is cleaned whenever the system feels like it: a purge
|
|
56
|
+
#: turns the next token count into a 1.7 MB download, and `/context` waits behind
|
|
57
|
+
#: it. Under this program's own directory the fetch happens once.
|
|
58
|
+
DEFAULT_TOKENIZER_CACHE = Path.home() / ".zettcode" / "cache" / "tiktoken"
|
|
59
|
+
|
|
60
|
+
#: Anything written to stderr while the TUI owns the screen is kept here: a
|
|
61
|
+
#: child process — an MCP server announcing itself, say — cannot know that the
|
|
62
|
+
#: frame is the interface, and the renderer only repaints what it changed.
|
|
63
|
+
DEFAULT_LOG = Path.home() / ".zettcode" / "log" / "tui.log"
|
|
64
|
+
|
|
65
|
+
#: The commands typed in the ``/bash`` panel, one per line, so they are there next time.
|
|
66
|
+
DEFAULT_COMMAND_HISTORY = Path.home() / ".zettcode" / "command_history"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def config_path(path: str | Path | None = None) -> Path:
|
|
70
|
+
"""Return the config file to read: an explicit path, ``$ZETTCODE_CONFIG``, or the default.
|
|
71
|
+
|
|
72
|
+
Here rather than beside the settings it loads because the frame drawn before
|
|
73
|
+
those settings are read needs to name the same file without importing them.
|
|
74
|
+
"""
|
|
75
|
+
if path is not None:
|
|
76
|
+
return Path(path).expanduser()
|
|
77
|
+
override = os.getenv("ZETTCODE_CONFIG")
|
|
78
|
+
return Path(override).expanduser() if override else CONFIG_FILE
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def check_config_path(path: str | Path | None = None) -> Path:
|
|
82
|
+
"""Return the config path, refusing one that exists and is not a file.
|
|
83
|
+
|
|
84
|
+
A path that exists and is not a file — a directory from ``ZETTCODE_CONFIG=.``, a FIFO — is a configuration
|
|
85
|
+
nobody can read, and it is not the fresh machine either: the terminal would start as if nothing were set up
|
|
86
|
+
and then fail every setting on that path. So is a link whose target is gone, which is not a file for the same
|
|
87
|
+
reason and would be replaced by the write rather than followed. A path with nothing at it is the fresh machine
|
|
88
|
+
and is answered with the path as it is.
|
|
89
|
+
|
|
90
|
+
Here, beside :func:`config_path`, because the command line asks this before it takes the terminal and before
|
|
91
|
+
the settings module may be imported at all.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
path: The path to check; ``$ZETTCODE_CONFIG`` or ``~/.zettcode/config.toml`` by default.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ValueError: The path exists (or is a link) and is not a file.
|
|
98
|
+
"""
|
|
99
|
+
source = config_path(path)
|
|
100
|
+
if not source.is_file() and (source.exists() or source.is_symlink()):
|
|
101
|
+
raise ValueError(f"{source} is not a file, so there is nothing to read there")
|
|
102
|
+
return source
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resume_command(session_id: str, workspace: Path) -> str:
|
|
106
|
+
"""Return the command that reopens one stored session from anywhere.
|
|
107
|
+
|
|
108
|
+
It is written down once because two places say it: the line printed on the way
|
|
109
|
+
out, and the line a fork leaves in the conversation. The workspace is part of it
|
|
110
|
+
because the store is keyed by workspace — the same id means nothing in another
|
|
111
|
+
checkout — and it is quoted, because a path may hold a space.
|
|
112
|
+
"""
|
|
113
|
+
return f"zettcode resume {shlex.quote(session_id)} --workspace {shlex.quote(str(workspace))}"
|
zettcode/plugin_tui.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The interface of ``ctx.tui``: what a plugin may register when the process has a screen.
|
|
2
|
+
|
|
3
|
+
The terminal's group implements this; the context is typed with it so a plugin author sees the surface from the type
|
|
4
|
+
checker, and the guard ``if ctx.tui is None`` narrows it. It lives here, in a leaf module both layers may import, so the
|
|
5
|
+
engine can name it without importing the terminal — and the terminal can implement it without the engine importing the
|
|
6
|
+
terminal's own types.
|
|
7
|
+
|
|
8
|
+
The parameter types are deliberately loose: a command handler, a theme, a pet, a presenter and a row builder are the
|
|
9
|
+
terminal's own classes, which this module does not import. The docstrings say what each argument is, and
|
|
10
|
+
:class:`zettcode.app.plugins.groups.TuiGroups` is the implementation that takes the real ones.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
__all__ = ["TuiGroup"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TuiGroup(ABC):
|
|
22
|
+
"""What only the terminal can hold, as ``ctx.tui``.
|
|
23
|
+
|
|
24
|
+
A process without a screen has ``ctx.tui is None``; a plugin that only adds these starts its ``activate`` with
|
|
25
|
+
``if ctx.tui is None: return``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def registered_commands(self) -> Any:
|
|
31
|
+
"""Return the commands registered so far, in registration order."""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def themes(self) -> Any:
|
|
36
|
+
"""Return the palettes registered so far, in registration order."""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def pets(self) -> Any:
|
|
41
|
+
"""Return the pets registered so far, in registration order."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def presenters(self) -> Any:
|
|
46
|
+
"""Return the presenters registered so far, in registration order."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def turn_segments(self) -> Any:
|
|
51
|
+
"""Return the items of the line a request ends on, in the order they stand."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def register_command(
|
|
55
|
+
self,
|
|
56
|
+
name: str,
|
|
57
|
+
description: str,
|
|
58
|
+
handler: Any,
|
|
59
|
+
*,
|
|
60
|
+
when_busy: str = "refuse",
|
|
61
|
+
subcommands: Any = (),
|
|
62
|
+
) -> Any:
|
|
63
|
+
"""Add a command that may open a page, ask the shell to act, or wait for a request to end.
|
|
64
|
+
|
|
65
|
+
``handler`` is called with a :class:`~zettcode.app.commands.CommandContext` and returns a
|
|
66
|
+
:class:`~zettcode.app.commands.CommandResult`. A command whose whole answer is text belongs on
|
|
67
|
+
``ctx.commands`` instead, where a process without a screen can run it too.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def register_command_provider(self, provider: Any) -> Any:
|
|
72
|
+
"""Add commands that are worked out when the menu is built, from the current state."""
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def register_theme(self, name: str, theme: Any, *, description: Any = "") -> Any:
|
|
76
|
+
"""Offer a palette under its name; ``/theme`` lists, previews and switches to it."""
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def register_pet(self, name: str, pet: Any, *, description: Any = "") -> Any:
|
|
80
|
+
"""Offer a companion under its name; ``/pet`` lists, previews and switches to it."""
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def register_presenter(self, presenter: Any) -> Any:
|
|
84
|
+
"""Draw a kind of entry, or one family inside a kind, instead of the way it is drawn now."""
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def register_header_left(self, builder: Any, *, name: str = "", override: bool = False) -> tuple[Any, bool]:
|
|
88
|
+
"""Draw a segment on the header's left side."""
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def register_header_right(self, builder: Any, *, name: str = "", override: bool = False) -> tuple[Any, bool]:
|
|
92
|
+
"""Draw a segment on the header's right side."""
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def register_status_left(self, builder: Any, *, name: str = "", override: bool = False) -> tuple[Any, bool]:
|
|
96
|
+
"""Draw a segment on the status line's left side."""
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def register_status_right(self, builder: Any, *, name: str = "", override: bool = False) -> tuple[Any, bool]:
|
|
100
|
+
"""Draw a segment on the status line's right side."""
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def register_turn_segment(self, name: str, summary: str, builder: Any) -> Any:
|
|
104
|
+
"""Add one item to the line a request ends on."""
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
def register_focus_handler(self, handler: Any) -> Any:
|
|
108
|
+
"""Be told when the terminal window gains or loses focus: ``handler(focused: bool)``.
|
|
109
|
+
|
|
110
|
+
The terminal asks for focus reports (DECSET 1004) when it starts, so a plugin can tell a reader who stepped
|
|
111
|
+
away from one who is watching — which is what a recap, a notification or a pause waits for. A terminal that
|
|
112
|
+
does not report focus never calls it, and nothing else changes.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def register_turn_start_handler(self, handler: Any) -> Any:
|
|
117
|
+
"""Be told when a turn of the conversation starts: ``handler()``, before the request is sent.
|
|
118
|
+
|
|
119
|
+
The counterpart of :meth:`register_turn_end_handler`, and what makes an idle wait possible: work that waits
|
|
120
|
+
for the reader to stop asking cancels on the start and is armed by the end. A side question and a resumed
|
|
121
|
+
request are not turns; a turn a command starts with ``host.prompt`` is.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
def register_turn_end_handler(self, handler: Any) -> Any:
|
|
126
|
+
"""Be told when a turn of the conversation ends: ``handler(completed: bool)``, after the row it ended on.
|
|
127
|
+
|
|
128
|
+
``completed`` is false for a turn that was stopped or that failed; both are ends, and a plugin that only
|
|
129
|
+
cares about answered requests tests it. A request the reader sent is a turn, and so is one a command started
|
|
130
|
+
with ``host.prompt``; running a command is not itself a turn, and neither is a side question or a resume. It is
|
|
131
|
+
called from inside the run that just ended, before the shell is free again, and nothing guards two turns at
|
|
132
|
+
once: a handler that starts work schedules it instead of running a turn from there.
|
|
133
|
+
"""
|