agent-bios 0.17.0 → 0.18.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/README.md +46 -0
- package/compose/bootstrap/SKILL.md +11 -1
- package/compose/corpus_store.py +75 -11
- package/compose/corpus_ui.py +330 -10
- package/compose/corpus_understand.py +6 -1
- package/package.json +1 -1
- package/provenance.json +1 -1
package/README.md
CHANGED
|
@@ -283,6 +283,52 @@ arbitrary package authoring/import, automatic semantic conflict resolution, or v
|
|
|
283
283
|
native skill-menu registration; those design stages must not be inferred from the
|
|
284
284
|
library UI.
|
|
285
285
|
|
|
286
|
+
Studio marks rules containing explicit `guides/*.md` references with **→ GUIDE**
|
|
287
|
+
and the guide names in the library. Their **Guide pointer** section links to exact
|
|
288
|
+
guide members in the same package and shows each target's current consumption surface
|
|
289
|
+
and state. Missing or ambiguous targets are disclosed rather than guessed. These
|
|
290
|
+
are display-only references, not new dependencies or proof of loading: rule bodies,
|
|
291
|
+
IDs, ordering, selection, and delivery remain unchanged. Personal body edits are
|
|
292
|
+
reflected when the library refreshes; ordinary rules are not classified by meaning.
|
|
293
|
+
|
|
294
|
+
Moving the library cursor to an item immediately displays its document; Enter is
|
|
295
|
+
not required. Group rows show group guidance and disable item-specific actions.
|
|
296
|
+
Cursor events cannot retarget an open editor or a prepared change preview.
|
|
297
|
+
|
|
298
|
+
Arrow keys also navigate the reading controls: **Search ↓ buttons ↓ library →
|
|
299
|
+
document**, with **←/→** between neighboring buttons and **↑** back toward search.
|
|
300
|
+
At the end of search text, **→** focuses the view selector; within text, the caret
|
|
301
|
+
moves normally. **←** from a closed view selector returns to search. Open menus
|
|
302
|
+
keep their native arrow selection. The library keeps normal **↑/↓** item movement;
|
|
303
|
+
at its top, **↑** returns to controls. In the document, arrows scroll until an
|
|
304
|
+
edge: **←** returns to the library, **↑** reaches the controls/member selector,
|
|
305
|
+
and **↓** at the bottom reaches pending on/off buttons when present. Disabled or
|
|
306
|
+
hidden buttons are skipped. TextArea editing, modal boundaries, and Tab remain intact.
|
|
307
|
+
|
|
308
|
+
In the library, **Space** toggles an item's use in future activated sessions:
|
|
309
|
+
`[x]` is on, `[ ]` is off, `[-]` is removed, and `*` marks an unapplied change.
|
|
310
|
+
The Available group means retained authoring items, not that every item is on.
|
|
311
|
+
Stage several choices, then use **Preview on/off → Apply**, or **Discard on/off**.
|
|
312
|
+
Text inputs keep normal spaces; Space on a group still expands or collapses it.
|
|
313
|
+
Unapplied choices require discard confirmation on exit and cannot be mixed with
|
|
314
|
+
content edits. A stale authoring revision refuses the preview or apply.
|
|
315
|
+
|
|
316
|
+
Per-item choices override domain and explicit launch selections, including core
|
|
317
|
+
and infrastructure defaults. Turning an item off does not delete its body, edits,
|
|
318
|
+
or identity, and old snapshots and session pins remain intact. Choices survive
|
|
319
|
+
updates and content restoration; full reset returns to installed defaults.
|
|
320
|
+
Enabling a removed item requires Restore/Recover first. A guide switched off is
|
|
321
|
+
marked in its referring rule, without automatically changing that rule. Native
|
|
322
|
+
hook/agent opt-in, trust, host support, and promotion rules still apply: a checked
|
|
323
|
+
item is a projection choice, not proof of execution. This does not block host
|
|
324
|
+
global/project instructions or a tool from opening a file independently.
|
|
325
|
+
|
|
326
|
+
The same revision-checked manager accepts `{"operation":"enable","items":{"@agent-bios/core:rule-003":false}}`
|
|
327
|
+
through `corpus plan`; `true` forces inclusion, `false` excludes, and `null`
|
|
328
|
+
removes that override so the normal selection applies. `list` reports `enabled`,
|
|
329
|
+
`enabled_override`, and the captured authoring `revision`; pass that revision as
|
|
330
|
+
`expected_revision` when planning a batch from the displayed inventory.
|
|
331
|
+
|
|
286
332
|
New personal identities are allocated once in the creation plan and remain stable
|
|
287
333
|
on retry; reset and history rollback do not make retired identities reusable.
|
|
288
334
|
Member files are the content authority: `primary_member` identifies the main file,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: corpus
|
|
3
|
-
description: Inspect or change the private agent-bios corpus used by activated sessions, including item creation, edits, consumption placement, removal, restore, recovery, selection, reset, rollback, history, and current-vs-pinned explanation. Use for requests about the user's agent-bios instructions or personal learnings; it does not alter the running session or native global files.
|
|
3
|
+
description: Inspect or change the private agent-bios corpus used by activated sessions, including item creation, edits, consumption placement, enablement, removal, restore, recovery, selection, reset, rollback, history, and current-vs-pinned explanation. Use for requests about the user's agent-bios instructions or personal learnings; it does not alter the running session or native global files.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Corpus management
|
|
@@ -64,11 +64,21 @@ Supported payloads:
|
|
|
64
64
|
{"operation":"restore","ref":"@scope/name:item-id"}
|
|
65
65
|
{"operation":"recover","ref":"@local/personal:item-id"}
|
|
66
66
|
{"operation":"select","selection":["@scope/name/domain"]}
|
|
67
|
+
{"operation":"enable","items":{"@scope/name:item-id":false},"expected_revision":"<revision from list>"}
|
|
67
68
|
{"operation":"reset"}
|
|
68
69
|
{"operation":"rollback","baseline_ref":"<baseline_ref>"}
|
|
69
70
|
{"operation":"rollback","history_id":"<history_id>"}
|
|
70
71
|
```
|
|
71
72
|
|
|
73
|
+
For turning items on or off without losing edits, use `enable`, not remove/restore.
|
|
74
|
+
Its `items` mapping supports batches: `true` forces inclusion, `false` excludes,
|
|
75
|
+
and `null` returns to normal selection. Read `enabled`, `enabled_override`, and
|
|
76
|
+
`revision` from `list`; the captured revision binds the whole batch. Overrides
|
|
77
|
+
take precedence over domain/core/infra and per-launch selections, survive updates
|
|
78
|
+
and content restoration, and clear on full reset. They do not bypass native
|
|
79
|
+
opt-in, trust, host support, or promotion rules, or prevent independent file reads.
|
|
80
|
+
Removed items require Restore/Recover first. Existing session pins remain unchanged.
|
|
81
|
+
|
|
72
82
|
For a body edit, submit `body`; the manager updates the item's `primary_member`
|
|
73
83
|
and derives every content view from that file. For a multi-file edit, submit
|
|
74
84
|
`members` retaining unchanged files. A conflicting simultaneous body/member edit
|
package/compose/corpus_store.py
CHANGED
|
@@ -37,6 +37,7 @@ ITEM_FIELDS = {
|
|
|
37
37
|
RUNTIME_FIELDS = {
|
|
38
38
|
"digest", "revision", "content_ref", "path", "state", "created_at",
|
|
39
39
|
"updated_at", "baseline_ref", "plan_id", "history_id",
|
|
40
|
+
"enabled", "enabled_override",
|
|
40
41
|
}
|
|
41
42
|
LEARNING_FIELDS = {"schema_version", "learning_id", "lesson", "domain", "created", "supporting_sessions",
|
|
42
43
|
"criteria", "classification", "proposed_domain", "context"}
|
|
@@ -307,8 +308,20 @@ class CorpusStore:
|
|
|
307
308
|
for field, typ in (("items", dict), ("overrides", dict), ("tombstones", dict), ("learning_suppressions", dict)):
|
|
308
309
|
if not isinstance(state.get(field), typ):
|
|
309
310
|
raise CorpusStoreError(f"personal state has invalid {field}")
|
|
311
|
+
# Optional, so reading an older state does not change its revision or the
|
|
312
|
+
# exact before/after documents used by prepared-transaction recovery.
|
|
313
|
+
self._enabled_overrides(state)
|
|
310
314
|
return state
|
|
311
315
|
|
|
316
|
+
@staticmethod
|
|
317
|
+
def _enabled_overrides(user: dict[str, Any]) -> dict[str, bool]:
|
|
318
|
+
values = user.get("enabled_overrides", {})
|
|
319
|
+
if (not isinstance(values, dict) or any(
|
|
320
|
+
not isinstance(ref, str) or ":" not in ref or not isinstance(value, bool)
|
|
321
|
+
for ref, value in values.items())):
|
|
322
|
+
raise CorpusStoreError("personal state has invalid enabled_overrides")
|
|
323
|
+
return values
|
|
324
|
+
|
|
312
325
|
def _write_transaction(self, tx_id: str, record: dict[str, Any]) -> None:
|
|
313
326
|
_atomic_write(self.runtime / "transactions" / tx_id / "journal.json", record)
|
|
314
327
|
|
|
@@ -351,7 +364,7 @@ class CorpusStore:
|
|
|
351
364
|
journal["history_id"] = history_id
|
|
352
365
|
_atomic_write(journal_path, journal)
|
|
353
366
|
history = {"runtime": prior["runtime"], "user": prior["user"],
|
|
354
|
-
"revision": plan.get("expected_revision")}
|
|
367
|
+
"revision": plan.get("expected_revision"), "details": plan.get("details", {})}
|
|
355
368
|
history_path = self.user_root / "history" / history_id / "state.json"
|
|
356
369
|
if not history_path.exists():
|
|
357
370
|
_atomic_write(history_path, history)
|
|
@@ -701,7 +714,7 @@ class CorpusStore:
|
|
|
701
714
|
active = [item for item in items if item.get("active", True) is not False]
|
|
702
715
|
selection = self._effective_selection(user, defaults, None)
|
|
703
716
|
self._validate_snapshot_selection(selection, _inventory, active)
|
|
704
|
-
selected = self._selected_items(active, selection)
|
|
717
|
+
selected = self._selected_items(active, selection, self._enabled_overrides(user))
|
|
705
718
|
self._require_resolved(selected)
|
|
706
719
|
catalog.compile_items(_copy_json(selected), Path(temp) / host, host)
|
|
707
720
|
except Exception as exc:
|
|
@@ -896,18 +909,24 @@ class CorpusStore:
|
|
|
896
909
|
"revision": revision, "baseline_count": len(refs),
|
|
897
910
|
"personal_items": len(user["items"]), "overrides": len(user["overrides"]),
|
|
898
911
|
"tombstones": len(user["tombstones"]), "selection": self._effective_selection(user, defaults, None),
|
|
912
|
+
"enabled_overrides": _copy_json(self._enabled_overrides(user)),
|
|
899
913
|
}
|
|
900
914
|
|
|
901
915
|
def list_items(self, include_removed: bool = True) -> list[dict[str, Any]]:
|
|
902
916
|
with self._lock():
|
|
903
917
|
self._recover_locked()
|
|
904
918
|
runtime, user = self._runtime_state(), self._user_state()
|
|
905
|
-
selected_ref, inventory,
|
|
919
|
+
selected_ref, inventory, defaults = self._selected_baseline(runtime)
|
|
906
920
|
all_items, _, _, _ = self._effective_items(runtime, user)
|
|
907
921
|
effective = {item["ref"]: item for item in all_items}
|
|
908
922
|
for host in ("claude", "codex"):
|
|
909
923
|
effective.update({item["ref"]: item for item in self._effective_items(runtime, user, host=host, include_suppressed=True)[0]})
|
|
910
924
|
source = {item["ref"]: self._validate_item(item, allow_origin=True) for item in inventory["items"]}
|
|
925
|
+
overrides = self._enabled_overrides(user)
|
|
926
|
+
selection = self._effective_selection(user, defaults, None)
|
|
927
|
+
enabled = {item["ref"] for item in self._selected_items(
|
|
928
|
+
[item for item in effective.values() if item.get("active", True) is not False], selection, overrides)}
|
|
929
|
+
revision = self._authoring_revision(runtime, user)
|
|
911
930
|
rows: list[dict[str, Any]] = []
|
|
912
931
|
for ref in sorted(set(source) | set(user["items"]) | set(effective)):
|
|
913
932
|
base = source.get(ref)
|
|
@@ -919,6 +938,9 @@ class CorpusStore:
|
|
|
919
938
|
row["state"] = "removed" if removed else ("conflict" if item and (item.get("conflict") or item.get("content_conflict")) else "active")
|
|
920
939
|
row["digest"] = _digest(item or base or user["items"][ref])
|
|
921
940
|
row["baseline_ref"] = selected_ref if base else None
|
|
941
|
+
row["enabled"] = not removed and ref in enabled
|
|
942
|
+
row["enabled_override"] = overrides.get(ref)
|
|
943
|
+
row["revision"] = revision
|
|
922
944
|
rows.append(row)
|
|
923
945
|
return rows
|
|
924
946
|
|
|
@@ -944,7 +966,11 @@ class CorpusStore:
|
|
|
944
966
|
return {"ref": ref, "history": self.history(ref)}
|
|
945
967
|
removed = ref in user["tombstones"] or (effective is not None and effective.get("active", True) is False)
|
|
946
968
|
state = "removed" if removed else ("conflict" if effective and (effective.get("conflict") or effective.get("content_conflict")) else "active")
|
|
947
|
-
|
|
969
|
+
overrides = self._enabled_overrides(user)
|
|
970
|
+
enabled = bool(effective and not removed and self._selected_items(
|
|
971
|
+
[effective], self._effective_selection(user, _defaults, None), overrides))
|
|
972
|
+
return {"ref": ref, "item": effective, "digest": _digest(effective) if effective else None, "state": state,
|
|
973
|
+
"enabled": enabled, "enabled_override": overrides.get(ref)}
|
|
948
974
|
|
|
949
975
|
# ---- plans --------------------------------------------------------------
|
|
950
976
|
|
|
@@ -971,7 +997,7 @@ class CorpusStore:
|
|
|
971
997
|
if not isinstance(payload, dict):
|
|
972
998
|
raise ValidationError("plan payload must be an object")
|
|
973
999
|
op = payload.get("operation", payload.get("op"))
|
|
974
|
-
if op not in {"create", "update", "remove", "restore", "recover", "reset", "rollback", "select"}:
|
|
1000
|
+
if op not in {"create", "update", "remove", "restore", "recover", "reset", "rollback", "select", "enable"}:
|
|
975
1001
|
raise ValidationError("unknown corpus operation")
|
|
976
1002
|
allowed = {
|
|
977
1003
|
"create": {"operation", "op", "item", "package_id", "expected_revision"},
|
|
@@ -982,6 +1008,7 @@ class CorpusStore:
|
|
|
982
1008
|
"reset": {"operation", "op", "expected_revision"},
|
|
983
1009
|
"rollback": {"operation", "op", "baseline_ref", "history_id", "expected_revision"},
|
|
984
1010
|
"select": {"operation", "op", "selection", "expected_revision"},
|
|
1011
|
+
"enable": {"operation", "op", "items", "expected_revision"},
|
|
985
1012
|
}[op]
|
|
986
1013
|
unknown = set(payload) - allowed
|
|
987
1014
|
if unknown:
|
|
@@ -1114,6 +1141,31 @@ class CorpusStore:
|
|
|
1114
1141
|
else:
|
|
1115
1142
|
next_user["items"][ref].pop("active", None)
|
|
1116
1143
|
details["ref"] = ref
|
|
1144
|
+
elif op == "enable":
|
|
1145
|
+
choices = payload.get("items")
|
|
1146
|
+
if not isinstance(choices, dict) or not choices:
|
|
1147
|
+
raise ValidationError("enable needs a non-empty mapping of refs to booleans or null")
|
|
1148
|
+
available = dict(effective)
|
|
1149
|
+
for host in ("claude", "codex"):
|
|
1150
|
+
available.update({item["ref"]: item for item in self._effective_items(runtime, user, host=host)[0]})
|
|
1151
|
+
overrides = dict(self._enabled_overrides(user))
|
|
1152
|
+
known = set(available) | set(overrides) | set(user["items"]) | {item["ref"] for item in inventory["items"]}
|
|
1153
|
+
for ref, value in choices.items():
|
|
1154
|
+
if not isinstance(ref, str) or ref not in known:
|
|
1155
|
+
raise ValidationError(f"unknown corpus enablement ref: {ref}")
|
|
1156
|
+
if value is None:
|
|
1157
|
+
overrides.pop(ref, None)
|
|
1158
|
+
elif not isinstance(value, bool):
|
|
1159
|
+
raise ValidationError("enable values must be booleans or null")
|
|
1160
|
+
elif ref not in available or available[ref].get("active", True) is False:
|
|
1161
|
+
raise ValidationError("recover or restore a removed item before enabling it")
|
|
1162
|
+
else:
|
|
1163
|
+
overrides[ref] = value
|
|
1164
|
+
if overrides:
|
|
1165
|
+
next_user["enabled_overrides"] = overrides
|
|
1166
|
+
else:
|
|
1167
|
+
next_user.pop("enabled_overrides", None)
|
|
1168
|
+
details["items"] = _copy_json(choices)
|
|
1117
1169
|
elif op == "select":
|
|
1118
1170
|
selection = self._validate_selection(payload.get("selection"), inventory)
|
|
1119
1171
|
next_user["selection"] = selection
|
|
@@ -1208,7 +1260,8 @@ class CorpusStore:
|
|
|
1208
1260
|
history_id = f"{prepared['prepared_at'].replace(':', '').replace('+00:00', 'Z')}-{plan_id[:12]}"
|
|
1209
1261
|
prepared["history_id"] = history_id
|
|
1210
1262
|
self._write_transaction(plan_id, prepared)
|
|
1211
|
-
_atomic_write(self.user_root / "history" / history_id / "state.json",
|
|
1263
|
+
_atomic_write(self.user_root / "history" / history_id / "state.json",
|
|
1264
|
+
{"runtime": runtime, "user": user, "revision": current, "details": details})
|
|
1212
1265
|
if details.get("operation") == "reset":
|
|
1213
1266
|
_atomic_write(self.user_root / "trash" / history_id / "state.json", {"runtime": runtime, "user": user, "revision": current})
|
|
1214
1267
|
_atomic_write(self._user_state_path, next_user)
|
|
@@ -1225,11 +1278,18 @@ class CorpusStore:
|
|
|
1225
1278
|
|
|
1226
1279
|
# ---- immutable snapshots and history -----------------------------------
|
|
1227
1280
|
|
|
1228
|
-
def _selected_items(self, items: list[dict[str, Any]], selection: list[str] | None
|
|
1229
|
-
|
|
1230
|
-
|
|
1281
|
+
def _selected_items(self, items: list[dict[str, Any]], selection: list[str] | None,
|
|
1282
|
+
overrides: dict[str, bool] | None = None) -> list[dict[str, Any]]:
|
|
1283
|
+
overrides = overrides or {}
|
|
1231
1284
|
selected: list[dict[str, Any]] = []
|
|
1232
1285
|
for item in items:
|
|
1286
|
+
if item["ref"] in overrides:
|
|
1287
|
+
if overrides[item["ref"]]:
|
|
1288
|
+
selected.append(item)
|
|
1289
|
+
continue
|
|
1290
|
+
if selection and "all" in selection:
|
|
1291
|
+
selected.append(item)
|
|
1292
|
+
continue
|
|
1233
1293
|
if item.get("tier") in {"core", "infra"}:
|
|
1234
1294
|
selected.append(item)
|
|
1235
1295
|
continue
|
|
@@ -1268,7 +1328,7 @@ class CorpusStore:
|
|
|
1268
1328
|
active = [item for item in items if item.get("active", True) is not False]
|
|
1269
1329
|
effective_selection = self._effective_selection(user, defaults, selection)
|
|
1270
1330
|
self._validate_snapshot_selection(effective_selection, inventory, active)
|
|
1271
|
-
selected = self._selected_items(active, effective_selection)
|
|
1331
|
+
selected = self._selected_items(active, effective_selection, self._enabled_overrides(user))
|
|
1272
1332
|
self._require_resolved(selected)
|
|
1273
1333
|
selected, promotion_warnings = self._resolve_promotions(selected, user, host, baseline_ref)
|
|
1274
1334
|
bootstrap_path = self.repo / "compose" / "bootstrap" / "SKILL.md"
|
|
@@ -1372,7 +1432,11 @@ class CorpusStore:
|
|
|
1372
1432
|
continue
|
|
1373
1433
|
if ref is not None:
|
|
1374
1434
|
user = record.get("user", {})
|
|
1375
|
-
|
|
1435
|
+
details = record.get("details", {})
|
|
1436
|
+
changed = details.get("items", {}) if details.get("operation") == "enable" else {}
|
|
1437
|
+
if (ref not in user.get("items", {}) and ref not in user.get("overrides", {})
|
|
1438
|
+
and ref not in user.get("tombstones", {}) and ref not in user.get("enabled_overrides", {})
|
|
1439
|
+
and ref not in changed):
|
|
1376
1440
|
continue
|
|
1377
1441
|
rows.append({"history_id": path.parent.name, "revision": record.get("revision"), "path": str(path.parent)})
|
|
1378
1442
|
return rows
|
package/compose/corpus_ui.py
CHANGED
|
@@ -11,11 +11,14 @@ import json
|
|
|
11
11
|
import re
|
|
12
12
|
from pathlib import PurePosixPath
|
|
13
13
|
from typing import Any
|
|
14
|
-
from urllib.parse import unquote
|
|
14
|
+
from urllib.parse import quote, unquote
|
|
15
|
+
|
|
16
|
+
from rich.text import Text
|
|
15
17
|
|
|
16
18
|
from textual.app import App, ComposeResult
|
|
17
19
|
from textual.binding import Binding
|
|
18
20
|
from textual.containers import Horizontal, Vertical
|
|
21
|
+
from textual.events import Key
|
|
19
22
|
from textual.screen import ModalScreen
|
|
20
23
|
from textual.widgets import (
|
|
21
24
|
Button,
|
|
@@ -41,6 +44,40 @@ SURFACES = ("always", "relevant", "requested", "event", "delegated")
|
|
|
41
44
|
VIEWS = ("effective", "installed", "change", "diff", "history")
|
|
42
45
|
|
|
43
46
|
|
|
47
|
+
def availability_label(state: str) -> str:
|
|
48
|
+
return "available" if state == "active" else state
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def guide_pointers(item: dict[str, Any], inventory: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
52
|
+
"""Display literal guide references, not inferred dependencies or new routing."""
|
|
53
|
+
if item.get("kind") != "rule":
|
|
54
|
+
return []
|
|
55
|
+
paths = dict.fromkeys(re.findall(r"\bguides/[A-Za-z0-9][A-Za-z0-9_./-]*\.md\b", item.get("body", "")))
|
|
56
|
+
result = []
|
|
57
|
+
for path in paths:
|
|
58
|
+
if ".." in PurePosixPath(path).parts:
|
|
59
|
+
continue
|
|
60
|
+
matches = [candidate for candidate in inventory
|
|
61
|
+
if candidate.get("package_id") == item.get("package_id")
|
|
62
|
+
and candidate.get("kind") == "guide" and path in candidate.get("members", {})]
|
|
63
|
+
result.append({"path": path, "target": matches[0] if len(matches) == 1 else None,
|
|
64
|
+
"problem": "Ambiguous guide reference" if matches else "Not found in this package"})
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def library_label(item: dict[str, Any], inventory: list[dict[str, Any]]) -> Text:
|
|
69
|
+
pointers = guide_pointers(item, inventory)
|
|
70
|
+
label = Text()
|
|
71
|
+
mark = "-" if item.get("state") == "removed" else "x" if item.get("enabled", True) else " "
|
|
72
|
+
label.append(f"[{mark}]" + ("* " if item.get("enabled_pending") else " "))
|
|
73
|
+
if pointers:
|
|
74
|
+
label.append("→ GUIDE ", style="bold cyan")
|
|
75
|
+
label.append(", ".join(PurePosixPath(pointer["path"]).stem for pointer in pointers))
|
|
76
|
+
label.append(" · ")
|
|
77
|
+
label.append(f"{item.get('title', item.get('ref'))} · {item.get('surface', '?')}")
|
|
78
|
+
return label
|
|
79
|
+
|
|
80
|
+
|
|
44
81
|
def render_member_body(body: str, member: str | None, kind: str | None = None) -> str:
|
|
45
82
|
"""Render prose as Markdown and preserve source files as literal code."""
|
|
46
83
|
suffix = PurePosixPath(member).suffix.lower() if member else ""
|
|
@@ -82,7 +119,35 @@ class Confirmation(ModalScreen[bool]):
|
|
|
82
119
|
class CorpusMarkdownViewer(MarkdownViewer):
|
|
83
120
|
"""Keep all links inside Studio; never hand a URL to the operating system."""
|
|
84
121
|
|
|
122
|
+
BINDINGS = [
|
|
123
|
+
Binding("left", "left_or_library", "Library", show=False),
|
|
124
|
+
Binding("up", "up_or_controls", "Controls", show=False),
|
|
125
|
+
Binding("down", "down_or_pending", "Pending choices", show=False),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
def action_left_or_library(self) -> None:
|
|
129
|
+
if self.scroll_x <= 0:
|
|
130
|
+
self.app.action_focus_library()
|
|
131
|
+
else:
|
|
132
|
+
self.action_scroll_left()
|
|
133
|
+
|
|
134
|
+
def action_up_or_controls(self) -> None:
|
|
135
|
+
if self.scroll_y <= 0:
|
|
136
|
+
if not self.app._focus_id("member-select"):
|
|
137
|
+
self.app._focus_controls()
|
|
138
|
+
else:
|
|
139
|
+
self.action_scroll_up()
|
|
140
|
+
|
|
141
|
+
def action_down_or_pending(self) -> None:
|
|
142
|
+
if self.scroll_y >= self.max_scroll_y:
|
|
143
|
+
self.app._focus_id("enablement-preview")
|
|
144
|
+
else:
|
|
145
|
+
self.action_scroll_down()
|
|
146
|
+
|
|
85
147
|
async def _on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
|
|
148
|
+
# stop() prevents bubbling, not the inherited MarkdownViewer handler.
|
|
149
|
+
# Its default go() would try to load corpus:// as a filesystem path.
|
|
150
|
+
message.prevent_default()
|
|
86
151
|
message.stop()
|
|
87
152
|
if message.href.startswith("corpus://"):
|
|
88
153
|
ref = unquote(message.href.removeprefix("corpus://"))
|
|
@@ -91,6 +156,57 @@ class CorpusMarkdownViewer(MarkdownViewer):
|
|
|
91
156
|
self.app.notify("External links are disabled in Corpus Studio.", severity="warning")
|
|
92
157
|
|
|
93
158
|
|
|
159
|
+
class CorpusSearch(Input):
|
|
160
|
+
"""Leave search with arrows without taking editing keys away from text."""
|
|
161
|
+
|
|
162
|
+
BINDINGS = [
|
|
163
|
+
Binding("down", "focus_controls", "Controls", show=False),
|
|
164
|
+
Binding("right", "right_or_view", "View", show=False),
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
def action_focus_controls(self) -> None:
|
|
168
|
+
self.app._focus_controls()
|
|
169
|
+
|
|
170
|
+
def action_right_or_view(self) -> None:
|
|
171
|
+
if self.selection.start == self.selection.end == len(self.value):
|
|
172
|
+
self.app._focus_id("view-select")
|
|
173
|
+
else:
|
|
174
|
+
self.action_cursor_right()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CorpusTree(Tree):
|
|
178
|
+
"""Space is local to the library, never intercepted in text inputs."""
|
|
179
|
+
|
|
180
|
+
BINDINGS = [
|
|
181
|
+
Binding("space", "toggle_item", "On/off"),
|
|
182
|
+
Binding("up", "up_or_controls", "Controls", show=False),
|
|
183
|
+
Binding("down", "down_or_pending", "Pending choices", show=False),
|
|
184
|
+
Binding("right", "focus_document", "Document", show=False),
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
def action_up_or_controls(self) -> None:
|
|
188
|
+
if self.cursor_line <= 0:
|
|
189
|
+
self.app._focus_controls()
|
|
190
|
+
else:
|
|
191
|
+
self.action_cursor_up()
|
|
192
|
+
|
|
193
|
+
def action_down_or_pending(self) -> None:
|
|
194
|
+
before = self.cursor_node
|
|
195
|
+
self.action_cursor_down()
|
|
196
|
+
if self.cursor_node is before:
|
|
197
|
+
self.app._focus_id("enablement-preview")
|
|
198
|
+
|
|
199
|
+
def action_focus_document(self) -> None:
|
|
200
|
+
self.app._focus_document()
|
|
201
|
+
|
|
202
|
+
async def action_toggle_item(self) -> None:
|
|
203
|
+
node = self.cursor_node
|
|
204
|
+
if node is not None and isinstance(node.data, str):
|
|
205
|
+
await self.app.toggle_enabled(node.data)
|
|
206
|
+
else:
|
|
207
|
+
self.action_toggle_node()
|
|
208
|
+
|
|
209
|
+
|
|
94
210
|
class CorpusStudio(App[None]):
|
|
95
211
|
"""Searchable corpus library, editor, and revision-bound Preview → Apply flow."""
|
|
96
212
|
|
|
@@ -136,6 +252,9 @@ class CorpusStudio(App[None]):
|
|
|
136
252
|
.actions { height: 3; align-horizontal: right; }
|
|
137
253
|
.actions Button { margin-left: 1; }
|
|
138
254
|
#status-line { height: 1; padding: 0 1; color: $text-muted; }
|
|
255
|
+
#enablement-bar { height: 3; display: none; }
|
|
256
|
+
#enablement-summary { width: 1fr; padding: 1; }
|
|
257
|
+
#enablement-bar Button { margin-right: 1; }
|
|
139
258
|
"""
|
|
140
259
|
|
|
141
260
|
def __init__(self, store: Any) -> None:
|
|
@@ -150,13 +269,16 @@ class CorpusStudio(App[None]):
|
|
|
150
269
|
self.view_item: dict[str, Any] | None = None
|
|
151
270
|
self.member_drafts: dict[str, str] = {}
|
|
152
271
|
self.pending_plan: dict[str, Any] | None = None
|
|
272
|
+
self.toggle_drafts: dict[str, bool] = {}
|
|
273
|
+
self.toggle_original: dict[str, bool] = {}
|
|
274
|
+
self.toggle_revision: str | None = None
|
|
153
275
|
self.mode = "view"
|
|
154
276
|
|
|
155
277
|
def compose(self) -> ComposeResult:
|
|
156
278
|
yield Header()
|
|
157
279
|
with Vertical(id="toolbar"):
|
|
158
280
|
with Horizontal(id="search-row"):
|
|
159
|
-
yield
|
|
281
|
+
yield CorpusSearch(placeholder="Search title, content, ref, package, domain, or state (↓ controls)", id="search")
|
|
160
282
|
yield Select([(view.title(), view) for view in VIEWS], value="effective",
|
|
161
283
|
allow_blank=False, id="view-select")
|
|
162
284
|
with Horizontal(id="action-row"):
|
|
@@ -167,7 +289,7 @@ class CorpusStudio(App[None]):
|
|
|
167
289
|
yield Button("Recover", id="recover", compact=True)
|
|
168
290
|
yield Button("Reset", id="reset", variant="error", compact=True)
|
|
169
291
|
with Horizontal(id="workspace"):
|
|
170
|
-
yield
|
|
292
|
+
yield CorpusTree("Corpus", id="library")
|
|
171
293
|
with Vertical(id="detail"):
|
|
172
294
|
yield Select([], prompt="Bundle document", allow_blank=True, id="member-select")
|
|
173
295
|
yield CorpusMarkdownViewer(
|
|
@@ -207,6 +329,10 @@ class CorpusStudio(App[None]):
|
|
|
207
329
|
with Horizontal(classes="actions"):
|
|
208
330
|
yield Button("Cancel plan", id="plan-cancel")
|
|
209
331
|
yield Button("Apply", id="apply", variant="success")
|
|
332
|
+
with Horizontal(id="enablement-bar"):
|
|
333
|
+
yield Static("", id="enablement-summary", markup=False)
|
|
334
|
+
yield Button("Preview on/off", id="enablement-preview", variant="primary")
|
|
335
|
+
yield Button("Discard on/off", id="enablement-discard")
|
|
210
336
|
yield Static("Stored privately; no host session is activated by this screen.", id="status-line")
|
|
211
337
|
yield Footer()
|
|
212
338
|
|
|
@@ -214,6 +340,63 @@ class CorpusStudio(App[None]):
|
|
|
214
340
|
await self.refresh_library()
|
|
215
341
|
self.query_one("#search", Input).focus()
|
|
216
342
|
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _focusable(widget: Any) -> bool:
|
|
345
|
+
return widget.focusable and all(getattr(node, "display", True) for node in [widget, *widget.ancestors])
|
|
346
|
+
|
|
347
|
+
def _focus_id(self, *ids: str) -> bool:
|
|
348
|
+
# Query the current screen only: arrows must never escape a modal.
|
|
349
|
+
for ident in ids:
|
|
350
|
+
for widget in self.screen.query(f"#{ident}"):
|
|
351
|
+
if self._focusable(widget):
|
|
352
|
+
widget.focus()
|
|
353
|
+
return True
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
def _focus_controls(self) -> None:
|
|
357
|
+
for button in self.screen.query("#action-row Button"):
|
|
358
|
+
if self._focusable(button):
|
|
359
|
+
button.focus()
|
|
360
|
+
return
|
|
361
|
+
self._focus_id("library" if getattr(self.focused, "id", None) == "search" else "search")
|
|
362
|
+
|
|
363
|
+
def _focus_document(self) -> None:
|
|
364
|
+
if self.mode != "view" or self._focus_id("member-select"):
|
|
365
|
+
return
|
|
366
|
+
for viewer in self.screen.query("#wiki"):
|
|
367
|
+
if self._focusable(viewer.document):
|
|
368
|
+
viewer.document.focus()
|
|
369
|
+
|
|
370
|
+
def on_key(self, event: Key) -> None:
|
|
371
|
+
focused = self.focused
|
|
372
|
+
moved = False
|
|
373
|
+
if isinstance(focused, Button) and focused.parent is not None:
|
|
374
|
+
siblings = [button for button in focused.parent.query(Button)
|
|
375
|
+
if button.parent is focused.parent and self._focusable(button)]
|
|
376
|
+
if event.key in {"left", "right"} and focused in siblings:
|
|
377
|
+
index = siblings.index(focused) + (-1 if event.key == "left" else 1)
|
|
378
|
+
if 0 <= index < len(siblings):
|
|
379
|
+
siblings[index].focus()
|
|
380
|
+
moved = True
|
|
381
|
+
elif event.key in {"up", "down"} and len(self.screen_stack) == 1:
|
|
382
|
+
if focused.parent.id == "action-row":
|
|
383
|
+
moved = self._focus_id("search" if event.key == "up" else "library")
|
|
384
|
+
elif focused.parent.id == "enablement-bar":
|
|
385
|
+
moved = self._focus_id("library")
|
|
386
|
+
elif self.mode == "editor" and event.key == "up":
|
|
387
|
+
moved = self._focus_id("editor-body")
|
|
388
|
+
elif isinstance(focused, Select) and not focused.expanded and self.mode == "view":
|
|
389
|
+
if event.key == "left" and focused.id in {"view-select", "member-select"}:
|
|
390
|
+
moved = self._focus_id("search" if focused.id == "view-select" else "library")
|
|
391
|
+
elif event.key == "right" and focused.id == "member-select":
|
|
392
|
+
for viewer in self.screen.query("#wiki"):
|
|
393
|
+
if self._focusable(viewer.document):
|
|
394
|
+
viewer.document.focus()
|
|
395
|
+
moved = True
|
|
396
|
+
if moved:
|
|
397
|
+
event.prevent_default()
|
|
398
|
+
event.stop()
|
|
399
|
+
|
|
217
400
|
def _set_mode(self, mode: str) -> None:
|
|
218
401
|
self.mode = mode
|
|
219
402
|
self.query_one("#member-select", Select).disabled = mode == "preview"
|
|
@@ -224,6 +407,83 @@ class CorpusStudio(App[None]):
|
|
|
224
407
|
self.query_one("#wiki").display = mode == "view"
|
|
225
408
|
self.query_one("#editor-panel").display = mode == "editor"
|
|
226
409
|
self.query_one("#preview-panel").display = mode == "preview"
|
|
410
|
+
self._toggle_controls()
|
|
411
|
+
# Hiding a panel does not reliably release its focused child. Choose a
|
|
412
|
+
# visible, non-destructive entry point for the next screen state.
|
|
413
|
+
if mode == "preview":
|
|
414
|
+
self._focus_id("plan-cancel")
|
|
415
|
+
elif mode == "view":
|
|
416
|
+
self._focus_id("enablement-preview", "library")
|
|
417
|
+
|
|
418
|
+
def _display_items(self) -> list[dict[str, Any]]:
|
|
419
|
+
return [dict(row, enabled=self.toggle_drafts[row["ref"]], enabled_pending=True)
|
|
420
|
+
if row.get("ref") in self.toggle_drafts else row for row in self.items]
|
|
421
|
+
|
|
422
|
+
def _toggle_controls(self) -> None:
|
|
423
|
+
pending = bool(self.toggle_drafts)
|
|
424
|
+
self.query_one("#enablement-bar").display = pending and self.mode == "view"
|
|
425
|
+
self.query_one("#enablement-summary", Static).update(
|
|
426
|
+
f"{len(self.toggle_drafts)} unapplied on/off change(s) · * = pending")
|
|
427
|
+
if self.mode == "view":
|
|
428
|
+
for selector in ("#create", "#reset"):
|
|
429
|
+
self.query_one(selector).disabled = pending
|
|
430
|
+
if pending:
|
|
431
|
+
for selector in ("#edit", "#remove", "#restore", "#recover"):
|
|
432
|
+
self.query_one(selector).disabled = True
|
|
433
|
+
|
|
434
|
+
async def _repaint_enablement(self) -> None:
|
|
435
|
+
displayed = self._display_items()
|
|
436
|
+
by_ref = {row["ref"]: row for row in displayed}
|
|
437
|
+
nodes = [self.query_one("#library", Tree).root]
|
|
438
|
+
while nodes:
|
|
439
|
+
node = nodes.pop()
|
|
440
|
+
if isinstance(node.data, str) and node.data in by_ref:
|
|
441
|
+
node.set_label(library_label(by_ref[node.data], displayed))
|
|
442
|
+
nodes.extend(node.children)
|
|
443
|
+
self._toggle_controls()
|
|
444
|
+
if self.current_ref:
|
|
445
|
+
await self.select_ref(self.current_ref)
|
|
446
|
+
|
|
447
|
+
async def toggle_enabled(self, ref: str) -> None:
|
|
448
|
+
if self.mode != "view":
|
|
449
|
+
return
|
|
450
|
+
row = self._row(ref)
|
|
451
|
+
if row is None or row.get("state") == "removed":
|
|
452
|
+
self.notify("Recover or restore this item before changing its use.", severity="warning")
|
|
453
|
+
return
|
|
454
|
+
if not self.toggle_drafts:
|
|
455
|
+
self.toggle_revision = row["revision"]
|
|
456
|
+
original = self.toggle_original.setdefault(ref, row["enabled"])
|
|
457
|
+
value = not self.toggle_drafts.get(ref, row["enabled"])
|
|
458
|
+
if value == original:
|
|
459
|
+
self.toggle_drafts.pop(ref, None)
|
|
460
|
+
self.toggle_original.pop(ref, None)
|
|
461
|
+
else:
|
|
462
|
+
self.toggle_drafts[ref] = value
|
|
463
|
+
if not self.toggle_drafts:
|
|
464
|
+
self.toggle_revision = None
|
|
465
|
+
await self._repaint_enablement()
|
|
466
|
+
|
|
467
|
+
def _clear_toggle_drafts(self) -> None:
|
|
468
|
+
self.toggle_drafts.clear()
|
|
469
|
+
self.toggle_original.clear()
|
|
470
|
+
self.toggle_revision = None
|
|
471
|
+
|
|
472
|
+
async def _discard_toggles(self) -> None:
|
|
473
|
+
self._clear_toggle_drafts()
|
|
474
|
+
self._toggle_controls()
|
|
475
|
+
await self.refresh_library(self.query_one("#search", Input).value)
|
|
476
|
+
if self.current_ref:
|
|
477
|
+
await self.select_ref(self.current_ref)
|
|
478
|
+
self._focus_id("library")
|
|
479
|
+
|
|
480
|
+
def _preview_toggles(self) -> None:
|
|
481
|
+
if not self.toggle_drafts or self.mode != "view":
|
|
482
|
+
return
|
|
483
|
+
summary = "Use in future sessions (no content is deleted):\n" + "\n".join(
|
|
484
|
+
f"{'ON' if enabled else 'OFF'} {ref}" for ref, enabled in self.toggle_drafts.items())
|
|
485
|
+
self._stage_plan({"operation": "enable", "items": dict(self.toggle_drafts),
|
|
486
|
+
"expected_revision": self.toggle_revision}, summary)
|
|
227
487
|
|
|
228
488
|
async def refresh_library(self, query: str = "") -> None:
|
|
229
489
|
if self.mode != "view":
|
|
@@ -239,20 +499,22 @@ class CorpusStudio(App[None]):
|
|
|
239
499
|
self.query_one("#status-line", Static).update(str(exc))
|
|
240
500
|
return
|
|
241
501
|
visible = [item for item in self.items if self._matches(item, query)]
|
|
502
|
+
displayed = self._display_items()
|
|
503
|
+
display_by_ref = {item["ref"]: item for item in displayed}
|
|
242
504
|
groups: dict[tuple[str, str], Any] = {}
|
|
243
505
|
state_nodes: dict[str, Any] = {}
|
|
244
506
|
for item in visible:
|
|
245
507
|
state = str(item.get("state", "active"))
|
|
246
508
|
package = str(item.get("package_id", "unknown"))
|
|
247
509
|
if state not in state_nodes:
|
|
248
|
-
state_nodes[state] = tree.root.add(state.title())
|
|
510
|
+
state_nodes[state] = tree.root.add(availability_label(state).title())
|
|
249
511
|
state_nodes[state].expand()
|
|
250
512
|
key = (state, package)
|
|
251
513
|
if key not in groups:
|
|
252
514
|
groups[key] = state_nodes[state].add(package)
|
|
253
515
|
groups[key].expand()
|
|
254
516
|
groups[key].add_leaf(
|
|
255
|
-
|
|
517
|
+
library_label(display_by_ref[item["ref"]], displayed),
|
|
256
518
|
data=item.get("ref"),
|
|
257
519
|
)
|
|
258
520
|
tree.root.expand()
|
|
@@ -263,6 +525,7 @@ class CorpusStudio(App[None]):
|
|
|
263
525
|
)
|
|
264
526
|
if visible and (self.current_ref is None or not any(i.get("ref") == self.current_ref for i in visible)):
|
|
265
527
|
await self.select_ref(str(visible[0]["ref"]))
|
|
528
|
+
self._toggle_controls()
|
|
266
529
|
|
|
267
530
|
@staticmethod
|
|
268
531
|
def _matches(item: dict[str, Any], query: str) -> bool:
|
|
@@ -309,6 +572,7 @@ class CorpusStudio(App[None]):
|
|
|
309
572
|
self.query_one("#remove", Button).disabled = row.get("state") == "removed"
|
|
310
573
|
self.query_one("#restore", Button).disabled = not bool(row.get("baseline_ref"))
|
|
311
574
|
self.query_one("#recover", Button).disabled = not self._recoverable(row)
|
|
575
|
+
self._toggle_controls()
|
|
312
576
|
|
|
313
577
|
def _select_members(self, item: dict[str, Any] | None, preferred: str | None = None) -> None:
|
|
314
578
|
selector = self.query_one("#member-select", Select)
|
|
@@ -327,8 +591,7 @@ class CorpusStudio(App[None]):
|
|
|
327
591
|
members[self.current_member] = self.query_one("#editor-body", TextArea).text
|
|
328
592
|
return members
|
|
329
593
|
|
|
330
|
-
|
|
331
|
-
def _render_item(row: dict[str, Any], result: dict[str, Any], view: str) -> str:
|
|
594
|
+
def _render_item(self, row: dict[str, Any], result: dict[str, Any], view: str) -> str:
|
|
332
595
|
if view == "effective" and isinstance(result.get("item"), dict):
|
|
333
596
|
item = result["item"]
|
|
334
597
|
links = "\n".join(
|
|
@@ -344,11 +607,34 @@ class CorpusStudio(App[None]):
|
|
|
344
607
|
+ (f" Available members: {members}." if members else "") + "\n"
|
|
345
608
|
)
|
|
346
609
|
body = render_member_body(item.get("body", ""), item.get("primary_member"), item.get("kind"))
|
|
610
|
+
displayed = self._display_items()
|
|
611
|
+
display_row = next((entry for entry in displayed if entry.get("ref") == item.get("ref")), row)
|
|
612
|
+
pointers = guide_pointers(item, displayed)
|
|
613
|
+
guide_links = ""
|
|
614
|
+
if pointers:
|
|
615
|
+
lines = []
|
|
616
|
+
for pointer in pointers:
|
|
617
|
+
target = pointer["target"]
|
|
618
|
+
if target is None:
|
|
619
|
+
lines.append(f"- `{pointer['path']}` — {pointer['problem']}")
|
|
620
|
+
else:
|
|
621
|
+
destination = quote(target["ref"], safe="@/:")
|
|
622
|
+
use = "ON" if target.get("enabled", True) else "OFF — linked guide disabled"
|
|
623
|
+
lines.append(f"- [{pointer['path']}](corpus://{destination}) — "
|
|
624
|
+
f"**{target['surface']}** · {availability_label(target.get('state', 'active'))} · {use}")
|
|
625
|
+
guide_links = (
|
|
626
|
+
"## Guide pointer\n\nThis rule explicitly references the following guide(s). "
|
|
627
|
+
"The rule keeps its own consumption surface; these links do not change "
|
|
628
|
+
"delivery or prove that a guide was loaded.\n\n" + "\n".join(lines) + "\n\n## Rule\n\n"
|
|
629
|
+
)
|
|
347
630
|
return (
|
|
348
631
|
f"# {item.get('title', item.get('ref'))}\n\n"
|
|
349
|
-
f"`{item.get('ref')}` · **{row.get('state', 'active')}** · "
|
|
632
|
+
f"`{item.get('ref')}` · **{availability_label(row.get('state', 'active'))}** · "
|
|
350
633
|
f"{item.get('surface')} · {item.get('kind')}\n\n"
|
|
351
|
-
f"{
|
|
634
|
+
f"Use in future sessions: **{'ON' if display_row.get('enabled', True) else 'OFF'}**"
|
|
635
|
+
+ (" (pending; not applied)" if display_row.get("enabled_pending") else " (saved selection)")
|
|
636
|
+
+ ". Inclusion is not proof of loading or permission to execute.\n\n"
|
|
637
|
+
f"{guide_links}{reconciliation}\n{body}\n\n## Dependencies\n\n{links}\n"
|
|
352
638
|
+ ("\n## Native consumption\n\nRequires explicit `agent-launch --corpus-native`.\n"
|
|
353
639
|
if item.get("kind") == "hook" else "")
|
|
354
640
|
)
|
|
@@ -359,6 +645,26 @@ class CorpusStudio(App[None]):
|
|
|
359
645
|
if isinstance(event.node.data, str):
|
|
360
646
|
await self.select_ref(event.node.data)
|
|
361
647
|
|
|
648
|
+
async def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
|
|
649
|
+
tree = self.query_one("#library", Tree)
|
|
650
|
+
# Ignore queued highlights superseded by another cursor move or tree rebuild.
|
|
651
|
+
if self.mode != "view" or event.node is not tree.cursor_node:
|
|
652
|
+
return
|
|
653
|
+
if isinstance(event.node.data, str):
|
|
654
|
+
await self.select_ref(event.node.data)
|
|
655
|
+
return
|
|
656
|
+
# A group is not the previously displayed item: never leave its edit/delete
|
|
657
|
+
# actions live while the cursor points at a different kind of node.
|
|
658
|
+
self.current_ref = None
|
|
659
|
+
self.view_item = None
|
|
660
|
+
self._select_members(None)
|
|
661
|
+
for selector in ("#edit", "#remove", "#restore", "#recover"):
|
|
662
|
+
self.query_one(selector, Button).disabled = True
|
|
663
|
+
label = str(event.node.label.plain)
|
|
664
|
+
await self.query_one("#wiki", CorpusMarkdownViewer).document.update(
|
|
665
|
+
f"# Corpus group\n\n{label}\n\nMove the cursor to a corpus item to read it."
|
|
666
|
+
)
|
|
667
|
+
|
|
362
668
|
async def on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
|
|
363
669
|
# CorpusMarkdownViewer normally handles this before it bubbles. Keeping
|
|
364
670
|
# the app handler makes direct Markdown messages safe in tests and future layouts.
|
|
@@ -410,6 +716,9 @@ class CorpusStudio(App[None]):
|
|
|
410
716
|
def _open_editor(self, item: dict[str, Any] | None) -> None:
|
|
411
717
|
if self.mode != "view":
|
|
412
718
|
return
|
|
719
|
+
if self.toggle_drafts:
|
|
720
|
+
self.notify("Apply or discard on/off changes first.", severity="warning")
|
|
721
|
+
return
|
|
413
722
|
preferred = self.current_member if item and item.get("ref") == self.current_ref else None
|
|
414
723
|
self.editor_item = item
|
|
415
724
|
self.editor_ref = str(item["ref"]) if item else None
|
|
@@ -528,6 +837,8 @@ class CorpusStudio(App[None]):
|
|
|
528
837
|
async def action_request_quit(self) -> None:
|
|
529
838
|
if self.editor_dirty():
|
|
530
839
|
self.push_screen(Confirmation("Discard the unsaved draft and exit Studio?"), self._discard_and_exit)
|
|
840
|
+
elif self.toggle_drafts:
|
|
841
|
+
self.push_screen(Confirmation("Discard unapplied on/off changes and exit Studio?"), self._discard_and_exit)
|
|
531
842
|
else:
|
|
532
843
|
self.exit()
|
|
533
844
|
|
|
@@ -588,6 +899,9 @@ class CorpusStudio(App[None]):
|
|
|
588
899
|
}, diff + hook_summary
|
|
589
900
|
|
|
590
901
|
def _stage_plan(self, payload: dict[str, Any], summary: str) -> None:
|
|
902
|
+
if self.toggle_drafts and payload.get("operation") != "enable":
|
|
903
|
+
self.notify("Apply or discard on/off changes first.", severity="warning")
|
|
904
|
+
return
|
|
591
905
|
try:
|
|
592
906
|
plan = self.store.plan(payload)
|
|
593
907
|
except Exception as exc:
|
|
@@ -616,6 +930,8 @@ class CorpusStudio(App[None]):
|
|
|
616
930
|
self.notify(str(exc), severity="error")
|
|
617
931
|
return
|
|
618
932
|
ref = result.get("details", {}).get("ref")
|
|
933
|
+
if result.get("details", {}).get("operation") == "enable":
|
|
934
|
+
self._clear_toggle_drafts()
|
|
619
935
|
self.pending_plan = None
|
|
620
936
|
self._set_mode("view")
|
|
621
937
|
await self.refresh_library(self.query_one("#search", Input).value)
|
|
@@ -625,7 +941,11 @@ class CorpusStudio(App[None]):
|
|
|
625
941
|
|
|
626
942
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
627
943
|
button = event.button.id
|
|
628
|
-
if button == "
|
|
944
|
+
if button == "enablement-preview":
|
|
945
|
+
self._preview_toggles()
|
|
946
|
+
elif button == "enablement-discard":
|
|
947
|
+
await self._discard_toggles()
|
|
948
|
+
elif button == "create":
|
|
629
949
|
self.action_create()
|
|
630
950
|
elif button == "edit":
|
|
631
951
|
self.action_edit()
|
|
@@ -144,7 +144,12 @@ class CorpusUnderstand:
|
|
|
144
144
|
return value
|
|
145
145
|
|
|
146
146
|
def _bundles(self) -> list[dict]:
|
|
147
|
-
|
|
147
|
+
# Projection preferences and the inventory read revision are not learning
|
|
148
|
+
# content. Disabled items remain readable and learnable without repinning
|
|
149
|
+
# every bundle when an unrelated activation preference changes.
|
|
150
|
+
rows = [{key: value for key, value in x.items()
|
|
151
|
+
if key not in {"enabled", "enabled_override", "revision"}}
|
|
152
|
+
for x in self.store.list_items(include_removed=False) if x.get("state") == "active"]
|
|
148
153
|
bundles, assigned = [], set()
|
|
149
154
|
for ident, title, purpose, numbers in CORE_BUNDLES:
|
|
150
155
|
wanted = {f"rule-{n:03}" for n in numbers}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-bios",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"releaseDate": "2026-09-11",
|
|
5
5
|
"description": "A thin, low-level instruction layer for LLM CLI agents: one set of principles and behavior whichever model you run. Stores a private, editable corpus for explicitly activated sessions.",
|
|
6
6
|
"bin": {
|
package/provenance.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"commit":"
|
|
1
|
+
{"commit":"588260e5b60f52f0f58b47e8d2ef7ae567b382a6","committedAt":"2026-09-11T21:09:27+09:00","dirty":false}
|