@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 +2 -2
- package/dist/api.d.ts +2 -1
- package/dist/api.js +9 -3
- package/dist/growth-recovery.js +3 -2
- package/dist/platform-writing.js +4 -3
- package/dist/publication-time.d.ts +5 -0
- package/dist/publication-time.js +18 -0
- package/dist/reply-discovery.d.ts +12 -0
- package/dist/reply-discovery.js +167 -0
- package/dist/types.d.ts +33 -0
- package/package.json +2 -2
- package/skill/references/batch-discovery.md +2 -0
- package/skill/references/reads.md +3 -1
package/SOURCE.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Corresponding source
|
|
2
2
|
|
|
3
|
-
Version 0.2.
|
|
3
|
+
Version 0.2.68, commit 9d121f1f8b24f7c1082d8f17a3f6ec473d45d534.
|
|
4
4
|
|
|
5
|
-
Complete source, build scripts and dependencies: https://github.com/Peiiii/x-harness/tree/
|
|
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: "
|
|
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})
|
|
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
|
-
|
|
397
|
-
|
|
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
|
}
|
package/dist/growth-recovery.js
CHANGED
|
@@ -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
|
-
|
|
130
|
-
|
|
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, {
|
package/dist/platform-writing.js
CHANGED
|
@@ -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(
|
|
208
|
-
|
|
209
|
-
|
|
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,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.
|
|
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": "
|
|
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
|
-
|
|
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).
|