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.
@@ -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