@nextclaw/x-harness 0.0.0-stage → 0.1.1

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 (110) hide show
  1. package/LICENSE +10 -0
  2. package/README.md +41 -2
  3. package/cli/main.js +219 -0
  4. package/dist/api.d.ts +10 -0
  5. package/dist/api.js +308 -0
  6. package/dist/boundary.d.ts +2 -0
  7. package/dist/browser-common.d.ts +4 -0
  8. package/dist/browser-common.js +29 -0
  9. package/dist/browser-dom-capabilities.d.ts +4 -0
  10. package/dist/browser-dom-capabilities.js +110 -0
  11. package/dist/browser-dom-dependencies.d.ts +16 -0
  12. package/dist/browser-dom-editing.d.ts +148 -0
  13. package/dist/browser-dom-editing.js +386 -0
  14. package/dist/browser-dom-navigation.d.ts +9 -0
  15. package/dist/browser-dom-navigation.js +165 -0
  16. package/dist/browser-dom-network.d.ts +1 -0
  17. package/dist/browser-dom-network.js +128 -0
  18. package/dist/browser-dom-submission.d.ts +2 -0
  19. package/dist/browser-dom-submission.js +82 -0
  20. package/dist/browser-dom.d.ts +2 -0
  21. package/dist/browser-dom.js +103 -0
  22. package/dist/browser-native.d.ts +1 -0
  23. package/dist/browser-native.js +215 -0
  24. package/dist/browser.d.ts +4 -0
  25. package/dist/browser.js +147 -0
  26. package/dist/creator.d.ts +29 -0
  27. package/dist/creator.js +164 -0
  28. package/dist/growth-batch.d.ts +5 -0
  29. package/dist/growth-batch.js +168 -0
  30. package/dist/growth-constants.d.ts +5 -0
  31. package/dist/growth-constants.js +13 -0
  32. package/dist/growth-dependencies.d.ts +33 -0
  33. package/dist/growth-execution.d.ts +11 -0
  34. package/dist/growth-execution.js +315 -0
  35. package/dist/growth-parallel.d.ts +4 -0
  36. package/dist/growth-parallel.js +301 -0
  37. package/dist/growth-plan.d.ts +4 -0
  38. package/dist/growth-plan.js +207 -0
  39. package/dist/growth-policy.d.ts +9 -0
  40. package/dist/growth-policy.js +181 -0
  41. package/dist/growth-publishing.d.ts +5 -0
  42. package/dist/growth-publishing.js +240 -0
  43. package/dist/growth-reading.d.ts +4 -0
  44. package/dist/growth-reading.js +135 -0
  45. package/dist/growth-recovery.d.ts +6 -0
  46. package/dist/growth-recovery.js +158 -0
  47. package/dist/growth-state.d.ts +8 -0
  48. package/dist/growth-state.js +183 -0
  49. package/dist/growth-tasks.d.ts +17 -0
  50. package/dist/growth-tasks.js +101 -0
  51. package/dist/growth.d.ts +2 -0
  52. package/dist/growth.js +138 -0
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.js +22 -0
  55. package/dist/observation.d.ts +1 -0
  56. package/dist/observation.js +2 -0
  57. package/dist/platform-capabilities.d.ts +4 -0
  58. package/dist/platform-capabilities.js +19 -0
  59. package/dist/platform-collections.d.ts +7 -0
  60. package/dist/platform-collections.js +223 -0
  61. package/dist/platform-dependencies.d.ts +43 -0
  62. package/dist/platform-diagnostics.d.ts +9 -0
  63. package/dist/platform-diagnostics.js +51 -0
  64. package/dist/platform-observation.d.ts +8 -0
  65. package/dist/platform-observation.js +75 -0
  66. package/dist/platform-rules.d.ts +17 -0
  67. package/dist/platform-rules.js +41 -0
  68. package/dist/platform-targets.d.ts +7 -0
  69. package/dist/platform-targets.js +129 -0
  70. package/dist/platform-writing.d.ts +8 -0
  71. package/dist/platform-writing.js +171 -0
  72. package/dist/platform.d.ts +2 -0
  73. package/dist/platform.js +60 -0
  74. package/dist/readers-actions.d.ts +2 -0
  75. package/dist/readers-actions.js +66 -0
  76. package/dist/readers-ax-host.d.ts +2 -0
  77. package/dist/readers-ax-host.js +125 -0
  78. package/dist/readers-ax-parser.d.ts +1 -0
  79. package/dist/readers-ax-parser.js +69 -0
  80. package/dist/readers-ax-program.d.ts +1 -0
  81. package/dist/readers-ax-tree.d.ts +19 -0
  82. package/dist/readers-ax-tree.js +110 -0
  83. package/dist/readers-ax-views.d.ts +87 -0
  84. package/dist/readers-ax-views.js +247 -0
  85. package/dist/readers-cli.d.ts +6 -0
  86. package/dist/readers-composer.d.ts +2 -0
  87. package/dist/readers-composer.js +131 -0
  88. package/dist/readers-navigation.d.ts +2 -0
  89. package/dist/readers-navigation.js +66 -0
  90. package/dist/readers-posts.d.ts +2 -0
  91. package/dist/readers-posts.js +141 -0
  92. package/dist/readers-profile.d.ts +3 -0
  93. package/dist/readers-profile.js +157 -0
  94. package/dist/readers.d.ts +9 -0
  95. package/dist/results.d.ts +6 -0
  96. package/dist/results.js +233 -0
  97. package/dist/round-state.d.ts +3 -0
  98. package/dist/round-state.js +155 -0
  99. package/dist/store.d.ts +30 -0
  100. package/dist/store.js +175 -0
  101. package/dist/types.d.ts +407 -0
  102. package/generated/observation.d.mts +1 -0
  103. package/generated/observation.mjs +515 -0
  104. package/package.json +29 -3
  105. package/skill/SKILL.md +33 -0
  106. package/skill/references/api.md +10 -0
  107. package/skill/references/reads.md +23 -0
  108. package/skill/references/recovery.md +25 -0
  109. package/skill/references/setup.md +23 -0
  110. package/skill/references/writes.md +51 -0
@@ -0,0 +1,515 @@
1
+ // Generated by packages/x-harness/scripts/generate-observation.mjs; edit TS reader owners only.
2
+ export const readXObservation = (query) => {
3
+ const readPosts = function readPosts({ offset = 0, limit = 5, targetId = "", } = {}) {
4
+ if (!Number.isInteger(offset) ||
5
+ offset < 0 ||
6
+ !Number.isInteger(limit) ||
7
+ limit < 1 ||
8
+ limit > 5)
9
+ throw new Error("offset>=0;limit为1~5");
10
+ if (targetId && !/^\d+$/.test(targetId))
11
+ throw new Error("targetId必须是数字公开ID");
12
+ const visible = (el) => el.getClientRects().length > 0;
13
+ const primaryTime = (a) => Array.from(a.querySelectorAll("time")).find((t) => visible(t) &&
14
+ /\/status\/\d+(?:$|[/?#])/.test(t.closest("a")?.getAttribute("href") ?? "")) ?? null;
15
+ const all = Array.from(document.querySelectorAll("article")).filter(visible);
16
+ const candidates = targetId
17
+ ? all.filter((a) => {
18
+ const primary = primaryTime(a)?.closest("a")?.getAttribute("href");
19
+ return (primary && new RegExp(`/status/${targetId}(?:$|[/?#])`).test(primary));
20
+ })
21
+ : all;
22
+ const posts = candidates.slice(offset, offset + limit).map((a) => {
23
+ const time = primaryTime(a);
24
+ const primary = time?.closest("a")?.getAttribute("href") ?? null;
25
+ const links = [
26
+ ...new Set(Array.from(a.querySelectorAll("a[href]"))
27
+ .filter(visible)
28
+ .map((l) => l.getAttribute("href"))),
29
+ ];
30
+ const buttons = Array.from(a.querySelectorAll("button"))
31
+ .filter(visible)
32
+ .map((b) => ({
33
+ label: b.getAttribute("aria-label") || b.innerText,
34
+ disabled: b.hasAttribute("disabled"),
35
+ }));
36
+ const text = a.innerText;
37
+ const datetime = time?.getAttribute("datetime") ?? null;
38
+ const id = primary?.match(/\/status\/(\d+)/)?.[1] ?? null;
39
+ const author = primary?.match(/\/([^/]+)\/status\//)?.[1] ?? null;
40
+ const images = Array.from(a.querySelectorAll('[data-testid="tweetPhoto"] img'))
41
+ .filter(visible)
42
+ .map((img) => ({
43
+ alt: img.getAttribute("alt"),
44
+ src: img.getAttribute("src"),
45
+ }));
46
+ const media = Array.from(a.querySelectorAll('[data-testid="videoPlayer"],video'))
47
+ .filter(visible)
48
+ .map((el) => ({
49
+ kind: "video",
50
+ label: el.getAttribute("aria-label"),
51
+ poster: el.getAttribute("poster"),
52
+ requiresViewing: true,
53
+ }));
54
+ return {
55
+ id,
56
+ author,
57
+ datetime,
58
+ primary,
59
+ text,
60
+ links,
61
+ buttons,
62
+ // View/quote counts may be visible links rather than buttons; collect in this same read.
63
+ metrics: [
64
+ ...buttons,
65
+ ...Array.from(a.querySelectorAll('a[href$="/analytics"],a[href$="/quotes"]'))
66
+ .filter(visible)
67
+ .map((link) => ({
68
+ label: link.getAttribute("aria-label") ||
69
+ `${link.getAttribute("href").endsWith("/analytics") ? "观看" : "引用"} ${link.innerText}`,
70
+ })),
71
+ ],
72
+ images,
73
+ media,
74
+ needsExpansion: /显示更多|Show more/i.test(text),
75
+ missing: [
76
+ !id && "id",
77
+ !author && "author",
78
+ !datetime && "datetime",
79
+ !text && "text",
80
+ ].filter(Boolean),
81
+ };
82
+ });
83
+ const versionLinks = Array.from(document.querySelectorAll("a[href]"))
84
+ .filter(visible)
85
+ .filter((a) => /最新版本|查看最新帖子|Latest version|View latest/i.test(a.innerText))
86
+ .slice(0, 5)
87
+ .map((a) => ({ label: a.innerText, href: a.getAttribute("href") }));
88
+ const emptyEvidence = all.length === 0
89
+ ? [
90
+ ...new Set(Array.from(document.querySelectorAll("main span"))
91
+ .filter(visible)
92
+ .map((e) => e.innerText?.trim())
93
+ .filter((text) => text &&
94
+ text.length <= 400 &&
95
+ !text.includes("\n") &&
96
+ /^(?:没有.*结果|未找到.*结果|No results for)/i.test(text))),
97
+ ].slice(0, 2)
98
+ : [];
99
+ return {
100
+ renderedArticles: all.length,
101
+ matched: candidates.length,
102
+ offset,
103
+ moreInDOM: candidates.length > offset + posts.length,
104
+ pageComplete: false,
105
+ needsStateCheck: all.length === 0 && emptyEvidence.length === 0,
106
+ posts,
107
+ versionLinks,
108
+ emptyEvidence,
109
+ alerts: Array.from(document.querySelectorAll('[role="alert"]'))
110
+ .filter(visible)
111
+ .map((e) => e.innerText),
112
+ };
113
+ };
114
+ const readProfile = function readProfile({ handle } = {}) {
115
+ if (!/^[A-Za-z0-9_]{1,15}$/.test(handle ?? ""))
116
+ throw new Error("需要已观察的X handle");
117
+ const visible = (el) => el.getClientRects().length > 0;
118
+ const main = document.querySelector("main");
119
+ if (!main)
120
+ return {
121
+ handle,
122
+ blue: null,
123
+ relationship: "unknown",
124
+ alerts: ["missing-main"],
125
+ observedAt: new Date().toISOString(),
126
+ };
127
+ const buttons = Array.from(main.querySelectorAll("button")).filter(visible);
128
+ const labels = buttons.map((b) => b.getAttribute("aria-label") || b.innerText);
129
+ const relationship = labels.includes(`正在关注 @${handle}`) ||
130
+ labels.includes(`取消关注 @${handle}`)
131
+ ? "following"
132
+ : labels.includes(`回关 @${handle}`)
133
+ ? "follows-you"
134
+ : labels.includes(`关注 @${handle}`)
135
+ ? "not-following"
136
+ : "unknown";
137
+ const badges = buttons
138
+ .filter((b) => b.getAttribute("aria-label") === "提供已验证账号的详细信息。")
139
+ .map((b) => b.querySelector("svg"))
140
+ .filter(Boolean);
141
+ const blue = badges.length === 1
142
+ ? [
143
+ getComputedStyle(badges[0]).color,
144
+ getComputedStyle(badges[0]).fill,
145
+ ].includes("rgb(29, 155, 240)")
146
+ : null;
147
+ const links = Array.from(main.querySelectorAll("a[href]")).filter(visible);
148
+ const counter = (suffixes) => {
149
+ const matched = links.filter((a) => suffixes.some((s) => a.getAttribute("href")?.toLowerCase() ===
150
+ `/${handle}/${s}`.toLowerCase()));
151
+ const texts = [...new Set(matched.map((a) => a.innerText.trim()))];
152
+ if (texts.length !== 1)
153
+ return { value: null, text: texts, precision: "unknown" };
154
+ const match = texts[0].match(/^([\d,.]+)\s*(万|亿|[KMB])?/i);
155
+ const factors = {
156
+ 万: 1e4,
157
+ 亿: 1e8,
158
+ K: 1e3,
159
+ M: 1e6,
160
+ B: 1e9,
161
+ };
162
+ const value = match
163
+ ? Number(match[1].replaceAll(",", "")) *
164
+ (factors[match[2]?.toUpperCase()] ?? 1)
165
+ : null;
166
+ const hrefs = [...new Set(matched.map((a) => a.getAttribute("href")))];
167
+ return {
168
+ value: Number.isFinite(value) ? value : null,
169
+ text: texts[0],
170
+ precision: match?.[2] ? "rounded" : "exact",
171
+ url: hrefs.length === 1 ? "https://x.com" + hrefs[0] : null,
172
+ };
173
+ };
174
+ return {
175
+ handle,
176
+ blue,
177
+ relationship,
178
+ relationshipControls: labels
179
+ .filter((label) => label.includes("@" + handle) || /取消关注|订阅/.test(label))
180
+ .slice(0, 5),
181
+ biography: main.querySelector('[data-testid="UserDescription"]')
182
+ ?.innerText ?? null,
183
+ following: counter(["following"]),
184
+ followers: counter(["followers", "verified_followers"]),
185
+ alerts: Array.from(document.querySelectorAll('[role="alert"]'))
186
+ .filter(visible)
187
+ .map((a) => a.innerText),
188
+ observedAt: new Date().toISOString(),
189
+ };
190
+ };
191
+ const readComposer = function readComposer() {
192
+ const visible = (el) => el.getClientRects().length > 0 && !el.closest('[aria-hidden="true"]');
193
+ const dialogs = Array.from(document.querySelectorAll('[role="dialog"]')).filter(visible);
194
+ const root = dialogs.length === 1
195
+ ? dialogs[0]
196
+ : dialogs.length === 0
197
+ ? document.querySelector("main")
198
+ : null;
199
+ if (location.pathname.startsWith("/compose/post/unsent/")) {
200
+ const text = root?.innerText ?? "";
201
+ return {
202
+ page: location.href,
203
+ unsent: true,
204
+ text: text.slice(0, 8000),
205
+ truncated: text.length > 8000,
206
+ controls: root
207
+ ? Array.from(root.querySelectorAll('button,a,[role="tab"]'))
208
+ .filter(visible)
209
+ .slice(0, 30)
210
+ .map((el) => ({
211
+ label: el.getAttribute("aria-label") || el.innerText,
212
+ href: el.getAttribute("href"),
213
+ }))
214
+ : [],
215
+ needsStateCheck: !root || text.length > 8000,
216
+ };
217
+ }
218
+ const navigation = Array.from(document.querySelectorAll('a[href="/compose/post"],button[data-testid="SideNav_AccountSwitcher_Button"]'))
219
+ .filter(visible)
220
+ .map((el) => ({
221
+ role: el.tagName === "A" ? "link" : "button",
222
+ label: el.getAttribute("aria-label") || el.innerText,
223
+ }));
224
+ if (Array.from(document.querySelectorAll('[role="textbox"],textarea,[contenteditable="true"]')).filter(visible).length !== 1)
225
+ return {
226
+ page: location.href,
227
+ navigation,
228
+ dialog: root &&
229
+ (dialogs.length === 1 || location.pathname === "/compose/post/media")
230
+ ? {
231
+ buttons: Array.from(root.querySelectorAll('button,[role="tab"]'))
232
+ .filter(visible)
233
+ .map((el) => ({
234
+ role: el.getAttribute("role") || "button",
235
+ label: el.getAttribute("aria-label") || el.innerText,
236
+ })),
237
+ fields: Array.from(root.querySelectorAll("textarea,input"))
238
+ .filter(visible)
239
+ .map((el) => ({
240
+ label: el.getAttribute("aria-label"),
241
+ placeholder: el.getAttribute("placeholder"),
242
+ value: el.value,
243
+ })),
244
+ }
245
+ : null,
246
+ needsStateCheck: true,
247
+ missing: ["composer-editor-not-found"],
248
+ editorCandidates: Array.from(document.querySelectorAll('[role="textbox"],textarea,[contenteditable="true"]'))
249
+ .filter(visible)
250
+ .map((el) => ({
251
+ label: el.getAttribute("aria-label"),
252
+ testId: el.getAttribute("data-testid"),
253
+ tag: el.tagName,
254
+ })),
255
+ };
256
+ const editors = Array.from(document.querySelectorAll('[role="textbox"],textarea,[contenteditable="true"]')).filter(visible);
257
+ let scope = editors[0];
258
+ while (scope.parentElement &&
259
+ !Array.from(scope.querySelectorAll("button")).some((el) => visible(el) &&
260
+ /^(发帖|回复|更新)$/.test(el.getAttribute("aria-label") || el.innerText)))
261
+ scope = scope.parentElement;
262
+ if (scope === document.body || scope === document.documentElement)
263
+ return {
264
+ page: location.href,
265
+ needsStateCheck: true,
266
+ missing: ["composer-submit-scope-not-found"],
267
+ };
268
+ return {
269
+ page: location.href,
270
+ navigation,
271
+ editors: editors.map((el) => ({
272
+ label: el.getAttribute("aria-label"),
273
+ testId: el.getAttribute("data-testid"),
274
+ text: el.innerText,
275
+ })),
276
+ buttons: Array.from(scope.querySelectorAll("button"))
277
+ .filter(visible)
278
+ .map((el) => ({
279
+ label: el.getAttribute("aria-label") || el.innerText,
280
+ disabled: el.disabled,
281
+ testId: el.getAttribute("data-testid"),
282
+ ariaDisabled: el.getAttribute("aria-disabled"),
283
+ ariaBusy: el.getAttribute("aria-busy"),
284
+ })),
285
+ images: Array.from(scope.querySelectorAll("img"))
286
+ .filter(visible)
287
+ .map((el) => ({ alt: el.alt })),
288
+ needsStateCheck: false,
289
+ };
290
+ };
291
+ const readMediaEditor = function readMediaEditor() {
292
+ if (location.pathname !== "/compose/post/media")
293
+ return { needsStateCheck: true, missing: ["media-route-not-found"] };
294
+ const visible = (el) => el.getClientRects().length > 0 && !el.closest('[aria-hidden="true"]');
295
+ const controls = Array.from(document.querySelectorAll('button,[role="tab"]'))
296
+ .filter(visible)
297
+ .map((el) => ({
298
+ role: el.getAttribute("role") || "button",
299
+ label: el.getAttribute("aria-label") || el.innerText,
300
+ disabled: el.disabled === true,
301
+ }));
302
+ const fields = Array.from(document.querySelectorAll('textarea,input,[role="textbox"]'))
303
+ .filter(visible)
304
+ .map((el) => ({
305
+ label: el.getAttribute("aria-label"),
306
+ placeholder: el.getAttribute("placeholder"),
307
+ value: el.value ?? el.innerText,
308
+ }));
309
+ const ready = controls.some((c) => c.role === "button" && c.label === "保存") &&
310
+ controls.some((c) => c.role === "tab" && c.label === "编辑图片描述");
311
+ return {
312
+ page: location.href,
313
+ controls: controls.slice(0, 20),
314
+ fields: fields.slice(0, 5),
315
+ needsStateCheck: !ready || controls.length > 20 || fields.length > 5,
316
+ missing: ready ? [] : ["media-editor-controls-not-ready"],
317
+ alerts: Array.from(document.querySelectorAll('[role="alert"]'))
318
+ .filter(visible)
319
+ .map((el) => el.innerText),
320
+ };
321
+ };
322
+ const readNavigation = function readNavigation({ targetLabel } = {}) {
323
+ const visible = (el) => el.getClientRects().length > 0 && !el.closest('[aria-hidden="true"]');
324
+ const primary = document.querySelector('[data-testid="primaryColumn"]') ??
325
+ document.querySelector("main");
326
+ const nodes = [
327
+ ...new Set([
328
+ ...Array.from(document.querySelectorAll("nav a[href],nav button")).filter((el) => !el.closest("main")),
329
+ ...Array.from(primary?.querySelectorAll('a[href],button,[role="button"],[role="tab"]') ?? []),
330
+ ...Array.from(document.querySelectorAll('[role="menu"] a[href], [role="menu"] button, [data-testid="Dropdown"] a[href], [data-testid="Dropdown"] button')),
331
+ ]),
332
+ ].filter((el) => visible(el) && !el.closest("article"));
333
+ const controls = nodes.map((el) => ({
334
+ role: el.getAttribute("role") || (el.tagName === "A" ? "link" : "button"),
335
+ label: el.getAttribute("aria-label") || el.innerText.trim(),
336
+ href: el.getAttribute("href"),
337
+ selected: el.getAttribute("aria-selected") === "true",
338
+ disabled: el.disabled === true,
339
+ }));
340
+ // Some folder cards are plain clickable text, with no ARIA role or href.
341
+ // Only the explicitly requested, visible exact label can be promoted to a semantic control.
342
+ if (targetLabel &&
343
+ !controls.some((c) => c.label.split("\n")[0] === targetLabel)) {
344
+ const labels = Array.from(document.querySelectorAll("span,div")).filter((el) => visible(el) &&
345
+ !el.closest("article") &&
346
+ el.innerText.trim() === targetLabel &&
347
+ !Array.from(el.children).some((child) => child.innerText?.trim() === targetLabel));
348
+ controls.push(...labels.map((el) => ({
349
+ role: "text",
350
+ label: el.innerText.trim(),
351
+ href: null,
352
+ disabled: false,
353
+ source: "exact-visible-folder-label",
354
+ })));
355
+ }
356
+ return {
357
+ page: location.href,
358
+ controls: controls.slice(0, 40),
359
+ headings: Array.from(primary?.querySelectorAll('h1,h2,[role="heading"]') ?? [])
360
+ .filter(visible)
361
+ .map((el) => el.innerText.trim())
362
+ .slice(0, 10),
363
+ needsStateCheck: !controls.length || controls.length > 40,
364
+ missing: controls.length > 40
365
+ ? ["navigation-window-overflow"]
366
+ : controls.length
367
+ ? []
368
+ : ["navigation-not-found"],
369
+ };
370
+ };
371
+ const readMenu = function readMenu() {
372
+ const visible = (el) => el.getClientRects().length > 0;
373
+ const roots = Array.from(document.querySelectorAll('[role="menu"],[role="dialog"],[data-testid="Dropdown"]')).filter(visible);
374
+ const items = [
375
+ ...new Set(roots.flatMap((root) => Array.from(root.querySelectorAll('a[href],button,[role="menuitem"]')))),
376
+ ].filter(visible);
377
+ return {
378
+ items: items.map((el) => ({
379
+ role: el.getAttribute("role") || (el.tagName === "A" ? "link" : "button"),
380
+ label: el.getAttribute("aria-label") || el.innerText,
381
+ href: el.getAttribute("href"),
382
+ })),
383
+ needsStateCheck: !items.length,
384
+ missing: items.length ? [] : ["menu-not-found"],
385
+ };
386
+ };
387
+ const readXSurface = function readXSurface({ view, posts = [], targetId, replyTargetId, } = {}) {
388
+ const visible = (el) => el.getClientRects().length > 0;
389
+ const profile = [...document.querySelectorAll("a[href]")].filter((el) => visible(el) &&
390
+ ["个人资料", "Profile"].includes(el.getAttribute("aria-label") || el.innerText.trim()) &&
391
+ /^\/\w{1,15}$/.test(el.getAttribute("href")));
392
+ const own = profile.length === 1 ? profile[0].getAttribute("href").slice(1) : null;
393
+ const base = {
394
+ page: location.href,
395
+ accountHandle: own,
396
+ accountURL: own ? `https://x.com/${own}` : null,
397
+ alerts: [...document.querySelectorAll('[role="alert"]')]
398
+ .filter(visible)
399
+ .map((el) => el.innerText),
400
+ };
401
+ if (view === "composer") {
402
+ const dialogs = [
403
+ ...document.querySelectorAll('[role="dialog"]'),
404
+ ].filter(visible);
405
+ const ids = dialogs.length === 1
406
+ ? [
407
+ ...new Set([...dialogs[0].querySelectorAll("a[href]")]
408
+ .filter(visible)
409
+ .flatMap((el) => el
410
+ .getAttribute("href")
411
+ .match(/^\/\w+\/status\/(\d+)$/)?.[1] ?? [])),
412
+ ]
413
+ : [];
414
+ const replyContexts = [...new Set(dialogs.map((el) => el.innerText))];
415
+ const replyQuotes = [
416
+ ...new Set(dialogs.flatMap((el) => [...el.querySelectorAll('[data-testid="tweetText"]')]
417
+ .filter(visible)
418
+ .map((text) => text.innerText))),
419
+ ];
420
+ return {
421
+ ...base,
422
+ replyToId: ids.length === 1 ? ids[0] : null,
423
+ inlineComposer: dialogs.length === 0,
424
+ replyContexts,
425
+ replyQuotes,
426
+ };
427
+ }
428
+ if (view === "posts") {
429
+ const all = [...document.querySelectorAll("article")].filter(visible);
430
+ return {
431
+ ...base,
432
+ bodies: posts.map((post) => {
433
+ const articles = all.filter((article) => [...article.querySelectorAll("time")]
434
+ .filter(visible)
435
+ .find((time) => time.closest("a")?.getAttribute("href") === post.primary)
436
+ ?.closest("a")
437
+ ?.getAttribute("href") === post.primary);
438
+ const texts = articles.length === 1
439
+ ? [
440
+ ...articles[0].querySelectorAll('[data-testid="tweetText"]'),
441
+ ].filter((el) => visible(el) && !el.closest?.('[role="link"][tabindex="0"]'))
442
+ : [];
443
+ let replyToId = null, parentEvidence = null;
444
+ const childIndex = all.indexOf(articles[0]);
445
+ if (replyTargetId &&
446
+ targetId === post.id &&
447
+ articles.length === 1 &&
448
+ childIndex > 0 &&
449
+ new URL(location.href).pathname.endsWith(`/status/${post.id}`)) {
450
+ // Nested conversations can include older ancestors before the direct parent.
451
+ const parent = [...all[childIndex - 1].querySelectorAll("time")]
452
+ .filter(visible)
453
+ .find((time) => /^\/\w+\/status\/\d+$/.test(time.closest("a")?.getAttribute("href") ?? ""))
454
+ ?.closest("a")
455
+ ?.getAttribute("href");
456
+ const match = parent?.match(/^\/(\w+)\/status\/(\d+)$/);
457
+ const labels = [...articles[0].querySelectorAll("div,span")]
458
+ .filter(visible)
459
+ .filter((el) => !el.closest('[data-testid="tweetText"]') &&
460
+ !el.querySelector('[data-testid="tweetText"]'))
461
+ .map((el) => el.innerText.trim().replace(/\s+/g, " "));
462
+ const directLabel = match &&
463
+ labels.some((text) => [`回复 @${match[1]}`, `Replying to @${match[1]}`].includes(text));
464
+ // X can omit the inline reply label on a permalink. Its title still prefixes the direct
465
+ // recipient before older ancestors; require that prefix and the adjacent parent together.
466
+ const titleRecipient = (document.title ?? "").match(/[“"]@([A-Za-z0-9_]{1,15})(?=\s)/)?.[1];
467
+ const titleMatchesParent = match && titleRecipient?.toLowerCase() === match[1].toLowerCase();
468
+ if (match?.[2] === replyTargetId &&
469
+ (directLabel || titleMatchesParent)) {
470
+ replyToId = match[2];
471
+ parentEvidence = {
472
+ parentPrimary: parent,
473
+ childPrimary: post.primary,
474
+ labels: labels.filter((text) => /^(回复|Replying to) @/.test(text)),
475
+ ...(titleMatchesParent ? { titleRecipient } : {}),
476
+ };
477
+ }
478
+ }
479
+ // Exclude observed nested quote-card text; any remaining ambiguity stays unknown.
480
+ return {
481
+ id: post.id,
482
+ body: texts.length === 1
483
+ ? (() => {
484
+ const root = texts[0];
485
+ // innerText adds layout breaks around inline mentions and URLs.
486
+ // Reconstruct only observed X permalinks; preserve all other text.
487
+ const read = (node) => {
488
+ if (node.nodeType === 3)
489
+ return node.textContent ?? "";
490
+ if (node.nodeType === 1 &&
491
+ node.tagName === "A") {
492
+ const href = node.getAttribute("href") ?? "";
493
+ if (/^\/\w{1,15}\/status\/\d+$/.test(href))
494
+ return `https://x.com${href}`;
495
+ }
496
+ return [...node.childNodes].map(read).join("");
497
+ };
498
+ return root.childNodes ? read(root) : root.innerText;
499
+ })()
500
+ : null,
501
+ replyToId,
502
+ parentEvidence,
503
+ };
504
+ }),
505
+ };
506
+ }
507
+ return base;
508
+ };
509
+ const readers = { posts:readPosts, person:readProfile, composer:readComposer, media:readMediaEditor, navigation:readNavigation, menu:readMenu, account:()=>({}) };
510
+ if (!readers[query.view]) throw new Error('未封装的DOM视图');
511
+ const raw = readers[query.view](query);
512
+ const surface = readXSurface({...query, posts:(raw.posts??[]).map(p=>({id:p.id,primary:p.primary}))});
513
+ return {...raw,...surface,needsStateCheck:Boolean(raw.needsStateCheck)||!surface.accountHandle,
514
+ ...(query.view==='posts'?{posts:raw.posts.map(p=>({...p,...surface.bodies.find(b=>b.id===p.id)}))}: {})};
515
+ };
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@nextclaw/x-harness",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "X tasks for browser-enabled AI agents: compact results, complete evidence and verified writes.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "bin": {
16
+ "x-harness": "./cli/main.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "generated",
21
+ "cli",
22
+ "skill",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
6
32
  }
package/skill/SKILL.md ADDED
@@ -0,0 +1,33 @@
1
+ ---
2
+ name: x-harness
3
+ version: 0.1.1
4
+ description: Use X through task APIs with compact receipts, complete target evidence and verified writes; requires compatible CUA and the user's X login.
5
+ ---
6
+
7
+ # X Harness
8
+
9
+ Use the root business API. Load only the current task reference; reuse unchanged instructions and a valid binding. Normal use needs no implementation source or type-file reading.
10
+
11
+ ## Bind once
12
+
13
+ Read current host CUA documentation; bind a real logged-in `tab` or native `app`, exactly one. Preserve other tasks' tabs/editors.
14
+
15
+ Installed module: `{{MODULE_URL}}`; CLI: `{{CLI_PATH}}`.
16
+ In this repository use the absolute file URL of `<repository>/packages/x-harness/dist/index.js` with original ledger/proof paths. Unresolved placeholders do not require reinstalling an existing repository runtime. Only absent runtime/workspace or an update needs [setup](references/setup.md).
17
+
18
+ ```js
19
+ var { createXGrowthHarness } = await import(moduleURL);
20
+ var x = createXGrowthHarness({ tab, ledgerPath, proofDirectory });
21
+ ```
22
+
23
+ ## Load by task
24
+
25
+ | Current need | Read only |
26
+ | --- | --- |
27
+ | Creator/bookmarks/search/post/person/feedback/account | [reads](references/reads.md) |
28
+ | Publish/reply/follow or a round | [writes](references/writes.md), preamble + common review + matching operation heading |
29
+ | Unknown submission, missing capability or connection failure | [recovery](references/recovery.md) |
30
+
31
+ Default `receipt` is for status; use `decision` on the first call needing content, `delta` for changed evidence with valid prior context. Keep observations in REPL/proof; return complete relevant evidence and actionable warnings. Never truncate target text or print full DOM/AX. Root calls own capability checks, intent, submission, verification and settlement; verified results need no extra preflight/reload/verify.
32
+
33
+ Writes require user authorization, verified identity, reviewed evidence and the original ledger plan; project rules own policy. Unknown submission: verify the same action before any new submission. Follow recovery for other failures; read source only to repair a concrete defect/unsupported capability, verify, then return to root calls.
@@ -0,0 +1,10 @@
1
+ # API navigation
2
+
3
+ Compatibility index; normal tasks route directly from the Skill. Load one current reference, not all files.
4
+
5
+ - Reading: [reads](reads.md).
6
+ - Reviewed writes/rounds: [writes](writes.md), matching operation heading.
7
+ - Failure/unknown submission: [recovery](recovery.md).
8
+ - Missing installation/workspace or update: [setup](setup.md).
9
+
10
+ Public declarations are the compatibility contract for custom integrations, not a prerequisite for documented calls.
@@ -0,0 +1,23 @@
1
+ # Read tasks
2
+
3
+ Load for reading only. A valid root binding `x` with existing ledger/proof paths suffices; no write plan needed. `mode` defaults to `receipt`; use `decision` immediately when drafting/reviewing needs text. `delta` omits only unchanged evidence previously returned by this instance; after context loss use `decision`.
4
+
5
+ | API | Required | Optional |
6
+ | --- | --- | --- |
7
+ | readCreator | handle | target (default 10, 1–50), maxScrolls, navigate, expand, mode |
8
+ | readBookmarks | folder (observed name) | processedIds, limit, maxPages, mode |
9
+ | searchSources | query | processedIds, limit, maxPages, mode |
10
+ | readPost | target: {postId,url,evidenceRef} | mode |
11
+ | readPerson | target: {handle,url,evidenceRef} | mode |
12
+ | readFeedback | targets: [{postId,url,evidenceRef}] | replyLimit, mode |
13
+ | inspectAccount | none | mode |
14
+ | inspectRound | taskId (original batch ID) | none |
15
+
16
+ ```js
17
+ var result = await x.readCreator({ handle: selectedHandle, target: 5, mode: 'decision' });
18
+ var postResult = await x.readPost({ target: observedPost, mode: 'decision' });
19
+ ```
20
+
21
+ Targets/proof come from actual observations, never guessed URLs or made-up references. API owns navigation/expansion and bounded collection. Native creator collection may return `needsCapability`; inspect that signal before changing adapter.
22
+
23
+ Results include status/evidenceRef/missing/IDs/completeness. In `decision`, creator/bookmark/search use `sources`, post `post`, person `person`, feedback `feedback`; account includes accountHandle/accountURL. `observed` alone does not prove every requested field exists. `pageComplete:false` is a collection boundary, not proof of truncated target text. View media before describing it. Return complete chosen evidence; keep other observations local. Only on failure load recovery.
@@ -0,0 +1,25 @@
1
+ # Recovery by signal
2
+
3
+ Load on failure/unknown only. Keep operation/status/missing/code/proof/error; never infer success or repeat identical failures.
4
+
5
+ | Signal | Next action and exit condition |
6
+ | --- | --- |
7
+ | unknown submission | `await x.verifySubmission({taskId,actionId})`, same IDs; no replacement action or new submission before resolution. |
8
+ | Existing public post needs recovery | `await x.verifyPublishedPost({taskId,draftPath,publicURL,evidenceRef})`; observed URL/proof. Optional sourceLink: {href,resolvedURL,evidenceRef,renderedText?}. |
9
+ | Follow needs independent recovery | `await x.verifyFollow({handle,url,evidenceRef,mode:'decision'})`; honor returned verified. Normal followBatch already verifies. |
10
+ | needsDecision | Resolve returned missing review/proof in original action, resume same ID. Current AI performs internal review under project contract. |
11
+ | waiting | Honor resumeAt/reason, continue independent permitted work; never bypass time/identity/claim gates. |
12
+ | needsCapability | Inspect exact missing method/operation. If a supported binding supplies it, rebind once and retry a necessary read; otherwise repair reported owner with failing sample and targeted validation. |
13
+ | diagnostic.next: inspect-local-proof | Read only `{operation,error}` from diagnostic.evidenceRef; resolve via this reference, keep full proof local. Unknown/intent needs same-ID verification. Unsupported: report code/error and update/support; no private source. |
14
+ | diagnostic.next: restore-proof-store | Restore the workspace's writable proof directory, then resume the same action. Do not replace its ledger or infer submission failure. |
15
+ | Missing fields/partial window | Narrow to target, expand/advance documented bounded window; preserve text. No whole-page output or automatic reload. |
16
+
17
+ ## Host connection or native focus
18
+
19
+ Failure to list/bind Chrome is a host-connection failure before reaching X, not proof of logged-out X. Per current host documentation rediscover the task tab once; validate navigation plus a necessary visible read. Native focus rejection instead needs latest AX URL/window confirmation and one supported focus correction. Do not repeat discovery or restart editors blindly.
20
+
21
+ Lost tab: reuse a live owned tab or create a task tab in the same actual browser; preserve other editors. Native fallback changes binding to app, not business calls; unsupported native tasks remain gaps. Network/TUN observations do not authorize setting changes.
22
+
23
+ ## Repair boundary
24
+
25
+ Normal use/known recovery need no source. Unresolved runtime defect/unsupported capability permits diagnostics: maintainers repair the owner, verify the failed boundary and return to root; consumers report gap/update/support, without private TS. Never scan adapters for arguments, add parsers, bypass refusal or resubmit unknown.