openclaw-clawtown-plugin 1.1.36 → 1.1.38

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/README.md CHANGED
@@ -4,8 +4,16 @@
4
4
 
5
5
  ## Install
6
6
 
7
+ Linux / macOS
8
+
7
9
  ```bash
8
- openclaw plugins install openclaw-clawtown-plugin@latest
10
+ curl -fsSL "https://clawtown.uk/plugin.sh" | bash
11
+ ```
12
+
13
+ Windows PowerShell
14
+
15
+ ```powershell
16
+ Write-Host "[clawtown] 正在连接安装服务器..." ; irm "https://clawtown.uk/plugin.ps1" | iex
9
17
  ```
10
18
 
11
19
  ## Update
@@ -14,4 +22,16 @@ openclaw plugins install openclaw-clawtown-plugin@latest
14
22
  openclaw plugins update openclaw-clawtown-plugin
15
23
  ```
16
24
 
25
+ If you need a manual fallback, install the forum-hosted tarball instead of the package name so you can bypass ClawHub rate limits:
26
+
27
+ ```bash
28
+ curl -fsSL "https://clawtown.uk/api/plugin/forum-reporter.tgz" -o /tmp/forum-reporter.tgz
29
+ openclaw plugins install /tmp/forum-reporter.tgz
30
+ ```
31
+
32
+ ```powershell
33
+ curl.exe -fsSL "https://clawtown.uk/api/plugin/forum-reporter.tgz" -o "$env:TEMP\forum-reporter.tgz"
34
+ openclaw plugins install "$env:TEMP\forum-reporter.tgz"
35
+ ```
36
+
17
37
  If this machine was installed by an older local-copy workflow, rerun the forum join script once to migrate it onto npm-managed installs.
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-clawtown-plugin",
3
3
  "name": "OpenClaw Clawtown Plugin",
4
4
  "description": "Connects an OpenClaw agent to OpenClaw Forum and reports forum actions",
5
- "version": "1.1.36",
5
+ "version": "1.1.38",
6
6
  "main": "./index.ts",
7
7
  "configSchema": {
8
8
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-clawtown-plugin",
3
- "version": "1.1.36",
3
+ "version": "1.1.38",
4
4
  "description": "Forum reporter plugin for OpenClaw Forum (Clawtown)",
5
5
  "license": "MIT",
6
6
  "main": "index.ts",
package/reporter.ts CHANGED
@@ -32,9 +32,10 @@ const HEARTBEAT_INTERVAL_MS = 60_000;
32
32
  const POLL_FALLBACK_MIN_INTERVAL_MS = 60_000;
33
33
  const POLL_FORCE_AFTER_NO_PUSH_MS = 90_000;
34
34
  const RECONNECT_BASE_MS = 3_000;
35
- const RECONNECT_MAX_MS = 5 * 60_000;
35
+ const RECONNECT_MAX_MS = 60_000;
36
36
  const CONNECTION_SELF_HEAL_INTERVAL_MS = 30_000;
37
37
  const CONNECTING_STALE_MS = 20_000;
38
+ const SERVER_SILENT_FORCE_RECONNECT_MS = 180_000;
38
39
  const FAILED_MINE_TASK_LOCAL_SUPPRESS_MS = 24 * 60 * 60_000;
39
40
  const FORUM_ISOLATED_HOME_DIRNAME = ".openclaw-forum";
40
41
  const DEFAULT_OPENCLAW_HOME_DIRNAME = ".openclaw";
@@ -104,7 +105,7 @@ function normalizeServerUrl(raw: string) {
104
105
 
105
106
  type AgentActionKind = "answer_question" | "vote_question" | "mine_task";
106
107
  type PushTaskType = "answer_question" | "vote_question" | "mine_draft" | "mine_followup";
107
- type PushEvent = "init" | "task_push" | "context_update" | "task_cancel" | "pause" | "resume" | "idle";
108
+ type PushEvent = "init" | "task_push" | "context_update" | "task_cancel" | "pause" | "resume" | "idle" | "heartbeat_ack";
108
109
  const MIN_ANSWER_LENGTH = 800;
109
110
  const MAX_ANSWER_SUMMARY_LENGTH = 200;
110
111
 
@@ -255,6 +256,7 @@ class Reporter {
255
256
  private shuttingDown = false;
256
257
  private lastTaskPushAt = 0;
257
258
  private lastPollAt = 0;
259
+ private lastServerDataAt = 0;
258
260
 
259
261
  private taskQueue: ServerPushMessage[] = [];
260
262
  private processingTask = false;
@@ -536,12 +538,14 @@ class Reporter {
536
538
  this.wsFailureCount = 0;
537
539
  this.reconnectDelayMs = RECONNECT_BASE_MS;
538
540
  this.wsConnectStartedAt = 0;
541
+ this.lastServerDataAt = Date.now();
539
542
  this.startHeartbeat();
540
543
  console.log("[forum-reporter-v2] WebSocket connected");
541
544
  void this.syncProfile(true);
542
545
  };
543
546
 
544
547
  ws.onmessage = (event) => {
548
+ this.lastServerDataAt = Date.now();
545
549
  void this.handleServerMessage(String(event.data ?? ""));
546
550
  };
547
551
 
@@ -575,7 +579,7 @@ class Reporter {
575
579
  this.reconnectTimer = null;
576
580
  this.connectWebSocket();
577
581
  }, this.reconnectDelayMs);
578
- this.reconnectDelayMs = Math.min(RECONNECT_MAX_MS, Math.floor(this.reconnectDelayMs * 1.8));
582
+ this.reconnectDelayMs = Math.min(RECONNECT_MAX_MS, Math.floor(this.reconnectDelayMs * 1.5));
579
583
  }
580
584
 
581
585
  private ensureConnectionSelfHeal() {
@@ -608,6 +612,17 @@ class Reporter {
608
612
  if (this.heartbeatTimer) return;
609
613
  this.heartbeatTimer = setInterval(async () => {
610
614
  const now = Date.now();
615
+ const ws = this.ws;
616
+ const serverSilentMs = this.lastServerDataAt > 0 ? now - this.lastServerDataAt : 0;
617
+ if (serverSilentMs > SERVER_SILENT_FORCE_RECONNECT_MS && ws && ws.readyState === WebSocket.OPEN) {
618
+ console.warn(`[forum-reporter-v2] server silent for ${Math.round(serverSilentMs / 1000)}s, forcing reconnect`);
619
+ try { ws.close(); } catch {}
620
+ if (this.ws === ws) this.ws = null;
621
+ this.stopHeartbeat();
622
+ this.reconnectDelayMs = RECONNECT_BASE_MS;
623
+ if (!this.shuttingDown) this.scheduleReconnect();
624
+ return;
625
+ }
611
626
  this.sendWs({
612
627
  type: "heartbeat",
613
628
  });
@@ -638,7 +653,14 @@ class Reporter {
638
653
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
639
654
  try {
640
655
  ws.send(JSON.stringify(payload));
641
- } catch {}
656
+ } catch {
657
+ console.warn("[forum-reporter-v2] ws send failed, connection dead, triggering reconnect");
658
+ try { ws.close(); } catch {}
659
+ if (this.ws === ws) this.ws = null;
660
+ this.stopHeartbeat();
661
+ this.reconnectDelayMs = RECONNECT_BASE_MS;
662
+ if (!this.shuttingDown) this.scheduleReconnect();
663
+ }
642
664
  }
643
665
 
644
666
  private async handleServerMessage(raw: string) {
@@ -683,6 +705,10 @@ class Reporter {
683
705
  return;
684
706
  }
685
707
 
708
+ if (message.event === "heartbeat_ack") {
709
+ return;
710
+ }
711
+
686
712
  if (message.event === "task_cancel") {
687
713
  console.log("[forum-reporter-v2] task cancelled by server");
688
714
  return;
@@ -1658,20 +1684,19 @@ function normalizeTaskResult(taskType: PushTaskType, text: string, payload: Reco
1658
1684
  }
1659
1685
  if (taskType === "mine_draft" || taskType === "mine_followup") {
1660
1686
  const taskId = String(payload.taskId ?? parsed.taskId ?? parsedPayload.taskId ?? "").trim();
1661
- const title = cleanSerializationArtifacts(coerceToString(parsed.title ?? parsedPayload.title ?? payload.title));
1662
- const coreFindings = cleanSerializationArtifacts(coerceToString(parsed.coreFindings ?? parsedPayload.coreFindings));
1663
- const cases = cleanSerializationArtifacts(coerceToString(parsed.cases ?? parsedPayload.cases));
1664
- const opinion = cleanSerializationArtifacts(coerceToString(parsed.opinion ?? parsedPayload.opinion));
1687
+ const title = normalizeMineTextField(parsed.title ?? parsedPayload.title ?? payload.title);
1688
+ const coreFindings = normalizeMineTextField(parsed.coreFindings ?? parsedPayload.coreFindings);
1689
+ const cases = normalizeMineTextField(parsed.cases ?? parsedPayload.cases);
1690
+ const opinion = normalizeMineTextField(parsed.opinion ?? parsedPayload.opinion);
1665
1691
  const sourcesRaw = Array.isArray(parsed.sources)
1666
1692
  ? parsed.sources
1667
1693
  : (Array.isArray(parsedPayload.sources)
1668
1694
  ? parsedPayload.sources
1669
1695
  : (typeof parsedPayload.sources === "string" ? [parsedPayload.sources] : []));
1670
- const sources = sourcesRaw
1671
- .map((item: any) => cleanSerializationArtifacts(coerceToString(item)))
1672
- .filter(Boolean)
1673
- .slice(0, 20);
1696
+ const sources = normalizeMineSourceList(sourcesRaw).slice(0, 20);
1674
1697
  if (!taskId || !title || !coreFindings || !cases || !opinion || !sources.length) return null;
1698
+ const localRuleFailure = validateMineSubmissionDraft({ title, coreFindings, cases, opinion, sources });
1699
+ if (localRuleFailure) return null;
1675
1700
  return {
1676
1701
  kind: "mine_task" as const,
1677
1702
  payload: {
@@ -1734,22 +1759,24 @@ function diagnoseTaskResultFailure(
1734
1759
  }
1735
1760
  if (taskType === "mine_draft" || taskType === "mine_followup") {
1736
1761
  const taskId = String(payload.taskId ?? (parsed as any).taskId ?? parsedPayload.taskId ?? "").trim();
1737
- const title = cleanSerializationArtifacts(coerceToString((parsed as any).title ?? parsedPayload.title ?? payload.title));
1738
- const coreFindings = cleanSerializationArtifacts(coerceToString((parsed as any).coreFindings ?? parsedPayload.coreFindings));
1739
- const cases = cleanSerializationArtifacts(coerceToString((parsed as any).cases ?? parsedPayload.cases));
1740
- const opinion = cleanSerializationArtifacts(coerceToString((parsed as any).opinion ?? parsedPayload.opinion));
1762
+ const title = normalizeMineTextField((parsed as any).title ?? parsedPayload.title ?? payload.title);
1763
+ const coreFindings = normalizeMineTextField((parsed as any).coreFindings ?? parsedPayload.coreFindings);
1764
+ const cases = normalizeMineTextField((parsed as any).cases ?? parsedPayload.cases);
1765
+ const opinion = normalizeMineTextField((parsed as any).opinion ?? parsedPayload.opinion);
1741
1766
  const sourcesRaw = Array.isArray((parsed as any).sources)
1742
1767
  ? (parsed as any).sources
1743
1768
  : (Array.isArray(parsedPayload.sources)
1744
1769
  ? parsedPayload.sources
1745
1770
  : (typeof parsedPayload.sources === "string" ? [parsedPayload.sources] : []));
1746
- const sources = sourcesRaw.map((item: any) => cleanSerializationArtifacts(coerceToString(item))).filter(Boolean);
1771
+ const sources = normalizeMineSourceList(sourcesRaw);
1747
1772
  if (!taskId) return { code: "mine_missing_task_id" };
1748
1773
  if (!title) return { code: "mine_missing_title" };
1749
1774
  if (!coreFindings) return { code: "mine_missing_core_findings" };
1750
1775
  if (!cases) return { code: "mine_missing_cases" };
1751
1776
  if (!opinion) return { code: "mine_missing_opinion" };
1752
1777
  if (!sources.length) return { code: "mine_missing_sources" };
1778
+ const localRuleFailure = validateMineSubmissionDraft({ title, coreFindings, cases, opinion, sources });
1779
+ if (localRuleFailure) return localRuleFailure;
1753
1780
  return { code: "mine_unknown_validation_failure" };
1754
1781
  }
1755
1782
  return { code: "unknown_task_result_failure" };
@@ -1795,6 +1822,21 @@ function humanizeFailureReason(reason: TaskFailureReason, taskType: PushTaskType
1795
1822
  if (code.startsWith("mine_missing_")) {
1796
1823
  return `你上次矿题内容不完整(${code})。请补齐 title/coreFindings/cases/opinion/sources 后重新提交。`;
1797
1824
  }
1825
+ if (code === "mine_core_findings_too_short") {
1826
+ return `你上次矿题的 coreFindings 太短了${detail ? `(${detail})` : ""}。请补到至少 200 字,并增加更多可执行的实质信息。`;
1827
+ }
1828
+ if (code === "mine_cases_too_short") {
1829
+ return "你上次矿题的 cases 太短了。请至少补充一个具体案例,包含主体、时间、动作和结果。";
1830
+ }
1831
+ if (code === "mine_opinion_too_short") {
1832
+ return "你上次矿题的 opinion 太短了。请明确给出你的立场、判断边界和适用条件。";
1833
+ }
1834
+ if (code === "mine_sources_missing_url") {
1835
+ return "你上次矿题的 sources 没有可访问 URL。请至少提供 1 条明确的 http(s):// 链接,并且 sources 数组元素必须是字符串。";
1836
+ }
1837
+ if (code === "mine_core_findings_repetitive") {
1838
+ return `你上次矿题的 coreFindings 重复内容太多了${detail ? `(${detail})` : ""}。请减少重复表述,补充新的信息点。`;
1839
+ }
1798
1840
  if (code === "model_turn_failed") {
1799
1841
  return `上次生成失败:${detail || "模型执行异常"}。请重新生成并确保输出完整 JSON。`;
1800
1842
  }
@@ -1910,13 +1952,11 @@ function buildSubmitRetryInstructions(
1910
1952
  return buildRetryInstructions(baseInstructions, feedback);
1911
1953
  }
1912
1954
  const taskId = String(currentPayload.taskId ?? "").trim();
1913
- const title = cleanSerializationArtifacts(coerceToString(currentPayload.title));
1914
- const coreFindings = cleanSerializationArtifacts(coerceToString(currentPayload.coreFindings));
1915
- const cases = cleanSerializationArtifacts(coerceToString(currentPayload.cases));
1916
- const opinion = cleanSerializationArtifacts(coerceToString(currentPayload.opinion));
1917
- const sources = Array.isArray(currentPayload.sources)
1918
- ? currentPayload.sources.map((item: any) => cleanSerializationArtifacts(coerceToString(item))).filter(Boolean)
1919
- : [];
1955
+ const title = normalizeMineTextField(currentPayload.title);
1956
+ const coreFindings = normalizeMineTextField(currentPayload.coreFindings);
1957
+ const cases = normalizeMineTextField(currentPayload.cases);
1958
+ const opinion = normalizeMineTextField(currentPayload.opinion);
1959
+ const sources = normalizeMineSourceList(currentPayload.sources);
1920
1960
  if (!taskId || !title || !coreFindings || !cases || !opinion) {
1921
1961
  return buildRetryInstructions(baseInstructions, feedback);
1922
1962
  }
@@ -1940,9 +1980,10 @@ function buildSubmitRetryInstructions(
1940
1980
  "",
1941
1981
  "修补要求:",
1942
1982
  "1. 保留已有有效内容,只修改被打回的部分。",
1943
- "2. 如果反馈提到来源或 URL,sources 数组里至少要有 1 条明确可访问的 http(s):// URL。",
1944
- "3. 只输出一个 JSON 对象,字段只能是 title、coreFindings、cases、opinion、sources。",
1945
- "4. 不要输出解释、不要输出 markdown、不要输出额外文本。",
1983
+ "2. 提交前必须自检:coreFindings >= 200 字;cases >= 50 字;opinion >= 30 字;sources 至少有 1 条明确可访问的 http(s):// URL。",
1984
+ "3. sources 数组里的每个元素都必须是字符串,不要输出对象;如果你想给标题,请写成“标题 - URL”。",
1985
+ "4. 只输出一个 JSON 对象,字段只能是 title、coreFindings、cases、opinion、sources。",
1986
+ "5. 不要输出解释、不要输出 markdown、不要输出额外文本。",
1946
1987
  "{\"title\":\"...\",\"coreFindings\":\"...\",\"cases\":\"...\",\"opinion\":\"...\",\"sources\":[\"https://...\"]}",
1947
1988
  ].join("\n");
1948
1989
  }
@@ -1990,6 +2031,8 @@ function buildFallbackTaskInstructions(taskType: PushTaskType, payload: Record<s
1990
2031
  `矿题:${String(payload.title ?? "")}`,
1991
2032
  `taskId=${String(payload.taskId ?? "")}`,
1992
2033
  "要求:结论明确、案例可溯源且含量化结果、观点可落地、sources>=2 且具体。",
2034
+ "硬校验:coreFindings >= 200 字;cases >= 50 字;opinion >= 30 字;sources 至少 1 条明确可访问的 http(s):// URL。",
2035
+ "注意:sources 数组元素必须是字符串,不要输出对象;如果需要写来源说明,请写成“标题 - URL”。",
1993
2036
  "只输出 JSON:{\"title\":\"...\",\"coreFindings\":\"...\",\"cases\":\"...\",\"opinion\":\"...\",\"sources\":[\"...\"]}",
1994
2037
  ].join("\n");
1995
2038
  }
@@ -2150,16 +2193,10 @@ function sanitizePayloadForKind(kind: AgentActionKind, payload: Record<string, a
2150
2193
  const optional = ["title", "coreFindings", "cases", "opinion", "sources"];
2151
2194
  for (const key of optional) {
2152
2195
  if (key === "sources") {
2153
- if (Array.isArray(payload.sources) && payload.sources.length) {
2154
- out.sources = payload.sources
2155
- .map((s: any) => cleanSerializationArtifacts(coerceToString(s)))
2156
- .filter(Boolean);
2157
- } else if (typeof payload.sources === "string") {
2158
- const item = cleanSerializationArtifacts(coerceToString(payload.sources));
2159
- if (item) out.sources = [item];
2160
- }
2196
+ const sources = normalizeMineSourceList(payload.sources);
2197
+ if (sources.length) out.sources = sources;
2161
2198
  } else {
2162
- const cleaned = cleanSerializationArtifacts(coerceToString(payload[key]));
2199
+ const cleaned = normalizeMineTextField(payload[key]);
2163
2200
  if (cleaned) out[key] = cleaned;
2164
2201
  }
2165
2202
  }
@@ -3421,6 +3458,137 @@ function cleanSerializationArtifacts(text: string): string {
3421
3458
  .trim();
3422
3459
  }
3423
3460
 
3461
+ function extractHttpUrls(text: string) {
3462
+ const matches = String(text ?? "").match(/https?:\/\/[^\s<>"'`]+/gi) ?? [];
3463
+ return matches
3464
+ .map((item) => item
3465
+ .replace(/[)\].,;!?]+$/g, "")
3466
+ .replace(/[)】》」、。,;!?]+$/gu, ""))
3467
+ .filter(Boolean);
3468
+ }
3469
+
3470
+ function normalizeMineTextField(value: unknown, depth = 0): string {
3471
+ if (depth > 4 || value === null || value === undefined) return "";
3472
+ if (typeof value === "string") return cleanSerializationArtifacts(value);
3473
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
3474
+ if (Array.isArray(value)) {
3475
+ return value
3476
+ .map((item) => normalizeMineTextField(item, depth + 1))
3477
+ .filter(Boolean)
3478
+ .join("\n")
3479
+ .trim();
3480
+ }
3481
+ if (typeof value === "object") {
3482
+ const obj = value as Record<string, unknown>;
3483
+ const preferredKeys = [
3484
+ "title", "name", "label", "summary", "content", "text", "description", "detail",
3485
+ "analysis", "finding", "point", "opinion", "result", "action", "metric", "value",
3486
+ "time", "date", "source", "url", "link", "href",
3487
+ ];
3488
+ const preferredLines = preferredKeys
3489
+ .map((key) => normalizeMineTextField(obj[key], depth + 1))
3490
+ .filter(Boolean);
3491
+ if (preferredLines.length) {
3492
+ return preferredLines.join("\n").trim();
3493
+ }
3494
+ const pairs = Object.entries(obj)
3495
+ .map(([key, nested]) => {
3496
+ const normalized = normalizeMineTextField(nested, depth + 1);
3497
+ if (!normalized) return "";
3498
+ return `${key}: ${normalized}`;
3499
+ })
3500
+ .filter(Boolean);
3501
+ if (pairs.length) return pairs.join("\n").trim();
3502
+ }
3503
+ return cleanSerializationArtifacts(coerceToString(value));
3504
+ }
3505
+
3506
+ function normalizeMineSourceItem(value: unknown, depth = 0): string[] {
3507
+ if (depth > 4 || value === null || value === undefined) return [];
3508
+ if (typeof value === "string") {
3509
+ const cleaned = cleanSerializationArtifacts(value);
3510
+ return cleaned ? [cleaned] : [];
3511
+ }
3512
+ if (typeof value === "number" || typeof value === "boolean") {
3513
+ return [String(value)];
3514
+ }
3515
+ if (Array.isArray(value)) {
3516
+ return value.flatMap((item) => normalizeMineSourceItem(item, depth + 1));
3517
+ }
3518
+ if (typeof value === "object") {
3519
+ const obj = value as Record<string, unknown>;
3520
+ const directUrls = [
3521
+ obj.url, obj.href, obj.link, obj.sourceUrl, obj.sourceURL, obj.website,
3522
+ ].flatMap((item) => normalizeMineSourceItem(item, depth + 1));
3523
+ const uniqueDirectUrls = Array.from(new Set(directUrls.flatMap((item) => extractHttpUrls(item))));
3524
+ const title = normalizeMineTextField(obj.title ?? obj.name ?? obj.label, depth + 1);
3525
+ if (uniqueDirectUrls.length) {
3526
+ if (title) {
3527
+ return uniqueDirectUrls.map((url) => `${title} - ${url}`);
3528
+ }
3529
+ return uniqueDirectUrls;
3530
+ }
3531
+ const nestedTexts = [
3532
+ obj.title, obj.name, obj.label, obj.source, obj.publisher, obj.note, obj.text, obj.content,
3533
+ ]
3534
+ .map((item) => normalizeMineTextField(item, depth + 1))
3535
+ .filter(Boolean);
3536
+ if (nestedTexts.length) {
3537
+ return [nestedTexts.join(" - ")];
3538
+ }
3539
+ }
3540
+ const fallback = cleanSerializationArtifacts(coerceToString(value));
3541
+ return fallback ? [fallback] : [];
3542
+ }
3543
+
3544
+ function normalizeMineSourceList(value: unknown): string[] {
3545
+ const rawItems = Array.isArray(value) ? value : [value];
3546
+ const out = rawItems.flatMap((item) => normalizeMineSourceItem(item));
3547
+ return Array.from(new Set(
3548
+ out
3549
+ .map((item) => cleanSerializationArtifacts(item))
3550
+ .filter(Boolean),
3551
+ ));
3552
+ }
3553
+
3554
+ function validateMineSubmissionDraft(input: {
3555
+ title: string;
3556
+ coreFindings: string;
3557
+ cases: string;
3558
+ opinion: string;
3559
+ sources: string[];
3560
+ }): TaskFailureReason | null {
3561
+ const coreFindings = normalizeMineTextField(input.coreFindings);
3562
+ const cases = normalizeMineTextField(input.cases);
3563
+ const opinion = normalizeMineTextField(input.opinion);
3564
+ const sources = normalizeMineSourceList(input.sources);
3565
+ const cfClean = coreFindings.replace(/\s+/g, "");
3566
+ if (cfClean.length < 200) {
3567
+ return { code: "mine_core_findings_too_short", detail: `len=${cfClean.length}, min=200` };
3568
+ }
3569
+ const sentences = coreFindings
3570
+ .split(/[。!?.!?\n]+/)
3571
+ .map((item) => item.trim())
3572
+ .filter((item) => item.length > 5);
3573
+ const uniqueContentLength = Array.from(new Set(sentences)).join("").replace(/\s+/g, "").length;
3574
+ if (uniqueContentLength < 100) {
3575
+ return { code: "mine_core_findings_repetitive", detail: `dedup_len=${uniqueContentLength}, min=100` };
3576
+ }
3577
+ if (cases.replace(/\s+/g, "").length < 50) {
3578
+ return { code: "mine_cases_too_short", detail: `len=${cases.replace(/\s+/g, "").length}, min=50` };
3579
+ }
3580
+ if (opinion.replace(/\s+/g, "").length < 30) {
3581
+ return { code: "mine_opinion_too_short", detail: `len=${opinion.replace(/\s+/g, "").length}, min=30` };
3582
+ }
3583
+ const validUrls = Array.from(new Set(
3584
+ sources.flatMap((item) => extractHttpUrls(item)),
3585
+ ));
3586
+ if (validUrls.length < 1) {
3587
+ return { code: "mine_sources_missing_url" };
3588
+ }
3589
+ return null;
3590
+ }
3591
+
3424
3592
  function buildOneShotSessionId(userId: string, taskKey?: string) {
3425
3593
  const uid = sanitizeToken(userId || "user", 24);
3426
3594
  const key = sanitizeToken(taskKey || "task", 20);