@nextclaw/x-harness 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,514 @@
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 && node.tagName === "A") {
491
+ const href = node.getAttribute("href") ?? "";
492
+ if (/^\/\w{1,15}\/status\/\d+$/.test(href))
493
+ return `https://x.com${href}`;
494
+ }
495
+ return [...node.childNodes].map(read).join("");
496
+ };
497
+ return root.childNodes ? read(root) : root.innerText;
498
+ })()
499
+ : null,
500
+ replyToId,
501
+ parentEvidence,
502
+ };
503
+ }),
504
+ };
505
+ }
506
+ return base;
507
+ };
508
+ const readers = { posts:readPosts, person:readProfile, composer:readComposer, media:readMediaEditor, navigation:readNavigation, menu:readMenu, account:()=>({}) };
509
+ if (!readers[query.view]) throw new Error('未封装的DOM视图');
510
+ const raw = readers[query.view](query);
511
+ const surface = readXSurface({...query, posts:(raw.posts??[]).map(p=>({id:p.id,primary:p.primary}))});
512
+ return {...raw,...surface,needsStateCheck:Boolean(raw.needsStateCheck)||!surface.accountHandle,
513
+ ...(query.view==='posts'?{posts:raw.posts.map(p=>({...p,...surface.bodies.find(b=>b.id===p.id)}))}: {})};
514
+ };
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.0",
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,52 @@
1
+ ---
2
+ name: x-harness
3
+ version: 0.1.0
4
+ description: Use X through named task APIs with compact receipts, complete target evidence and verified writes. Requires a compatible CUA browser host and the user's own logged-in X account. Install and update with the npm-distributed X Harness runtime.
5
+ ---
6
+
7
+ # X Harness
8
+
9
+ Use the installed runtime; normal tasks call its root business API. Do not recreate browser selectors, print full DOM/AX, or call lower layers for normal work. Keep observations local and only return the relevant complete evidence, receipt and actionable warnings.
10
+
11
+ ## First use
12
+
13
+ Installed module: `{{MODULE_URL}}`
14
+ CLI: `{{CLI_PATH}}`
15
+
16
+ If these placeholders are unresolved, install first:
17
+ `npx @nextclaw/x-harness@latest setup --target codex` (or `--target claude`).
18
+
19
+ Requires Node 22+, a host that exposes compatible CUA `tab` or native `app` methods, and the user's own X login. Codex desktop CUA is validated. Installing this Skill into Claude only installs instructions; confirm that host's browser methods before claiming it can execute. If browser access/login is absent, report the actual missing condition; never fabricate execution or borrow the publisher's account.
20
+
21
+ Read the host's CUA documentation before using its controls. Bind/create a task tab through the host's documented API, preserving the user's session. A compatible tab has `url()`, `playwright.evaluate()`, locator methods and optional navigation/AX/screenshot methods; native apps have `getAXState()`, click/key/paste methods. Use the provided connection; do not launch an unrelated automation driver.
22
+
23
+ Initialize the user's own workspace once with `node <CLI> init --directory <user workspace> --account <user handle>`. It returns `ledgerPath`, `proofDirectory`, `taskId` and account. Existing project configurations and ledgers remain the owner; reuse those instead of creating duplicate state. Never import the publisher's operations directories, policies or credentials.
24
+
25
+ In the host's Node-capable REPL:
26
+
27
+ ```js
28
+ const { createXGrowthHarness } = await import("MODULE_URL_FROM_SETUP");
29
+ const x = createXGrowthHarness({ tab, ledgerPath, proofDirectory });
30
+ const result = await x.readCreator({
31
+ handle: "USER_HANDLE",
32
+ target: 5,
33
+ mode: "decision",
34
+ });
35
+ // Print only the needed result; keep the full browser observation in the REPL.
36
+ ```
37
+
38
+ Substitute the stable installed URL and initialized paths. Never assume an old imported module hot-reloads after updating. Read `references/api.md` only for the next operation's arguments and the installed `.d.ts` for detailed types.
39
+
40
+ ## Execution
41
+
42
+ Choose the single named task: readCreator/readBookmarks/searchSources/readPost/readPerson/readFeedback/inspectAccount; publishPost/replyToPost/followOne/followBatch; inspectRound/runGrowthRound; verifySubmission/verifyPublishedPost/verifyFollow. `receipt` is the default compact result; `decision` includes full relevant evidence; `delta` rereads and returns changed complete evidence plus unchanged IDs. Do not trim the target's body to save tokens.
43
+
44
+ Writing needs explicit authorization from the current user, verified identity, their own ledger plan, reviewed prepared actions and existing evidence files. Initialization plans zero writes. Set the authorized planned count in the original ledger before execution; never expand an authorized scope yourself. Check the installed types for action, quality and executor fields; retain one owner for the ledger, do not invent approvals or evidence. The current runtime writes only 07:00–22:00 Asia/Shanghai and follow candidate gates require reviewed Chinese-language, blue-check candidates; disclose these conditions when relevant.
45
+
46
+ Interpret returned status literally: `verified` requires public proof; `unknown` is not success. For an unknown submission, call verifySubmission on the same action before any new submission. `needsCapability`, `needsDecision`, `waiting` and partial windows must be reported, with the next required condition. Capture visual media before making claims about its contents.
47
+
48
+ ## Updates
49
+
50
+ Run `npx @nextclaw/x-harness@latest update --target codex` (or claude). It stages the exact fetched release, replaces only managed runtime/Skill directories, saves the previous version and retains user workspaces/configurations. It refuses unfamiliar directories. Start a fresh module binding afterward and verify the next real read operation. A release's distributed Skill and runtime use the same version.
51
+
52
+ Free use under the bundled proprietary license; the distributed JS is readable, the TS source repository remains private. Website: https://x-harness.gemigo.app/
@@ -0,0 +1,37 @@
1
+ # Root API usage
2
+
3
+ Bind once: `createXGrowthHarness({ tab, ledgerPath, proofDirectory })`. Exactly one `tab` or native `app`. Read operations can use the initialized zero-write plan.
4
+
5
+ ```js
6
+ await x.readCreator({
7
+ handle: "USER_HANDLE",
8
+ target: 10,
9
+ maxScrolls: 8,
10
+ mode: "decision",
11
+ });
12
+ await x.searchSources({ query: "AI agents", limit: 5, mode: "decision" });
13
+ await x.readBookmarks({ folder: "FOLDER_NAME", limit: 5, mode: "decision" });
14
+ await x.inspectAccount({ mode: "decision" });
15
+ await x.readPost({ target: { postId, url, evidenceRef }, mode: "decision" });
16
+ await x.readPerson({ target: { handle, url, evidenceRef }, mode: "decision" });
17
+ await x.readFeedback({
18
+ targets: [{ postId, url, evidenceRef }],
19
+ mode: "decision",
20
+ });
21
+ await x.inspectRound({ taskId });
22
+ ```
23
+
24
+ Targets/evidence come from prior observed results. Exact option types are in the installed `dist/types.d.ts`. `readFeedback` may need your accountURL as documented there. Preserve full relevant content and expose missing fields rather than inventing them.
25
+
26
+ ```js
27
+ await x.publishPost({ taskId, draftPath, preparedAction: originalAction });
28
+ await x.replyToPost({ taskId, postId, text, preparedAction: replyAction });
29
+ await x.followOne({ taskId, handle }); // action already prepared in the original ledger
30
+ await x.followBatch({ taskId, handles, preparedActions });
31
+ await x.verifySubmission({ taskId, actionId });
32
+ await x.verifyPublishedPost({ taskId, draftPath, publicURL, evidenceRef });
33
+ await x.verifyFollow({ handle, url, evidenceRef });
34
+ await x.runGrowthRound({ taskId, maxActions: 3 });
35
+ ```
36
+
37
+ Before preparing writes, read the appropriate installed PreparedAction and operation option types. Required reviewed evidence cannot be replaced with a made-up reference. Original posts include actual draftPath, complete text, observed compose URL/proof, author proof and approvedText/draftFingerprint quality. Reply/follow targets require observed evidence. Concurrent task tabs need an explicitly evidenced executor scope; see HarnessOptions/Executor. Business root calls own capability checks, intent journaling, single submission, public verification and ledger settlement.