packet-tracer-skill 0.3.0 → 0.3.2
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/CHANGELOG.md +116 -0
- package/README.md +55 -9
- package/SKILL.md +214 -2
- package/package.json +2 -1
- package/scripts/build_sample_catalog.py +42 -0
- package/scripts/generate_pkt.py +4408 -187
- package/scripts/intent_parser.py +31 -4
- package/scripts/lab_coherence.py +455 -0
- package/scripts/pkt_editor.py +117 -35
- package/scripts/pkt_transformer.py +99 -1
- package/scripts/sample_catalog.py +65 -12
- package/scripts/session_log.py +325 -0
- package/scripts/usage_ledger.py +230 -218
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Where a multi-step edit session had got to, so a compacted agent can ask.
|
|
3
|
+
|
|
4
|
+
Almost nothing in this skill lives in conversation memory: `--doctor`,
|
|
5
|
+
`--parity-report` and `--explain-plan` all recompute from the environment, so
|
|
6
|
+
losing an earlier turn costs nothing but the time to run them again. One thing
|
|
7
|
+
is not recoverable that way. Part-way through a chain of `--explain-plan` ->
|
|
8
|
+
`--edit` -> `--parity-report` calls, the question "which file am I working on,
|
|
9
|
+
and which step of which plan am I on" has no source but the conversation --
|
|
10
|
+
and that is exactly what compaction takes.
|
|
11
|
+
|
|
12
|
+
So each invocation appends a line here, and `--resume` reads it back.
|
|
13
|
+
|
|
14
|
+
The rules are the usage ledger's, for the same reasons:
|
|
15
|
+
|
|
16
|
+
- local only. Written under `output/`, which is gitignored and appears in no
|
|
17
|
+
`package.json` file list, so it reaches neither a repository nor a registry.
|
|
18
|
+
- **nothing is load-bearing.** Deleting this file must change no result
|
|
19
|
+
anywhere. It answers a question; it never feeds a decision.
|
|
20
|
+
- bounded, and a corrupt or unreadable log is ignored rather than fatal.
|
|
21
|
+
- `PKT_SESSION_LOG=off` disables it entirely.
|
|
22
|
+
|
|
23
|
+
And one rule of its own. Facts are recorded by **allow-list**, never by copying
|
|
24
|
+
a payload and stripping what looks sensitive. An edit prompt carries secrets in
|
|
25
|
+
ordinary fields -- `passphrase` on `set_wireless_ssid`, `password` on three
|
|
26
|
+
more operations, `community` on `set_bgp_neighbor` -- so a deny-list would leak
|
|
27
|
+
the first secret field anyone adds after this was written. Operation *names and
|
|
28
|
+
counts* are recorded; operation *values* never are, and neither is the prompt,
|
|
29
|
+
which is kept as the same non-reversible shape fingerprint the ledger uses.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
from datetime import datetime, timezone
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
42
|
+
DEFAULT_LOG_PATH = SKILL_ROOT / "output" / "session-log.jsonl"
|
|
43
|
+
MAX_ENTRIES = 500
|
|
44
|
+
LOG_VERSION = 1
|
|
45
|
+
|
|
46
|
+
# Every fact that may be written. A key absent from here is dropped whatever it
|
|
47
|
+
# holds -- see the module docstring for why this is an allow-list.
|
|
48
|
+
ALLOWED_FACTS = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"goal",
|
|
51
|
+
"prompt_shape",
|
|
52
|
+
"device_counts",
|
|
53
|
+
"vlan_ids",
|
|
54
|
+
"capabilities",
|
|
55
|
+
"scenario_family",
|
|
56
|
+
"readiness_status",
|
|
57
|
+
"allow_generate",
|
|
58
|
+
"donor",
|
|
59
|
+
"target_version",
|
|
60
|
+
"operation_counts",
|
|
61
|
+
"next_best_action",
|
|
62
|
+
"parity_counts",
|
|
63
|
+
"contradiction_counts",
|
|
64
|
+
"device_count",
|
|
65
|
+
"link_count",
|
|
66
|
+
"opened",
|
|
67
|
+
"detail",
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Values are summarised, never copied wholesale, so a long list cannot become a
|
|
72
|
+
# long line and a stray string cannot smuggle a secret through a counter.
|
|
73
|
+
_MAX_STRING = 200
|
|
74
|
+
_MAX_ITEMS = 40
|
|
75
|
+
|
|
76
|
+
_OFF_WORDS = {"off", "0", "false", "none"}
|
|
77
|
+
_ON_WORDS = {"on", "1", "true"}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def log_path() -> Path:
|
|
81
|
+
override = (os.getenv("PKT_SESSION_LOG") or "").strip()
|
|
82
|
+
if override and override.lower() not in (_OFF_WORDS | _ON_WORDS):
|
|
83
|
+
return Path(override).expanduser()
|
|
84
|
+
return DEFAULT_LOG_PATH
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def log_enabled() -> bool:
|
|
88
|
+
return (os.getenv("PKT_SESSION_LOG") or "").strip().lower() not in _OFF_WORDS
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Set when a branch has already written a fuller entry for this process, so the
|
|
92
|
+
# wrapper around the dispatch does not add a thinner duplicate beside it. The
|
|
93
|
+
# wrapper still fires for every command that writes nothing of its own, which
|
|
94
|
+
# is the point of wrapping rather than hooking each branch.
|
|
95
|
+
_DETAILED = False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def note_detailed() -> None:
|
|
99
|
+
global _DETAILED
|
|
100
|
+
_DETAILED = True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def had_detailed() -> bool:
|
|
104
|
+
return _DETAILED
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _clean(value: Any) -> Any:
|
|
108
|
+
"""Reduce a value to something small, printable and free of running text."""
|
|
109
|
+
if value is None or isinstance(value, bool) or isinstance(value, int):
|
|
110
|
+
return value
|
|
111
|
+
if isinstance(value, float):
|
|
112
|
+
return round(value, 3)
|
|
113
|
+
if isinstance(value, str):
|
|
114
|
+
return value[:_MAX_STRING]
|
|
115
|
+
if isinstance(value, dict):
|
|
116
|
+
return {str(key)[:_MAX_STRING]: _clean(item) for key, item in list(value.items())[:_MAX_ITEMS]}
|
|
117
|
+
if isinstance(value, (list, tuple, set)):
|
|
118
|
+
return [_clean(item) for item in list(value)[:_MAX_ITEMS]]
|
|
119
|
+
return str(value)[:_MAX_STRING]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normalise(path: Path | str | None) -> str:
|
|
123
|
+
"""One spelling per file, so one lab is not mistaken for two.
|
|
124
|
+
|
|
125
|
+
The same lab arrived down two routes and was listed twice: the generation
|
|
126
|
+
branch passes a `Path`, which prints with backslashes on Windows, while the
|
|
127
|
+
flag branches pass the string the shell gave, which had forward slashes.
|
|
128
|
+
Resolving both collapses separators and relative prefixes together.
|
|
129
|
+
"""
|
|
130
|
+
if not path:
|
|
131
|
+
return ""
|
|
132
|
+
try:
|
|
133
|
+
return str(Path(path).resolve())
|
|
134
|
+
except (OSError, ValueError):
|
|
135
|
+
return str(path)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def artifact_digest(path: Path | str | None) -> str:
|
|
139
|
+
"""A `.pkt` identifies itself by its bytes.
|
|
140
|
+
|
|
141
|
+
Measured before relying on it: decoding a lab and re-encoding it reproduces
|
|
142
|
+
the file byte for byte, so identical content always hashes identically and
|
|
143
|
+
a changed digest means the lab really changed. That is what lets `--resume`
|
|
144
|
+
check its own answer instead of asserting it.
|
|
145
|
+
"""
|
|
146
|
+
if not path:
|
|
147
|
+
return ""
|
|
148
|
+
candidate = Path(path)
|
|
149
|
+
try:
|
|
150
|
+
if not candidate.is_file():
|
|
151
|
+
return ""
|
|
152
|
+
return hashlib.sha256(candidate.read_bytes()).hexdigest()
|
|
153
|
+
except OSError:
|
|
154
|
+
return ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def record(
|
|
158
|
+
command: str,
|
|
159
|
+
*,
|
|
160
|
+
artifact: Path | str | None = None,
|
|
161
|
+
source: Path | str | None = None,
|
|
162
|
+
status: str = "ok",
|
|
163
|
+
facts: dict[str, Any] | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Append one step. Never raises: a broken log must not break a build."""
|
|
166
|
+
if not log_enabled():
|
|
167
|
+
return
|
|
168
|
+
try:
|
|
169
|
+
entry: dict[str, Any] = {
|
|
170
|
+
"v": LOG_VERSION,
|
|
171
|
+
"at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
172
|
+
"command": str(command)[:_MAX_STRING],
|
|
173
|
+
"status": str(status)[:_MAX_STRING],
|
|
174
|
+
}
|
|
175
|
+
if artifact:
|
|
176
|
+
entry["artifact"] = normalise(artifact)[:_MAX_STRING]
|
|
177
|
+
entry["artifact_sha256"] = artifact_digest(artifact)
|
|
178
|
+
if source:
|
|
179
|
+
entry["source"] = normalise(source)[:_MAX_STRING]
|
|
180
|
+
entry["source_sha256"] = artifact_digest(source)
|
|
181
|
+
kept = {key: _clean(value) for key, value in (facts or {}).items() if key in ALLOWED_FACTS}
|
|
182
|
+
if kept:
|
|
183
|
+
entry["facts"] = kept
|
|
184
|
+
|
|
185
|
+
path = log_path()
|
|
186
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
# One write of one line, so concurrent runs interleave whole lines and
|
|
188
|
+
# never characters: two agents working at once cannot corrupt a log
|
|
189
|
+
# neither of them is allowed to depend on anyway.
|
|
190
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
191
|
+
handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
192
|
+
_trim(path)
|
|
193
|
+
except Exception:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _trim(path: Path) -> None:
|
|
198
|
+
try:
|
|
199
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
200
|
+
if len(lines) <= MAX_ENTRIES:
|
|
201
|
+
return
|
|
202
|
+
path.write_text("\n".join(lines[-MAX_ENTRIES:]) + "\n", encoding="utf-8")
|
|
203
|
+
except Exception:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def read_entries(path: Path | None = None) -> list[dict[str, Any]]:
|
|
208
|
+
"""Every readable entry, oldest first. An unreadable line is skipped."""
|
|
209
|
+
target = path or log_path()
|
|
210
|
+
try:
|
|
211
|
+
if not target.is_file():
|
|
212
|
+
return []
|
|
213
|
+
text = target.read_text(encoding="utf-8")
|
|
214
|
+
except OSError:
|
|
215
|
+
return []
|
|
216
|
+
entries: list[dict[str, Any]] = []
|
|
217
|
+
for line in text.splitlines():
|
|
218
|
+
line = line.strip()
|
|
219
|
+
if not line:
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
parsed = json.loads(line)
|
|
223
|
+
except json.JSONDecodeError:
|
|
224
|
+
continue
|
|
225
|
+
if isinstance(parsed, dict):
|
|
226
|
+
entries.append(parsed)
|
|
227
|
+
return entries
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _names_match(entry: dict[str, Any], wanted: Path) -> bool:
|
|
231
|
+
target = normalise(wanted)
|
|
232
|
+
for key in ("artifact", "source"):
|
|
233
|
+
recorded = entry.get(key)
|
|
234
|
+
if not recorded:
|
|
235
|
+
continue
|
|
236
|
+
recorded = str(recorded)
|
|
237
|
+
if normalise(recorded) == target or Path(recorded).name == wanted.name:
|
|
238
|
+
return True
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def history_for(artifact: Path | str, path: Path | None = None) -> list[dict[str, Any]]:
|
|
243
|
+
"""The steps touching one lab, oldest first."""
|
|
244
|
+
wanted = Path(artifact)
|
|
245
|
+
return [entry for entry in read_entries(path) if _names_match(entry, wanted)]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def latest_for(artifact: Path | str, path: Path | None = None) -> dict[str, Any] | None:
|
|
249
|
+
steps = history_for(artifact, path)
|
|
250
|
+
return steps[-1] if steps else None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def artifacts_seen(path: Path | None = None) -> list[str]:
|
|
254
|
+
"""Every lab the log mentions, most recently touched first."""
|
|
255
|
+
order: list[str] = []
|
|
256
|
+
for entry in read_entries(path):
|
|
257
|
+
for key in ("artifact", "source"):
|
|
258
|
+
name = entry.get(key)
|
|
259
|
+
if not name:
|
|
260
|
+
continue
|
|
261
|
+
name = str(name)
|
|
262
|
+
if name in order:
|
|
263
|
+
order.remove(name)
|
|
264
|
+
order.append(name)
|
|
265
|
+
return list(reversed(order))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def resume_report(artifact: Path | str, path: Path | None = None) -> dict[str, Any]:
|
|
269
|
+
"""Where this lab was left, and whether the log still describes it.
|
|
270
|
+
|
|
271
|
+
The check is the point. A log that simply asserted a position would be the
|
|
272
|
+
defect this repository keeps finding: a fact derived in one place and
|
|
273
|
+
believed in another, with nothing comparing them. So the lab is re-hashed
|
|
274
|
+
and a position is claimed only when the two agree. Otherwise the mismatch
|
|
275
|
+
is the answer -- which is the same refusal-first stance the rest of the
|
|
276
|
+
skill takes when it cannot prove something.
|
|
277
|
+
"""
|
|
278
|
+
wanted = Path(artifact)
|
|
279
|
+
steps = history_for(wanted, path)
|
|
280
|
+
if not steps:
|
|
281
|
+
return {
|
|
282
|
+
"artifact": str(wanted),
|
|
283
|
+
"known": False,
|
|
284
|
+
"matches_log": False,
|
|
285
|
+
"steps": 0,
|
|
286
|
+
"summary": "no recorded steps for this lab",
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
last = steps[-1]
|
|
290
|
+
recorded = str(last.get("artifact_sha256") or last.get("source_sha256") or "")
|
|
291
|
+
on_disk = artifact_digest(wanted)
|
|
292
|
+
exists = wanted.is_file()
|
|
293
|
+
matches = bool(on_disk) and bool(recorded) and on_disk == recorded
|
|
294
|
+
|
|
295
|
+
if not exists:
|
|
296
|
+
summary = "the lab the log describes is not on disk any more"
|
|
297
|
+
elif not recorded:
|
|
298
|
+
summary = "the last step recorded no digest, so the position cannot be confirmed"
|
|
299
|
+
elif matches:
|
|
300
|
+
summary = f"last step was `{last.get('command')}` and the lab still matches it"
|
|
301
|
+
else:
|
|
302
|
+
summary = "the lab has changed since the last recorded step; position not claimed"
|
|
303
|
+
|
|
304
|
+
report: dict[str, Any] = {
|
|
305
|
+
"artifact": str(wanted),
|
|
306
|
+
"known": True,
|
|
307
|
+
"exists": exists,
|
|
308
|
+
"matches_log": matches,
|
|
309
|
+
"steps": len(steps),
|
|
310
|
+
"last_command": last.get("command"),
|
|
311
|
+
"last_status": last.get("status"),
|
|
312
|
+
"at": last.get("at"),
|
|
313
|
+
"summary": summary,
|
|
314
|
+
"history": [
|
|
315
|
+
{"at": step.get("at"), "command": step.get("command"), "status": step.get("status")}
|
|
316
|
+
for step in steps[-10:]
|
|
317
|
+
],
|
|
318
|
+
}
|
|
319
|
+
facts = last.get("facts") or {}
|
|
320
|
+
if isinstance(facts, dict):
|
|
321
|
+
if facts.get("next_best_action"):
|
|
322
|
+
report["next_best_action"] = facts["next_best_action"]
|
|
323
|
+
if facts:
|
|
324
|
+
report["last_facts"] = facts
|
|
325
|
+
return report
|