mono-pilot 0.2.9 → 0.2.12
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 +270 -7
- package/dist/src/agents-paths.js +36 -0
- package/dist/src/brief/blocks.js +83 -0
- package/dist/src/brief/defaults.js +60 -0
- package/dist/src/brief/frontmatter.js +53 -0
- package/dist/src/brief/paths.js +10 -0
- package/dist/src/brief/reflection.js +27 -0
- package/dist/src/cli.js +62 -5
- package/dist/src/cluster/bus.js +102 -0
- package/dist/src/cluster/follower.js +137 -0
- package/dist/src/cluster/init.js +182 -0
- package/dist/src/cluster/leader.js +97 -0
- package/dist/src/cluster/log.js +49 -0
- package/dist/src/cluster/protocol.js +34 -0
- package/dist/src/cluster/services/bus.js +243 -0
- package/dist/src/cluster/services/embedding.js +12 -0
- package/dist/src/cluster/socket.js +86 -0
- package/dist/src/cluster/test-bus.js +175 -0
- package/dist/src/cluster_v2/connection-lifecycle.js +31 -0
- package/dist/src/cluster_v2/connection-lifecycle.test.js +24 -0
- package/dist/src/cluster_v2/connection.js +159 -0
- package/dist/src/cluster_v2/connection.test.js +55 -0
- package/dist/src/cluster_v2/events.js +102 -0
- package/dist/src/cluster_v2/index.js +2 -0
- package/dist/src/cluster_v2/observability.js +99 -0
- package/dist/src/cluster_v2/observability.test.js +46 -0
- package/dist/src/cluster_v2/rpc.js +389 -0
- package/dist/src/cluster_v2/rpc.test.js +110 -0
- package/dist/src/cluster_v2/runtime.failover.integration.test.js +156 -0
- package/dist/src/cluster_v2/runtime.js +531 -0
- package/dist/src/cluster_v2/runtime.lease-compromise.integration.test.js +91 -0
- package/dist/src/cluster_v2/runtime.lifecycle.integration.test.js +225 -0
- package/dist/src/cluster_v2/services/bus.integration.test.js +140 -0
- package/dist/src/cluster_v2/services/bus.js +450 -0
- package/dist/src/cluster_v2/services/discord/auth-store.js +82 -0
- package/dist/src/cluster_v2/services/discord/collector.js +569 -0
- package/dist/src/cluster_v2/services/discord/index.js +1 -0
- package/dist/src/cluster_v2/services/discord/oauth.js +87 -0
- package/dist/src/cluster_v2/services/discord/rpc-client.js +325 -0
- package/dist/src/cluster_v2/services/embedding.js +66 -0
- package/dist/src/cluster_v2/services/registry-cache.js +107 -0
- package/dist/src/cluster_v2/services/registry-cache.test.js +66 -0
- package/dist/src/cluster_v2/services/registry.js +36 -0
- package/dist/src/cluster_v2/services/twitter/collector.js +1055 -0
- package/dist/src/cluster_v2/services/twitter/index.js +1 -0
- package/dist/src/config/digest.js +78 -0
- package/dist/src/config/discord.js +143 -0
- package/dist/src/config/image-gen.js +48 -0
- package/dist/src/config/mono-pilot.js +31 -0
- package/dist/src/config/twitter.js +100 -0
- package/dist/src/extensions/cluster.js +311 -0
- package/dist/src/extensions/commands/build-memory.js +76 -0
- package/dist/src/extensions/commands/digest/backfill.js +779 -0
- package/dist/src/extensions/commands/digest/index.js +1133 -0
- package/dist/src/extensions/commands/image-model.js +214 -0
- package/dist/src/extensions/game/bus-injection.js +47 -0
- package/dist/src/extensions/game/identity.js +83 -0
- package/dist/src/extensions/game/mailbox.js +61 -0
- package/dist/src/extensions/game/system-prompt.js +134 -0
- package/dist/src/extensions/game/tools.js +28 -0
- package/dist/src/extensions/lifecycle.js +337 -0
- package/dist/src/extensions/mode-runtime.js +26 -2
- package/dist/src/extensions/mono-game.js +66 -0
- package/dist/src/extensions/mono-pilot.js +100 -18
- package/dist/src/extensions/nvim.js +47 -0
- package/dist/src/extensions/session-hints.js +60 -35
- package/dist/src/extensions/sftp.js +897 -0
- package/dist/src/extensions/status.js +676 -0
- package/dist/src/extensions/system-events.js +478 -0
- package/dist/src/extensions/system-prompt.js +24 -14
- package/dist/src/extensions/user-message.js +94 -50
- package/dist/src/lsp/client.js +235 -0
- package/dist/src/lsp/index.js +165 -0
- package/dist/src/lsp/runtime.js +67 -0
- package/dist/src/lsp/server.js +242 -0
- package/dist/src/mcp/config.js +112 -0
- package/dist/src/{utils/mcp-client.js → mcp/protocol.js} +1 -100
- package/dist/src/mcp/servers.js +90 -0
- package/dist/src/memory/build-memory.js +103 -0
- package/dist/src/memory/config/defaults.js +55 -0
- package/dist/src/memory/config/loader.js +29 -0
- package/dist/src/memory/config/paths.js +9 -0
- package/dist/src/memory/config/resolve.js +90 -0
- package/dist/src/memory/config/types.js +1 -0
- package/dist/src/memory/embeddings/batch-runner.js +39 -0
- package/dist/src/memory/embeddings/cache.js +47 -0
- package/dist/src/memory/embeddings/chunk-limits.js +26 -0
- package/dist/src/memory/embeddings/input-limits.js +48 -0
- package/dist/src/memory/embeddings/local.js +108 -0
- package/dist/src/memory/embeddings/types.js +1 -0
- package/dist/src/memory/index-manager.js +552 -0
- package/dist/src/memory/indexing/embeddings.js +67 -0
- package/dist/src/memory/indexing/files.js +180 -0
- package/dist/src/memory/indexing/index-file.js +105 -0
- package/dist/src/memory/log.js +38 -0
- package/dist/src/memory/paths.js +15 -0
- package/dist/src/memory/runtime/index.js +299 -0
- package/dist/src/memory/runtime/thread.js +116 -0
- package/dist/src/memory/search/fts.js +57 -0
- package/dist/src/memory/search/hybrid.js +50 -0
- package/dist/src/memory/search/text.js +30 -0
- package/dist/src/memory/search/vector.js +43 -0
- package/dist/src/memory/session/content-hash.js +7 -0
- package/dist/src/memory/session/entry.js +33 -0
- package/dist/src/memory/session/flush-policy.js +34 -0
- package/dist/src/memory/session/hook.js +191 -0
- package/dist/src/memory/session/paths.js +15 -0
- package/dist/src/memory/session/session-reader.js +88 -0
- package/dist/src/memory/session/transcript/content-hash.js +7 -0
- package/dist/src/memory/session/transcript/entry.js +28 -0
- package/dist/src/memory/session/transcript/flush.js +56 -0
- package/dist/src/memory/session/transcript/paths.js +28 -0
- package/dist/src/memory/session/transcript/reader.js +112 -0
- package/dist/src/memory/session/transcript/state.js +31 -0
- package/dist/src/memory/store/schema.js +89 -0
- package/dist/src/memory/store/sqlite.js +89 -0
- package/dist/src/memory/types.js +1 -0
- package/dist/src/memory/warm.js +25 -0
- package/dist/src/rules/discovery.js +41 -0
- package/dist/{tools → src/tools}/README.md +29 -3
- package/dist/{tools → src/tools}/apply-patch-description.md +8 -2
- package/dist/{tools → src/tools}/apply-patch.js +174 -104
- package/dist/{tools → src/tools}/apply-patch.test.js +52 -1
- package/dist/{tools/ask-question.js → src/tools/ask-user-question.js} +3 -3
- package/dist/src/tools/ast-grep.js +357 -0
- package/dist/src/tools/brief-write.js +122 -0
- package/dist/src/tools/bus-send.js +100 -0
- package/dist/{tools → src/tools}/call-mcp-tool.js +40 -124
- package/dist/src/tools/codex-apply-patch-description.md +52 -0
- package/dist/src/tools/codex-apply-patch.js +540 -0
- package/dist/{tools → src/tools}/delete.js +24 -0
- package/dist/src/tools/exit-plan-mode.js +83 -0
- package/dist/{tools → src/tools}/fetch-mcp-resource.js +56 -100
- package/dist/src/tools/generate-image.js +567 -0
- package/dist/{tools → src/tools}/glob.js +55 -1
- package/dist/{tools → src/tools}/list-mcp-resources.js +46 -57
- package/dist/{tools → src/tools}/list-mcp-tools.js +52 -63
- package/dist/src/tools/ls.js +48 -0
- package/dist/src/tools/lsp-diagnostics.js +67 -0
- package/dist/src/tools/lsp-symbols.js +54 -0
- package/dist/src/tools/mailbox.js +85 -0
- package/dist/src/tools/memory-get.js +90 -0
- package/dist/src/tools/memory-search.js +180 -0
- package/dist/{tools → src/tools}/plan-mode-reminder.md +3 -4
- package/dist/{tools → src/tools}/read-file.js +8 -19
- package/dist/{tools → src/tools}/rg.js +10 -20
- package/dist/{tools → src/tools}/shell.js +19 -42
- package/dist/{tools → src/tools}/subagent.js +255 -6
- package/dist/{tools → src/tools}/switch-mode.js +37 -6
- package/dist/{tools → src/tools}/web-fetch.js +105 -7
- package/dist/{tools → src/tools}/web-search.js +29 -1
- package/package.json +21 -9
- /package/dist/{tools → src/tools}/ask-mode-reminder.md +0 -0
- /package/dist/{tools → src/tools}/rg.test.js +0 -0
- /package/dist/{tools → src/tools}/semantic-search-description.md +0 -0
- /package/dist/{tools → src/tools}/semantic-search.js +0 -0
- /package/dist/{tools → src/tools}/shell-description.md +0 -0
- /package/dist/{tools → src/tools}/subagent-description.md +0 -0
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
import { completeSimple } from "@mariozechner/pi-ai";
|
|
2
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, parse, resolve } from "node:path";
|
|
5
|
+
import { extractDigestConfig } from "../../../config/digest.js";
|
|
6
|
+
import { loadMonoPilotConfigObject } from "../../../config/mono-pilot.js";
|
|
7
|
+
import { extractTwitterCollectorConfig } from "../../../config/twitter.js";
|
|
8
|
+
import { publishSystemEvent } from "../../system-events.js";
|
|
9
|
+
import { runDigestBackfill } from "./backfill.js";
|
|
10
|
+
const IRRELEVANT_CATEGORY = "无关";
|
|
11
|
+
const CATEGORIES = [
|
|
12
|
+
"技术",
|
|
13
|
+
"产品",
|
|
14
|
+
"融资并购",
|
|
15
|
+
"开源生态",
|
|
16
|
+
"组织动态",
|
|
17
|
+
"政策监管",
|
|
18
|
+
"学术研究",
|
|
19
|
+
IRRELEVANT_CATEGORY,
|
|
20
|
+
];
|
|
21
|
+
const CATEGORY_SET = new Set(CATEGORIES);
|
|
22
|
+
const FOCUS_CATEGORY_SET = new Set(CATEGORIES.filter((category) => category !== IRRELEVANT_CATEGORY));
|
|
23
|
+
const DEFAULT_DEBUG_SAMPLE_SIZE = 4;
|
|
24
|
+
const CLASSIFICATION_TEXT_MAX_CHARS = 1024;
|
|
25
|
+
const DIGEST_DRAFT_PATH = join(homedir(), ".mono-pilot", "twitter", "draft.md");
|
|
26
|
+
const DIGEST_DRAFT_DEBUG_PATH = join(homedir(), ".mono-pilot", "twitter", "draft-debug.md");
|
|
27
|
+
const ENABLE_CLASSIFICATION_BRANCH = true;
|
|
28
|
+
let digestBackfillRunning = false;
|
|
29
|
+
let digestDraftRunning = false;
|
|
30
|
+
const CLASSIFY_COOPERATIVE_YIELD_EVERY_LINES = 20;
|
|
31
|
+
const USAGE = [
|
|
32
|
+
"Usage:",
|
|
33
|
+
" /digest draft [--date YYYY-MM-DD] [--file <path>]",
|
|
34
|
+
" [--concurrency <N>] [--sample <N>]",
|
|
35
|
+
" [--provider <name>] [--model <id>]",
|
|
36
|
+
" /digest backfill [--date YYYY-MM-DD] [--file <path>]",
|
|
37
|
+
" (serial backfill for tweetFull/quotedTweetFull/shortLinkMappings)",
|
|
38
|
+
].join("\n");
|
|
39
|
+
const CLASSIFIER_SYSTEM_PROMPT = [
|
|
40
|
+
"You classify AI-industry tweets into exactly one category.",
|
|
41
|
+
`Allowed categories: ${CATEGORIES.join(" | ")}`,
|
|
42
|
+
"Return strict JSON only. Do not use markdown.",
|
|
43
|
+
"Format: {\"category\":\"<one allowed category>\",\"confidence\":<0..1>,\"reason\":\"<=40 chars\"}",
|
|
44
|
+
].join("\n");
|
|
45
|
+
const CLASSIFIER_LABEL_FALLBACK_PROMPT = [
|
|
46
|
+
"Choose exactly one category for the tweet.",
|
|
47
|
+
`Allowed categories: ${CATEGORIES.join(" | ")}`,
|
|
48
|
+
"Return category name only, no extra text.",
|
|
49
|
+
].join("\n");
|
|
50
|
+
function parseArgs(raw) {
|
|
51
|
+
const tokens = raw
|
|
52
|
+
.trim()
|
|
53
|
+
.split(/\s+/)
|
|
54
|
+
.map((token) => token.trim())
|
|
55
|
+
.filter((token) => token.length > 0);
|
|
56
|
+
if (tokens.length === 0) {
|
|
57
|
+
return { subcommand: "draft" };
|
|
58
|
+
}
|
|
59
|
+
let cursor = 0;
|
|
60
|
+
const first = tokens[0];
|
|
61
|
+
let subcommand = "draft";
|
|
62
|
+
if (first && !first.startsWith("--")) {
|
|
63
|
+
if (first !== "draft" && first !== "classify" && first !== "backfill") {
|
|
64
|
+
return { subcommand: "draft", error: `Unknown subcommand: ${first}.\n${USAGE}` };
|
|
65
|
+
}
|
|
66
|
+
subcommand = first === "classify" ? "draft" : first;
|
|
67
|
+
cursor = 1;
|
|
68
|
+
}
|
|
69
|
+
const parsed = { subcommand };
|
|
70
|
+
for (let i = cursor; i < tokens.length; i += 1) {
|
|
71
|
+
const token = tokens[i] ?? "";
|
|
72
|
+
if (token === "--date") {
|
|
73
|
+
parsed.date = tokens[i + 1];
|
|
74
|
+
i += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (token.startsWith("--date=")) {
|
|
78
|
+
parsed.date = token.slice("--date=".length);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (token === "--file") {
|
|
82
|
+
parsed.file = tokens[i + 1];
|
|
83
|
+
i += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (token.startsWith("--file=")) {
|
|
87
|
+
parsed.file = token.slice("--file=".length);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (token === "--concurrency") {
|
|
91
|
+
parsed.concurrency = toPositiveInt(tokens[i + 1]);
|
|
92
|
+
i += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (token.startsWith("--concurrency=")) {
|
|
96
|
+
parsed.concurrency = toPositiveInt(token.slice("--concurrency=".length));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (token === "--limit") {
|
|
100
|
+
parsed.limit = toPositiveInt(tokens[i + 1]);
|
|
101
|
+
i += 1;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (token.startsWith("--limit=")) {
|
|
105
|
+
parsed.limit = toPositiveInt(token.slice("--limit=".length));
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (token === "--sample") {
|
|
109
|
+
parsed.sample = toPositiveInt(tokens[i + 1]);
|
|
110
|
+
i += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (token.startsWith("--sample=")) {
|
|
114
|
+
parsed.sample = toPositiveInt(token.slice("--sample=".length));
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (token === "--provider") {
|
|
118
|
+
parsed.provider = tokens[i + 1];
|
|
119
|
+
i += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (token.startsWith("--provider=")) {
|
|
123
|
+
parsed.provider = token.slice("--provider=".length);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (token === "--model") {
|
|
127
|
+
parsed.model = tokens[i + 1];
|
|
128
|
+
i += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (token.startsWith("--model=")) {
|
|
132
|
+
parsed.model = token.slice("--model=".length);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
return { subcommand: "draft", error: `Unknown argument: ${token}.\n${USAGE}` };
|
|
136
|
+
}
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
function toPositiveInt(raw) {
|
|
140
|
+
if (!raw)
|
|
141
|
+
return undefined;
|
|
142
|
+
const parsed = Number(raw);
|
|
143
|
+
if (!Number.isFinite(parsed))
|
|
144
|
+
return undefined;
|
|
145
|
+
const value = Math.floor(parsed);
|
|
146
|
+
return value > 0 ? value : undefined;
|
|
147
|
+
}
|
|
148
|
+
async function cooperativeYield() {
|
|
149
|
+
await new Promise((resolve) => {
|
|
150
|
+
setImmediate(resolve);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function readString(value) {
|
|
154
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
155
|
+
}
|
|
156
|
+
function pickLongerText(a, b) {
|
|
157
|
+
if (a && b) {
|
|
158
|
+
return a.length >= b.length ? a : b;
|
|
159
|
+
}
|
|
160
|
+
return a ?? b;
|
|
161
|
+
}
|
|
162
|
+
function truncateForClassification(value) {
|
|
163
|
+
if (!value) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const trimmed = value.trim();
|
|
167
|
+
if (trimmed.length <= CLASSIFICATION_TEXT_MAX_CHARS) {
|
|
168
|
+
return trimmed;
|
|
169
|
+
}
|
|
170
|
+
return trimmed.slice(0, CLASSIFICATION_TEXT_MAX_CHARS);
|
|
171
|
+
}
|
|
172
|
+
function isRecord(value) {
|
|
173
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
174
|
+
}
|
|
175
|
+
function isDateStamp(value) {
|
|
176
|
+
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
177
|
+
}
|
|
178
|
+
function formatLocalDateStamp(date) {
|
|
179
|
+
const year = String(date.getFullYear());
|
|
180
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
181
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
182
|
+
return `${year}-${month}-${day}`;
|
|
183
|
+
}
|
|
184
|
+
function expandAndResolvePath(rawPath) {
|
|
185
|
+
let expanded = rawPath.trim();
|
|
186
|
+
if (expanded === "~") {
|
|
187
|
+
expanded = homedir();
|
|
188
|
+
}
|
|
189
|
+
else if (expanded.startsWith("~/")) {
|
|
190
|
+
expanded = join(homedir(), expanded.slice(2));
|
|
191
|
+
}
|
|
192
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
193
|
+
}
|
|
194
|
+
function buildDailyArchivePath(baseOutputPath, dateStamp) {
|
|
195
|
+
const parsed = parse(baseOutputPath);
|
|
196
|
+
const ext = parsed.ext || ".jsonl";
|
|
197
|
+
const name = parsed.name || parsed.base || "home";
|
|
198
|
+
return join(parsed.dir, `${name}.${dateStamp}${ext}`);
|
|
199
|
+
}
|
|
200
|
+
function escapeRegExp(text) {
|
|
201
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
202
|
+
}
|
|
203
|
+
async function listArchiveDates(baseOutputPath) {
|
|
204
|
+
const parsed = parse(baseOutputPath);
|
|
205
|
+
const ext = parsed.ext || ".jsonl";
|
|
206
|
+
const name = parsed.name || parsed.base || "home";
|
|
207
|
+
const pattern = new RegExp(`^${escapeRegExp(name)}\\.(\\d{4}-\\d{2}-\\d{2})${escapeRegExp(ext)}$`);
|
|
208
|
+
let entries = [];
|
|
209
|
+
try {
|
|
210
|
+
entries = await readdir(parsed.dir);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
const dates = entries
|
|
216
|
+
.map((entry) => {
|
|
217
|
+
const match = entry.match(pattern);
|
|
218
|
+
return match?.[1] ?? null;
|
|
219
|
+
})
|
|
220
|
+
.filter((item) => Boolean(item));
|
|
221
|
+
return [...new Set(dates)].sort((a, b) => b.localeCompare(a));
|
|
222
|
+
}
|
|
223
|
+
async function pickArchiveDateWithUi(ctx, archiveDates, todayDate) {
|
|
224
|
+
if (!ctx.hasUI || !ctx.ui?.select || archiveDates.length === 0) {
|
|
225
|
+
return todayDate;
|
|
226
|
+
}
|
|
227
|
+
const options = archiveDates.map((date) => (date === todayDate ? `${date} (today)` : date));
|
|
228
|
+
const selected = await ctx.ui.select("Select digest archive date", options);
|
|
229
|
+
if (!selected) {
|
|
230
|
+
return todayDate;
|
|
231
|
+
}
|
|
232
|
+
return selected.slice(0, 10);
|
|
233
|
+
}
|
|
234
|
+
function extractTweetId(tweet) {
|
|
235
|
+
return (readString(tweet.id) ??
|
|
236
|
+
readString(tweet.id_str) ??
|
|
237
|
+
readString(tweet.rest_id) ??
|
|
238
|
+
(isRecord(tweet.legacy) ? readString(tweet.legacy.id_str) : null));
|
|
239
|
+
}
|
|
240
|
+
function extractAuthorName(tweet) {
|
|
241
|
+
if (!tweet) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
const author = isRecord(tweet.author) ? tweet.author : null;
|
|
245
|
+
if (!author) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return readString(author.name) ?? readString(author.screen_name);
|
|
249
|
+
}
|
|
250
|
+
function buildClassificationPayload(tweet) {
|
|
251
|
+
const tweetFull = isRecord(tweet.tweetFull) ? tweet.tweetFull : null;
|
|
252
|
+
const quoted = isRecord(tweet.quotedTweet) ? tweet.quotedTweet : null;
|
|
253
|
+
const quotedFull = isRecord(tweet.quotedTweetFull) ? tweet.quotedTweetFull : null;
|
|
254
|
+
const tweetText = readString(tweet.text);
|
|
255
|
+
const tweetFullText = pickLongerText(pickLongerText(tweetFull ? readString(tweetFull.fullText) : null, tweetFull ? readString(tweetFull.text) : null), tweetText);
|
|
256
|
+
const truncatedTweetText = truncateForClassification(tweetText);
|
|
257
|
+
const truncatedTweetFullText = truncateForClassification(tweetFullText);
|
|
258
|
+
const tweetMedia = Array.isArray(tweetFull?.media) ? tweetFull.media.length : 0;
|
|
259
|
+
const quotedText = quoted ? readString(quoted.text) : null;
|
|
260
|
+
const quotedFullText = pickLongerText(pickLongerText(quotedFull ? readString(quotedFull.fullText) : null, quotedFull ? readString(quotedFull.text) : null), quotedText);
|
|
261
|
+
const truncatedQuotedText = truncateForClassification(quotedText);
|
|
262
|
+
const truncatedQuotedFullText = truncateForClassification(quotedFullText);
|
|
263
|
+
const quotedMedia = Array.isArray(quotedFull?.media) ? quotedFull.media.length : 0;
|
|
264
|
+
return {
|
|
265
|
+
tweet: {
|
|
266
|
+
id: extractTweetId(tweet),
|
|
267
|
+
text: truncatedTweetText,
|
|
268
|
+
fullText: truncatedTweetFullText,
|
|
269
|
+
mediaCount: tweetMedia,
|
|
270
|
+
},
|
|
271
|
+
quotedTweet: quoted
|
|
272
|
+
? {
|
|
273
|
+
id: extractTweetId(quoted),
|
|
274
|
+
text: truncatedQuotedText,
|
|
275
|
+
fullText: truncatedQuotedFullText,
|
|
276
|
+
mediaCount: quotedMedia,
|
|
277
|
+
}
|
|
278
|
+
: null,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function buildScratchPayload(tweet) {
|
|
282
|
+
const tweetFull = isRecord(tweet.tweetFull) ? tweet.tweetFull : null;
|
|
283
|
+
const quoted = isRecord(tweet.quotedTweet) ? tweet.quotedTweet : null;
|
|
284
|
+
const quotedFull = isRecord(tweet.quotedTweetFull) ? tweet.quotedTweetFull : null;
|
|
285
|
+
const tweetText = readString(tweet.text);
|
|
286
|
+
const tweetFullText = pickLongerText(pickLongerText(tweetFull ? readString(tweetFull.fullText) : null, tweetFull ? readString(tweetFull.text) : null), tweetText);
|
|
287
|
+
const quotedText = quoted ? readString(quoted.text) : null;
|
|
288
|
+
const quotedFullText = pickLongerText(pickLongerText(quotedFull ? readString(quotedFull.fullText) : null, quotedFull ? readString(quotedFull.text) : null), quotedText);
|
|
289
|
+
const tweetAuthorName = extractAuthorName(tweet) ?? extractAuthorName(tweetFull);
|
|
290
|
+
const quotedAuthorName = extractAuthorName(quotedFull) ?? extractAuthorName(quoted);
|
|
291
|
+
return {
|
|
292
|
+
tweet: {
|
|
293
|
+
id: extractTweetId(tweet),
|
|
294
|
+
text: tweetText,
|
|
295
|
+
fullText: tweetFullText,
|
|
296
|
+
authorName: tweetAuthorName,
|
|
297
|
+
},
|
|
298
|
+
quotedTweet: quoted
|
|
299
|
+
? {
|
|
300
|
+
id: extractTweetId(quoted),
|
|
301
|
+
text: quotedText,
|
|
302
|
+
fullText: quotedFullText,
|
|
303
|
+
authorName: quotedAuthorName,
|
|
304
|
+
}
|
|
305
|
+
: null,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function buildClassifierUserPrompt(tweet) {
|
|
309
|
+
const payload = buildClassificationPayload(tweet);
|
|
310
|
+
return [
|
|
311
|
+
"Classify this tweet into exactly one allowed category.",
|
|
312
|
+
"Output strict JSON only.",
|
|
313
|
+
"Tweet payload:",
|
|
314
|
+
JSON.stringify(payload, null, 2),
|
|
315
|
+
].join("\n");
|
|
316
|
+
}
|
|
317
|
+
function extractAssistantText(message) {
|
|
318
|
+
const textChunks = message.content
|
|
319
|
+
.filter((item) => item.type === "text")
|
|
320
|
+
.map((item) => item.text ?? "");
|
|
321
|
+
const text = textChunks.join("\n").trim();
|
|
322
|
+
if (text.length > 0) {
|
|
323
|
+
return text;
|
|
324
|
+
}
|
|
325
|
+
const thinkingChunks = message.content
|
|
326
|
+
.filter((item) => item.type === "thinking")
|
|
327
|
+
.map((item) => item.thinking ?? "");
|
|
328
|
+
return thinkingChunks.join("\n").trim();
|
|
329
|
+
}
|
|
330
|
+
function stripCodeFence(text) {
|
|
331
|
+
const trimmed = text.trim();
|
|
332
|
+
const matched = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
333
|
+
if (!matched) {
|
|
334
|
+
return trimmed;
|
|
335
|
+
}
|
|
336
|
+
return matched[1]?.trim() ?? "";
|
|
337
|
+
}
|
|
338
|
+
function parseClassificationOutput(text) {
|
|
339
|
+
const normalized = stripCodeFence(text);
|
|
340
|
+
const parsed = tryParseClassificationJson(normalized) ??
|
|
341
|
+
tryParseClassificationJson(extractJsonObject(normalized)) ??
|
|
342
|
+
tryParseClassificationJson(stripCodeFence(extractJsonObject(normalized)));
|
|
343
|
+
if (!parsed) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
if (!CATEGORY_SET.has(parsed.category)) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const confidence = Number.isFinite(parsed.confidence)
|
|
350
|
+
? Math.max(0, Math.min(1, parsed.confidence))
|
|
351
|
+
: 0.5;
|
|
352
|
+
return {
|
|
353
|
+
category: parsed.category,
|
|
354
|
+
confidence,
|
|
355
|
+
reason: parsed.reason ?? "",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function parseCategoryOnlyOutput(text) {
|
|
359
|
+
const normalized = stripCodeFence(text).replace(/[:]/g, ":").trim();
|
|
360
|
+
for (const category of CATEGORIES) {
|
|
361
|
+
if (normalized === category) {
|
|
362
|
+
return {
|
|
363
|
+
category,
|
|
364
|
+
confidence: 0.6,
|
|
365
|
+
reason: "fallback-label",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
function summarizeAssistantFailure(message, rawText) {
|
|
372
|
+
const stop = message.stopReason;
|
|
373
|
+
const apiError = message.errorMessage?.trim();
|
|
374
|
+
if (apiError) {
|
|
375
|
+
return `stop=${stop}, error=${apiError.slice(0, 160)}`;
|
|
376
|
+
}
|
|
377
|
+
const raw = rawText.trim();
|
|
378
|
+
if (raw.length > 0) {
|
|
379
|
+
return `stop=${stop}, raw=${raw.slice(0, 160)}`;
|
|
380
|
+
}
|
|
381
|
+
return `stop=${stop}, empty-output`;
|
|
382
|
+
}
|
|
383
|
+
function extractJsonObject(text) {
|
|
384
|
+
const start = text.indexOf("{");
|
|
385
|
+
const end = text.lastIndexOf("}");
|
|
386
|
+
if (start < 0 || end <= start) {
|
|
387
|
+
return "";
|
|
388
|
+
}
|
|
389
|
+
return text.slice(start, end + 1);
|
|
390
|
+
}
|
|
391
|
+
function tryParseClassificationJson(raw) {
|
|
392
|
+
if (!raw) {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const parsed = JSON.parse(raw);
|
|
397
|
+
if (!isRecord(parsed)) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
const category = readString(parsed.category);
|
|
401
|
+
if (!category) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
const confidenceValue = typeof parsed.confidence === "number"
|
|
405
|
+
? parsed.confidence
|
|
406
|
+
: typeof parsed.confidence === "string"
|
|
407
|
+
? Number(parsed.confidence)
|
|
408
|
+
: NaN;
|
|
409
|
+
const reason = readString(parsed.reason) ?? "";
|
|
410
|
+
return {
|
|
411
|
+
category,
|
|
412
|
+
confidence: Number.isFinite(confidenceValue) ? confidenceValue : 0.5,
|
|
413
|
+
reason,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
async function mapWithConcurrency(items, concurrency, mapper, onDone) {
|
|
421
|
+
if (items.length === 0) {
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
|
425
|
+
const results = new Array(items.length);
|
|
426
|
+
let cursor = 0;
|
|
427
|
+
let completed = 0;
|
|
428
|
+
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
429
|
+
while (true) {
|
|
430
|
+
const index = cursor;
|
|
431
|
+
cursor += 1;
|
|
432
|
+
if (index >= items.length) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
results[index] = await mapper(items[index], index);
|
|
436
|
+
completed += 1;
|
|
437
|
+
onDone?.(completed, items.length);
|
|
438
|
+
}
|
|
439
|
+
}));
|
|
440
|
+
return results;
|
|
441
|
+
}
|
|
442
|
+
async function parseJsonlItems(content, meta) {
|
|
443
|
+
const items = [];
|
|
444
|
+
const lines = content.split(/\r?\n/);
|
|
445
|
+
let processedLinesSinceYield = 0;
|
|
446
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
447
|
+
processedLinesSinceYield += 1;
|
|
448
|
+
if (processedLinesSinceYield >= CLASSIFY_COOPERATIVE_YIELD_EVERY_LINES) {
|
|
449
|
+
processedLinesSinceYield = 0;
|
|
450
|
+
await cooperativeYield();
|
|
451
|
+
}
|
|
452
|
+
const rawLine = lines[lineIndex]?.trim();
|
|
453
|
+
if (!rawLine) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
let batch;
|
|
457
|
+
try {
|
|
458
|
+
batch = JSON.parse(rawLine);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!isRecord(batch)) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const seqValue = batch.seq;
|
|
467
|
+
const batchSeq = typeof seqValue === "number" && Number.isFinite(seqValue) ? seqValue : null;
|
|
468
|
+
const tweets = Array.isArray(batch.tweets) ? batch.tweets : [];
|
|
469
|
+
for (let tweetIndex = 0; tweetIndex < tweets.length; tweetIndex += 1) {
|
|
470
|
+
const tweet = tweets[tweetIndex];
|
|
471
|
+
if (!isRecord(tweet)) {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
items.push({
|
|
475
|
+
archiveFile: meta.archiveFile,
|
|
476
|
+
archiveOrder: meta.archiveOrder,
|
|
477
|
+
lineNumber: lineIndex + 1,
|
|
478
|
+
batchSeq,
|
|
479
|
+
tweetIndex,
|
|
480
|
+
tweetId: extractTweetId(tweet),
|
|
481
|
+
tweet,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return items;
|
|
486
|
+
}
|
|
487
|
+
function pickLongestText(...candidates) {
|
|
488
|
+
let best = "";
|
|
489
|
+
for (const candidate of candidates) {
|
|
490
|
+
if (typeof candidate !== "string") {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const trimmed = candidate.trim();
|
|
494
|
+
if (trimmed.length > best.length) {
|
|
495
|
+
best = trimmed;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return best;
|
|
499
|
+
}
|
|
500
|
+
function extractMainTextForDedup(tweet) {
|
|
501
|
+
const tweetFull = isRecord(tweet.tweetFull) ? tweet.tweetFull : null;
|
|
502
|
+
return pickLongestText(readString(tweet.text), tweetFull ? readString(tweetFull.text) : null, tweetFull ? readString(tweetFull.fullText) : null);
|
|
503
|
+
}
|
|
504
|
+
function extractQuotedTextForDedup(tweet) {
|
|
505
|
+
const quoted = isRecord(tweet.quotedTweet) ? tweet.quotedTweet : null;
|
|
506
|
+
const quotedFull = isRecord(tweet.quotedTweetFull) ? tweet.quotedTweetFull : null;
|
|
507
|
+
return pickLongestText(quoted ? readString(quoted.text) : null, quotedFull ? readString(quotedFull.text) : null, quotedFull ? readString(quotedFull.fullText) : null);
|
|
508
|
+
}
|
|
509
|
+
function scoreItemTextLengthForDedup(item) {
|
|
510
|
+
const mainText = extractMainTextForDedup(item.tweet);
|
|
511
|
+
const quotedText = extractQuotedTextForDedup(item.tweet);
|
|
512
|
+
return mainText.length + quotedText.length;
|
|
513
|
+
}
|
|
514
|
+
function dedupeItemsByTweetId(items) {
|
|
515
|
+
const byId = new Map();
|
|
516
|
+
const withoutId = [];
|
|
517
|
+
for (const item of items) {
|
|
518
|
+
if (!item.tweetId) {
|
|
519
|
+
withoutId.push(item);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
const existing = byId.get(item.tweetId);
|
|
523
|
+
if (!existing) {
|
|
524
|
+
byId.set(item.tweetId, item);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (scoreItemTextLengthForDedup(item) > scoreItemTextLengthForDedup(existing)) {
|
|
528
|
+
byId.set(item.tweetId, item);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const deduped = [...byId.values(), ...withoutId];
|
|
532
|
+
return {
|
|
533
|
+
items: deduped,
|
|
534
|
+
beforeCount: items.length,
|
|
535
|
+
afterCount: deduped.length,
|
|
536
|
+
removedCount: items.length - deduped.length,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function sortItemsForScratch(items) {
|
|
540
|
+
return [...items].sort((a, b) => {
|
|
541
|
+
if (a.archiveOrder !== b.archiveOrder) {
|
|
542
|
+
return a.archiveOrder - b.archiveOrder;
|
|
543
|
+
}
|
|
544
|
+
if (a.lineNumber !== b.lineNumber) {
|
|
545
|
+
return a.lineNumber - b.lineNumber;
|
|
546
|
+
}
|
|
547
|
+
if (a.tweetIndex !== b.tweetIndex) {
|
|
548
|
+
return a.tweetIndex - b.tweetIndex;
|
|
549
|
+
}
|
|
550
|
+
const aId = a.tweetId ?? "";
|
|
551
|
+
const bId = b.tweetId ?? "";
|
|
552
|
+
return aId.localeCompare(bId);
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
function pickRandomItems(items, count) {
|
|
556
|
+
if (items.length <= count) {
|
|
557
|
+
return [...items];
|
|
558
|
+
}
|
|
559
|
+
const shuffled = [...items];
|
|
560
|
+
for (let i = shuffled.length - 1; i > 0; i -= 1) {
|
|
561
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
562
|
+
const current = shuffled[i];
|
|
563
|
+
shuffled[i] = shuffled[j];
|
|
564
|
+
shuffled[j] = current;
|
|
565
|
+
}
|
|
566
|
+
return shuffled.slice(0, count);
|
|
567
|
+
}
|
|
568
|
+
function pickPayloadPrimaryText(part) {
|
|
569
|
+
const full = part ? readString(part.fullText) : null;
|
|
570
|
+
const text = part ? readString(part.text) : null;
|
|
571
|
+
return pickLongerText(full, text) ?? "";
|
|
572
|
+
}
|
|
573
|
+
function extractStatusIdFromUrl(url) {
|
|
574
|
+
if (!url) {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
const normalized = url.trim();
|
|
578
|
+
if (!normalized) {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
const matchedUserStatus = normalized.match(/^https?:\/\/(?:www\.)?(?:x|twitter)\.com\/[A-Za-z0-9_]+\/status\/(\d+)/i);
|
|
582
|
+
if (matchedUserStatus?.[1]) {
|
|
583
|
+
return matchedUserStatus[1];
|
|
584
|
+
}
|
|
585
|
+
const matchedWebStatus = normalized.match(/^https?:\/\/(?:www\.)?(?:x|twitter)\.com\/i\/web\/status\/(\d+)/i);
|
|
586
|
+
if (matchedWebStatus?.[1]) {
|
|
587
|
+
return matchedWebStatus[1];
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
function toScratchShortLinkMappings(value) {
|
|
592
|
+
if (!Array.isArray(value)) {
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
const parsed = [];
|
|
596
|
+
for (const item of value) {
|
|
597
|
+
if (!isRecord(item)) {
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
const shortUrl = readString(item.shortUrl);
|
|
601
|
+
if (!shortUrl) {
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
parsed.push({
|
|
605
|
+
shortUrl,
|
|
606
|
+
resolvedUrl: readString(item.resolvedUrl),
|
|
607
|
+
tweetId: readString(item.tweetId),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
return parsed;
|
|
611
|
+
}
|
|
612
|
+
function rewriteShortLinksForScratch(text, mappings, currentTweetId) {
|
|
613
|
+
if (!text || mappings.length === 0) {
|
|
614
|
+
return text;
|
|
615
|
+
}
|
|
616
|
+
let rewritten = text;
|
|
617
|
+
for (const mapping of mappings) {
|
|
618
|
+
const resolvedStatusId = extractStatusIdFromUrl(mapping.resolvedUrl);
|
|
619
|
+
const isSelfResolvedTweet = Boolean(mapping.resolvedUrl && resolvedStatusId && currentTweetId && resolvedStatusId === currentTweetId);
|
|
620
|
+
const replacement = isSelfResolvedTweet ? "" : (mapping.resolvedUrl ?? mapping.shortUrl);
|
|
621
|
+
rewritten = rewritten.split(mapping.shortUrl).join(replacement);
|
|
622
|
+
}
|
|
623
|
+
return rewritten.replace(/[ \t]{2,}/g, " ").replace(/ +\n/g, "\n").trim();
|
|
624
|
+
}
|
|
625
|
+
function buildScratchQuotedKey(tweetId, text) {
|
|
626
|
+
if (tweetId) {
|
|
627
|
+
return `id:${tweetId}`;
|
|
628
|
+
}
|
|
629
|
+
return `text:${text.slice(0, 160)}`;
|
|
630
|
+
}
|
|
631
|
+
function pushUnique(items, value) {
|
|
632
|
+
if (!items.includes(value)) {
|
|
633
|
+
items.push(value);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
function buildClassificationScratchMarkdown(items, results, sourceFile) {
|
|
637
|
+
const quotedNodesByKey = new Map();
|
|
638
|
+
const quotedNodesInOrder = [];
|
|
639
|
+
const mainNodes = [];
|
|
640
|
+
let nextQuotedIndex = 1;
|
|
641
|
+
let keptCount = 0;
|
|
642
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
643
|
+
const item = items[index];
|
|
644
|
+
const result = results[index];
|
|
645
|
+
if (!result?.category || result.category === IRRELEVANT_CATEGORY) {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
keptCount += 1;
|
|
649
|
+
const payload = buildScratchPayload(item.tweet);
|
|
650
|
+
const tweetPart = isRecord(payload.tweet) ? payload.tweet : null;
|
|
651
|
+
const quotedPart = isRecord(payload.quotedTweet) ? payload.quotedTweet : null;
|
|
652
|
+
const mainIndexId = `T${keptCount}`;
|
|
653
|
+
const mainTweetId = tweetPart ? readString(tweetPart.id) : null;
|
|
654
|
+
const mainAuthorName = tweetPart ? readString(tweetPart.authorName) : null;
|
|
655
|
+
const mainShortLinkMappings = toScratchShortLinkMappings(item.tweet.shortLinkMappings);
|
|
656
|
+
const mainText = rewriteShortLinksForScratch(pickPayloadPrimaryText(tweetPart), mainShortLinkMappings, mainTweetId);
|
|
657
|
+
const referenceIndexIds = [];
|
|
658
|
+
if (quotedPart) {
|
|
659
|
+
const quotedTweetId = readString(quotedPart.id);
|
|
660
|
+
const quotedAuthorName = readString(quotedPart.authorName);
|
|
661
|
+
const quotedSource = isRecord(item.tweet.quotedTweetFull)
|
|
662
|
+
? item.tweet.quotedTweetFull
|
|
663
|
+
: isRecord(item.tweet.quotedTweet)
|
|
664
|
+
? item.tweet.quotedTweet
|
|
665
|
+
: null;
|
|
666
|
+
const quotedShortLinkMappings = toScratchShortLinkMappings(quotedSource?.shortLinkMappings);
|
|
667
|
+
const quotedText = rewriteShortLinksForScratch(pickPayloadPrimaryText(quotedPart), quotedShortLinkMappings, quotedTweetId);
|
|
668
|
+
const quotedKey = buildScratchQuotedKey(quotedTweetId, quotedText);
|
|
669
|
+
let quotedNode = quotedNodesByKey.get(quotedKey);
|
|
670
|
+
if (!quotedNode) {
|
|
671
|
+
quotedNode = {
|
|
672
|
+
indexId: `Q${nextQuotedIndex}`,
|
|
673
|
+
tweetId: quotedTweetId,
|
|
674
|
+
authorName: quotedAuthorName,
|
|
675
|
+
text: quotedText,
|
|
676
|
+
referencedBy: [],
|
|
677
|
+
sourceKinds: ["quoted"],
|
|
678
|
+
};
|
|
679
|
+
nextQuotedIndex += 1;
|
|
680
|
+
quotedNodesByKey.set(quotedKey, quotedNode);
|
|
681
|
+
quotedNodesInOrder.push(quotedNode);
|
|
682
|
+
}
|
|
683
|
+
pushUnique(referenceIndexIds, quotedNode.indexId);
|
|
684
|
+
pushUnique(quotedNode.referencedBy, mainIndexId);
|
|
685
|
+
pushUnique(quotedNode.sourceKinds, "quoted");
|
|
686
|
+
if (!quotedNode.authorName && quotedAuthorName) {
|
|
687
|
+
quotedNode.authorName = quotedAuthorName;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
const shortLinkMappings = Array.isArray(item.tweet.shortLinkMappings) ? item.tweet.shortLinkMappings : [];
|
|
691
|
+
for (const mapping of shortLinkMappings) {
|
|
692
|
+
if (!isRecord(mapping)) {
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const mappingTweetId = readString(mapping.tweetId);
|
|
696
|
+
const mappingTweetFull = isRecord(mapping.tweetFull) ? mapping.tweetFull : null;
|
|
697
|
+
const mappingAuthorName = extractAuthorName(mappingTweetFull);
|
|
698
|
+
const mappingShortLinkMappings = toScratchShortLinkMappings(mappingTweetFull?.shortLinkMappings);
|
|
699
|
+
const mappingText = rewriteShortLinksForScratch(pickPayloadPrimaryText(mappingTweetFull), mappingShortLinkMappings, mappingTweetId);
|
|
700
|
+
if (!mappingTweetId && !mappingText) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const fallbackText = mappingText || readString(mapping.resolvedUrl) || readString(mapping.shortUrl) || "(no text)";
|
|
704
|
+
const mappingKey = buildScratchQuotedKey(mappingTweetId, fallbackText);
|
|
705
|
+
let mappingNode = quotedNodesByKey.get(mappingKey);
|
|
706
|
+
if (!mappingNode) {
|
|
707
|
+
mappingNode = {
|
|
708
|
+
indexId: `Q${nextQuotedIndex}`,
|
|
709
|
+
tweetId: mappingTweetId,
|
|
710
|
+
authorName: mappingAuthorName,
|
|
711
|
+
text: fallbackText,
|
|
712
|
+
referencedBy: [],
|
|
713
|
+
sourceKinds: ["shortLink"],
|
|
714
|
+
};
|
|
715
|
+
nextQuotedIndex += 1;
|
|
716
|
+
quotedNodesByKey.set(mappingKey, mappingNode);
|
|
717
|
+
quotedNodesInOrder.push(mappingNode);
|
|
718
|
+
}
|
|
719
|
+
pushUnique(referenceIndexIds, mappingNode.indexId);
|
|
720
|
+
pushUnique(mappingNode.referencedBy, mainIndexId);
|
|
721
|
+
pushUnique(mappingNode.sourceKinds, "shortLink");
|
|
722
|
+
if (!mappingNode.authorName && mappingAuthorName) {
|
|
723
|
+
mappingNode.authorName = mappingAuthorName;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
mainNodes.push({
|
|
727
|
+
indexId: mainIndexId,
|
|
728
|
+
tweetId: mainTweetId,
|
|
729
|
+
authorName: mainAuthorName,
|
|
730
|
+
text: mainText,
|
|
731
|
+
referenceIndexIds,
|
|
732
|
+
sourceRef: `${parse(item.archiveFile).base}:line=${item.lineNumber},tweetIndex=${item.tweetIndex}`,
|
|
733
|
+
category: result.category,
|
|
734
|
+
confidence: result.confidence,
|
|
735
|
+
reason: result.reason,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
const droppedCount = items.length - keptCount;
|
|
739
|
+
const lines = [];
|
|
740
|
+
lines.push("# Digest Draft");
|
|
741
|
+
lines.push("");
|
|
742
|
+
lines.push(`- generatedAt: ${new Date().toISOString()}`);
|
|
743
|
+
lines.push(`- source: ${sourceFile}`);
|
|
744
|
+
lines.push(`- totalTweets: ${items.length}`);
|
|
745
|
+
lines.push(`- keptTweets: ${keptCount}`);
|
|
746
|
+
lines.push(`- droppedIrrelevant: ${droppedCount}`);
|
|
747
|
+
lines.push("");
|
|
748
|
+
lines.push("## Referenced Tweets (Before Referencing Tweets)");
|
|
749
|
+
lines.push("");
|
|
750
|
+
if (quotedNodesInOrder.length === 0) {
|
|
751
|
+
lines.push("- (none)");
|
|
752
|
+
lines.push("");
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
for (const quotedNode of quotedNodesInOrder) {
|
|
756
|
+
const refs = quotedNode.referencedBy.join(", ");
|
|
757
|
+
const sourceKinds = quotedNode.sourceKinds.join("+");
|
|
758
|
+
lines.push(`### [${quotedNode.indexId}] id=${quotedNode.tweetId ?? "n/a"} author=${quotedNode.authorName ?? "n/a"} source=${sourceKinds || "unknown"} referencedBy=${refs || "none"}`);
|
|
759
|
+
lines.push("```text");
|
|
760
|
+
lines.push(quotedNode.text || "(no text)");
|
|
761
|
+
lines.push("```");
|
|
762
|
+
lines.push("");
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
lines.push("## Referencing Tweets (After Quoted Tweets)");
|
|
766
|
+
lines.push("");
|
|
767
|
+
if (mainNodes.length === 0) {
|
|
768
|
+
lines.push("- (none)");
|
|
769
|
+
lines.push("");
|
|
770
|
+
}
|
|
771
|
+
for (const mainNode of mainNodes) {
|
|
772
|
+
const refs = mainNode.referenceIndexIds.length > 0 ? mainNode.referenceIndexIds.join(",") : "none";
|
|
773
|
+
lines.push(`### [${mainNode.indexId}] id=${mainNode.tweetId ?? "n/a"} author=${mainNode.authorName ?? "n/a"} category=${mainNode.category} confidence=${mainNode.confidence.toFixed(2)} reason=${mainNode.reason || ""} refs=${refs} source=${mainNode.sourceRef}`);
|
|
774
|
+
lines.push("```text");
|
|
775
|
+
lines.push(mainNode.text || "(no text)");
|
|
776
|
+
lines.push("```");
|
|
777
|
+
lines.push("");
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
markdown: lines.join("\n"),
|
|
781
|
+
keptCount,
|
|
782
|
+
droppedCount,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function buildClassificationResultMarkdown(items, results, meta) {
|
|
786
|
+
const lines = [];
|
|
787
|
+
lines.push("# Digest Draft Classification Result");
|
|
788
|
+
lines.push("");
|
|
789
|
+
lines.push(`- generatedAt: ${new Date().toISOString()}`);
|
|
790
|
+
lines.push(`- source: ${meta.sourceFile}`);
|
|
791
|
+
lines.push(`- model: ${meta.modelLabel}`);
|
|
792
|
+
lines.push(`- elapsedMs: ${meta.elapsedMs}`);
|
|
793
|
+
lines.push(`- total: ${results.length}`);
|
|
794
|
+
lines.push("");
|
|
795
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
796
|
+
const result = results[index];
|
|
797
|
+
const item = items[index];
|
|
798
|
+
const preview = getTweetPreview(item.tweet);
|
|
799
|
+
lines.push(`## [${index + 1}] id=${result.tweetId ?? "n/a"} category=${result.category ?? "n/a"} confidence=${result.confidence.toFixed(2)}`);
|
|
800
|
+
lines.push(`- reason: ${result.reason || ""}`);
|
|
801
|
+
if (result.error) {
|
|
802
|
+
lines.push(`- error: ${result.error}`);
|
|
803
|
+
}
|
|
804
|
+
lines.push(`- source: ${parse(item.archiveFile).base}:line=${item.lineNumber},tweetIndex=${item.tweetIndex}`);
|
|
805
|
+
lines.push("```text");
|
|
806
|
+
lines.push(preview || "(no text)");
|
|
807
|
+
lines.push("```");
|
|
808
|
+
lines.push("");
|
|
809
|
+
}
|
|
810
|
+
return lines.join("\n");
|
|
811
|
+
}
|
|
812
|
+
function toIrrelevantResult(item, classifiedAt, model, error, reason = "fallback-unrelated") {
|
|
813
|
+
return {
|
|
814
|
+
lineNumber: item.lineNumber,
|
|
815
|
+
batchSeq: item.batchSeq,
|
|
816
|
+
tweetIndex: item.tweetIndex,
|
|
817
|
+
tweetId: item.tweetId,
|
|
818
|
+
category: IRRELEVANT_CATEGORY,
|
|
819
|
+
confidence: 0.35,
|
|
820
|
+
reason,
|
|
821
|
+
error,
|
|
822
|
+
model,
|
|
823
|
+
classifiedAt,
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
function compactText(value, maxLength = 72) {
|
|
827
|
+
if (!value) {
|
|
828
|
+
return "(no text)";
|
|
829
|
+
}
|
|
830
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
831
|
+
if (oneLine.length <= maxLength) {
|
|
832
|
+
return oneLine;
|
|
833
|
+
}
|
|
834
|
+
return `${oneLine.slice(0, maxLength - 1)}…`;
|
|
835
|
+
}
|
|
836
|
+
function getTweetPreview(tweet) {
|
|
837
|
+
const payload = buildClassificationPayload(tweet);
|
|
838
|
+
const tweetPart = isRecord(payload.tweet) ? payload.tweet : null;
|
|
839
|
+
const fullText = tweetPart ? readString(tweetPart.fullText) : null;
|
|
840
|
+
const text = tweetPart ? readString(tweetPart.text) : null;
|
|
841
|
+
return compactText(fullText ?? text);
|
|
842
|
+
}
|
|
843
|
+
function emitDebugResults(ctx, items, results) {
|
|
844
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
845
|
+
const result = results[index];
|
|
846
|
+
const item = items[index];
|
|
847
|
+
const preview = getTweetPreview(item.tweet);
|
|
848
|
+
const label = result.category ?? "分类失败";
|
|
849
|
+
const confidence = result.confidence.toFixed(2);
|
|
850
|
+
const reason = compactText(result.reason || result.error || "", 48);
|
|
851
|
+
const text = `[${index + 1}] ${label} (${confidence}) id=${result.tweetId ?? "n/a"} | ${reason} | ${preview}`;
|
|
852
|
+
notify(ctx, text, result.category ? "info" : "warning");
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
function summarizeByCategory(results) {
|
|
856
|
+
const counts = new Map();
|
|
857
|
+
let failed = 0;
|
|
858
|
+
for (const result of results) {
|
|
859
|
+
if (!result.category) {
|
|
860
|
+
failed += 1;
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
counts.set(result.category, (counts.get(result.category) ?? 0) + 1);
|
|
864
|
+
}
|
|
865
|
+
const categorySummary = CATEGORIES.map((category) => `${category}:${counts.get(category) ?? 0}`).join(" ");
|
|
866
|
+
return `classified=${results.length - failed} failed=${failed} ${categorySummary}`;
|
|
867
|
+
}
|
|
868
|
+
function estimateTextTokens(text) {
|
|
869
|
+
const normalized = text.trim();
|
|
870
|
+
if (!normalized) {
|
|
871
|
+
return 0;
|
|
872
|
+
}
|
|
873
|
+
// Approximation for mixed CN/EN content used by cost debug metrics.
|
|
874
|
+
return Math.max(1, Math.ceil(Buffer.byteLength(normalized, "utf8") / 3));
|
|
875
|
+
}
|
|
876
|
+
function pickPrimaryText(part) {
|
|
877
|
+
if (!part) {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
return readString(part.fullText) ?? readString(part.text);
|
|
881
|
+
}
|
|
882
|
+
function estimateTweetFocusTokens(tweet) {
|
|
883
|
+
const payload = buildClassificationPayload(tweet);
|
|
884
|
+
const tweetPart = isRecord(payload.tweet) ? payload.tweet : null;
|
|
885
|
+
const quotedPart = isRecord(payload.quotedTweet) ? payload.quotedTweet : null;
|
|
886
|
+
const mainText = pickPrimaryText(tweetPart);
|
|
887
|
+
const quotedText = pickPrimaryText(quotedPart);
|
|
888
|
+
return estimateTextTokens(mainText ?? "") + estimateTextTokens(quotedText ?? "");
|
|
889
|
+
}
|
|
890
|
+
function summarizeFocusTokenStats(items, results) {
|
|
891
|
+
let focusTweetCount = 0;
|
|
892
|
+
let focusTextTokens = 0;
|
|
893
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
894
|
+
const result = results[index];
|
|
895
|
+
const item = items[index];
|
|
896
|
+
if (!result || !item) {
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
if (!result.category || !FOCUS_CATEGORY_SET.has(result.category)) {
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
focusTweetCount += 1;
|
|
903
|
+
focusTextTokens += estimateTweetFocusTokens(item.tweet);
|
|
904
|
+
}
|
|
905
|
+
return { focusTweetCount, focusTextTokens };
|
|
906
|
+
}
|
|
907
|
+
function notify(ctx, message, level) {
|
|
908
|
+
publishSystemEvent({
|
|
909
|
+
source: "digest",
|
|
910
|
+
level,
|
|
911
|
+
message,
|
|
912
|
+
toast: false,
|
|
913
|
+
ctx,
|
|
914
|
+
});
|
|
915
|
+
if (ctx.hasUI && ctx.ui?.notify) {
|
|
916
|
+
ctx.ui.notify(message, level);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const prefix = level === "error" ? "[error]" : level === "warning" ? "[warn]" : "[info]";
|
|
920
|
+
console.log(`${prefix} ${message}`);
|
|
921
|
+
}
|
|
922
|
+
async function runDigestDraftTask(parsed, ctx, twitterConfig, digestConfig) {
|
|
923
|
+
const todayDate = formatLocalDateStamp(new Date());
|
|
924
|
+
let dateStamp = parsed.date ?? todayDate;
|
|
925
|
+
if (!parsed.file && !parsed.date) {
|
|
926
|
+
const archiveDates = await listArchiveDates(twitterConfig.outputPath);
|
|
927
|
+
dateStamp = await pickArchiveDateWithUi(ctx, archiveDates, todayDate);
|
|
928
|
+
}
|
|
929
|
+
const sourceFile = parsed.file
|
|
930
|
+
? expandAndResolvePath(parsed.file)
|
|
931
|
+
: buildDailyArchivePath(twitterConfig.outputPath, dateStamp);
|
|
932
|
+
let sourceRaw;
|
|
933
|
+
try {
|
|
934
|
+
sourceRaw = await readFile(sourceFile, "utf-8");
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
notify(ctx, `Unable to read archive file: ${sourceFile} (${error instanceof Error ? error.message : String(error)})`, "error");
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
const parsedItems = await parseJsonlItems(sourceRaw, {
|
|
941
|
+
archiveFile: sourceFile,
|
|
942
|
+
archiveOrder: 0,
|
|
943
|
+
});
|
|
944
|
+
if (parsedItems.length === 0) {
|
|
945
|
+
notify(ctx, `No tweets found in ${sourceFile}.`, "warning");
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
const deduped = dedupeItemsByTweetId(parsedItems);
|
|
949
|
+
notify(ctx, `Digest dedupe done: before=${deduped.beforeCount} after=${deduped.afterCount} removed=${deduped.removedCount}.`, "info");
|
|
950
|
+
const items = sortItemsForScratch(deduped.items);
|
|
951
|
+
if (!ENABLE_CLASSIFICATION_BRANCH) {
|
|
952
|
+
notify(ctx, "Digest draft branch is disabled; exported sorted single-day cumulative archive to draft only.", "info");
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const provider = parsed.provider ?? digestConfig.classifier.provider;
|
|
956
|
+
const modelId = parsed.model ?? digestConfig.classifier.model;
|
|
957
|
+
if (!provider || !modelId) {
|
|
958
|
+
notify(ctx, "Missing digest classifier model. Set digest.classifier.provider/model in ~/.mono-pilot/config.json or pass --provider/--model.", "warning");
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const model = ctx.modelRegistry.find(provider, modelId);
|
|
962
|
+
if (!model) {
|
|
963
|
+
notify(ctx, `Model not found: ${provider}/${modelId}.`, "error");
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
967
|
+
if (!apiKey) {
|
|
968
|
+
notify(ctx, `No API key for model provider: ${provider}.`, "error");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
const concurrency = parsed.concurrency ?? digestConfig.classifier.concurrency;
|
|
972
|
+
const maxTokens = digestConfig.classifier.maxTokens;
|
|
973
|
+
notify(ctx, `Digest draft start: full ${items.length} tweets from ${sourceFile}, model=${provider}/${modelId}, concurrency=${concurrency}.`, "info");
|
|
974
|
+
const startedAt = Date.now();
|
|
975
|
+
let lastProgressAt = 0;
|
|
976
|
+
const results = await mapWithConcurrency(items, concurrency, async (item) => {
|
|
977
|
+
const classifiedAt = new Date().toISOString();
|
|
978
|
+
const prompt = buildClassifierUserPrompt(item.tweet);
|
|
979
|
+
try {
|
|
980
|
+
const assistant = await completeSimple(model, {
|
|
981
|
+
systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
|
|
982
|
+
messages: [
|
|
983
|
+
{
|
|
984
|
+
role: "user",
|
|
985
|
+
content: [{ type: "text", text: prompt }],
|
|
986
|
+
timestamp: Date.now(),
|
|
987
|
+
},
|
|
988
|
+
],
|
|
989
|
+
}, { apiKey, maxTokens });
|
|
990
|
+
const rawText = extractAssistantText(assistant);
|
|
991
|
+
const parsedOutput = parseClassificationOutput(rawText);
|
|
992
|
+
if (parsedOutput) {
|
|
993
|
+
return {
|
|
994
|
+
lineNumber: item.lineNumber,
|
|
995
|
+
batchSeq: item.batchSeq,
|
|
996
|
+
tweetIndex: item.tweetIndex,
|
|
997
|
+
tweetId: item.tweetId,
|
|
998
|
+
category: parsedOutput.category,
|
|
999
|
+
confidence: parsedOutput.confidence,
|
|
1000
|
+
reason: parsedOutput.reason,
|
|
1001
|
+
model: { provider: model.provider, id: model.id },
|
|
1002
|
+
classifiedAt,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
const fallbackAssistant = await completeSimple(model, {
|
|
1006
|
+
systemPrompt: CLASSIFIER_LABEL_FALLBACK_PROMPT,
|
|
1007
|
+
messages: [
|
|
1008
|
+
{
|
|
1009
|
+
role: "user",
|
|
1010
|
+
content: [{ type: "text", text: prompt }],
|
|
1011
|
+
timestamp: Date.now(),
|
|
1012
|
+
},
|
|
1013
|
+
],
|
|
1014
|
+
}, { apiKey, maxTokens: 64 });
|
|
1015
|
+
const fallbackRawText = extractAssistantText(fallbackAssistant);
|
|
1016
|
+
const fallbackParsed = parseCategoryOnlyOutput(fallbackRawText);
|
|
1017
|
+
if (fallbackParsed) {
|
|
1018
|
+
return {
|
|
1019
|
+
lineNumber: item.lineNumber,
|
|
1020
|
+
batchSeq: item.batchSeq,
|
|
1021
|
+
tweetIndex: item.tweetIndex,
|
|
1022
|
+
tweetId: item.tweetId,
|
|
1023
|
+
category: fallbackParsed.category,
|
|
1024
|
+
confidence: fallbackParsed.confidence,
|
|
1025
|
+
reason: fallbackParsed.reason,
|
|
1026
|
+
model: { provider: model.provider, id: model.id },
|
|
1027
|
+
classifiedAt,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
return toIrrelevantResult(item, classifiedAt, { provider: model.provider, id: model.id }, `invalid classifier output (${summarizeAssistantFailure(assistant, rawText)}; fallback=${summarizeAssistantFailure(fallbackAssistant, fallbackRawText)})`);
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
return toIrrelevantResult(item, classifiedAt, { provider: model.provider, id: model.id }, error instanceof Error ? error.message : String(error), "model-error-unrelated");
|
|
1034
|
+
}
|
|
1035
|
+
}, (completed, total) => {
|
|
1036
|
+
const now = Date.now();
|
|
1037
|
+
if (completed === total || now - lastProgressAt >= 3_000) {
|
|
1038
|
+
lastProgressAt = now;
|
|
1039
|
+
notify(ctx, `Digest draft progress: ${completed}/${total}`, "info");
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1043
|
+
try {
|
|
1044
|
+
const scratch = buildClassificationScratchMarkdown(items, results, sourceFile);
|
|
1045
|
+
await mkdir(dirname(DIGEST_DRAFT_PATH), { recursive: true });
|
|
1046
|
+
await writeFile(DIGEST_DRAFT_PATH, scratch.markdown, "utf-8");
|
|
1047
|
+
notify(ctx, `Digest draft updated: ${DIGEST_DRAFT_PATH} (kept=${scratch.keptCount}, droppedIrrelevant=${scratch.droppedCount})`, "info");
|
|
1048
|
+
}
|
|
1049
|
+
catch (error) {
|
|
1050
|
+
notify(ctx, `Digest draft write failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
1051
|
+
}
|
|
1052
|
+
try {
|
|
1053
|
+
const classifyMarkdown = buildClassificationResultMarkdown(items, results, {
|
|
1054
|
+
sourceFile,
|
|
1055
|
+
modelLabel: `${model.provider}/${model.id}`,
|
|
1056
|
+
elapsedMs,
|
|
1057
|
+
});
|
|
1058
|
+
await mkdir(dirname(DIGEST_DRAFT_DEBUG_PATH), { recursive: true });
|
|
1059
|
+
await writeFile(DIGEST_DRAFT_DEBUG_PATH, classifyMarkdown, "utf-8");
|
|
1060
|
+
notify(ctx, `Digest draft debug written: ${DIGEST_DRAFT_DEBUG_PATH}`, "info");
|
|
1061
|
+
}
|
|
1062
|
+
catch (error) {
|
|
1063
|
+
notify(ctx, `Digest draft debug write failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
1064
|
+
}
|
|
1065
|
+
emitDebugResults(ctx, items, results);
|
|
1066
|
+
const focusStats = summarizeFocusTokenStats(items, results);
|
|
1067
|
+
notify(ctx, `Digest draft debug done (${elapsedMs}ms). ${summarizeByCategory(results)}. focusTweets=${focusStats.focusTweetCount} focusTextTokens=${focusStats.focusTextTokens} (estimated).`, "info");
|
|
1068
|
+
}
|
|
1069
|
+
export function registerDigestCommand(pi) {
|
|
1070
|
+
pi.registerCommand("digest", {
|
|
1071
|
+
description: "Digest commands: draft/backfill twitter archive",
|
|
1072
|
+
handler: async (args, ctx) => {
|
|
1073
|
+
const parsed = parseArgs(args);
|
|
1074
|
+
if (parsed.error) {
|
|
1075
|
+
notify(ctx, parsed.error, "warning");
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (parsed.subcommand !== "draft" && parsed.subcommand !== "backfill") {
|
|
1079
|
+
notify(ctx, USAGE, "warning");
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (parsed.date && !isDateStamp(parsed.date)) {
|
|
1083
|
+
notify(ctx, `Invalid --date: ${parsed.date}. Expected YYYY-MM-DD.`, "warning");
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
let config;
|
|
1087
|
+
try {
|
|
1088
|
+
config = await loadMonoPilotConfigObject();
|
|
1089
|
+
}
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
notify(ctx, `Failed to load config: ${String(error)}`, "error");
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const twitterConfig = extractTwitterCollectorConfig(config);
|
|
1095
|
+
const digestConfig = extractDigestConfig(config);
|
|
1096
|
+
if (parsed.subcommand === "backfill") {
|
|
1097
|
+
if (digestBackfillRunning) {
|
|
1098
|
+
notify(ctx, "Digest backfill is already running.", "warning");
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
digestBackfillRunning = true;
|
|
1102
|
+
notify(ctx, "Digest backfill started in background (serial). Check /events for progress.", "info");
|
|
1103
|
+
void runDigestBackfill({
|
|
1104
|
+
twitterConfig,
|
|
1105
|
+
date: parsed.date,
|
|
1106
|
+
file: parsed.file,
|
|
1107
|
+
notify: (message, level) => notify(ctx, message, level),
|
|
1108
|
+
})
|
|
1109
|
+
.catch((error) => {
|
|
1110
|
+
notify(ctx, `Digest backfill crashed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1111
|
+
})
|
|
1112
|
+
.finally(() => {
|
|
1113
|
+
digestBackfillRunning = false;
|
|
1114
|
+
});
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (digestDraftRunning) {
|
|
1118
|
+
notify(ctx, "Digest draft is already running.", "warning");
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
digestDraftRunning = true;
|
|
1122
|
+
notify(ctx, "Digest draft started in background. Check /events for progress.", "info");
|
|
1123
|
+
void runDigestDraftTask(parsed, ctx, twitterConfig, digestConfig)
|
|
1124
|
+
.catch((error) => {
|
|
1125
|
+
notify(ctx, `Digest draft crashed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1126
|
+
})
|
|
1127
|
+
.finally(() => {
|
|
1128
|
+
digestDraftRunning = false;
|
|
1129
|
+
});
|
|
1130
|
+
return;
|
|
1131
|
+
},
|
|
1132
|
+
});
|
|
1133
|
+
}
|