browserwright 0.6.2__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.
Files changed (98) hide show
  1. browserwright/__init__.py +33 -0
  2. browserwright/__main__.py +6 -0
  3. browserwright/_executor/__init__.py +47 -0
  4. browserwright/_executor/__main__.py +9 -0
  5. browserwright/_executor/client.py +127 -0
  6. browserwright/_executor/process.py +652 -0
  7. browserwright/_executor/protocol.py +152 -0
  8. browserwright/api.py +66 -0
  9. browserwright/cdp.py +285 -0
  10. browserwright/cli.py +741 -0
  11. browserwright/daemon/__init__.py +8 -0
  12. browserwright/daemon/_ipc.py +444 -0
  13. browserwright/daemon/active_tab.py +183 -0
  14. browserwright/daemon/auth.py +395 -0
  15. browserwright/daemon/backends/__init__.py +59 -0
  16. browserwright/daemon/backends/base.py +120 -0
  17. browserwright/daemon/backends/cloud.py +222 -0
  18. browserwright/daemon/backends/env.py +119 -0
  19. browserwright/daemon/backends/extension.py +185 -0
  20. browserwright/daemon/backends/rdp.py +214 -0
  21. browserwright/daemon/cli.py +1437 -0
  22. browserwright/daemon/config.py +380 -0
  23. browserwright/daemon/doctor.py +179 -0
  24. browserwright/daemon/errors.py +34 -0
  25. browserwright/daemon/launch_chrome.py +353 -0
  26. browserwright/daemon/observability.py +181 -0
  27. browserwright/daemon/platforms.py +234 -0
  28. browserwright/daemon/resolver.py +72 -0
  29. browserwright/daemon/server/__init__.py +6 -0
  30. browserwright/daemon/server/daemon.py +229 -0
  31. browserwright/daemon/server/executor_registry.py +434 -0
  32. browserwright/daemon/server/extension_upstream.py +677 -0
  33. browserwright/daemon/server/facade.py +375 -0
  34. browserwright/daemon/server/facade_extension.py +969 -0
  35. browserwright/daemon/server/listener.py +1058 -0
  36. browserwright/daemon/server/proxy.py +1991 -0
  37. browserwright/daemon/server/relay.py +783 -0
  38. browserwright/daemon/server/state.py +432 -0
  39. browserwright/daemon/server/upstream.py +266 -0
  40. browserwright/daemon/userscripts.py +150 -0
  41. browserwright/discovery.py +213 -0
  42. browserwright/errors.py +177 -0
  43. browserwright/health.py +169 -0
  44. browserwright/install.py +628 -0
  45. browserwright/memory/__init__.py +15 -0
  46. browserwright/memory/_md.py +120 -0
  47. browserwright/memory/_yaml.py +217 -0
  48. browserwright/memory/global_mem.py +201 -0
  49. browserwright/memory/repl_mem.py +28 -0
  50. browserwright/memory/session_decisions.py +53 -0
  51. browserwright/memory/site_mem.py +381 -0
  52. browserwright/mode_b_client.py +590 -0
  53. browserwright/multitask.py +131 -0
  54. browserwright/output_schema.py +99 -0
  55. browserwright/primitives/__init__.py +67 -0
  56. browserwright/primitives/discovery_api.py +79 -0
  57. browserwright/primitives/http.py +42 -0
  58. browserwright/primitives/inspect.py +876 -0
  59. browserwright/primitives/interact.py +518 -0
  60. browserwright/primitives/page.py +556 -0
  61. browserwright/primitives/site.py +143 -0
  62. browserwright/release_install.py +466 -0
  63. browserwright/repl/__init__.py +6 -0
  64. browserwright/repl/_namespace.py +106 -0
  65. browserwright/repl/_smart_goto.py +236 -0
  66. browserwright/repl/inline.py +180 -0
  67. browserwright/repl/playwright_handle.py +449 -0
  68. browserwright/repl/snapshot.py +150 -0
  69. browserwright/session.py +229 -0
  70. browserwright/session_create.py +252 -0
  71. browserwright/session_ctx.py +24 -0
  72. browserwright/session_registry.py +133 -0
  73. browserwright/session_runtime.py +133 -0
  74. browserwright/site_skills_starter/github.com/SKILL.md +14 -0
  75. browserwright/site_skills_starter/github.com/memory.md +29 -0
  76. browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
  77. browserwright/site_skills_starter/google.com/SKILL.md +16 -0
  78. browserwright/site_skills_starter/google.com/memory.md +27 -0
  79. browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
  80. browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
  81. browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
  82. browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
  83. browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
  84. browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
  85. browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
  86. browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
  87. browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
  88. browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
  89. browserwright/skill_doc.py +140 -0
  90. browserwright/skill_runtime.md +194 -0
  91. browserwright/subscriptions.py +213 -0
  92. browserwright/task_runner.py +125 -0
  93. browserwright/version.py +117 -0
  94. browserwright-0.6.2.dist-info/METADATA +12 -0
  95. browserwright-0.6.2.dist-info/RECORD +98 -0
  96. browserwright-0.6.2.dist-info/WHEEL +5 -0
  97. browserwright-0.6.2.dist-info/entry_points.txt +3 -0
  98. browserwright-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,120 @@
1
+ """Markdown helpers for frontmatter + section editing.
2
+
3
+ Lightweight: split a file into (frontmatter_dict, body_str). When appending
4
+ into a named ``## Section``, create the section at the bottom if it doesn't
5
+ exist; otherwise append to the end of that section preserving order.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from . import _yaml
12
+
13
+
14
+ def parse_doc(text: str) -> tuple[dict, str]:
15
+ if text.startswith("---\n"):
16
+ end = text.find("\n---", 4)
17
+ if end != -1:
18
+ fm_raw = text[4:end]
19
+ body = text[end + 4:].lstrip("\n")
20
+ try:
21
+ fm = _yaml.loads(fm_raw)
22
+ except Exception:
23
+ fm = {}
24
+ return fm, body
25
+ return {}, text
26
+
27
+
28
+ def render_doc(frontmatter: dict, body: str) -> str:
29
+ body = body.lstrip("\n")
30
+ if not body.endswith("\n"):
31
+ body += "\n"
32
+ if frontmatter:
33
+ return "---\n" + _yaml.dumps(frontmatter) + "---\n\n" + body
34
+ return body
35
+
36
+
37
+ def append_to_section(body: str, section: str, line: str) -> str:
38
+ """Append ``line`` to ``## {section}`` block, creating it if absent.
39
+
40
+ Sections are matched case-insensitively against the level-2 heading.
41
+ Body remains markdown — no parser, just header line manipulation.
42
+ """
43
+ heading = f"## {section}"
44
+ lines = body.splitlines()
45
+ out: list[str] = []
46
+ in_target = False
47
+ appended = False
48
+ next_heading_idx: int | None = None
49
+
50
+ # First pass: find target heading + the next heading after it.
51
+ target_idx: int | None = None
52
+ for idx, ln in enumerate(lines):
53
+ if ln.strip().lower() == heading.lower():
54
+ target_idx = idx
55
+ break
56
+ if target_idx is not None:
57
+ for idx in range(target_idx + 1, len(lines)):
58
+ if lines[idx].startswith("## "):
59
+ next_heading_idx = idx
60
+ break
61
+ # Decide where to insert.
62
+ if target_idx is None:
63
+ out = list(lines)
64
+ if out and out[-1].strip():
65
+ out.append("")
66
+ out.append(heading)
67
+ out.append("")
68
+ out.append(line)
69
+ appended = True
70
+ else:
71
+ end = next_heading_idx if next_heading_idx is not None else len(lines)
72
+ # Strip trailing blanks inside the target block before appending.
73
+ section_block = list(lines[target_idx:end])
74
+ while section_block and not section_block[-1].strip():
75
+ section_block.pop()
76
+ section_block.append(line)
77
+ out = list(lines[:target_idx]) + section_block + [""] + list(lines[end:])
78
+ appended = True
79
+ if not appended:
80
+ out = list(lines) + [line]
81
+ text = "\n".join(out)
82
+ if not text.endswith("\n"):
83
+ text += "\n"
84
+ return text
85
+
86
+
87
+ def write_atomic(path: Path, content: str) -> None:
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ tmp = path.with_suffix(path.suffix + ".tmp")
90
+ tmp.write_text(content, encoding="utf-8")
91
+ tmp.replace(path)
92
+
93
+
94
+ def find_matching_lines(body: str, pattern: str) -> list[tuple[int, str]]:
95
+ """Return ``(line_no, content)`` pairs for bullet lines whose content
96
+ contains ``pattern`` (case-insensitive substring). Headings and
97
+ blank lines are skipped — we only match list items so we don't
98
+ accidentally delete the user's prose.
99
+ """
100
+ import re
101
+
102
+ rx = re.compile(re.escape(pattern), re.IGNORECASE)
103
+ out: list[tuple[int, str]] = []
104
+ for i, ln in enumerate(body.splitlines()):
105
+ stripped = ln.lstrip()
106
+ if not stripped.startswith(("- ", "* ", "+ ")):
107
+ continue
108
+ item = stripped[2:].rstrip()
109
+ if rx.search(item):
110
+ out.append((i, ln))
111
+ return out
112
+
113
+
114
+ def remove_lines(body: str, line_nos: set[int]) -> str:
115
+ """Return ``body`` with the listed line numbers removed."""
116
+ kept = [ln for i, ln in enumerate(body.splitlines()) if i not in line_nos]
117
+ text = "\n".join(kept)
118
+ if not text.endswith("\n"):
119
+ text += "\n"
120
+ return text
@@ -0,0 +1,217 @@
1
+ """Tiny YAML subset for memory frontmatter.
2
+
3
+ We don't pull in PyYAML — frontmatter blocks in spec §C.2 are simple:
4
+ top-level keys, scalar / list / nested-dict values one level deep, ISO dates
5
+ as strings. Hand-rolling a parser keeps install lean and the surface tiny.
6
+
7
+ If a frontmatter ever needs richer YAML (anchors, multi-line strings,
8
+ explicit typing) we can swap to PyYAML — but lazy-loading keeps the
9
+ zero-dep default.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import datetime as _dt
14
+ from typing import Any
15
+
16
+
17
+ def _scalar(s: str) -> Any:
18
+ s = s.strip()
19
+ if not s:
20
+ return ""
21
+ if s in ("null", "~"):
22
+ return None
23
+ if s in ("true", "True"):
24
+ return True
25
+ if s in ("false", "False"):
26
+ return False
27
+ if (s.startswith("\"") and s.endswith("\"")) or (s.startswith("'") and s.endswith("'")):
28
+ return s[1:-1]
29
+ if s.startswith("[") and s.endswith("]"):
30
+ body = s[1:-1].strip()
31
+ if not body:
32
+ return []
33
+ return [_scalar(p.strip()) for p in _split_top_level(body, ",")]
34
+ if s.startswith("{") and s.endswith("}"):
35
+ body = s[1:-1].strip()
36
+ if not body:
37
+ return {}
38
+ out: dict[str, Any] = {}
39
+ for part in _split_top_level(body, ","):
40
+ if ":" not in part:
41
+ continue
42
+ k, v = part.split(":", 1)
43
+ out[_scalar(k.strip())] = _scalar(v.strip())
44
+ return out
45
+ # numbers
46
+ try:
47
+ if s.startswith("0") and "." not in s and len(s) > 1:
48
+ raise ValueError
49
+ return int(s)
50
+ except ValueError:
51
+ pass
52
+ try:
53
+ return float(s)
54
+ except ValueError:
55
+ pass
56
+ return s
57
+
58
+
59
+ def _split_top_level(text: str, sep: str) -> list[str]:
60
+ """Split ``text`` on ``sep`` while respecting (), [], {} nesting and quotes."""
61
+ parts: list[str] = []
62
+ buf: list[str] = []
63
+ depth = 0
64
+ quote: str | None = None
65
+ for ch in text:
66
+ if quote:
67
+ buf.append(ch)
68
+ if ch == quote:
69
+ quote = None
70
+ continue
71
+ if ch in "\"'":
72
+ quote = ch
73
+ buf.append(ch)
74
+ continue
75
+ if ch in "[{(":
76
+ depth += 1
77
+ elif ch in "]})":
78
+ depth -= 1
79
+ if ch == sep and depth == 0:
80
+ parts.append("".join(buf))
81
+ buf = []
82
+ continue
83
+ buf.append(ch)
84
+ if buf:
85
+ parts.append("".join(buf))
86
+ return parts
87
+
88
+
89
+ def loads(text: str) -> dict[str, Any]:
90
+ """Parse a tiny-YAML frontmatter block. Supports::
91
+
92
+ key: value
93
+ key:
94
+ nested: value
95
+ list:
96
+ - item
97
+ list_inline: [a, b, c]
98
+ """
99
+ lines = text.splitlines()
100
+ return _parse_block(lines, 0, indent=0)[0]
101
+
102
+
103
+ def _parse_block(lines: list[str], i: int, *, indent: int) -> tuple[dict[str, Any], int]:
104
+ out: dict[str, Any] = {}
105
+ while i < len(lines):
106
+ raw = lines[i]
107
+ if not raw.strip() or raw.lstrip().startswith("#"):
108
+ i += 1
109
+ continue
110
+ cur_indent = len(raw) - len(raw.lstrip(" "))
111
+ if cur_indent < indent:
112
+ break
113
+ if cur_indent > indent:
114
+ i += 1
115
+ continue
116
+ line = raw.strip()
117
+ if ":" not in line:
118
+ i += 1
119
+ continue
120
+ key, rest = line.split(":", 1)
121
+ key = key.strip()
122
+ rest = rest.strip()
123
+ if rest:
124
+ out[key] = _scalar(rest)
125
+ i += 1
126
+ continue
127
+ # Nested. Could be list (- starts) or map.
128
+ nxt = i + 1
129
+ # Skip blanks
130
+ while nxt < len(lines) and not lines[nxt].strip():
131
+ nxt += 1
132
+ if nxt < len(lines):
133
+ child = lines[nxt]
134
+ child_indent = len(child) - len(child.lstrip(" "))
135
+ stripped = child.lstrip(" ")
136
+ if child_indent > indent and stripped.startswith("- "):
137
+ # list
138
+ items, i = _parse_list(lines, nxt, indent=child_indent)
139
+ out[key] = items
140
+ continue
141
+ if child_indent > indent:
142
+ nested, i = _parse_block(lines, nxt, indent=child_indent)
143
+ out[key] = nested
144
+ continue
145
+ out[key] = None
146
+ i += 1
147
+ return out, i
148
+
149
+
150
+ def _parse_list(lines: list[str], i: int, *, indent: int) -> tuple[list[Any], int]:
151
+ items: list[Any] = []
152
+ while i < len(lines):
153
+ raw = lines[i]
154
+ if not raw.strip():
155
+ i += 1
156
+ continue
157
+ cur_indent = len(raw) - len(raw.lstrip(" "))
158
+ if cur_indent < indent:
159
+ break
160
+ stripped = raw.lstrip(" ")
161
+ if not stripped.startswith("- "):
162
+ break
163
+ items.append(_scalar(stripped[2:].strip()))
164
+ i += 1
165
+ return items, i
166
+
167
+
168
+ # -- dump ---------------------------------------------------------------
169
+
170
+
171
+ def _dump_scalar(v: Any) -> str:
172
+ if v is None:
173
+ return "null"
174
+ if isinstance(v, bool):
175
+ return "true" if v else "false"
176
+ if isinstance(v, (int, float)):
177
+ return repr(v)
178
+ if isinstance(v, _dt.datetime):
179
+ return v.isoformat()
180
+ if isinstance(v, str):
181
+ if v == "" or any(c in v for c in ":#{}[],&*?|<>=!%@`") or v.startswith(("-", "?", ":", "!")):
182
+ return "\"" + v.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
183
+ return v
184
+ raise TypeError(f"unsupported scalar type: {type(v).__name__}")
185
+
186
+
187
+ def dumps(data: dict[str, Any]) -> str:
188
+ out: list[str] = []
189
+ _emit(data, indent=0, out=out)
190
+ return "\n".join(out) + ("\n" if out else "")
191
+
192
+
193
+ def _emit(value: Any, *, indent: int, out: list[str]) -> None:
194
+ pad = " " * indent
195
+ if isinstance(value, dict):
196
+ for k, v in value.items():
197
+ if isinstance(v, dict) and v:
198
+ out.append(f"{pad}{k}:")
199
+ _emit(v, indent=indent + 2, out=out)
200
+ elif isinstance(v, list):
201
+ if not v:
202
+ out.append(f"{pad}{k}: []")
203
+ else:
204
+ out.append(f"{pad}{k}:")
205
+ for item in v:
206
+ if isinstance(item, (dict, list)):
207
+ out.append(f"{pad} - ")
208
+ _emit(item, indent=indent + 4, out=out)
209
+ else:
210
+ out.append(f"{pad} - {_dump_scalar(item)}")
211
+ else:
212
+ out.append(f"{pad}{k}: {_dump_scalar(v)}")
213
+ elif isinstance(value, list):
214
+ for item in value:
215
+ out.append(f"{pad}- {_dump_scalar(item)}")
216
+ else:
217
+ out.append(f"{pad}{_dump_scalar(value)}")
@@ -0,0 +1,201 @@
1
+ """Global memory — ``$BS_HOME/global.md`` (default ``~/.browserwright/global.md``)."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as _dt
5
+ import fcntl
6
+ import os
7
+ import threading
8
+ from pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+ from ..errors import NeedsUserConfirm
12
+ from . import _md
13
+
14
+
15
+ _DEFAULT_BODY = """# Global skill memory
16
+
17
+ Notes here apply across every site. See frontmatter for machine-readable
18
+ preferences (e.g. ``daemon.preferred_backend``).
19
+
20
+ ## Aliases (user-defined)
21
+
22
+ ## Last choices
23
+ """
24
+
25
+
26
+ def home_dir() -> Path:
27
+ return Path(os.path.expanduser(os.environ.get("BS_HOME", "~/.browserwright"))).resolve()
28
+
29
+
30
+ def global_path() -> Path:
31
+ return home_dir() / "global.md"
32
+
33
+
34
+ class _FileLock:
35
+ """Cross-process advisory lock on the global memory file."""
36
+
37
+ def __init__(self, path: Path):
38
+ self.path = path
39
+ self._fd: Optional[int] = None
40
+ self._thread_lock = threading.Lock()
41
+
42
+ def __enter__(self):
43
+ self._thread_lock.acquire()
44
+ self.path.parent.mkdir(parents=True, exist_ok=True)
45
+ self._fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
46
+ try:
47
+ fcntl.flock(self._fd, fcntl.LOCK_EX)
48
+ except OSError:
49
+ # Non-POSIX (Windows) — skip; rely on the thread lock.
50
+ pass
51
+ return self
52
+
53
+ def __exit__(self, *exc):
54
+ try:
55
+ if self._fd is not None:
56
+ try:
57
+ fcntl.flock(self._fd, fcntl.LOCK_UN)
58
+ except OSError:
59
+ pass
60
+ os.close(self._fd)
61
+ finally:
62
+ self._fd = None
63
+ self._thread_lock.release()
64
+
65
+
66
+ class GlobalMemory:
67
+ def __init__(self, path: Optional[Path] = None):
68
+ self.path = path or global_path()
69
+
70
+ # ---- low-level R/W ------------------------------------------------
71
+
72
+ def _read(self) -> tuple[dict, str]:
73
+ if not self.path.exists():
74
+ return {"schema_version": 1}, _DEFAULT_BODY
75
+ return _md.parse_doc(self.path.read_text(encoding="utf-8"))
76
+
77
+ def read(self) -> dict:
78
+ fm, body = self._read()
79
+ return {"frontmatter": fm, "body": body}
80
+
81
+ def _write(self, fm: dict, body: str) -> None:
82
+ fm = dict(fm) if fm else {"schema_version": 1}
83
+ fm.setdefault("schema_version", 1)
84
+ _md.write_atomic(self.path, _md.render_doc(fm, body))
85
+
86
+ # ---- high-level helpers ------------------------------------------
87
+
88
+ def append(self, line: str, section: str = "Notes") -> None:
89
+ with _FileLock(self.path):
90
+ fm, body = self._read()
91
+ new_body = _md.append_to_section(body, section, f"- {line}".rstrip())
92
+ self._write(fm, new_body)
93
+
94
+ def find(self, pattern: str) -> list[tuple[int, str]]:
95
+ _fm, body = self._read()
96
+ return _md.find_matching_lines(body, pattern)
97
+
98
+ def forget(self, pattern: str, *, confirm: bool = True) -> list[str]:
99
+ """Remove every bullet whose text contains ``pattern`` from the
100
+ global memory body. ``confirm=True`` is a dry-run; ``confirm=False``
101
+ actually deletes. See ``SiteMemory.forget`` for rationale."""
102
+ matches = self.find(pattern)
103
+ if not matches:
104
+ return []
105
+ if confirm:
106
+ return [ln for _i, ln in matches]
107
+ with _FileLock(self.path):
108
+ fm, body = self._read()
109
+ new_body = _md.remove_lines(body, {i for i, _ln in matches})
110
+ self._write(fm, new_body)
111
+ return [ln for _i, ln in matches]
112
+
113
+ def set_preference(self, key: str, value: Any, *, confirm: bool = True) -> dict:
114
+ """Write a structured preference (e.g. ``daemon.preferred_backend``).
115
+
116
+ spec §C.3 type D: **strong confirm required**. When ``confirm=True`` we
117
+ raise ``NeedsUserConfirm`` carrying the proposal so the agent can
118
+ surface a dialog. Pass ``confirm=False`` after user assent to actually
119
+ write.
120
+ """
121
+ if confirm:
122
+ raise NeedsUserConfirm(
123
+ what=f"set {key} = {value!r}",
124
+ proposal={"key": key, "value": value},
125
+ )
126
+ with _FileLock(self.path):
127
+ fm, body = self._read()
128
+ old = _set_dotted(fm, key, value)
129
+ # Stamp set_by_user_at on the relevant block (top-level container).
130
+ container = key.split(".")[0]
131
+ now_iso = _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat()
132
+ if container in fm and isinstance(fm[container], dict):
133
+ fm[container]["set_by_user_at"] = now_iso
134
+ # spec §C.3: keep history rather than silent overwrite.
135
+ if old is not None and old != value:
136
+ note = (
137
+ fm[container].get("notes")
138
+ or ""
139
+ )
140
+ suffix = f"prev {key}={old!r} until {now_iso}"
141
+ fm[container]["notes"] = (note + "; " + suffix) if note else suffix
142
+ self._write(fm, body)
143
+ return {"key": key, "value": value, "previous": old}
144
+
145
+
146
+ # ---- dotted-path helpers --------------------------------------------
147
+
148
+
149
+ def _set_dotted(obj: dict, dotted: str, value: Any) -> Any:
150
+ """Set ``obj[a][b] = value`` for dotted key ``a.b``; return previous value."""
151
+ parts = dotted.split(".")
152
+ cur = obj
153
+ for p in parts[:-1]:
154
+ nxt = cur.get(p)
155
+ if not isinstance(nxt, dict):
156
+ nxt = {}
157
+ cur[p] = nxt
158
+ cur = nxt
159
+ leaf = parts[-1]
160
+ prev = cur.get(leaf)
161
+ cur[leaf] = value
162
+ return prev
163
+
164
+
165
+ def _get_dotted(obj: dict, dotted: str, default=None):
166
+ cur: Any = obj
167
+ for p in dotted.split("."):
168
+ if not isinstance(cur, dict):
169
+ return default
170
+ cur = cur.get(p)
171
+ if cur is None:
172
+ return default
173
+ return cur
174
+
175
+
176
+ # ---- module-level convenience ---------------------------------------
177
+
178
+
179
+ _singleton: Optional[GlobalMemory] = None
180
+ _singleton_lock = threading.Lock()
181
+
182
+
183
+ def global_memory() -> GlobalMemory:
184
+ global _singleton
185
+ if _singleton is None:
186
+ with _singleton_lock:
187
+ if _singleton is None:
188
+ _singleton = GlobalMemory()
189
+ return _singleton
190
+
191
+
192
+ def read_daemon_preferred_backend() -> Optional[str]:
193
+ """Backend resolution helper used at Skill startup (spec §C.3)."""
194
+ mem = global_memory().read()
195
+ return _get_dotted(mem["frontmatter"], "daemon.preferred_backend")
196
+
197
+
198
+ def write_daemon_preferred_backend(backend: str, *, confirm: bool = True) -> dict:
199
+ return global_memory().set_preference(
200
+ "daemon.preferred_backend", backend, confirm=confirm
201
+ )
@@ -0,0 +1,28 @@
1
+ """REPL temporary context — in-process dict, no disk."""
2
+ from __future__ import annotations
3
+
4
+ import threading
5
+
6
+
7
+ class ReplMemory:
8
+ def __init__(self):
9
+ self._d: dict[str, object] = {}
10
+ self._lock = threading.Lock()
11
+
12
+ def set(self, key: str, value):
13
+ with self._lock:
14
+ self._d[key] = value
15
+
16
+ def get(self, key: str, default=None):
17
+ return self._d.get(key, default)
18
+
19
+ def all(self) -> dict:
20
+ with self._lock:
21
+ return dict(self._d)
22
+
23
+
24
+ _singleton = ReplMemory()
25
+
26
+
27
+ def repl_memory() -> ReplMemory:
28
+ return _singleton
@@ -0,0 +1,53 @@
1
+ """Session-decision memory (P7): situation → how to start a session.
2
+
3
+ A file-locked JSON map at ``$BS_HOME/session_decisions.json``. The agent flow
4
+ is **hit → auto-start; miss → ask the user, then record** (see
5
+ ``session_create.choose``). Decisions capture the backend + mode (and, for
6
+ fingerprint attach, the target port/recipe) so a recurring situation doesn't
7
+ re-prompt.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import fcntl
12
+ import json
13
+ import os
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Iterator, Optional
17
+
18
+
19
+ def _home() -> Path:
20
+ return Path(os.path.expanduser(os.environ.get("BS_HOME", "~/.browserwright")))
21
+
22
+
23
+ def _path() -> Path:
24
+ _home().mkdir(parents=True, exist_ok=True)
25
+ return _home() / "session_decisions.json"
26
+
27
+
28
+ @contextmanager
29
+ def _locked() -> Iterator[dict]:
30
+ p = _path()
31
+ lock = _home() / ".session_decisions.lock"
32
+ with open(lock, "w") as lf:
33
+ fcntl.flock(lf, fcntl.LOCK_EX)
34
+ try:
35
+ data = json.loads(p.read_text()) if p.exists() else {}
36
+ yield data
37
+ p.write_text(json.dumps(data))
38
+ finally:
39
+ fcntl.flock(lf, fcntl.LOCK_UN)
40
+
41
+
42
+ def lookup(situation: str) -> Optional[dict]:
43
+ """Return the recorded decision for ``situation``, or None."""
44
+ p = _path()
45
+ if not p.exists():
46
+ return None
47
+ return json.loads(p.read_text()).get(situation)
48
+
49
+
50
+ def record(situation: str, decision: dict) -> None:
51
+ """Persist (overwriting) the decision for ``situation``."""
52
+ with _locked() as data:
53
+ data[situation] = decision