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.
- browserwright/__init__.py +33 -0
- browserwright/__main__.py +6 -0
- browserwright/_executor/__init__.py +47 -0
- browserwright/_executor/__main__.py +9 -0
- browserwright/_executor/client.py +127 -0
- browserwright/_executor/process.py +652 -0
- browserwright/_executor/protocol.py +152 -0
- browserwright/api.py +66 -0
- browserwright/cdp.py +285 -0
- browserwright/cli.py +741 -0
- browserwright/daemon/__init__.py +8 -0
- browserwright/daemon/_ipc.py +444 -0
- browserwright/daemon/active_tab.py +183 -0
- browserwright/daemon/auth.py +395 -0
- browserwright/daemon/backends/__init__.py +59 -0
- browserwright/daemon/backends/base.py +120 -0
- browserwright/daemon/backends/cloud.py +222 -0
- browserwright/daemon/backends/env.py +119 -0
- browserwright/daemon/backends/extension.py +185 -0
- browserwright/daemon/backends/rdp.py +214 -0
- browserwright/daemon/cli.py +1437 -0
- browserwright/daemon/config.py +380 -0
- browserwright/daemon/doctor.py +179 -0
- browserwright/daemon/errors.py +34 -0
- browserwright/daemon/launch_chrome.py +353 -0
- browserwright/daemon/observability.py +181 -0
- browserwright/daemon/platforms.py +234 -0
- browserwright/daemon/resolver.py +72 -0
- browserwright/daemon/server/__init__.py +6 -0
- browserwright/daemon/server/daemon.py +229 -0
- browserwright/daemon/server/executor_registry.py +434 -0
- browserwright/daemon/server/extension_upstream.py +677 -0
- browserwright/daemon/server/facade.py +375 -0
- browserwright/daemon/server/facade_extension.py +969 -0
- browserwright/daemon/server/listener.py +1058 -0
- browserwright/daemon/server/proxy.py +1991 -0
- browserwright/daemon/server/relay.py +783 -0
- browserwright/daemon/server/state.py +432 -0
- browserwright/daemon/server/upstream.py +266 -0
- browserwright/daemon/userscripts.py +150 -0
- browserwright/discovery.py +213 -0
- browserwright/errors.py +177 -0
- browserwright/health.py +169 -0
- browserwright/install.py +628 -0
- browserwright/memory/__init__.py +15 -0
- browserwright/memory/_md.py +120 -0
- browserwright/memory/_yaml.py +217 -0
- browserwright/memory/global_mem.py +201 -0
- browserwright/memory/repl_mem.py +28 -0
- browserwright/memory/session_decisions.py +53 -0
- browserwright/memory/site_mem.py +381 -0
- browserwright/mode_b_client.py +590 -0
- browserwright/multitask.py +131 -0
- browserwright/output_schema.py +99 -0
- browserwright/primitives/__init__.py +67 -0
- browserwright/primitives/discovery_api.py +79 -0
- browserwright/primitives/http.py +42 -0
- browserwright/primitives/inspect.py +876 -0
- browserwright/primitives/interact.py +518 -0
- browserwright/primitives/page.py +556 -0
- browserwright/primitives/site.py +143 -0
- browserwright/release_install.py +466 -0
- browserwright/repl/__init__.py +6 -0
- browserwright/repl/_namespace.py +106 -0
- browserwright/repl/_smart_goto.py +236 -0
- browserwright/repl/inline.py +180 -0
- browserwright/repl/playwright_handle.py +449 -0
- browserwright/repl/snapshot.py +150 -0
- browserwright/session.py +229 -0
- browserwright/session_create.py +252 -0
- browserwright/session_ctx.py +24 -0
- browserwright/session_registry.py +133 -0
- browserwright/session_runtime.py +133 -0
- browserwright/site_skills_starter/github.com/SKILL.md +14 -0
- browserwright/site_skills_starter/github.com/memory.md +29 -0
- browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
- browserwright/site_skills_starter/google.com/SKILL.md +16 -0
- browserwright/site_skills_starter/google.com/memory.md +27 -0
- browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
- browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
- browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
- browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
- browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
- browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
- browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
- browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
- browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
- browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
- browserwright/skill_doc.py +140 -0
- browserwright/skill_runtime.md +194 -0
- browserwright/subscriptions.py +213 -0
- browserwright/task_runner.py +125 -0
- browserwright/version.py +117 -0
- browserwright-0.6.2.dist-info/METADATA +12 -0
- browserwright-0.6.2.dist-info/RECORD +98 -0
- browserwright-0.6.2.dist-info/WHEEL +5 -0
- browserwright-0.6.2.dist-info/entry_points.txt +3 -0
- 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
|