transcript-viewer 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """transcript-viewer — browse ATIF trajectories in a local web viewer."""
2
+
3
+ from .viewer import serve
4
+
5
+ __all__ = ["serve"]
@@ -0,0 +1,374 @@
1
+ """Optional Claude-backed help: explain one tool call, or answer a question
2
+ about a transcript.
3
+
4
+ Nothing here runs unless asked. A transcript carries source code, file contents
5
+ and tool output, so no call is made in the background, on load, or ahead of a
6
+ click — the viewer is local-only until you press a button.
7
+
8
+ The `anthropic` SDK is an optional extra (`pip install "transcript-viewer[ai]"`) so the
9
+ package keeps its zero-dependency install; these features simply hide when it,
10
+ or a credential, is absent.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from collections.abc import Iterator
18
+ from typing import Any
19
+
20
+ from . import config
21
+
22
+ MODEL = "claude-opus-5"
23
+
24
+ # Most transcripts are small enough to send whole; a few are not. The largest
25
+ # here is 8,445 steps and 12.9M characters, so there has to be a ceiling — but
26
+ # it applies only when a session actually exceeds it.
27
+ ASK_BUDGET_CHARS = 300_000
28
+ ASK_STEPS = 40
29
+ STEP_CLIP = 8_000
30
+
31
+
32
+ class Unavailable(RuntimeError):
33
+ """No SDK, or no credential. Both mean the same thing to a caller."""
34
+
35
+
36
+ def _client():
37
+ try:
38
+ import anthropic
39
+ except ImportError as exc: # pragma: no cover - depends on the extra
40
+ raise Unavailable(
41
+ 'The anthropic package is not installed: uv tool install "transcript-viewer[ai]"'
42
+ ) from exc
43
+ try:
44
+ # A key from settings, else the SDK's own resolution: ANTHROPIC_API_KEY,
45
+ # ANTHROPIC_AUTH_TOKEN, or an `ant auth login` profile. Never a key sent
46
+ # up by the page — it only ever travels from the browser to storage.
47
+ stored = config.api_key()
48
+ return anthropic.Anthropic(api_key=stored) if stored else anthropic.Anthropic()
49
+ except Exception as exc:
50
+ raise Unavailable(
51
+ "No Anthropic credential found. Add a key here, or set ANTHROPIC_API_KEY."
52
+ ) from exc
53
+
54
+
55
+ def status() -> tuple[bool, str]:
56
+ """Whether AI can run, and if not, what is missing.
57
+
58
+ A bare boolean was not enough: a saved key with no SDK installed looks
59
+ exactly like no key at all, which reads as "the save didn't work".
60
+ """
61
+ try:
62
+ _client()
63
+ except Unavailable as exc:
64
+ return False, str(exc)
65
+ return True, ""
66
+
67
+
68
+ def available() -> bool:
69
+ """Whether the controls should be offered at all."""
70
+ return status()[0]
71
+
72
+
73
+ def _stream(
74
+ client,
75
+ system: str,
76
+ messages: list[dict],
77
+ max_tokens: int,
78
+ thinking: bool = True,
79
+ ) -> Iterator[tuple[str, str]]:
80
+ """One request, yielding ("thinking" | "text", piece) as it arrives.
81
+
82
+ Two kinds, not one, because thinking produces no text: a model that thinks
83
+ for twenty seconds before its first word looks identical to a hang. The
84
+ caller can say "thinking" while that is what is happening.
85
+
86
+ `thinking` is off for work that does not need it — a two-sentence
87
+ description of one tool call is delayed, not improved, by deliberation.
88
+ """
89
+ import anthropic
90
+
91
+ request: dict[str, Any] = {
92
+ "model": MODEL,
93
+ "max_tokens": max_tokens,
94
+ "system": system,
95
+ "messages": messages,
96
+ }
97
+ if thinking:
98
+ request["thinking"] = {"type": "adaptive"}
99
+
100
+ try:
101
+ with client.messages.stream(**request) as stream:
102
+ for event in stream:
103
+ if event.type != "content_block_delta":
104
+ continue
105
+ kind = getattr(event.delta, "type", "")
106
+ if kind == "thinking_delta":
107
+ yield "thinking", event.delta.thinking
108
+ elif kind == "text_delta":
109
+ yield "text", event.delta.text
110
+ message = stream.get_final_message()
111
+ except anthropic.NotFoundError as exc:
112
+ raise Unavailable(f"Model {MODEL} is not available to this account.") from exc
113
+ except anthropic.RateLimitError as exc:
114
+ raise Unavailable("Rate limited by the API — try again shortly.") from exc
115
+ except anthropic.APIStatusError as exc:
116
+ raise Unavailable(f"The API refused that request: {exc.status_code}") from exc
117
+ except anthropic.APIConnectionError as exc:
118
+ raise Unavailable("Could not reach the API.") from exc
119
+
120
+ # Raised after the text, so a refusal is not silently shown as an answer.
121
+ if message.stop_reason == "refusal":
122
+ raise Unavailable("The model declined to answer that.")
123
+
124
+
125
+ def _text_only(pieces: Iterator[tuple[str, str]]) -> str:
126
+ """Collect a stream into the answer, dropping the thinking."""
127
+ return "".join(text for kind, text in pieces if kind == "text").strip()
128
+
129
+
130
+ def _clip(value: Any, limit: int = 4000) -> str:
131
+ text = value if isinstance(value, str) else json.dumps(value, indent=2)[:limit]
132
+ return text[:limit]
133
+
134
+
135
+ # ---------------------------------------------------------------- one call ---
136
+
137
+ CALL_SYSTEM = """You explain a single step from an agent transcript to someone \
138
+ reviewing it later.
139
+
140
+ Two or three sentences. Start with the call itself. What's it asking, in layperson terms? Then \
141
+ explain what actually came back — including whether it failed. No markdown \
142
+ headings."""
143
+
144
+
145
+ def _call_prompt(call: dict, output: Any) -> str:
146
+ parts = [
147
+ f"Tool: {call.get('function_name', 'unknown')}",
148
+ f"Arguments:\n{_clip(call.get('arguments', {}))}",
149
+ ]
150
+ parts.append(f"Output:\n{_clip(output, 6000)}" if output else "Output: (none recorded)")
151
+ return "\n\n".join(parts)
152
+
153
+
154
+ def summarise_call_stream(call: dict, output: Any = None) -> Iterator[tuple[str, str]]:
155
+ """Explain one tool call, a piece at a time.
156
+
157
+ The client is built before returning, so a missing credential is an error
158
+ the caller sees immediately rather than one that surfaces mid-stream.
159
+ Thinking is off: this is a description, and the first word should be quick.
160
+ """
161
+ client = _client()
162
+ return _stream(
163
+ client,
164
+ CALL_SYSTEM,
165
+ [{"role": "user", "content": _call_prompt(call, output)}],
166
+ max_tokens=1000,
167
+ thinking=False,
168
+ )
169
+
170
+
171
+ def summarise_call(call: dict, output: Any = None) -> str:
172
+ """Explain one tool call and its result."""
173
+ return _text_only(summarise_call_stream(call, output))
174
+
175
+
176
+ # ------------------------------------------------------------------- ask -----
177
+
178
+ ASK_SYSTEM = """You answer questions about an agent transcript, for someone \
179
+ reviewing what happened.
180
+
181
+ Answer only from the steps given. Cite the step numbers you used, as (step 42). \
182
+ If the steps do not contain the answer, say so plainly rather than guessing — \
183
+ they are a relevant-looking subset, not the whole transcript, so "it is not in \
184
+ what I was shown" is a useful answer.
185
+
186
+ This is a conversation. Each turn brings a fresh selection of steps chosen for \
187
+ that question, so steps quoted earlier may not be in front of you now; rely on \
188
+ what you said before rather than pretending to re-read them. A follow-up like \
189
+ "why?" refers to the previous answer.
190
+
191
+ Be direct and concrete. No preamble."""
192
+
193
+ # Enough for a real conversation, bounded so a long one cannot grow without
194
+ # limit — each turn already carries a fresh page of steps.
195
+ HISTORY_TURNS = 8
196
+ HISTORY_CHARS = 4000
197
+
198
+
199
+ def _text_of(step: dict) -> str:
200
+ """Everything in a step a reader could have seen."""
201
+ parts: list[str] = []
202
+ message = step.get("message")
203
+ if isinstance(message, str):
204
+ parts.append(message)
205
+ elif isinstance(message, list):
206
+ parts += [p.get("text", "") for p in message if isinstance(p, dict)]
207
+ if step.get("reasoning_content"):
208
+ parts.append(step["reasoning_content"])
209
+ for call in step.get("tool_calls") or []:
210
+ parts.append(call.get("function_name", ""))
211
+ parts.append(json.dumps(call.get("arguments", {})))
212
+ for result in (step.get("observation") or {}).get("results") or []:
213
+ content = result.get("content")
214
+ if isinstance(content, str):
215
+ parts.append(content)
216
+ return "\n".join(p for p in parts if p)
217
+
218
+
219
+ def _fallback(steps: list[dict], limit: int, focus: list[int] | None) -> list[dict]:
220
+ """Nothing in the question matched anything in the transcript.
221
+
222
+ A follow-up is almost always about what the last answer read, so those steps
223
+ are the better guess than an arbitrary slice. Only a question with no prior
224
+ turn to lean on gets the closing steps.
225
+ """
226
+ if focus:
227
+ wanted = set(focus)
228
+ carried = [s for s in steps if s.get("step_id") in wanted]
229
+ if carried:
230
+ return carried[:limit]
231
+ # Latest first: this is a recency guess, so if the budget forces a trim it
232
+ # should give up the oldest steps, not the newest.
233
+ return list(reversed(steps[-limit:]))
234
+
235
+
236
+ def _ranked_steps(
237
+ question: str,
238
+ steps: list[dict],
239
+ limit: int = ASK_STEPS,
240
+ focus: list[int] | None = None,
241
+ ) -> list[dict]:
242
+ """The candidate steps, best first.
243
+
244
+ Priority order rather than document order, so that a budget trim gives up
245
+ the least useful steps instead of whichever happen to come last.
246
+ """
247
+ words = {w for w in re.findall(r"[a-zA-Z_][\w./-]{2,}", question.lower())}
248
+
249
+ # A question says "authentication" where the transcript says "test_auth.py",
250
+ # so a long word also scores, at a discount, on its first four characters.
251
+ # Cheaper than tokenising every step, and wrong guesses only mis-rank.
252
+ stems = {w: w[:4] for w in words if len(w) > 6}
253
+
254
+ scored = []
255
+ for step in steps:
256
+ haystack = _text_of(step).lower()
257
+ score = 0
258
+ for word in words:
259
+ if word in haystack:
260
+ score += len(word)
261
+ elif word in stems and stems[word] in haystack:
262
+ score += 2
263
+ if score:
264
+ scored.append((score, step.get("step_id", 0), step))
265
+
266
+ # "Nothing matched" is the condition that matters, not "no words": a
267
+ # question can be all common words and still hit nothing.
268
+ if not scored:
269
+ return _fallback(steps, limit, focus)
270
+
271
+ scored.sort(key=lambda row: (-row[0], row[1]))
272
+ return [row[2] for row in scored[:limit]]
273
+
274
+
275
+ def relevant_steps(
276
+ question: str,
277
+ steps: list[dict],
278
+ limit: int = ASK_STEPS,
279
+ focus: list[int] | None = None,
280
+ ) -> list[dict]:
281
+ """The steps most likely to bear on the question, in document order.
282
+
283
+ Scored by how many of the question's words a step mentions, preferring
284
+ longer words, then sorted chronologically so an answer reads in order.
285
+ Only reached when a transcript is too large to send whole.
286
+ """
287
+ return sorted(
288
+ _ranked_steps(question, steps, limit, focus),
289
+ key=lambda s: s.get("step_id", 0),
290
+ )
291
+
292
+
293
+ def _block(step: dict) -> str:
294
+ body = _text_of(step)[:STEP_CLIP]
295
+ return f"--- step {step.get('step_id')} ({step.get('source')}) ---\n{body}"
296
+
297
+
298
+ def _ask_prompt(
299
+ question: str, steps: list[dict], focus: list[int] | None = None
300
+ ) -> tuple[str, list[int]]:
301
+ """The prompt, and the step numbers that went into it.
302
+
303
+ When the whole transcript fits, the whole transcript goes. Choosing forty
304
+ steps out of a session that would fit entire only throws information away,
305
+ and most sessions are that size — selection is the exception, not the rule.
306
+ """
307
+ whole = [_block(s) for s in steps]
308
+ if sum(len(b) + 2 for b in whole) <= ASK_BUDGET_CHARS:
309
+ chosen = steps
310
+ else:
311
+ # Trimmed in priority order, then read back in document order: what
312
+ # survives the budget should be the most useful steps, but the model
313
+ # should still see them chronologically.
314
+ kept: list[dict] = []
315
+ budget = ASK_BUDGET_CHARS
316
+ for step in _ranked_steps(question, steps, focus=focus):
317
+ size = len(_block(step))
318
+ # Skipped, not stopped at: one oversized step used to truncate every
319
+ # step after it, including small ones that would have fitted.
320
+ if size > budget:
321
+ continue
322
+ budget -= size
323
+ kept.append(step)
324
+ chosen = sorted(kept, key=lambda s: s.get("step_id", 0))
325
+
326
+ used = [s.get("step_id", 0) for s in chosen]
327
+ body = "\n\n".join(_block(s) for s in chosen)
328
+ return f"Question: {question}\n\nSteps:\n\n{body}", used
329
+
330
+
331
+ def _history(turns: list[dict] | None) -> list[dict]:
332
+ """Prior questions and answers as messages.
333
+
334
+ The steps that were sent with an earlier question are deliberately not
335
+ replayed: they are already digested into the answer, and re-sending a page
336
+ of transcript per turn would make a long conversation quadratic.
337
+ """
338
+ messages: list[dict] = []
339
+ for turn in (turns or [])[-HISTORY_TURNS:]:
340
+ question = str(turn.get("q") or "").strip()[:HISTORY_CHARS]
341
+ answer = str(turn.get("a") or "").strip()[:HISTORY_CHARS]
342
+ if not question or not answer:
343
+ continue
344
+ messages.append({"role": "user", "content": question})
345
+ messages.append({"role": "assistant", "content": answer})
346
+ return messages
347
+
348
+
349
+ def ask_stream(
350
+ question: str,
351
+ steps: list[dict],
352
+ history: list[dict] | None = None,
353
+ focus: list[int] | None = None,
354
+ ) -> tuple[list[int], Iterator[tuple[str, str]]]:
355
+ """The steps being used, and the answer as it arrives.
356
+
357
+ The steps are known before the first token, so the viewer can say what it is
358
+ reading while the answer is still being written.
359
+ """
360
+ client = _client()
361
+ prompt, used = _ask_prompt(question, steps, focus)
362
+ messages = [*_history(history), {"role": "user", "content": prompt}]
363
+ return used, _stream(client, ASK_SYSTEM, messages, max_tokens=4000)
364
+
365
+
366
+ def ask(
367
+ question: str,
368
+ steps: list[dict],
369
+ history: list[dict] | None = None,
370
+ focus: list[int] | None = None,
371
+ ) -> tuple[str, list[int]]:
372
+ """Answer a question about a transcript. Returns the answer and steps used."""
373
+ used, chunks = ask_stream(question, steps, history, focus)
374
+ return _text_only(chunks), used
@@ -0,0 +1,87 @@
1
+ """transcript-viewer command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import corpus, store
10
+ from atif_make.archive import is_archive
11
+
12
+ from .viewer import serve
13
+
14
+
15
+ def _single_entry(path: Path) -> list[corpus.Entry]:
16
+ """One file as an index entry, so it can be viewed without indexing.
17
+
18
+ Built by corpus.describe rather than assembled here: an Entry that is put
19
+ together in two places drifts the moment a field is added, and this one had
20
+ already fallen behind.
21
+ """
22
+ entry = corpus.describe(path)
23
+ return [entry] if entry else []
24
+
25
+
26
+ def cmd_view(args: argparse.Namespace) -> int:
27
+ # The tool was called atif-view and kept its library under ~/.atif. Adopt
28
+ # one if it is still there, before anything reads an index that would
29
+ # otherwise look empty.
30
+ if store.adopt_legacy():
31
+ print(
32
+ f"transcript-viewer: moved your library from {store.LEGACY_ROOT} "
33
+ f"to {store.ROOT}",
34
+ file=sys.stderr,
35
+ )
36
+
37
+ if args.input:
38
+ path = Path(args.input).expanduser()
39
+ if not path.exists():
40
+ print(f"transcript-viewer: no such path: {path}", file=sys.stderr)
41
+ return 2
42
+ # A directory or archive holds many sessions; a plain file holds one.
43
+ entries = (
44
+ corpus.scan([path])
45
+ if path.is_dir() or is_archive(path)
46
+ else _single_entry(path)
47
+ )
48
+ # An explicit path that holds nothing is a mistake worth reporting; an
49
+ # empty library is not, so this only guards the argument.
50
+ if not entries:
51
+ print(f"transcript-viewer: nothing convertible in {path}", file=sys.stderr)
52
+ return 1
53
+ else:
54
+ # Only what has been added deliberately. Scanning a machine because the
55
+ # library happens to be empty indexes someone's whole history of every
56
+ # agent without being asked; the viewer opens empty and offers to.
57
+ entries = corpus.load()
58
+ serve(
59
+ entries,
60
+ port=args.port or 7433,
61
+ open_browser=not args.no_open,
62
+ explicit_port=args.port is not None,
63
+ )
64
+ return 0
65
+
66
+
67
+ def build_parser() -> argparse.ArgumentParser:
68
+ parser = argparse.ArgumentParser(
69
+ prog="transcript-viewer",
70
+ description="Browse agent transcripts in a local viewer.",
71
+ )
72
+ parser.add_argument("input", nargs="?",
73
+ help="file, directory or archive (default: the atif-make index)")
74
+ parser.add_argument("--port", type=int, default=None,
75
+ help="port to listen on (default: 7433, or the next free one)")
76
+ parser.add_argument("--no-open", action="store_true", help="do not open a browser")
77
+ parser.set_defaults(func=cmd_view)
78
+ return parser
79
+
80
+
81
+ def main(argv: list[str] | None = None) -> int:
82
+ args = build_parser().parse_args(list(sys.argv[1:] if argv is None else argv))
83
+ return args.func(args)
84
+
85
+
86
+ if __name__ == "__main__":
87
+ raise SystemExit(main())
@@ -0,0 +1,193 @@
1
+ """Viewer settings, including optional API tokens.
2
+
3
+ A token kept in a file is weaker than one in the system keychain or a password
4
+ manager, so this module is deliberately narrow about them:
5
+
6
+ * they live at ~/.transcript-viewer/config.json, 0600, inside a 0700 directory;
7
+ * they are never returned to the page, never logged, and never put in a URL —
8
+ the page only ever learns that one is set and its last four characters;
9
+ * they are only ever read to authenticate a request.
10
+
11
+ The environment is still honoured. With nothing stored, each service's usual
12
+ variables apply as they would anywhere else.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from . import store
24
+
25
+ CONFIG_PATH = store.ROOT / "config.json"
26
+ VERSION = 1
27
+
28
+ # Long enough to be a real token, short enough to reject a pasted paragraph.
29
+ MIN_TOKEN = 20
30
+ MAX_TOKEN = 300
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Secret:
35
+ """One credential the viewer can hold."""
36
+
37
+ name: str
38
+ label: str
39
+ field: str # where it is kept in config.json
40
+ env: tuple[str, ...] # the variables that stand in for it
41
+ placeholder: str
42
+
43
+
44
+ SECRETS: dict[str, Secret] = {
45
+ "anthropic": Secret(
46
+ "anthropic",
47
+ "Anthropic API key",
48
+ "api_key",
49
+ ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
50
+ "sk-ant-…",
51
+ ),
52
+ "hf": Secret(
53
+ "hf",
54
+ "Hugging Face token",
55
+ "hf_token",
56
+ ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"),
57
+ "hf_…",
58
+ ),
59
+ "github": Secret(
60
+ "github",
61
+ "GitHub token",
62
+ "github_token",
63
+ ("GITHUB_TOKEN", "GH_TOKEN"),
64
+ "ghp_…",
65
+ ),
66
+ }
67
+
68
+
69
+ # The AWS profile is a name rather than a credential — the CLI holds the
70
+ # credential — so it is stored and shown in full.
71
+ PROFILE_FIELD = "aws_profile"
72
+ # A leading "-" would be read as an option by the CLI, so a name may not
73
+ # start with one. Empty is allowed and means the default profile.
74
+ PROFILE_OK = re.compile(r"^(?:[\w.@][\w.@-]{0,63})?$")
75
+
76
+
77
+ def aws_profile(path: Path | None = None) -> str:
78
+ value = load(path).get(PROFILE_FIELD)
79
+ return value if isinstance(value, str) else ""
80
+
81
+
82
+ def set_aws_profile(value: str, path: Path | None = None) -> None:
83
+ value = (value or "").strip()
84
+ if not PROFILE_OK.match(value):
85
+ raise ValueError("that does not look like an AWS profile name")
86
+ path = path or CONFIG_PATH
87
+ store.write_json(
88
+ path, {**load(path), "version": VERSION, PROFILE_FIELD: value}, private=True
89
+ )
90
+
91
+
92
+ def load(path: Path | None = None) -> dict[str, Any]:
93
+ return store.read_json(path or CONFIG_PATH)
94
+
95
+
96
+ def _spec(name: str) -> Secret:
97
+ secret = SECRETS.get(name)
98
+ if secret is None:
99
+ raise ValueError(f"unknown secret: {name}")
100
+ return secret
101
+
102
+
103
+ def secret(name: str, path: Path | None = None) -> str | None:
104
+ """The stored token, else whatever the environment supplies.
105
+
106
+ A token typed into settings wins: it is the more explicit, more recent act,
107
+ and `source()` shows which is in use so the precedence is never a surprise.
108
+ """
109
+ spec = _spec(name)
110
+ value = load(path).get(spec.field)
111
+ if isinstance(value, str) and value:
112
+ return value
113
+ for variable in spec.env:
114
+ from_env = os.environ.get(variable)
115
+ if from_env:
116
+ return from_env
117
+ return None
118
+
119
+
120
+ def stored(name: str, path: Path | None = None) -> str | None:
121
+ """Only what settings holds, ignoring the environment."""
122
+ value = load(path).get(_spec(name).field)
123
+ return value if isinstance(value, str) and value else None
124
+
125
+
126
+ def source(name: str, path: Path | None = None) -> str | None:
127
+ """Where a credential comes from — for display, not for logic."""
128
+ if stored(name, path):
129
+ return "settings"
130
+ if any(os.environ.get(v) for v in _spec(name).env):
131
+ return "environment"
132
+ return None
133
+
134
+
135
+ def hint(name: str, path: Path | None = None) -> str:
136
+ """The last four characters of a stored token, enough to tell two apart."""
137
+ value = stored(name, path)
138
+ return f"…{value[-4:]}" if value else ""
139
+
140
+
141
+ def set_secret(name: str, value: str, path: Path | None = None) -> None:
142
+ """Store a token. Raises ValueError on anything that is plainly not one."""
143
+ spec = _spec(name)
144
+ value = (value or "").strip()
145
+ if not value:
146
+ raise ValueError("no token given")
147
+ if len(value) < MIN_TOKEN or len(value) > MAX_TOKEN:
148
+ raise ValueError(f"that does not look like a {spec.label.lower()}")
149
+ if any(c.isspace() for c in value):
150
+ raise ValueError("a token contains no spaces — check what was pasted")
151
+
152
+ path = path or CONFIG_PATH
153
+ store.write_json(
154
+ path, {**load(path), "version": VERSION, spec.field: value}, private=True
155
+ )
156
+
157
+
158
+ def clear_secret(name: str, path: Path | None = None) -> None:
159
+ """Forget a stored token, falling back to the environment."""
160
+ path = path or CONFIG_PATH
161
+ data = load(path)
162
+ data.pop(_spec(name).field, None)
163
+ store.write_json(path, {**data, "version": VERSION}, private=True)
164
+
165
+
166
+ def state(path: Path | None = None) -> dict[str, dict[str, str]]:
167
+ """What the page may know: which tokens exist and where from. Never a value."""
168
+ return {
169
+ name: {
170
+ "label": spec.label,
171
+ "placeholder": spec.placeholder,
172
+ "env": spec.env[0],
173
+ "source": source(name, path) or "",
174
+ "hint": hint(name, path),
175
+ }
176
+ for name, spec in SECRETS.items()
177
+ }
178
+
179
+
180
+ def tokens(path: Path | None = None) -> dict[str, str | None]:
181
+ """The fetch credentials, by service name."""
182
+ return {
183
+ "hf": secret("hf", path),
184
+ "github": secret("github", path),
185
+ # Not a credential: the aws CLI resolves the session from this name.
186
+ "aws": aws_profile(path),
187
+ }
188
+
189
+
190
+ # The Anthropic key predates the others and is referenced by name elsewhere.
191
+ def api_key(path: Path | None = None) -> str | None:
192
+ """The Anthropic key, from settings or the environment."""
193
+ return secret("anthropic", path)