@widgex/wgx 0.0.0-stage → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +49 -0
- package/README.md +33 -2
- package/ThirdPartyNotices.txt +4685 -0
- package/package.json +30 -4
- package/provider-clis.json +1211 -0
- package/python/XACTOR.md +146 -0
- package/python/browser_actor_host.py +456 -0
- package/python/cdp_page.py +363 -0
- package/python/harness_check.py +77 -0
- package/python/requirements.txt +1 -0
- package/python/test_line_endings.py +185 -0
- package/python/xactor/__init__.py +18 -0
- package/python/xactor/__main__.py +226 -0
- package/python/xactor/api.py +882 -0
- package/python/xactor/bridge.py +784 -0
- package/python/xactor/dom.py +117 -0
- package/python/xactor/man/add_card.md +89 -0
- package/python/xactor/man/agent-vs-fork.md +108 -0
- package/python/xactor/man/agent.md +333 -0
- package/python/xactor/man/ask_model.md +110 -0
- package/python/xactor/man/ask_reference.md +56 -0
- package/python/xactor/man/ask_user.md +103 -0
- package/python/xactor/man/attach_app.md +595 -0
- package/python/xactor/man/attach_ide.md +65 -0
- package/python/xactor/man/attach_ide_surface.md +31 -0
- package/python/xactor/man/await_user.md +214 -0
- package/python/xactor/man/bash.md +142 -0
- package/python/xactor/man/browser-driver.md +137 -0
- package/python/xactor/man/browser_activity.md +53 -0
- package/python/xactor/man/browser_attach.md +84 -0
- package/python/xactor/man/browser_close_tab.md +50 -0
- package/python/xactor/man/browser_detach.md +50 -0
- package/python/xactor/man/browser_exec.md +66 -0
- package/python/xactor/man/browser_focus_tab.md +47 -0
- package/python/xactor/man/browser_hybrid.md +47 -0
- package/python/xactor/man/browser_open.md +50 -0
- package/python/xactor/man/browser_open_tab.md +58 -0
- package/python/xactor/man/browser_plumbing.md +107 -0
- package/python/xactor/man/browser_screenshot.md +56 -0
- package/python/xactor/man/browser_snapshot.md +52 -0
- package/python/xactor/man/browser_tabs.md +67 -0
- package/python/xactor/man/bulletin.md +88 -0
- package/python/xactor/man/capture_page.md +54 -0
- package/python/xactor/man/cd.md +64 -0
- package/python/xactor/man/chess_move.md +53 -0
- package/python/xactor/man/children.md +89 -0
- package/python/xactor/man/close_task.md +14 -0
- package/python/xactor/man/compact.md +75 -0
- package/python/xactor/man/drive.md +140 -0
- package/python/xactor/man/drive_space.md +72 -0
- package/python/xactor/man/driving.md +168 -0
- package/python/xactor/man/edit_file.md +112 -0
- package/python/xactor/man/finding.md +194 -0
- package/python/xactor/man/fork_agent.md +45 -0
- package/python/xactor/man/glob.md +83 -0
- package/python/xactor/man/grep.md +115 -0
- package/python/xactor/man/harness.md +165 -0
- package/python/xactor/man/harness_check.md +70 -0
- package/python/xactor/man/harness_format.md +257 -0
- package/python/xactor/man/highlight.md +66 -0
- package/python/xactor/man/instruct.md +111 -0
- package/python/xactor/man/ls.md +63 -0
- package/python/xactor/man/man.md +86 -0
- package/python/xactor/man/merge_wiz.md +40 -0
- package/python/xactor/man/notebook.md +98 -0
- package/python/xactor/man/overview.md +89 -0
- package/python/xactor/man/permissions.md +137 -0
- package/python/xactor/man/phase.md +150 -0
- package/python/xactor/man/powershell.md +74 -0
- package/python/xactor/man/profiles.md +60 -0
- package/python/xactor/man/project_drive.md +49 -0
- package/python/xactor/man/read_bulletin.md +89 -0
- package/python/xactor/man/read_file.md +90 -0
- package/python/xactor/man/relay.md +216 -0
- package/python/xactor/man/reply.md +72 -0
- package/python/xactor/man/reply_message.md +78 -0
- package/python/xactor/man/report_bug.md +118 -0
- package/python/xactor/man/request_answer.md +142 -0
- package/python/xactor/man/request_merge_decisions.md +121 -0
- package/python/xactor/man/request_operator_action.md +152 -0
- package/python/xactor/man/request_review.md +122 -0
- package/python/xactor/man/request_user_action.md +126 -0
- package/python/xactor/man/return_card.md +71 -0
- package/python/xactor/man/run.md +99 -0
- package/python/xactor/man/sandbox.md +144 -0
- package/python/xactor/man/session_fork.md +74 -0
- package/python/xactor/man/session_harness.md +109 -0
- package/python/xactor/man/session_join.md +102 -0
- package/python/xactor/man/session_land.md +73 -0
- package/python/xactor/man/session_nudge.md +73 -0
- package/python/xactor/man/session_rest.md +73 -0
- package/python/xactor/man/session_scope.md +84 -0
- package/python/xactor/man/session_terminate.md +58 -0
- package/python/xactor/man/session_token.md +78 -0
- package/python/xactor/man/sessions.md +93 -0
- package/python/xactor/man/set_harness.md +15 -0
- package/python/xactor/man/spawn_swarm.md +80 -0
- package/python/xactor/man/submit_evidence.md +54 -0
- package/python/xactor/man/swarm_brief.md +30 -0
- package/python/xactor/man/task_status.md +82 -0
- package/python/xactor/man/tasks.md +47 -0
- package/python/xactor/man/todo_write.md +104 -0
- package/python/xactor/man/update_card.md +70 -0
- package/python/xactor/man/use_profile.md +90 -0
- package/python/xactor/man/use_widget.md +82 -0
- package/python/xactor/man/wait_for_task.md +105 -0
- package/python/xactor/man/widget-model.md +284 -0
- package/python/xactor/man/widgets.md +59 -0
- package/python/xactor/man/write_file.md +76 -0
- package/python/xactor/man/writing-a-widget.md +142 -0
- package/python/xactor/man/xylon.md +120 -0
- package/python/xactor/man/xylon_blame.md +80 -0
- package/python/xactor/man/xylon_read.md +131 -0
- package/python/xactor/man/xylon_search.md +125 -0
- package/python/xactor/manual.py +195 -0
- package/python/xactor/parent_watch.py +115 -0
- package/python/xactor/plan_mode.py +350 -0
- package/python/xactor/sandbox.py +856 -0
- package/python/xactor/seals.py +438 -0
- package/python/xactor/shell_hints.py +210 -0
- package/python/xactor/states/__init__.py +13 -0
- package/python/xactor/states/base.py +523 -0
- package/python/xactor/states/registry.py +274 -0
- package/python/xactor/stdlib_api.py +6345 -0
- package/python/xactor/widgets/__init__.py +1 -0
- package/python/xactor/widgets/github_tokens.py +314 -0
- package/python/xactor/widgets/google_aistudio.py +365 -0
- package/settings-schema.json +403 -0
- package/viewer/index.css +160 -0
- package/viewer/index.js +15 -0
- package/wgx.mjs +290659 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# api.xylon_blame(path, line, end_line=None, tree_name=None) -> dict
|
|
2
|
+
<!-- also: blame, who-wrote-this, line_origin, git_blame -->
|
|
3
|
+
<!-- group: Xylon -->
|
|
4
|
+
|
|
5
|
+
Which task, agent and tool call wrote a line of code. Not gated; writes nothing.
|
|
6
|
+
|
|
7
|
+
`git blame` says which commit last changed a line. This carries on from there
|
|
8
|
+
through WidgeX's own record:
|
|
9
|
+
|
|
10
|
+
1. **Commit to task.** Each task records the commits it made, and that record
|
|
11
|
+
outlives its branch. A commit newer than the record is found through the open
|
|
12
|
+
tasks' working copies. A merge commit names the branch it merged.
|
|
13
|
+
2. **Task to agent.** Every session that worked in the task is in its folder.
|
|
14
|
+
3. **Agent to tool call.** The transcripts keep every tool call with its input.
|
|
15
|
+
An edit whose new text contains the line is the one that wrote it.
|
|
16
|
+
|
|
17
|
+
Use it when you need to know *why* a line is the way it is: the conversation
|
|
18
|
+
around the edit usually says.
|
|
19
|
+
|
|
20
|
+
## Result
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
{"tree": "widgex", "file": "widgex-ide/extension/src/services/handoff.ts",
|
|
24
|
+
"lines": [
|
|
25
|
+
{"from": 41, "to": 41, "text": "export const HANDOFF_TAIL_MAX = 20_000;",
|
|
26
|
+
"commit": {"sha": "3f2a…", "subject": "Hand-offs keep the tail…",
|
|
27
|
+
"author": "…", "at": "2026-10-09T…"},
|
|
28
|
+
"task": {"title": "Switch models mid-conversation", "ref": "widgex/switch-models-1a2b3c4d",
|
|
29
|
+
"branch": "widgex/switch-models", "via": "task-record"},
|
|
30
|
+
"agent": {"node": "widgex/switch-models-1a2b3c4d/session2", "model": "claude-opus-5"},
|
|
31
|
+
"edit": {"tool": "Edit", "tool_use_id": "toolu_…", "at": "2026-10-09T…",
|
|
32
|
+
"turn": 14, "match": "exact", "excerpt": "…HANDOFF_TAIL_MAX = 20_000…"}}]}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- Consecutive lines with the same commit and the same edit come back as one
|
|
36
|
+
entry, `from` to `to`.
|
|
37
|
+
- **`task.via`** says how the task was found: `task-record`, `worktree`,
|
|
38
|
+
`merge-subject`, or `uncommitted` for a line you or your task wrote and nobody
|
|
39
|
+
has committed yet.
|
|
40
|
+
- **`edit.match`**: `exact` means the edit's new text contains the line.
|
|
41
|
+
`file` means the call touched the file but the text is not in it. That is
|
|
42
|
+
usually a `sed` or a script run through the shell, and the attribution is a
|
|
43
|
+
best guess.
|
|
44
|
+
- **`commit: None`**: not committed yet. **`task: None`**: no task made that
|
|
45
|
+
commit, because a person did, or it predates WidgeX. **`edit: None`**: the
|
|
46
|
+
task is known but no tool call in its sessions touched the file.
|
|
47
|
+
|
|
48
|
+
## Examples
|
|
49
|
+
|
|
50
|
+
Why is this line here?
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
b = await api.xylon_blame("src/services/handoff.ts", 41)
|
|
54
|
+
hit = b["lines"][0]
|
|
55
|
+
if hit["edit"]:
|
|
56
|
+
return await api.xylon_read_turns(hit["agent"]["node"], around=hit["edit"]["turn"])
|
|
57
|
+
return hit
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Who wrote this function?
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
b = await api.xylon_blame("src/services/handoff.ts", 68, end_line=80)
|
|
64
|
+
return [(l["from"], l["to"], l["task"] and l["task"]["title"]) for l in b["lines"]]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Getting it wrong
|
|
68
|
+
|
|
69
|
+
**Reading `match: 'file'` as certain.** It names the last call that touched the
|
|
70
|
+
file before the commit, not one known to have written the line.
|
|
71
|
+
|
|
72
|
+
**Blaming a whole file.** At most 50 lines per call. For a file's history,
|
|
73
|
+
`git log --follow` through `api.bash` is the better tool.
|
|
74
|
+
|
|
75
|
+
**Passing another worktree's path without `tree_name`.** The tasks searched are
|
|
76
|
+
your own project's.
|
|
77
|
+
|
|
78
|
+
## See also
|
|
79
|
+
|
|
80
|
+
`xylon_search`, `xylon_read`, `compact`
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# api.xylon_list_trees() / xylon_get_tree / xylon_get_node / xylon_read_messages
|
|
2
|
+
<!-- also: xylon_list_trees, xylon_get_tree, xylon_get_node, xylon_read_messages -->
|
|
3
|
+
<!-- group: Xylon -->
|
|
4
|
+
|
|
5
|
+
The four read calls on the session tree. None is gated; none writes.
|
|
6
|
+
|
|
7
|
+
The path grammar is shared by three of the four, and it is most of what there is
|
|
8
|
+
to know, so they live on one page.
|
|
9
|
+
|
|
10
|
+
## Session paths
|
|
11
|
+
|
|
12
|
+
A path is `{repo}/{issue}/{session}` under a project. Sessions are **files** in
|
|
13
|
+
an issue's directory and are always siblings — nothing nests.
|
|
14
|
+
|
|
15
|
+
- **`.`** — this session.
|
|
16
|
+
- **`..`** — the session yours came from, read off `parent` on your header.
|
|
17
|
+
- **a bare `sessionN`** — a sibling in your own issue.
|
|
18
|
+
- **anything with slashes** — absolute: `widgex-ide/fix-relay/session3`.
|
|
19
|
+
|
|
20
|
+
`tree_name` defaults to this session's own project.
|
|
21
|
+
|
|
22
|
+
**A relative path cannot be combined with a different `tree_name`.** You get
|
|
23
|
+
`Cannot use a relative node_path ('..') with a different tree_name ('other')` —
|
|
24
|
+
`..` has no meaning over there. Other refusals: an empty `node_path`, a session
|
|
25
|
+
that has no address yet ("nothing to be relative to"), and `..` on the first
|
|
26
|
+
session in an issue, which came from nothing.
|
|
27
|
+
|
|
28
|
+
## The calls
|
|
29
|
+
|
|
30
|
+
### `xylon_list_trees() -> dict`
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
{"working": [{"name", "sourceName", "path", "createdAt"}],
|
|
34
|
+
"saved": [{"name", "sizeBytes", "modifiedAt"}]}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Two lists, not one.** `working` trees are unpacked and live. `saved` trees are
|
|
38
|
+
`.wxylon` archives. A name can appear in both.
|
|
39
|
+
|
|
40
|
+
### `xylon_get_tree(tree_name=None) -> dict`
|
|
41
|
+
|
|
42
|
+
The whole project, three levels deep: repos, the issues in each, and the
|
|
43
|
+
sessions in each issue. Headers only — no transcripts.
|
|
44
|
+
|
|
45
|
+
### `xylon_get_node(node_path, tree_name=None) -> dict`
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
{"name": str, "path": str, "tree": str,
|
|
49
|
+
"hasCosm": bool, "cosmData": dict | None, "children": [...]}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`path` and `tree` are **what your arguments resolved to** — the whole point of
|
|
53
|
+
them is that `.`, `..` and an omitted `tree_name` are otherwise opaque. `name`
|
|
54
|
+
is only the last segment, and on its own it identifies nothing: the first
|
|
55
|
+
session in every issue is called `session1`.
|
|
56
|
+
|
|
57
|
+
To find out what *you* are called, prefer the name in your system prompt. It is
|
|
58
|
+
`issue/session`, which is the form other agents address you by.
|
|
59
|
+
|
|
60
|
+
`children` is always empty: a session is a leaf, and its siblings are found by
|
|
61
|
+
listing the issue rather than by descending into it.
|
|
62
|
+
|
|
63
|
+
`hasCosm` false means there is no file at that path. `cosmData` is the header:
|
|
64
|
+
`summary`, `summaryKind` (`'handoff'` or `'final'`), `parent`, `branch`,
|
|
65
|
+
`files` — the repo-relative paths the session wrote, in the order it first
|
|
66
|
+
touched them — and `usage`, tokens and cost per `<provider>/<model>`.
|
|
67
|
+
|
|
68
|
+
### `xylon_read_messages(node_path, tree_name=None) -> list[dict]`
|
|
69
|
+
|
|
70
|
+
The transcript, as a list of message objects. **This is large.** A full session
|
|
71
|
+
transcript can be a substantial fraction of your context. If what you want is
|
|
72
|
+
what was *said* — not what tools printed — `xylon_read_turns` returns only that,
|
|
73
|
+
a page at a time.
|
|
74
|
+
|
|
75
|
+
A sealed session's transcript is gzipped on disk and inflated here, so this
|
|
76
|
+
costs more than the header read above it whatever the file size suggests.
|
|
77
|
+
|
|
78
|
+
## Examples
|
|
79
|
+
|
|
80
|
+
What did the session that spawned me do?
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
msgs = await api.xylon_read_messages("..")
|
|
84
|
+
return f"parent said {len(msgs)} things"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
What files have *I* written to so far?
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
me = await api.xylon_get_node(".")
|
|
91
|
+
return me["cosmData"].get("files") or "nothing yet"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Reading a child's work after joining it:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
r = await api.session_join(child_id, timeout=900)
|
|
98
|
+
if r["status"] == "done":
|
|
99
|
+
return await api.xylon_get_node(r["xylonNodePath"])
|
|
100
|
+
return r
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Finding a tree by name:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
trees = await api.xylon_list_trees()
|
|
107
|
+
return [t["name"] for t in trees["working"]]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Getting it wrong
|
|
111
|
+
|
|
112
|
+
**Expecting bash edits in `files`.** They are not there, and nothing warns you.
|
|
113
|
+
|
|
114
|
+
**Reading `..` as a matter of course.** A three-sentence `summary` on
|
|
115
|
+
`agent` costs a fraction of a transcript. The parent read is the
|
|
116
|
+
fallback for when the brief was not enough.
|
|
117
|
+
|
|
118
|
+
**Reading `files` as a diff.** It is a list of paths and says nothing about what
|
|
119
|
+
changed in them. `git diff` on the Issue's branch is what answers that.
|
|
120
|
+
|
|
121
|
+
**Passing `tree_name` alongside `..`.** Refused.
|
|
122
|
+
|
|
123
|
+
**Treating `xylon_list_trees` as a list.** It is a dict of two lists. Iterating
|
|
124
|
+
it gives you the strings `"working"` and `"saved"`.
|
|
125
|
+
|
|
126
|
+
**Expecting a write call.** There is none. Summaries and the file list are the
|
|
127
|
+
IDE's job.
|
|
128
|
+
|
|
129
|
+
## See also
|
|
130
|
+
|
|
131
|
+
`xylon`, `xylon_search`, `agent`, `session_join`, `bash`
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# api.xylon_search(query=None, who=None, tree_name=None, limit=20) / xylon_read_turns
|
|
2
|
+
<!-- also: xylon_read_turns -->
|
|
3
|
+
<!-- group: Xylon -->
|
|
4
|
+
|
|
5
|
+
Find past work by what was said in it, then read just the part that matters.
|
|
6
|
+
Neither is gated; neither writes.
|
|
7
|
+
|
|
8
|
+
Every conversation in a project is kept. These two calls search and read the
|
|
9
|
+
**prose** of them — what the user typed and what agents answered — and leave out
|
|
10
|
+
tool calls and their output, which is most of a transcript and almost never what
|
|
11
|
+
you are looking for.
|
|
12
|
+
|
|
13
|
+
## `xylon_search(query=None, who=None, tree_name=None, limit=20) -> dict`
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
{"query": "notarize",
|
|
17
|
+
"total": 3, # tasks that matched, before limit
|
|
18
|
+
"tasks": [
|
|
19
|
+
{"tree": "widgex", "task": "widgex/release-signing",
|
|
20
|
+
"title": "Release signing on CI", "open": False,
|
|
21
|
+
"branch": "ci-signing", "updatedAt": "2026-10-09T…",
|
|
22
|
+
"bullets": ["CI signs from a keychain it creates for each job", …],
|
|
23
|
+
"mentions": 4,
|
|
24
|
+
"hits": [{"node": "widgex/release-signing/3f2a…", "turn": 12,
|
|
25
|
+
"who": "user", "at": "2026-10-09T…",
|
|
26
|
+
"text": "…why does notarize hang on the runner"}]}]}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **`query`** — every word must be in the same turn. Case is ignored and a word
|
|
30
|
+
matches inside a longer one, so `notariz` finds *notarize* and *notarization*.
|
|
31
|
+
`"a quoted phrase"` is matched whole. There is no stemming and nothing fuzzy:
|
|
32
|
+
a miss means it was not said in those words, so try another word before
|
|
33
|
+
concluding it never came up.
|
|
34
|
+
- **No query** lists the most recent tasks with what each built and its
|
|
35
|
+
conversations (`conversations: [{node, turns, brief?}]`) — the answer to "what
|
|
36
|
+
has been done here".
|
|
37
|
+
- **`who='user'`** keeps only what a person typed. That is the strongest signal
|
|
38
|
+
for what was *asked for* and *decided*; agents repeat a term across a dozen
|
|
39
|
+
replies for every once it was asked about. A prompt another agent relayed
|
|
40
|
+
carries `from` and counts as neither side.
|
|
41
|
+
- **`tree_name`** defaults to your own project. `'*'` searches every project on
|
|
42
|
+
this machine.
|
|
43
|
+
|
|
44
|
+
Tasks come best first: one named for the query, then by the best hit, the
|
|
45
|
+
user's own words ahead of an agent's, newer ahead of older. `hits` holds up to
|
|
46
|
+
three per task; `mentions` is how many turns matched in all.
|
|
47
|
+
|
|
48
|
+
`bullets` is what the task built, a line each. It is empty for a task nobody has
|
|
49
|
+
summarised yet — that says nothing about whether work was done.
|
|
50
|
+
|
|
51
|
+
**`reading` means the answer is partial.** The first search on a machine reads
|
|
52
|
+
every conversation once (seconds for hundreds of megabytes). If that is still
|
|
53
|
+
going after twenty seconds the call returns what it has with
|
|
54
|
+
`"reading": {"done": 120, "total": 271}`. Ask again.
|
|
55
|
+
|
|
56
|
+
## `xylon_read_turns(node_path, tree_name=None, around=None, before=3, after=3, who=None, offset=0, limit=40, max_chars=4000) -> dict`
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
{"tree": "widgex", "node": "widgex/release-signing/3f2a…", "total": 58,
|
|
60
|
+
"turns": [{"n": 11, "who": "agent", "at": "…", "text": "…"},
|
|
61
|
+
{"n": 12, "who": "user", "at": "…", "text": "why does notarize hang…"}],
|
|
62
|
+
"more": True}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`node_path` is a hit's `node`, or `.` / `..` as for `xylon_get_node`.
|
|
66
|
+
|
|
67
|
+
- **`around=turn`** returns that turn with `before` ahead of it and `after`
|
|
68
|
+
behind. This is how a hit is opened up.
|
|
69
|
+
- Without `around`, a page: `limit` turns from `offset`. `more` says whether
|
|
70
|
+
there are turns after the last one returned; `total` is how many there are.
|
|
71
|
+
- A turn longer than `max_chars` is cut and carries `clipped`. Raise `max_chars`
|
|
72
|
+
for the one turn you need whole rather than for a page.
|
|
73
|
+
|
|
74
|
+
Turn numbers are stable: a turn keeps its `n` as the conversation goes on.
|
|
75
|
+
|
|
76
|
+
## Examples
|
|
77
|
+
|
|
78
|
+
Has this been dealt with before?
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
found = await api.xylon_search("notarize hang")
|
|
82
|
+
return [(t["title"], t["mentions"], t["hits"][:1]) for t in found["tasks"]]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Open the best hit:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
hit = found["tasks"][0]["hits"][0]
|
|
89
|
+
return await api.xylon_read_turns(hit["node"], around=hit["turn"])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
What did the user actually ask for in that conversation?
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
return await api.xylon_read_turns(hit["node"], who="user", limit=100)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
What has been done in this project lately?
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
recent = await api.xylon_search(limit=10)
|
|
102
|
+
return [(t["title"], t["bullets"]) for t in recent["tasks"]]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Getting it wrong
|
|
106
|
+
|
|
107
|
+
**Reaching for `xylon_read_messages` to find something.** It returns one whole
|
|
108
|
+
transcript, tool output included, and you would have to know which one. Search,
|
|
109
|
+
then read turns.
|
|
110
|
+
|
|
111
|
+
**Searching for a sentence.** Every word has to be in one turn. Two or three
|
|
112
|
+
distinctive words find more than a description does.
|
|
113
|
+
|
|
114
|
+
**Treating no hits as "never discussed".** It is keyword search. The same thing
|
|
115
|
+
may have been called something else — and tool output is not searched at all, so
|
|
116
|
+
a string that only ever appeared in a log will not be found.
|
|
117
|
+
|
|
118
|
+
**Ignoring `reading`.** A partial answer with no hits is not a "no".
|
|
119
|
+
|
|
120
|
+
**Passing a hit's `node` with another project's `tree_name` omitted.** After a
|
|
121
|
+
`tree_name='*'` search, pass the task's `tree` along with the node.
|
|
122
|
+
|
|
123
|
+
## See also
|
|
124
|
+
|
|
125
|
+
`xylon`, `xylon_list_trees`, `ask_reference`
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The bundled manual — one markdown page per api call and per WidgeX feature.
|
|
2
|
+
|
|
3
|
+
Pages live in ``man/`` beside this module and are read straight off disk. No
|
|
4
|
+
bridge call, no permission, and it works in every sandbox mode: the shell's
|
|
5
|
+
Python process runs with ``<extension>/python`` as its cwd, which puts that
|
|
6
|
+
directory on ``sys.path``, and ``sandbox._python_prefixes`` always allows reads
|
|
7
|
+
under a ``sys.path`` prefix. The read happens inside ``api_scope`` — the same
|
|
8
|
+
thing that lets ``read_file`` call a bare ``open()``.
|
|
9
|
+
|
|
10
|
+
Named ``manual.py`` rather than ``man.py`` so the module never sits ambiguously
|
|
11
|
+
beside the ``man/`` directory it reads, and so ``man/*.md`` means exactly "every
|
|
12
|
+
page" with no ``__init__.py`` to exclude from the glob.
|
|
13
|
+
|
|
14
|
+
A page's names are its filename stem plus anything in an ``also:`` comment, so
|
|
15
|
+
adding ``foo.md`` for a new ``api.foo`` is complete on its own. Only pages that
|
|
16
|
+
cover a family of calls need the comment. The alternative — a registry mapping
|
|
17
|
+
names to files — costs two edits per method and makes "the page exists but
|
|
18
|
+
nothing resolves to it" the *default* failure rather than an unusual one.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import difflib
|
|
22
|
+
import functools
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# .resolve() because sandbox.py canonicalises both its allowed prefixes and the
|
|
27
|
+
# path being opened; an unresolved path here would be compared against resolved
|
|
28
|
+
# prefixes and could miss on a symlinked or short-name install path.
|
|
29
|
+
MAN_DIR = Path(__file__).resolve().parent / "man"
|
|
30
|
+
|
|
31
|
+
_ALSO = "<!-- also:"
|
|
32
|
+
_GROUP = "<!-- group:"
|
|
33
|
+
|
|
34
|
+
# How the index orders its sections. Anything with a group not named here is
|
|
35
|
+
# sorted alphabetically after these, so a new group appears rather than vanishes.
|
|
36
|
+
GROUP_ORDER = (
|
|
37
|
+
"Coding",
|
|
38
|
+
"Talking to the user",
|
|
39
|
+
"Subagents",
|
|
40
|
+
"Talking to other agents",
|
|
41
|
+
"Sessions",
|
|
42
|
+
"Xylon",
|
|
43
|
+
"Working on a website",
|
|
44
|
+
"Reporting a WidgeX problem",
|
|
45
|
+
"Documentation",
|
|
46
|
+
"Concepts",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Front matter is only ever at the top; scanning the whole file would let a
|
|
50
|
+
# fenced code block containing an `also:` line hijack a page's names.
|
|
51
|
+
_FRONT_MATTER_LINES = 6
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize(topic: str) -> str:
|
|
55
|
+
"""Reduce a topic to its lookup key.
|
|
56
|
+
|
|
57
|
+
Agents reach for this call precisely when they are unsure, so the spellings
|
|
58
|
+
that arrive are the ones a model would guess: ``api.bash``, ``bash()``,
|
|
59
|
+
``await api.session_join``, ``session join``. All of them should land.
|
|
60
|
+
"""
|
|
61
|
+
text = topic.strip().lower()
|
|
62
|
+
for prefix in ("await ", "api.", "widgex_terminal.", "man "):
|
|
63
|
+
if text.startswith(prefix):
|
|
64
|
+
text = text[len(prefix):].strip()
|
|
65
|
+
text = text.rstrip("()").strip()
|
|
66
|
+
return text.replace(" ", "-").replace("_", "-")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _strip_comment(line: str, marker: str) -> str:
|
|
70
|
+
"""Pull the payload out of a ``<!-- marker: payload -->`` line."""
|
|
71
|
+
body = line[len(marker):].strip()
|
|
72
|
+
if body.endswith("-->"):
|
|
73
|
+
body = body[:-3]
|
|
74
|
+
return body.strip()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse(path: Path) -> dict[str, Any]:
|
|
78
|
+
"""Read one page and pull out its title, blurb, names and group."""
|
|
79
|
+
# utf-8-sig, never the default: Path.read_text() falls back to the locale
|
|
80
|
+
# encoding, which is cp1252 on a stock Windows box and mangles every em-dash
|
|
81
|
+
# in these pages. The -sig also drops a BOM that would otherwise end up
|
|
82
|
+
# glued to the leading '#' and break the title parse.
|
|
83
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
84
|
+
lines = text.splitlines()
|
|
85
|
+
|
|
86
|
+
title = lines[0].lstrip("#").strip() if lines else path.stem
|
|
87
|
+
also: list[str] = []
|
|
88
|
+
group = "Other"
|
|
89
|
+
blurb = ""
|
|
90
|
+
|
|
91
|
+
for line in lines[1:_FRONT_MATTER_LINES]:
|
|
92
|
+
stripped = line.strip()
|
|
93
|
+
if stripped.startswith(_ALSO):
|
|
94
|
+
also = [n.strip() for n in _strip_comment(stripped, _ALSO).split(",") if n.strip()]
|
|
95
|
+
elif stripped.startswith(_GROUP):
|
|
96
|
+
group = _strip_comment(stripped, _GROUP) or "Other"
|
|
97
|
+
elif stripped and not stripped.startswith("<!--") and not blurb:
|
|
98
|
+
blurb = stripped
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"stem": path.stem,
|
|
102
|
+
"title": title,
|
|
103
|
+
"blurb": blurb,
|
|
104
|
+
"group": group,
|
|
105
|
+
"names": [path.stem, *also],
|
|
106
|
+
"text": text,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@functools.lru_cache(maxsize=1)
|
|
111
|
+
def _index() -> tuple[dict[str, dict[str, Any]], tuple[dict[str, Any], ...]]:
|
|
112
|
+
"""Build ``({normalized name: page}, all pages)``. Cached; pages ship read-only."""
|
|
113
|
+
pages = [_parse(p) for p in sorted(MAN_DIR.glob("*.md"))]
|
|
114
|
+
by_name: dict[str, dict[str, Any]] = {}
|
|
115
|
+
for page in pages:
|
|
116
|
+
for name in page["names"]:
|
|
117
|
+
by_name.setdefault(_normalize(name), page)
|
|
118
|
+
return by_name, tuple(pages)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _index_page(pages: tuple[dict[str, Any], ...]) -> str:
|
|
122
|
+
"""The contents listing, generated so it cannot fall behind the pages."""
|
|
123
|
+
out = [
|
|
124
|
+
"# The WidgeX api manual",
|
|
125
|
+
"",
|
|
126
|
+
"`await api.man(name)` returns any page below. Names are forgiving —",
|
|
127
|
+
"`'bash'`, `'api.bash'` and `'bash()'` all reach the same page.",
|
|
128
|
+
"",
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
grouped: dict[str, list[dict[str, Any]]] = {}
|
|
132
|
+
for page in pages:
|
|
133
|
+
grouped.setdefault(page["group"], []).append(page)
|
|
134
|
+
|
|
135
|
+
ordered = [g for g in GROUP_ORDER if g in grouped]
|
|
136
|
+
ordered += sorted(g for g in grouped if g not in GROUP_ORDER)
|
|
137
|
+
|
|
138
|
+
for group in ordered:
|
|
139
|
+
out.append(f"## {group}")
|
|
140
|
+
for page in sorted(grouped[group], key=lambda p: p["stem"]):
|
|
141
|
+
out.append(f" {page['stem']:<26} {page['blurb']}".rstrip())
|
|
142
|
+
out.append("")
|
|
143
|
+
|
|
144
|
+
return "\n".join(out).rstrip() + "\n"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _miss(topic: str, key: str, by_name: dict[str, dict[str, Any]],
|
|
148
|
+
partial: list[dict[str, Any]]) -> str:
|
|
149
|
+
"""What to say when nothing matched.
|
|
150
|
+
|
|
151
|
+
Returned, never raised — see ``lookup``.
|
|
152
|
+
"""
|
|
153
|
+
out = [f"No manual page for {topic!r}."]
|
|
154
|
+
|
|
155
|
+
if partial:
|
|
156
|
+
stems = sorted({p["stem"] for p in partial})
|
|
157
|
+
out += ["", "Did you mean: " + ", ".join(stems) + "?"]
|
|
158
|
+
else:
|
|
159
|
+
close = difflib.get_close_matches(key, sorted(by_name), n=3, cutoff=0.6)
|
|
160
|
+
stems = list(dict.fromkeys(by_name[c]["stem"] for c in close))
|
|
161
|
+
if stems:
|
|
162
|
+
out += ["", "Closest matches: " + ", ".join(stems)]
|
|
163
|
+
|
|
164
|
+
out += ["", "`await api.man()` lists every topic."]
|
|
165
|
+
return "\n".join(out)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def lookup(topic: str | None = None) -> str:
|
|
169
|
+
"""The page for ``topic``, or the index when it is omitted.
|
|
170
|
+
|
|
171
|
+
Never raises. An unknown topic comes back as prose suggesting near misses,
|
|
172
|
+
because this is the call an agent makes when it does *not* know something —
|
|
173
|
+
if getting the name wrong could abort the exec, the surrounding work dies
|
|
174
|
+
with it and guessing becomes the cheaper strategy. That is the exact
|
|
175
|
+
incentive this call exists to reverse.
|
|
176
|
+
"""
|
|
177
|
+
by_name, pages = _index()
|
|
178
|
+
|
|
179
|
+
if not isinstance(topic, str) or not topic.strip():
|
|
180
|
+
return _index_page(pages)
|
|
181
|
+
|
|
182
|
+
key = _normalize(topic)
|
|
183
|
+
page = by_name.get(key)
|
|
184
|
+
if page is not None:
|
|
185
|
+
return page["text"]
|
|
186
|
+
|
|
187
|
+
# A prefix that reaches exactly one page is an answer, not a miss:
|
|
188
|
+
# 'session_ext' can only mean session_extend. Several pages means the agent
|
|
189
|
+
# is browsing a family ('browser'), so name them instead of guessing.
|
|
190
|
+
partial = [by_name[n] for n in sorted(by_name) if n.startswith(key)]
|
|
191
|
+
unique = {p["stem"]: p for p in partial}
|
|
192
|
+
if len(unique) == 1:
|
|
193
|
+
return next(iter(unique.values()))["text"]
|
|
194
|
+
|
|
195
|
+
return _miss(topic, key, by_name, partial)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Leave when the process that started this daemon is gone.
|
|
2
|
+
|
|
3
|
+
The daemon's normal way out is EOF on stdin: the host closes the pipe, the read
|
|
4
|
+
loop stops, the interpreter exits. That depends on the pipe closing reaching
|
|
5
|
+
*this* process, and on Windows it often does not. `python3` there is commonly a
|
|
6
|
+
launcher — the Python install manager's, a venv redirector — that starts the
|
|
7
|
+
real `python.exe` as a child and relays stdio to it, and a host that dies or
|
|
8
|
+
calls `process.exit()` closes the launcher's pipe without the child ever seeing
|
|
9
|
+
EOF. Both processes then live on with nothing to read, naming their worktree on
|
|
10
|
+
the command line, and with their background commands standing in it: a closed
|
|
11
|
+
task's folder that can never be deleted. 138 of them were found on one machine,
|
|
12
|
+
almost all left behind by test runs in task worktrees.
|
|
13
|
+
|
|
14
|
+
So the host passes its own pid in `WIDGEX_PARENT_PID`, and this watches that
|
|
15
|
+
process directly. On Windows it holds a handle opened at startup, while the
|
|
16
|
+
parent was certainly alive, so a later process given the same pid cannot be
|
|
17
|
+
mistaken for it. Elsewhere it watches `getppid()` and, when a launcher sits in
|
|
18
|
+
between, polls the pid.
|
|
19
|
+
|
|
20
|
+
The variable is removed from the environment once read: commands this daemon
|
|
21
|
+
starts inherit its environment, and one of them starting a daemon of its own
|
|
22
|
+
must watch *its* parent, not ours.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
from typing import Callable
|
|
32
|
+
|
|
33
|
+
ENV = "WIDGEX_PARENT_PID"
|
|
34
|
+
|
|
35
|
+
_POLL_S = 2.0
|
|
36
|
+
_SYNCHRONIZE = 0x00100000
|
|
37
|
+
_INFINITE = 0xFFFFFFFF
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parent_pid() -> int | None:
|
|
41
|
+
raw = os.environ.pop(ENV, None)
|
|
42
|
+
if not raw:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
pid = int(raw)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
return pid if pid > 0 else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def watch(on_gone: Callable[[], None]) -> bool:
|
|
52
|
+
"""Call `on_gone` from a background thread once the host process exits.
|
|
53
|
+
|
|
54
|
+
Returns whether a watch was started. Started before the sandbox is
|
|
55
|
+
installed, so the handle is opened while nothing refuses it.
|
|
56
|
+
"""
|
|
57
|
+
pid = _parent_pid()
|
|
58
|
+
if pid is None or pid == os.getpid():
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
if sys.platform == "win32":
|
|
62
|
+
import _winapi
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
handle = _winapi.OpenProcess(_SYNCHRONIZE, False, pid)
|
|
66
|
+
except OSError:
|
|
67
|
+
# Gone already, or not ours to open. The first is worth acting on;
|
|
68
|
+
# the second cannot be told apart from it here, and a daemon that
|
|
69
|
+
# leaves because it could not watch is worse than one that does
|
|
70
|
+
# not watch — EOF still works wherever it reaches us.
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def wait() -> None:
|
|
74
|
+
_winapi.WaitForSingleObject(handle, _INFINITE)
|
|
75
|
+
on_gone()
|
|
76
|
+
else:
|
|
77
|
+
direct = os.getppid() == pid
|
|
78
|
+
|
|
79
|
+
def alive() -> bool:
|
|
80
|
+
if direct:
|
|
81
|
+
return os.getppid() == pid
|
|
82
|
+
try:
|
|
83
|
+
os.kill(pid, 0)
|
|
84
|
+
except ProcessLookupError:
|
|
85
|
+
return False
|
|
86
|
+
except PermissionError:
|
|
87
|
+
return True
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def wait() -> None:
|
|
91
|
+
while alive():
|
|
92
|
+
time.sleep(_POLL_S)
|
|
93
|
+
on_gone()
|
|
94
|
+
|
|
95
|
+
threading.Thread(target=wait, name="xactor-parent-watch", daemon=True).start()
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def leave() -> None:
|
|
100
|
+
"""Stop what this daemon started, then exit without waiting on anything.
|
|
101
|
+
|
|
102
|
+
`os._exit` rather than a normal shutdown: a normal one is exactly what has
|
|
103
|
+
already failed to happen, and it waits on threads that may never finish.
|
|
104
|
+
Background commands are stopped first, since they are what actually stands
|
|
105
|
+
in the worktree.
|
|
106
|
+
"""
|
|
107
|
+
# Only if it is loaded: the browser backend never imports it, and importing
|
|
108
|
+
# it here would run its module setup on the way out.
|
|
109
|
+
stdlib_api = sys.modules.get(f"{__package__}.stdlib_api")
|
|
110
|
+
try:
|
|
111
|
+
if stdlib_api is not None:
|
|
112
|
+
stdlib_api._stop_background_commands()
|
|
113
|
+
except BaseException: # noqa: BLE001 — leaving regardless
|
|
114
|
+
pass
|
|
115
|
+
os._exit(0)
|