@nextclaw/x-harness 0.2.67 → 0.2.68

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/SOURCE.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Corresponding source
2
2
 
3
- Version 0.2.67, commit 03a90495bd2c781577399aca850742082abe3556.
3
+ Version 0.2.68, commit 9d121f1f8b24f7c1082d8f17a3f6ec473d45d534.
4
4
 
5
- Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/03a90495bd2c781577399aca850742082abe3556
5
+ Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/9d121f1f8b24f7c1082d8f17a3f6ec473d45d534
6
6
 
7
7
  Clone this revision, run npm ci, npm run bootstrap and npm run release:build.
8
8
 
package/dist/api.d.ts CHANGED
@@ -22,7 +22,7 @@ export declare class BusinessHarness implements XGrowthHarness {
22
22
  results: never[];
23
23
  reason: string;
24
24
  } | {
25
- status: "needsDecision" | "verified";
25
+ status: "verified" | "needsDecision";
26
26
  results: ActionResult[];
27
27
  reason?: undefined;
28
28
  }>;
@@ -81,6 +81,7 @@ export declare class BusinessHarness implements XGrowthHarness {
81
81
  evidenceRef?: undefined;
82
82
  diagnostic?: undefined;
83
83
  }>;
84
+ discoverReplyCandidates(args: import("./types.js").ReplyDiscoveryOptions): Promise<import("./types.js").ReplyDiscoveryResult>;
84
85
  searchSources(args: import("./types.js").SearchOptions): Promise<ReadResult>;
85
86
  readExploreRecommendations(args?: import("./types.js").ExploreRecommendationOptions): Promise<ReadResult>;
86
87
  readPost({ target, mode, includeContext, includeThread, resolveQuote, }: {
package/dist/api.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { ReplyDiscovery } from "./reply-discovery.js";
1
2
  import { sameHandle, samePublishedText } from "./platform-rules.js";
3
+ import { publicationTime } from "./publication-time.js";
2
4
  import { FollowbackAudit } from "./followback-audit.js";
3
5
  import { IncomingReactions } from "./incoming-reactions.js";
4
6
  import { createGrowthHarness } from "./growth.js";
@@ -32,6 +34,7 @@ export class BusinessHarness {
32
34
  this.readPost = this.readPost.bind(this);
33
35
  this.readIncomingConversations = this.readIncomingConversations.bind(this);
34
36
  this.readIncomingReplies = this.readIncomingReplies.bind(this);
37
+ this.discoverReplyCandidates = this.discoverReplyCandidates.bind(this);
35
38
  this.searchSources = this.searchSources.bind(this);
36
39
  this.readExploreRecommendations =
37
40
  this.readExploreRecommendations.bind(this);
@@ -368,7 +371,7 @@ export class BusinessHarness {
368
371
  const phrases = pending
369
372
  .map((a) => '"' + a.text.slice(0, 80).replace(/["\\]/g, " ") + '"')
370
373
  .join(" OR ");
371
- const query = `from:${pending[0].accountHandle} (${phrases}) since:${new Date(Math.min(...pending.map((a) => Date.parse(a.intentAt)))).toISOString().slice(0, 10)}`;
374
+ const query = `from:${pending[0].accountHandle} (${phrases}) filter:replies`;
372
375
  let found = await this.searchSources({
373
376
  query,
374
377
  limit: 5,
@@ -393,8 +396,8 @@ export class BusinessHarness {
393
396
  p.url?.toLowerCase() ===
394
397
  `https://x.com/${action.accountHandle}/status/${p.id}`.toLowerCase() &&
395
398
  !(action.beforeIds ?? []).includes(p.id) &&
396
- Date.parse(p.datetime ?? "") >= Date.parse(action.intentAt) &&
397
- Date.parse(p.datetime ?? "") <= this.#clock() + 5000);
399
+ publicationTime(p) >= Date.parse(action.intentAt) &&
400
+ publicationTime(p) <= this.#clock() + 5000);
398
401
  if (matches.length !== 1)
399
402
  continue;
400
403
  let bound = false;
@@ -598,6 +601,9 @@ export class BusinessHarness {
598
601
  pageComplete: false,
599
602
  };
600
603
  }
604
+ discoverReplyCandidates(args) {
605
+ return new ReplyDiscovery((request) => this.searchSources(request), this.#store, this.#clock).run(args);
606
+ }
601
607
  searchSources(args) {
602
608
  return this.read("searchSources", args, args.mode);
603
609
  }
@@ -1,3 +1,4 @@
1
+ import { publicationTime } from "./publication-time.js";
1
2
  import { xURL, matchesReviewedAssets } from "./platform-rules.js";
2
3
  import { matchesRenderedLinks } from "./source-link-recovery.js";
3
4
  import { same } from "./growth-constants.js";
@@ -126,8 +127,8 @@ export class GrowthRecovery {
126
127
  matchesRenderedLinks(action.text, p, sourceLinks, this.#evidenceExists)) &&
127
128
  !p.needsExpansion &&
128
129
  !(action.beforeIds ?? []).includes(p.id) &&
129
- Date.parse(p.datetime) >= Date.parse(action.intentAt) &&
130
- Date.parse(p.datetime) <= this.#clock() + 5000 &&
130
+ publicationTime(p) >= Date.parse(action.intentAt) &&
131
+ publicationTime(p) <= this.#clock() + 5000 &&
131
132
  matchesReviewedAssets(p, action.assets));
132
133
  });
133
134
  Object.assign(observation, {
@@ -1,3 +1,4 @@
1
+ import { publicationTime } from "./publication-time.js";
1
2
  import { xURL, sameHandle, sameText, samePublishedText, matchesReviewedAssets, } from "./platform-rules.js";
2
3
  export class PlatformWriting {
3
4
  #observed;
@@ -204,9 +205,9 @@ export class PlatformWriting {
204
205
  /^\d+$/.test(post.id ?? "") &&
205
206
  matchesReviewedAssets(post, action.assets) &&
206
207
  !(action.beforeIds ?? []).includes(post.id) &&
207
- Number.isFinite(Date.parse(post.datetime)) &&
208
- Date.parse(post.datetime) >= Date.parse(action.intentAt) &&
209
- Date.parse(post.datetime) <= this.#clock() + 5000 &&
208
+ Number.isFinite(publicationTime(post)) &&
209
+ publicationTime(post) >= Date.parse(action.intentAt) &&
210
+ publicationTime(post) <= this.#clock() + 5000 &&
210
211
  !post.needsExpansion);
211
212
  }
212
213
  async readQuotedReceipt(value, action, candidate) {
@@ -0,0 +1,5 @@
1
+ /** Refine an observed second-resolution timestamp only with its matching public X ID. */
2
+ export declare function publicationTime(post: {
3
+ id?: string | null;
4
+ datetime?: string | null;
5
+ }): number;
@@ -0,0 +1,18 @@
1
+ /** Refine an observed second-resolution timestamp only with its matching public X ID. */
2
+ export function publicationTime(post) {
3
+ const rendered = Date.parse(post.datetime ?? "");
4
+ if (!Number.isFinite(rendered))
5
+ return NaN;
6
+ if (!/^[1-9]\d{17,19}$/.test(post.id ?? ""))
7
+ return rendered;
8
+ const id = BigInt(post.id);
9
+ if (id > 9223372036854775807n)
10
+ return NaN;
11
+ // Twitter's published Snowflake layout: 41 timestamp bits, 22 lower bits.
12
+ const decoded = Number((id >> 22n) + 1288834974657n);
13
+ if (Math.floor(decoded / 1000) !== Math.floor(rendered / 1000))
14
+ return NaN;
15
+ if (rendered % 1000 !== 0 && rendered !== decoded)
16
+ return NaN;
17
+ return decoded;
18
+ }
@@ -0,0 +1,12 @@
1
+ import type { RoundStore } from "./store.js";
2
+ import type { ReadResult, ReplyDiscoveryOptions, ReplyDiscoveryResult, SearchOptions } from "./types.js";
3
+ /** Read-only discovery; shortlisted evidence still requires semantic review. */
4
+ export declare class ReplyDiscovery {
5
+ private search;
6
+ private store;
7
+ private clock;
8
+ constructor(search: (args: SearchOptions) => Promise<ReadResult>, store: RoundStore, clock: () => number);
9
+ private exclusion;
10
+ private rejection;
11
+ run(args: ReplyDiscoveryOptions): Promise<ReplyDiscoveryResult>;
12
+ }
@@ -0,0 +1,167 @@
1
+ /** Read-only discovery; shortlisted evidence still requires semantic review. */
2
+ export class ReplyDiscovery {
3
+ search;
4
+ store;
5
+ clock;
6
+ constructor(search, store, clock) {
7
+ this.search = search;
8
+ this.store = store;
9
+ this.clock = clock;
10
+ }
11
+ exclusion(state, handles) {
12
+ const authors = new Set(handles.map((h) => h.replace(/^@/, "").toLowerCase()));
13
+ const ids = new Set();
14
+ if (state.accountHandle)
15
+ authors.add(state.accountHandle.toLowerCase());
16
+ for (const a of [
17
+ ...(state.actions ?? []),
18
+ ...(state.replyAuthorHistory?.entries ?? []),
19
+ ]) {
20
+ if (a.kind !== "reply" || a.status === "skipped")
21
+ continue;
22
+ const author = a.author ?? a.conversation?.recipientHandle;
23
+ if (author)
24
+ authors.add(author.toLowerCase());
25
+ if (a.postId)
26
+ ids.add(a.postId);
27
+ }
28
+ return { authors, ids };
29
+ }
30
+ rejection(p, args, now) {
31
+ if (!p.id ||
32
+ !p.author ||
33
+ !p.url ||
34
+ !p.body?.trim() ||
35
+ p.needsExpansion ||
36
+ p.missing?.length)
37
+ return "incomplete-source";
38
+ const time = Date.parse(p.datetime ?? "");
39
+ if (!Number.isFinite(time) ||
40
+ time > now ||
41
+ now - time > (args.maxAgeHours ?? 12) * 3600000)
42
+ return "source-time";
43
+ if ((args.chineseOnly ?? true) && !/[\u3400-\u9fff]/u.test(p.body))
44
+ return "source-language";
45
+ const raw = p.metrics?.find((m) => /喜欢|点赞|likes?\b/i.test(m));
46
+ const match = raw?.match(/([\d,]+(?:\.\d+)?)\s*([万千KM])?/i);
47
+ const multiplier = {
48
+ 万: 10000,
49
+ 千: 1000,
50
+ K: 1000,
51
+ M: 1000000,
52
+ };
53
+ const likes = match
54
+ ? Number(match[1].replaceAll(",", "")) *
55
+ (multiplier[(match[2] ?? "").toUpperCase()] ?? 1)
56
+ : NaN;
57
+ if (!Number.isFinite(likes) || likes < (args.minLikes ?? 20))
58
+ return "source-likes";
59
+ }
60
+ async run(args) {
61
+ const target = args.target ?? 20, maxWindows = args.maxWindows ?? 12;
62
+ if (!args.queries?.length ||
63
+ args.queries.length > 10 ||
64
+ args.queries.some((q) => !q.trim()) ||
65
+ !Number.isInteger(target) ||
66
+ target < 1 ||
67
+ target > 50 ||
68
+ !Number.isInteger(maxWindows) ||
69
+ maxWindows < 1 ||
70
+ maxWindows > 30 ||
71
+ !Number.isFinite(args.minLikes ?? 20) ||
72
+ (args.minLikes ?? 20) < 0 ||
73
+ !Number.isFinite(args.maxAgeHours ?? 12) ||
74
+ (args.maxAgeHours ?? 12) <= 0) {
75
+ return {
76
+ status: "needsDecision",
77
+ reason: "discovery-options-invalid",
78
+ candidates: [],
79
+ examinedIds: [],
80
+ windows: [],
81
+ rejections: {},
82
+ targetReached: false,
83
+ pageComplete: false,
84
+ };
85
+ }
86
+ const examined = new Set(args.processedIds ?? []), authors = new Set();
87
+ const candidates = [], windows = [], rejections = {};
88
+ let reason;
89
+ for (let i = 0; i < maxWindows && candidates.length < target; i++) {
90
+ const now = this.clock(), hour = new Date(now + 8 * 3600000).getUTCHours();
91
+ if (hour < 7) {
92
+ reason = "night-platform-boundary";
93
+ break;
94
+ }
95
+ const excluded = this.exclusion((await this.store.load()).state, args.excludedHandles ?? []);
96
+ const query = args.queries[i % args.queries.length];
97
+ const request = {
98
+ query,
99
+ processedIds: [...examined, ...excluded.ids],
100
+ limit: 5,
101
+ maxPages: 3,
102
+ mode: "decision",
103
+ };
104
+ let read = await this.search(request);
105
+ if ((read.reason === "source-page-unconfirmed" ||
106
+ read.missing?.includes("source-page-unconfirmed")) &&
107
+ new Date(this.clock() + 8 * 3600000).getUTCHours() >= 7)
108
+ read = await this.search(request);
109
+ windows.push({
110
+ query,
111
+ evidenceRef: read.evidenceRef,
112
+ status: read.status,
113
+ missing: read.missing ?? [],
114
+ observed: read.sources?.length ?? 0,
115
+ });
116
+ if (read.status === "needsCapability" ||
117
+ read.missing?.includes("platform-alert") ||
118
+ read.reason === "night-platform-boundary") {
119
+ reason = read.reason ?? "platform-alert";
120
+ break;
121
+ }
122
+ for (const post of read.sources ?? []) {
123
+ if (post.id && examined.has(post.id))
124
+ continue;
125
+ if (post.id)
126
+ examined.add(post.id);
127
+ const author = post.author?.toLowerCase();
128
+ const rejection = author && (excluded.authors.has(author) || authors.has(author))
129
+ ? "author-history-or-duplicate"
130
+ : post.id && excluded.ids.has(post.id)
131
+ ? "target-history"
132
+ : this.rejection(post, args, this.clock());
133
+ if (rejection || !read.evidenceRef) {
134
+ const key = rejection ?? "source-proof-missing";
135
+ rejections[key] = (rejections[key] ?? 0) + 1;
136
+ continue;
137
+ }
138
+ candidates.push({
139
+ post,
140
+ evidenceRef: read.evidenceRef,
141
+ query,
142
+ requiresReview: true,
143
+ });
144
+ authors.add(author);
145
+ if (candidates.length === target)
146
+ break;
147
+ }
148
+ }
149
+ const result = {
150
+ status: candidates.length >= target ? "observed" : "needsDecision",
151
+ reason: reason ??
152
+ (candidates.length < target ? "bounded-candidate-shortage" : undefined),
153
+ candidates,
154
+ examinedIds: [...examined],
155
+ windows,
156
+ rejections,
157
+ targetReached: candidates.length >= target,
158
+ pageComplete: false,
159
+ };
160
+ result.evidenceRef = await this.store.recordProof({
161
+ operation: "discoverReplyCandidates",
162
+ capturedAt: new Date(this.clock()).toISOString(),
163
+ result,
164
+ });
165
+ return result;
166
+ }
167
+ }
package/dist/types.d.ts CHANGED
@@ -259,6 +259,38 @@ export interface BookmarkOptions extends WindowOptions {
259
259
  export interface SearchOptions extends WindowOptions {
260
260
  query: string;
261
261
  }
262
+ export interface ReplyDiscoveryOptions {
263
+ queries: string[];
264
+ target?: number;
265
+ maxWindows?: number;
266
+ processedIds?: string[];
267
+ excludedHandles?: string[];
268
+ minLikes?: number;
269
+ maxAgeHours?: number;
270
+ chineseOnly?: boolean;
271
+ }
272
+ export interface ReplyDiscoveryResult {
273
+ status: "observed" | "needsDecision";
274
+ reason?: string;
275
+ candidates: {
276
+ post: PostEvidence;
277
+ evidenceRef: EvidenceRef;
278
+ query: string;
279
+ requiresReview: true;
280
+ }[];
281
+ examinedIds: string[];
282
+ windows: {
283
+ query: string;
284
+ evidenceRef?: EvidenceRef;
285
+ status: ReadResult["status"];
286
+ missing: string[];
287
+ observed: number;
288
+ }[];
289
+ rejections: Record<string, number>;
290
+ targetReached: boolean;
291
+ pageComplete: false;
292
+ evidenceRef?: EvidenceRef;
293
+ }
262
294
  export interface ExploreRecommendationOptions extends WindowOptions {
263
295
  }
264
296
  export interface CreatorOptions {
@@ -570,6 +602,7 @@ export interface XGrowthHarness {
570
602
  readPeople(args: PeopleOptions): Promise<ReadResult>;
571
603
  readFollowbackAudit(args: FollowbackAuditOptions): Promise<FollowbackAuditResult>;
572
604
  readBookmarks(args: BookmarkOptions): Promise<ReadResult>;
605
+ discoverReplyCandidates(args: ReplyDiscoveryOptions): Promise<ReplyDiscoveryResult>;
573
606
  searchSources(args: SearchOptions): Promise<ReadResult>;
574
607
  readExploreRecommendations(args?: ExploreRecommendationOptions): Promise<ReadResult>;
575
608
  readIncomingReplies(args?: IncomingReplyOptions): Promise<ReadResult>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/x-harness",
3
- "version": "0.2.67",
3
+ "version": "0.2.68",
4
4
  "type": "module",
5
5
  "description": "Typed X task harness with compact business results and explicit diagnostics.",
6
6
  "main": "./dist/index.js",
@@ -42,5 +42,5 @@
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "gitHead": "03a90495bd2c781577399aca850742082abe3556"
45
+ "gitHead": "9d121f1f8b24f7c1082d8f17a3f6ec473d45d534"
46
46
  }
@@ -0,0 +1,2 @@
1
+ # Batch discovery
2
+ For a batch of active replies, first use `x.discoverReplyCandidates({queries:["互关 min_faves:20", "串门 min_faves:20"],target:20,processedIds})`. One call owns serial paginated searches, original-ledger target/author exclusion, deduplication and deterministic age/likes/language/body gates. It performs no writes. Save/reuse `examinedIds` on the original actor; full candidate text, original per-window proofs and the combined proof stay available locally. `requiresReview:true` means relevance, human identity and suitable text still need review. Review the batch, then use existing `replyBatch`; do not rebuild per-person discovery or infer approval from metadata. Partial shortages include rejection totals and window warnings; `pageComplete:false` never claims all of X was read. Night boundary stops further reads. Missing metrics remain unknown. Repair a concrete missing capability at its original owner, validate it and resume the task; no implementation reading for normal calls.
@@ -18,7 +18,7 @@ Targets need proof. Native `readCreator` requires a selected profile (`navigate:
18
18
 
19
19
  Bookmarks `resume:true` continues the bound folder; rendered tail ≠ full proof.
20
20
 
21
- Observed ≠ complete; `pageComplete:false` means finite scope. Preserve full text; view media before describing; keep raw reads local.
21
+ `pageComplete:false`: finite scope. Preserve full text; view media before describing.
22
22
 
23
23
  Incoming replies: ledger only; carry processedIds; skip settled/intent/unknown. Read parents with `readPost(includeContext:true)`; deduplicate.
24
24
 
@@ -31,3 +31,5 @@ Incoming conversations: `readIncomingConversations({targets?,...window})` discov
31
31
  Discovery: names only.
32
32
 
33
33
  Count `sourceOrigin` only with confirmed `/explore` “For you” tab; exclude trends/search. Preserve visible counts; absent stays unknown.
34
+
35
+ Batch replies: [automated discovery](batch-discovery.md).