@craftsamo/whatsapp-tools 0.0.0-stage → 0.1.0
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.
- package/.agents/plugins/marketplace.json +20 -0
- package/.antigravity-plugin/plugin.json +12 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +18 -0
- package/.codex-plugin/plugin.json +31 -0
- package/.mcp.json +8 -0
- package/LICENSE +429 -0
- package/README.md +569 -2
- package/__init__.py +36 -0
- package/dist/cli.js +30714 -0
- package/dist/mcp.js +37673 -0
- package/dist/opencode.js +27584 -0
- package/mcp_config.json +8 -0
- package/package.json +70 -4
- package/plugin.yaml +46 -0
- package/skills/.gitkeep +0 -0
- package/skills/whatsapp-account/SKILL.md +55 -0
- package/skills/whatsapp-actions/SKILL.md +109 -0
- package/skills/whatsapp-chats/SKILL.md +125 -0
- package/skills/whatsapp-contacts/SKILL.md +59 -0
- package/skills/whatsapp-count-activity/SKILL.md +63 -0
- package/skills/whatsapp-files/SKILL.md +112 -0
- package/skills/whatsapp-sends/SKILL.md +50 -0
- package/src/hermes/whatsapp_tools_hermes/__init__.py +5 -0
- package/src/hermes/whatsapp_tools_hermes/approval.py +330 -0
- package/src/hermes/whatsapp_tools_hermes/card.py +507 -0
- package/src/hermes/whatsapp_tools_hermes/context.py +34 -0
- package/src/hermes/whatsapp_tools_hermes/listing.py +78 -0
- package/src/hermes/whatsapp_tools_hermes/plugin.py +38 -0
- package/src/hermes/whatsapp_tools_hermes/project.py +47 -0
- package/src/hermes/whatsapp_tools_hermes/runtime.py +204 -0
- package/src/hermes/whatsapp_tools_hermes/settings.py +171 -0
- package/src/hermes/whatsapp_tools_hermes/skills.py +131 -0
- package/src/hermes/whatsapp_tools_hermes/tools.py +97 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""The message of the approval card: the headline, the targets and what the call carries, fitted to the surface it is shown on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import unicodedata
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
#: The name of the card's section that lists the targets and the facts; the plugin's own word, in English.
|
|
12
|
+
DETAILS_HEADING = "Details"
|
|
13
|
+
|
|
14
|
+
#: The label of a target's line in the Details; the plugin's own word, in English.
|
|
15
|
+
TARGET_LABEL = "Target"
|
|
16
|
+
|
|
17
|
+
#: The notice of the facts that are not shown, in English; ``{count}`` stands for how many.
|
|
18
|
+
MORE_NOTICE = "(+{count} more)"
|
|
19
|
+
|
|
20
|
+
#: The notice of the characters a cut value or text leaves out, in English, for one character; ``{count}`` stands for how many.
|
|
21
|
+
MORE_CHARACTER_NOTICE = "(+{count} more character)"
|
|
22
|
+
|
|
23
|
+
#: The same notice for any other count.
|
|
24
|
+
MORE_CHARACTERS_NOTICE = "(+{count} more characters)"
|
|
25
|
+
|
|
26
|
+
#: Where the number goes in the notice.
|
|
27
|
+
COUNT_PLACEHOLDER = "{count}"
|
|
28
|
+
|
|
29
|
+
#: What a target is when the resource a call writes to cannot be told; the card names no target for it.
|
|
30
|
+
ANY_RESOURCE = "*"
|
|
31
|
+
|
|
32
|
+
#: The budget, in UTF-16 code units of the HTML-escaped text, of the Telegram card;
|
|
33
|
+
#: Hermes escapes it and cuts it from the end.
|
|
34
|
+
TELEGRAM_BUDGET = 500
|
|
35
|
+
|
|
36
|
+
#: The budget, in characters, of the Slack card; Hermes cuts it from the end.
|
|
37
|
+
SLACK_BUDGET = 500
|
|
38
|
+
|
|
39
|
+
#: The budget, in characters, of the Discord card, which is also the strictest one; Hermes cuts it from the end.
|
|
40
|
+
DISCORD_BUDGET = 300
|
|
41
|
+
|
|
42
|
+
#: The names of the surfaces Hermes shows a card on without cutting it or rendering markup in it: its own clients.
|
|
43
|
+
PLAIN_SURFACE_NAMES = ("cli", "tui", "desktop", "acp")
|
|
44
|
+
|
|
45
|
+
#: How few code points of a value are worth showing cut; a value that does not keep this many is dropped instead.
|
|
46
|
+
MIN_VALUE_CODE_POINTS = 8
|
|
47
|
+
|
|
48
|
+
#: What stands in the place of the end of a value that was cut.
|
|
49
|
+
ELLIPSIS = "…"
|
|
50
|
+
|
|
51
|
+
#: The categories whose code points are shown as a mark: control, format, line and
|
|
52
|
+
#: paragraph separators, lone surrogates.
|
|
53
|
+
HIDDEN_CATEGORIES = frozenset({"Cc", "Cf", "Zl", "Zp", "Cs"})
|
|
54
|
+
|
|
55
|
+
#: The default-ignorable code points, including variation selectors and the Hangul fillers.
|
|
56
|
+
DEFAULT_IGNORABLE = (
|
|
57
|
+
(0x00AD, 0x00AD),
|
|
58
|
+
(0x034F, 0x034F),
|
|
59
|
+
(0x061C, 0x061C),
|
|
60
|
+
(0x115F, 0x1160),
|
|
61
|
+
(0x17B4, 0x17B5),
|
|
62
|
+
(0x180B, 0x180F),
|
|
63
|
+
(0x200B, 0x200F),
|
|
64
|
+
(0x202A, 0x202E),
|
|
65
|
+
(0x2060, 0x206F),
|
|
66
|
+
(0x3164, 0x3164),
|
|
67
|
+
(0xFE00, 0xFE0F),
|
|
68
|
+
(0xFEFF, 0xFEFF),
|
|
69
|
+
(0xFFA0, 0xFFA0),
|
|
70
|
+
(0xFFF0, 0xFFF8),
|
|
71
|
+
(0x1BCA0, 0x1BCA3),
|
|
72
|
+
(0x1D173, 0x1D17A),
|
|
73
|
+
(0xE0000, 0xE0FFF),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
#: The characters Discord Markdown reads as markup, mentions or links, each escaped by a backslash.
|
|
77
|
+
DISCORD_MARKUP = "\\*_~`|><[]()#-@"
|
|
78
|
+
|
|
79
|
+
#: What Slack's ``mrkdwn`` reads as the start of a mention or link, and so is written as an entity.
|
|
80
|
+
SLACK_ENTITIES = {"&": "&", "<": "<", ">": ">"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Surface:
|
|
85
|
+
"""Where a card is shown: how much of its message the surface keeps and how the message must be written for it.
|
|
86
|
+
|
|
87
|
+
``budget`` is what ``measure`` may count at most (``None`` for no cut) and ``escape`` makes a text safe in the
|
|
88
|
+
surface's markup; a surface Hermes escapes for itself has the identity.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
budget: int | None
|
|
92
|
+
measure: Callable[[str], int]
|
|
93
|
+
escape: Callable[[str], str]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _identity(text: str) -> str:
|
|
97
|
+
return text
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _characters(text: str) -> int:
|
|
101
|
+
return len(text)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _telegram_units(text: str) -> int:
|
|
105
|
+
"""The UTF-16 code units of ``text`` once Hermes has HTML-escaped it."""
|
|
106
|
+
return len(html.escape(text, quote=True).encode("utf-16-le")) // 2
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _slack_escape(text: str) -> str:
|
|
110
|
+
return text.translate(str.maketrans(SLACK_ENTITIES))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _discord_escape(text: str) -> str:
|
|
114
|
+
return text.translate({ord(character): f"\\{character}" for character in DISCORD_MARKUP})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _strictest_escape(text: str) -> str:
|
|
118
|
+
return _discord_escape(_slack_escape(text))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
#: With no surface named the card may be on any of them, so use the strictest budget, measure and escaping.
|
|
122
|
+
STRICTEST_SURFACE = Surface(DISCORD_BUDGET, _telegram_units, _strictest_escape)
|
|
123
|
+
|
|
124
|
+
#: A surface Hermes does not cut: the card is shown in full and as written.
|
|
125
|
+
PLAIN_SURFACE = Surface(None, _characters, _identity)
|
|
126
|
+
|
|
127
|
+
#: A surface Hermes names that the plugin does not know: Hermes does not cut it, but it may render markup.
|
|
128
|
+
UNKNOWN_SURFACE = Surface(None, _characters, _strictest_escape)
|
|
129
|
+
|
|
130
|
+
#: The surfaces the plugin knows, by name.
|
|
131
|
+
KNOWN_SURFACES = {
|
|
132
|
+
**{name: PLAIN_SURFACE for name in PLAIN_SURFACE_NAMES},
|
|
133
|
+
"telegram": Surface(TELEGRAM_BUDGET, _telegram_units, _identity),
|
|
134
|
+
"slack": Surface(SLACK_BUDGET, _characters, _slack_escape),
|
|
135
|
+
"discord": Surface(DISCORD_BUDGET, _characters, _discord_escape),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def surface_for(name: str | None) -> Surface:
|
|
140
|
+
"""The surface called ``name`` (any case).
|
|
141
|
+
|
|
142
|
+
Hermes cuts the card only on the surfaces the plugin knows, so any other
|
|
143
|
+
name gets no budget; it may still render markup, so it gets the strictest
|
|
144
|
+
escaping. A card whose surface cannot be told (none, empty or not a
|
|
145
|
+
string) gets the strictest one.
|
|
146
|
+
"""
|
|
147
|
+
if not isinstance(name, str) or not name:
|
|
148
|
+
return STRICTEST_SURFACE
|
|
149
|
+
return KNOWN_SURFACES.get(name.lower(), UNKNOWN_SURFACE)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _mark(character: str) -> str:
|
|
153
|
+
return f"<U+{ord(character):04X}>"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def visible(text: str, keep_newlines: bool = False) -> str:
|
|
157
|
+
"""Show every code point another person could use to forge a line or hide part of a value as ``<U+XXXX>``.
|
|
158
|
+
|
|
159
|
+
These are control, format, separators, lone surrogates and the default-ignorable
|
|
160
|
+
code points (such as variation selectors and the Hangul fillers); ordinary spaces and all
|
|
161
|
+
visible text stay. With ``keep_newlines``, ``\\r\\n`` becomes ``\\n`` and
|
|
162
|
+
``\\n`` stays; any other ``\\r`` is marked. The same rules as ``visible`` in
|
|
163
|
+
``src/entry/description.ts``.
|
|
164
|
+
"""
|
|
165
|
+
if keep_newlines:
|
|
166
|
+
text = text.replace("\r\n", "\n")
|
|
167
|
+
return "".join(
|
|
168
|
+
_mark(character)
|
|
169
|
+
if (
|
|
170
|
+
unicodedata.category(character) in HIDDEN_CATEGORIES
|
|
171
|
+
or any(first <= ord(character) <= last for first, last in DEFAULT_IGNORABLE)
|
|
172
|
+
)
|
|
173
|
+
and not (keep_newlines and character == "\n")
|
|
174
|
+
else character
|
|
175
|
+
for character in text
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class Description:
|
|
181
|
+
"""What a call carries: a headline, short ``(label, value)`` facts and a ``(label, body)`` text, as ``describe`` gave them.
|
|
182
|
+
|
|
183
|
+
``fact_omitted`` holds, per fact, the code points the tool's cut left out of its value, or is empty when no
|
|
184
|
+
value was cut; ``text_omitted`` is the same for the text (0 for none). ``dropped`` counts the facts the tool's
|
|
185
|
+
description left out past the eighth (0 for none).
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
headline: str | None
|
|
189
|
+
facts: tuple[tuple[str, str], ...]
|
|
190
|
+
text: tuple[str, str] | None
|
|
191
|
+
fact_omitted: tuple[int, ...] = ()
|
|
192
|
+
text_omitted: int = 0
|
|
193
|
+
dropped: int = 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_omitted(raw: Any) -> int:
|
|
197
|
+
"""How many characters or facts were left out: ``raw`` when it is a positive integer, else 0 (never an error)."""
|
|
198
|
+
return raw if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0 else 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _parse_fact(raw: Any) -> tuple[str, str, int] | None:
|
|
202
|
+
if not isinstance(raw, dict):
|
|
203
|
+
return None
|
|
204
|
+
label, value = raw.get("label"), raw.get("value")
|
|
205
|
+
if not isinstance(label, str) or not label.strip() or not isinstance(value, str):
|
|
206
|
+
return None
|
|
207
|
+
return label, value, _parse_omitted(raw.get("omitted"))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_text(raw: Any) -> tuple[str, str, int] | None:
|
|
211
|
+
"""The ``(label, body, omitted)`` of a text, or ``None`` for any other shape; an empty body is a text that says nothing."""
|
|
212
|
+
if not isinstance(raw, dict):
|
|
213
|
+
return None
|
|
214
|
+
label, body = raw.get("label"), raw.get("body")
|
|
215
|
+
if not isinstance(label, str) or not label.strip() or not isinstance(body, str):
|
|
216
|
+
return None
|
|
217
|
+
return label, body, _parse_omitted(raw.get("omitted"))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def parse_description(raw: Any) -> Description | None:
|
|
221
|
+
"""The description the command line printed, or ``None`` for any other shape or for one that says nothing.
|
|
222
|
+
|
|
223
|
+
``{"headline"?: str, "facts": [{"label": str, "value": str, "omitted"?: int}, ...], "dropped"?: int,
|
|
224
|
+
"text"?: {"label": str, "body": str, "omitted"?: int}}``; extra keys are ignored. A headline that
|
|
225
|
+
is not a non-blank string is dropped alone, and so is an ``omitted`` or a ``dropped`` that
|
|
226
|
+
is not a positive integer (or a ``dropped`` without facts); a bad fact or a bad text voids the whole
|
|
227
|
+
description. The command line has already marked every string;
|
|
228
|
+
``build_message`` marks again, which changes nothing.
|
|
229
|
+
"""
|
|
230
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("facts"), list):
|
|
231
|
+
return None
|
|
232
|
+
parsed = [_parse_fact(item) for item in raw["facts"]]
|
|
233
|
+
entries = [fact for fact in parsed if fact is not None]
|
|
234
|
+
if len(entries) != len(parsed):
|
|
235
|
+
return None
|
|
236
|
+
facts = tuple((label, value) for label, value, _ in entries)
|
|
237
|
+
fact_omitted = tuple(omitted for _, _, omitted in entries)
|
|
238
|
+
headline = raw.get("headline")
|
|
239
|
+
headline = headline if isinstance(headline, str) and headline.strip() else None
|
|
240
|
+
text = None
|
|
241
|
+
text_omitted = 0
|
|
242
|
+
if "text" in raw:
|
|
243
|
+
parsed_text = _parse_text(raw["text"])
|
|
244
|
+
if parsed_text is None:
|
|
245
|
+
return None
|
|
246
|
+
text, text_omitted = parsed_text[:2], parsed_text[2]
|
|
247
|
+
if text is not None and not text[1]:
|
|
248
|
+
text, text_omitted = None, 0
|
|
249
|
+
if headline is None and not facts and text is None:
|
|
250
|
+
return None
|
|
251
|
+
return Description(
|
|
252
|
+
headline=headline,
|
|
253
|
+
facts=facts,
|
|
254
|
+
text=text,
|
|
255
|
+
fact_omitted=fact_omitted if any(fact_omitted) else (),
|
|
256
|
+
text_omitted=text_omitted,
|
|
257
|
+
dropped=_parse_omitted(raw.get("dropped")) if facts else 0,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass(frozen=True)
|
|
262
|
+
class Words:
|
|
263
|
+
"""The plugin's own words of the card, in one language: the Details heading, the Target label and the notices.
|
|
264
|
+
|
|
265
|
+
``more`` (the facts not shown), ``more_character`` (one character a cut
|
|
266
|
+
left out) and ``more_characters`` (any other count) hold ``{count}`` once.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
details: str
|
|
270
|
+
target: str
|
|
271
|
+
more: str
|
|
272
|
+
more_character: str
|
|
273
|
+
more_characters: str
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
#: The words in English, which the card uses when the command line gave no others or gave bad ones.
|
|
277
|
+
ENGLISH_WORDS = Words(DETAILS_HEADING, TARGET_LABEL, MORE_NOTICE, MORE_CHARACTER_NOTICE, MORE_CHARACTERS_NOTICE)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _is_word(raw: Any) -> bool:
|
|
281
|
+
return isinstance(raw, str) and bool(raw.strip())
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _is_notice(raw: Any) -> bool:
|
|
285
|
+
"""Whether ``raw`` is a word that holds ``{count}`` once and no other brace."""
|
|
286
|
+
if not _is_word(raw) or raw.count(COUNT_PLACEHOLDER) != 1:
|
|
287
|
+
return False
|
|
288
|
+
rest = raw.replace(COUNT_PLACEHOLDER, "")
|
|
289
|
+
return "{" not in rest and "}" not in rest
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def parse_line(raw: Any) -> str | None:
|
|
293
|
+
"""The translated title or intent the command line printed, or ``None`` for anything but a non-blank string."""
|
|
294
|
+
return raw if _is_word(raw) else None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def parse_words(raw: Any) -> Words:
|
|
298
|
+
"""The words the command line printed, or the English ones when any of the five is missing or bad.
|
|
299
|
+
|
|
300
|
+
``{"details": str, "target": str, "more": str, "more_character": str,
|
|
301
|
+
"more_characters": str}`` with non-blank strings and ``{count}`` once in
|
|
302
|
+
each notice; extra keys are ignored. The command line has already marked
|
|
303
|
+
every string; ``build_message`` marks again, which changes nothing.
|
|
304
|
+
"""
|
|
305
|
+
if not isinstance(raw, dict) or not _is_word(raw.get("details")) or not _is_word(raw.get("target")):
|
|
306
|
+
return ENGLISH_WORDS
|
|
307
|
+
notices = ("more", "more_character", "more_characters")
|
|
308
|
+
if not all(_is_notice(raw.get(name)) for name in notices):
|
|
309
|
+
return ENGLISH_WORDS
|
|
310
|
+
return Words(details=raw["details"], target=raw["target"], **{name: raw[name] for name in notices})
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _own_word(surface: Surface, word: str, english: str) -> str:
|
|
314
|
+
"""``word`` as the card shows it: the English word as it is, any other made ``visible`` and escaped for ``surface``."""
|
|
315
|
+
return word if word == english else surface.escape(visible(word))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _shown_words(surface: Surface, words: Words) -> Words:
|
|
319
|
+
return Words(
|
|
320
|
+
details=_own_word(surface, words.details, ENGLISH_WORDS.details),
|
|
321
|
+
target=_own_word(surface, words.target, ENGLISH_WORDS.target),
|
|
322
|
+
more=_own_word(surface, words.more, ENGLISH_WORDS.more),
|
|
323
|
+
more_character=_own_word(surface, words.more_character, ENGLISH_WORDS.more_character),
|
|
324
|
+
more_characters=_own_word(surface, words.more_characters, ENGLISH_WORDS.more_characters),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _more(count: int, words: Words) -> list[str]:
|
|
329
|
+
"""The line that says how many facts are not shown, or no line when all are."""
|
|
330
|
+
return [words.more.replace(COUNT_PLACEHOLDER, str(count))] if count else []
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _characters_left_out(count: int, words: Words) -> str:
|
|
334
|
+
"""The notice of the ``count`` characters a cut left out, in its singular form for one."""
|
|
335
|
+
notice = words.more_character if count == 1 else words.more_characters
|
|
336
|
+
return notice.replace(COUNT_PLACEHOLDER, str(count))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _fact_line(label: str, value: str) -> str:
|
|
340
|
+
return f"{label}: {value}"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _section_heading(name: str) -> str:
|
|
344
|
+
return f"--- {name} ---"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _fit_facts(
|
|
348
|
+
surface: Surface,
|
|
349
|
+
first: str,
|
|
350
|
+
targets: list[str],
|
|
351
|
+
facts: list[tuple[str, str, int]],
|
|
352
|
+
words: Words,
|
|
353
|
+
dropped: int = 0,
|
|
354
|
+
) -> list[str]:
|
|
355
|
+
"""The head and as many of ``facts`` as fit the budget, in order, with a notice line (``words.more``) for the rest.
|
|
356
|
+
|
|
357
|
+
The head is the first line, then the Details section's frame and the
|
|
358
|
+
``targets`` (never cut or dropped); with no target the frame comes only
|
|
359
|
+
with a fact or the notice. ``first``, ``targets`` and ``words`` are
|
|
360
|
+
escaped; ``facts`` are ``(label, value, left_out)``, marked and not
|
|
361
|
+
escaped. The lines are escaped, and the budget is measured on them as
|
|
362
|
+
joined, with the words as given. N counts the facts after the last one
|
|
363
|
+
shown, whole or cut, and the ``dropped`` facts the tool's description left
|
|
364
|
+
out past the eighth, so the notice comes even when every fact fits. When
|
|
365
|
+
the head alone is over the budget, or leaves no room for the notice, no
|
|
366
|
+
fact is shown and the notice still is, past the budget: facts are never
|
|
367
|
+
left out without a word.
|
|
368
|
+
|
|
369
|
+
A whole value the tool cut (``left_out`` characters) is followed by the
|
|
370
|
+
notice of those characters when the line still fits; a value the budget
|
|
371
|
+
cuts further ends in ``…`` alone, and a notice that does not fit is left
|
|
372
|
+
out before its fact is dropped.
|
|
373
|
+
"""
|
|
374
|
+
budget = surface.budget
|
|
375
|
+
|
|
376
|
+
def fits(lines: list[str]) -> bool:
|
|
377
|
+
return budget is None or surface.measure("\n".join(lines)) <= budget
|
|
378
|
+
|
|
379
|
+
def compose(details: list[str]) -> list[str]:
|
|
380
|
+
if not targets and not details:
|
|
381
|
+
return [first]
|
|
382
|
+
return [first, "", _section_heading(words.details), "", *targets, *details]
|
|
383
|
+
|
|
384
|
+
def escaped_fact(label: str, value: str) -> str:
|
|
385
|
+
return surface.escape(_fact_line(label, value))
|
|
386
|
+
|
|
387
|
+
def lines_of(entries: list[tuple[str, str]]) -> list[str]:
|
|
388
|
+
return [line for line, _ in entries]
|
|
389
|
+
|
|
390
|
+
head = compose([])
|
|
391
|
+
if not fits(head):
|
|
392
|
+
return compose(_more(len(facts) + dropped, words))
|
|
393
|
+
# Each shown fact is its line and the line without the notice of the characters the tool left out.
|
|
394
|
+
shown: list[tuple[str, str]] = []
|
|
395
|
+
omitted = 0
|
|
396
|
+
# The dropped facts are counted whatever fits, so each fact leaves room for their notice.
|
|
397
|
+
certain = _more(dropped, words)
|
|
398
|
+
for index, (label, value, left_out) in enumerate(facts):
|
|
399
|
+
bare = escaped_fact(label, value)
|
|
400
|
+
candidates = [f"{bare} {_characters_left_out(left_out, words)}", bare] if left_out else [bare]
|
|
401
|
+
whole = next((line for line in candidates if fits(compose([*lines_of(shown), line, *certain]))), None)
|
|
402
|
+
if whole is not None:
|
|
403
|
+
shown.append((whole, bare))
|
|
404
|
+
continue
|
|
405
|
+
rest = len(facts) - index - 1 + dropped
|
|
406
|
+
cut = _cut_value(
|
|
407
|
+
value, lambda part: fits(compose([*lines_of(shown), escaped_fact(label, part), *_more(rest, words)]))
|
|
408
|
+
)
|
|
409
|
+
if cut is None:
|
|
410
|
+
omitted = rest + 1
|
|
411
|
+
else:
|
|
412
|
+
line = escaped_fact(label, cut)
|
|
413
|
+
shown.append((line, line))
|
|
414
|
+
omitted = rest
|
|
415
|
+
break
|
|
416
|
+
else:
|
|
417
|
+
omitted = dropped
|
|
418
|
+
while shown and not fits(compose([*lines_of(shown), *_more(omitted, words)])):
|
|
419
|
+
line, bare = shown[-1]
|
|
420
|
+
if line != bare:
|
|
421
|
+
shown[-1] = (bare, bare)
|
|
422
|
+
else:
|
|
423
|
+
shown.pop()
|
|
424
|
+
omitted += 1
|
|
425
|
+
return compose([*lines_of(shown), *_more(omitted, words)])
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _cut_value(value: str, fits: Callable[[str], bool]) -> str | None:
|
|
429
|
+
"""The longest prefix of ``value`` followed by ``…`` that ``fits``, cut in code points before any escaping.
|
|
430
|
+
|
|
431
|
+
``None`` when fewer than ``MIN_VALUE_CODE_POINTS`` code points of the value
|
|
432
|
+
fit. A longer prefix never measures less, so the longest is found by bisection.
|
|
433
|
+
"""
|
|
434
|
+
low, high = 0, len(value) - 1
|
|
435
|
+
while low < high:
|
|
436
|
+
middle = (low + high + 1) // 2
|
|
437
|
+
if fits(value[:middle] + ELLIPSIS):
|
|
438
|
+
low = middle
|
|
439
|
+
else:
|
|
440
|
+
high = middle - 1
|
|
441
|
+
if low < MIN_VALUE_CODE_POINTS or not fits(value[:low] + ELLIPSIS):
|
|
442
|
+
return None
|
|
443
|
+
return value[:low] + ELLIPSIS
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _head_fits(surface: Surface, first: str, targets: list[str], words: Words) -> bool:
|
|
447
|
+
"""Whether the first line and every Target line, with the Details frame, fit the surface's budget."""
|
|
448
|
+
if surface.budget is None:
|
|
449
|
+
return True
|
|
450
|
+
lines = [first, "", _section_heading(words.details), "", *targets] if targets else [first]
|
|
451
|
+
return surface.measure("\n".join(lines)) <= surface.budget
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def build_message(
|
|
455
|
+
stand_in: str,
|
|
456
|
+
resources: list[str],
|
|
457
|
+
description: Description | None,
|
|
458
|
+
surface: str | None,
|
|
459
|
+
words: Words = ENGLISH_WORDS,
|
|
460
|
+
) -> str:
|
|
461
|
+
"""The message of the card, in the shape of ``approvalPrompt`` in ``src/entry/description.ts``.
|
|
462
|
+
|
|
463
|
+
The headline (``stand_in`` without one: the line the caller chose, an
|
|
464
|
+
intent or a title); a Details section with a ``Target`` line per resource,
|
|
465
|
+
unless the only resource is ``*`` (what cannot be told), then one line per
|
|
466
|
+
fact; and the text under its own label, followed by the notice of the
|
|
467
|
+
characters the tool's cut left out of it, if any. A section with nothing
|
|
468
|
+
to show is left out. The plugin's own words (the Details heading, the
|
|
469
|
+
``Target`` label, the notice of the facts not shown and the notices of
|
|
470
|
+
the characters left out) are ``words``.
|
|
471
|
+
|
|
472
|
+
Every piece that is not the plugin's own English words is made ``visible``
|
|
473
|
+
and escaped for the surface named ``surface``, ``words`` of another
|
|
474
|
+
language included; the section frames and the ``: `` after the label are
|
|
475
|
+
not. The first line, the Details frame, the targets and the fact lines
|
|
476
|
+
stay within the surface's budget, measured with ``words``: the targets are
|
|
477
|
+
never cut or dropped, facts that do not fit are cut or dropped and counted
|
|
478
|
+
in a notice line. The text section is not counted; the surface cuts it
|
|
479
|
+
from its end.
|
|
480
|
+
"""
|
|
481
|
+
target = surface_for(surface)
|
|
482
|
+
shown = _shown_words(target, words)
|
|
483
|
+
named = [] if resources == [ANY_RESOURCE] else resources
|
|
484
|
+
targets = [f"{shown.target}: {target.escape(visible(resource))}" for resource in named]
|
|
485
|
+
first = target.escape(visible(stand_in))
|
|
486
|
+
if description and description.headline:
|
|
487
|
+
headline = target.escape(visible(description.headline))
|
|
488
|
+
# A headline that would push a target past the cut gives way to the stand-in: the targets are what an
|
|
489
|
+
# "always" answer stores, so they must stay in sight.
|
|
490
|
+
if _head_fits(target, headline, targets, shown) or not _head_fits(target, first, targets, shown):
|
|
491
|
+
first = headline
|
|
492
|
+
facts = []
|
|
493
|
+
if description:
|
|
494
|
+
left_out = description.fact_omitted or (0,) * len(description.facts)
|
|
495
|
+
facts = [(visible(label), visible(value), count) for (label, value), count in zip(description.facts, left_out)]
|
|
496
|
+
lines = _fit_facts(target, first, targets, facts, shown, description.dropped if description else 0)
|
|
497
|
+
if description and description.text is not None:
|
|
498
|
+
label, body = description.text
|
|
499
|
+
lines += [
|
|
500
|
+
"",
|
|
501
|
+
_section_heading(target.escape(visible(label))),
|
|
502
|
+
"",
|
|
503
|
+
target.escape(visible(body, keep_newlines=True)),
|
|
504
|
+
]
|
|
505
|
+
if description.text_omitted:
|
|
506
|
+
lines.append(_characters_left_out(description.text_omitted, shown))
|
|
507
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""The slice of Hermes' plugin context this package uses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PluginContext(Protocol):
|
|
11
|
+
"""What ``register(ctx)`` is handed; Hermes' ``PluginContext`` provides all of it.
|
|
12
|
+
|
|
13
|
+
``session_surface()`` is read when present (see ``approval.session_surface``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def get_config(self, key: str, default: Any = None) -> Any:
|
|
17
|
+
"""Read ``plugins.entries.<plugin>.settings.<key>``."""
|
|
18
|
+
|
|
19
|
+
def register_tool(
|
|
20
|
+
self,
|
|
21
|
+
name: str,
|
|
22
|
+
toolset: str,
|
|
23
|
+
schema: dict[str, Any],
|
|
24
|
+
handler: Callable[..., str],
|
|
25
|
+
description: str = "",
|
|
26
|
+
emoji: str = "",
|
|
27
|
+
) -> Any:
|
|
28
|
+
"""Add a tool to the agent."""
|
|
29
|
+
|
|
30
|
+
def register_hook(self, hook_name: str, callback: Callable[..., Any]) -> Any:
|
|
31
|
+
"""Add a lifecycle hook callback."""
|
|
32
|
+
|
|
33
|
+
def register_skill(self, name: str, path: Path, description: str = "") -> Any:
|
|
34
|
+
"""Add a read-only skill, shown as ``<plugin>:<name>``."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The tools the command line offers for the current settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from . import project
|
|
10
|
+
from .runtime import CliError, Runtime, Unavailable
|
|
11
|
+
|
|
12
|
+
#: How long ``list`` may take, in seconds.
|
|
13
|
+
LIST_TIMEOUT_SECONDS = 30
|
|
14
|
+
|
|
15
|
+
#: The write class of a tool that changes nothing.
|
|
16
|
+
READ_CLASS = "read"
|
|
17
|
+
|
|
18
|
+
#: The fields of a listed tool that are text.
|
|
19
|
+
_TEXT_FIELDS = ("id", "title", "description", "toolset", "class")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ToolInfo:
|
|
24
|
+
"""One tool as ``list`` describes it; ``intent`` is its English approval line, ``None`` when it has none or a bad one."""
|
|
25
|
+
|
|
26
|
+
id: str
|
|
27
|
+
title: str
|
|
28
|
+
description: str
|
|
29
|
+
toolset: str
|
|
30
|
+
write_class: str
|
|
31
|
+
input_schema: dict[str, Any]
|
|
32
|
+
intent: str | None = None
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_listing(cls, entry: Any) -> ToolInfo:
|
|
36
|
+
"""Read one element of the ``list`` array.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
Unavailable: the element lacks a field the plugin needs.
|
|
40
|
+
"""
|
|
41
|
+
if (
|
|
42
|
+
not isinstance(entry, dict)
|
|
43
|
+
or not all(isinstance(entry.get(field), str) for field in _TEXT_FIELDS)
|
|
44
|
+
or not isinstance(entry.get("input_schema"), dict)
|
|
45
|
+
):
|
|
46
|
+
raise Unavailable(f"{project.CLI_NAME} listed a tool the plugin cannot read: {entry!r}")
|
|
47
|
+
intent = entry.get("intent")
|
|
48
|
+
return cls(
|
|
49
|
+
intent=intent if isinstance(intent, str) and intent.strip() else None,
|
|
50
|
+
id=entry["id"],
|
|
51
|
+
title=entry["title"],
|
|
52
|
+
description=entry["description"],
|
|
53
|
+
toolset=entry["toolset"],
|
|
54
|
+
write_class=entry["class"],
|
|
55
|
+
input_schema=entry["input_schema"],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def list_tools(runtime: Runtime, flags: list[str]) -> list[ToolInfo]:
|
|
60
|
+
"""Ask the command line which tools the settings select.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
Unavailable: the command line failed or answered with something else than a tool list.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
result = runtime.run(["list", *flags], timeout=LIST_TIMEOUT_SECONDS)
|
|
67
|
+
except CliError as error:
|
|
68
|
+
raise Unavailable(str(error)) from error
|
|
69
|
+
if result.code != 0:
|
|
70
|
+
detail = result.stderr.strip() or f"exit status {result.code}"
|
|
71
|
+
raise Unavailable(f"`{project.CLI_NAME} list` failed: {detail}")
|
|
72
|
+
try:
|
|
73
|
+
listing = json.loads(result.stdout)
|
|
74
|
+
except json.JSONDecodeError as error:
|
|
75
|
+
raise Unavailable(f"`{project.CLI_NAME} list` did not answer with JSON") from error
|
|
76
|
+
if not isinstance(listing, list):
|
|
77
|
+
raise Unavailable(f"`{project.CLI_NAME} list` did not answer with a list")
|
|
78
|
+
return [ToolInfo.from_listing(entry) for entry in listing]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The plugin's entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from .approval import register_approval
|
|
8
|
+
from .context import PluginContext
|
|
9
|
+
from .listing import list_tools
|
|
10
|
+
from . import project
|
|
11
|
+
from .runtime import Unavailable, locate_runtime
|
|
12
|
+
from .settings import SettingsError, directory_flags, emoji_setting, language_setting, option_flags, settings_flags
|
|
13
|
+
from .skills import register_skills
|
|
14
|
+
from .tools import register_tools
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(ctx: PluginContext) -> None:
|
|
20
|
+
"""Register the tools the settings select, the approval hook and the skills.
|
|
21
|
+
|
|
22
|
+
A skill linked to a toolset is registered only when a selected tool has it.
|
|
23
|
+
|
|
24
|
+
The tools run in the package's command line, so node and ``dist/cli.js``
|
|
25
|
+
must be there and ``list`` must answer. When they are not, or a setting is
|
|
26
|
+
wrong, this logs one warning and registers nothing; it never raises.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
runtime = locate_runtime()
|
|
30
|
+
flags = settings_flags(ctx)
|
|
31
|
+
tools = list_tools(runtime, flags)
|
|
32
|
+
except (Unavailable, SettingsError) as error:
|
|
33
|
+
logger.warning("%s is not available and registers nothing: %s", project.PLUGIN_NAME, error)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
register_tools(ctx, runtime, tools, flags, emoji_setting(ctx))
|
|
37
|
+
register_approval(ctx, runtime, tools, directory_flags(ctx), language_setting(ctx), option_flags(ctx))
|
|
38
|
+
register_skills(ctx, runtime.skills_dir, {tool.toolset for tool in tools})
|