@comchatx/cx-topics 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/extensions/topics.js +517 -0
- package/package.json +19 -4
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ComChatX
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// src/source-packages/cx-topics/box/extensions/topics.ts
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
// src/source-packages/cx-topics/box/core/protocol.ts
|
|
7
|
+
var STOPPING = /* @__PURE__ */ new Set(["refused", "timeout", "no-model"]);
|
|
8
|
+
var ReviewFailure = class extends Error {
|
|
9
|
+
constructor(kind) {
|
|
10
|
+
super(kind);
|
|
11
|
+
this.kind = kind;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
var bounded = (value, max) => typeof value === "string" && value.length <= max * 2 && value.trim() !== "" && [...value].length <= max;
|
|
16
|
+
var validTitle = (value) => bounded(value, 80) && !/[\r\n]/.test(value) && value.trim().split(/\s+/).length <= 8;
|
|
17
|
+
var pick = (value, keys) => record(value) ? Object.fromEntries(keys.map((key) => [key, value[key]])) : void 0;
|
|
18
|
+
function list(value, max, copy) {
|
|
19
|
+
if (!Array.isArray(value)) return void 0;
|
|
20
|
+
const length = value.length;
|
|
21
|
+
if (length > max) return void 0;
|
|
22
|
+
const items = [];
|
|
23
|
+
for (let i = 0; i < length; i++) items.push(copy(value[i]));
|
|
24
|
+
return items;
|
|
25
|
+
}
|
|
26
|
+
var GATE_CODES = /\b(membership_required|debt_owed|model_not_served|sign_in_again)\b/;
|
|
27
|
+
var statusOf = (message) => {
|
|
28
|
+
const found = /^(\d{3})\b/.exec(message) ?? /^[^()\n{}":]* error \((\d{3})\)(?::|$)/.exec(message);
|
|
29
|
+
return found === null ? void 0 : Number(found[1]);
|
|
30
|
+
};
|
|
31
|
+
var billed = (usage) => record(usage) && ["input", "output", "totalTokens"].some((key) => typeof usage[key] === "number" && usage[key] > 0);
|
|
32
|
+
function failureOf(raw) {
|
|
33
|
+
if (raw.stopReason === "aborted") return "timeout";
|
|
34
|
+
if (raw.stopReason === "length" || raw.stopReason === "toolUse" || billed(raw.usage)) return "malformed";
|
|
35
|
+
const message = typeof raw.errorMessage === "string" ? raw.errorMessage : "";
|
|
36
|
+
if (/timed out/i.test(message)) return "timeout";
|
|
37
|
+
const status = statusOf(message);
|
|
38
|
+
return status === 400 || status === 413 || status === 422 || status === 403 && !GATE_CODES.test(message) ? "rejected" : "refused";
|
|
39
|
+
}
|
|
40
|
+
function parseAnswers(raw, questions) {
|
|
41
|
+
if (raw === void 0) return "no-model";
|
|
42
|
+
if (!record(raw)) return "malformed";
|
|
43
|
+
if (raw.stopReason !== "stop") return failureOf(raw);
|
|
44
|
+
if (!record(raw.answers)) return "malformed";
|
|
45
|
+
const answers = {};
|
|
46
|
+
for (const key of Object.keys(questions)) {
|
|
47
|
+
if (!Object.hasOwn(raw.answers, key)) return "missing-answers";
|
|
48
|
+
const answer = raw.answers[key];
|
|
49
|
+
if (!record(answer) || answer.type !== "bool" || typeof answer.probability !== "number" || !Number.isFinite(answer.probability) || answer.probability < 0 || answer.probability > 1) return "malformed";
|
|
50
|
+
answers[key] = answer.probability;
|
|
51
|
+
}
|
|
52
|
+
return Object.freeze(answers);
|
|
53
|
+
}
|
|
54
|
+
async function askQuestions(state, questions, ask, timeoutMs) {
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
let timer;
|
|
57
|
+
try {
|
|
58
|
+
const deadline = new Promise((_, reject) => {
|
|
59
|
+
timer = setTimeout(() => {
|
|
60
|
+
reject(new ReviewFailure("timeout"));
|
|
61
|
+
controller.abort();
|
|
62
|
+
}, timeoutMs);
|
|
63
|
+
});
|
|
64
|
+
const raw = await Promise.race([ask(state, questions, controller.signal), deadline]);
|
|
65
|
+
try {
|
|
66
|
+
return parseAnswers(raw, questions);
|
|
67
|
+
} catch {
|
|
68
|
+
return "malformed";
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return error instanceof ReviewFailure && error.kind === "timeout" ? "timeout" : "refused";
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/source-packages/cx-topics/box/core/routing.ts
|
|
78
|
+
var ROUTING_LIMITS = Object.freeze({
|
|
79
|
+
// A seller message over 512 code points, and the latest over 2,048, keeps its head and tail (`cut`).
|
|
80
|
+
messageChars: 512,
|
|
81
|
+
latestChars: 2048,
|
|
82
|
+
// The newest 31 of a topic's seller messages are shown, as many as kept a full request in Chinese text within the size of a full one when
|
|
83
|
+
// Jev was also shown cx's words and three earlier topics (56,355 bytes): where a topic now stands is in its newest messages, and its title
|
|
84
|
+
// says where it began. The topic's length still counts every one. The bounds are code points, so the largest request is of characters
|
|
85
|
+
// JSON writes in six bytes, 109,260 bytes.
|
|
86
|
+
sellerMessages: 31,
|
|
87
|
+
// How long the router gives the asker when its caller names no deadline.
|
|
88
|
+
timeoutMs: 1e3,
|
|
89
|
+
confident: 0.8,
|
|
90
|
+
unlikely: 0.2,
|
|
91
|
+
// A title is rewritten only when Jev answers at most this that it still names the topic's work: a rewrite is a paid call and renames a
|
|
92
|
+
// row the seller reads, so it takes the answer a settled topic takes to call a message a lasting change.
|
|
93
|
+
staleTitle: 0.2,
|
|
94
|
+
// How long the title model is given: nothing waits for a title, so the bound only ends a call that would otherwise hang, at the time
|
|
95
|
+
// Jev's own call is given.
|
|
96
|
+
titleMs: 2e4,
|
|
97
|
+
// A title of 80 characters is under 128 tokens in any language; the cap bounds what a runaway answer costs.
|
|
98
|
+
titleTokens: 128,
|
|
99
|
+
// A topic of two seller messages or fewer is still finding its shape (Jack's example: two messages on one product's listing, then a
|
|
100
|
+
// third on another's, is one topic), so an even answer continues it.
|
|
101
|
+
shortTopic: 2,
|
|
102
|
+
shortTopicBar: 0.5,
|
|
103
|
+
// By eight seller messages a topic is settled, and a message continues it only on the confident answer every message needed before.
|
|
104
|
+
longTopic: 8
|
|
105
|
+
});
|
|
106
|
+
var filled = (value) => typeof value === "string" && value.trim() !== "";
|
|
107
|
+
function cut(text2, max) {
|
|
108
|
+
const points = [...text2];
|
|
109
|
+
if (points.length <= max) return text2;
|
|
110
|
+
const marker = (omitted) => `
|
|
111
|
+
[\u2026 ${omitted} characters omitted \u2026]
|
|
112
|
+
`;
|
|
113
|
+
const keep = max - marker(points.length).length;
|
|
114
|
+
const head = Math.ceil(keep / 2);
|
|
115
|
+
return `${points.slice(0, head).join("")}${marker(points.length - keep)}${points.slice(points.length - (keep - head)).join("")}`;
|
|
116
|
+
}
|
|
117
|
+
var cutText = (value, max) => filled(value) ? cut(value, max) : value;
|
|
118
|
+
var copyTopic = (value) => {
|
|
119
|
+
const topic = pick(value, ["title", "messages"]);
|
|
120
|
+
return topic && { ...topic, messages: list(topic.messages, ROUTING_LIMITS.sellerMessages, (text2) => cutText(text2, ROUTING_LIMITS.messageChars)) };
|
|
121
|
+
};
|
|
122
|
+
function copied(input) {
|
|
123
|
+
const envelope = pick(input, ["current", "latestUserMessage", "baselineDecision", "currentLength"]) ?? {};
|
|
124
|
+
return { ...envelope, latestUserMessage: cutText(envelope.latestUserMessage, ROUTING_LIMITS.latestChars), current: copyTopic(envelope.current) };
|
|
125
|
+
}
|
|
126
|
+
function validTopic(topic) {
|
|
127
|
+
return record(topic) && (topic.title === void 0 || validTitle(topic.title)) && Array.isArray(topic.messages) && (topic.title !== void 0 || topic.messages.length > 0) && topic.messages.every(filled);
|
|
128
|
+
}
|
|
129
|
+
function valid(input) {
|
|
130
|
+
return (input.baselineDecision === "new-topic" || input.baselineDecision === "continuation") && filled(input.latestUserMessage) && validTopic(input.current) && (input.currentLength === void 0 || Number.isSafeInteger(input.currentLength) && input.currentLength >= input.current.messages.length);
|
|
131
|
+
}
|
|
132
|
+
var question = (instructions, yes, no) => Object.freeze({ type: "bool", instructions, criteria: Object.freeze({ true: yes, false: no }) });
|
|
133
|
+
function questionsFor(state) {
|
|
134
|
+
return Object.freeze({
|
|
135
|
+
significantChange: question("Does state.latestUserMessage request lasting separate work rather than a brief tangent or correction to state.current?", "Lasting separate work", "A brief tangent or a correction"),
|
|
136
|
+
// The same task on another product sits between the two answers, so a topic's length can decide it; another kind of task does not.
|
|
137
|
+
continueCurrent: question("Does state.latestUserMessage intend to continue or correct the work in state.current, its title and the seller's messages so far, rather than start separate work? The same task on another product continues it less surely than the same product; another kind of task does not continue it. Judge intent, not shared product words.", "Continues or corrects the work", "Starts another kind of work"),
|
|
138
|
+
...state.current.title === void 0 ? {} : { titleAdequate: question("Does state.current.title still name the work in state.current, the seller's messages so far, together with state.latestUserMessage? A title that names where the work began but not where it has gone does not.", "Still names the work", "No longer names the work") }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
var fieldPaths = (state) => Object.freeze([
|
|
142
|
+
"current",
|
|
143
|
+
...state.current.title === void 0 ? [] : ["current.title"],
|
|
144
|
+
"current.messages",
|
|
145
|
+
...state.current.messages.map((_, i) => `current.messages[${i}]`),
|
|
146
|
+
"latestUserMessage"
|
|
147
|
+
]);
|
|
148
|
+
function continuationBars(length) {
|
|
149
|
+
const { shortTopic, longTopic, shortTopicBar, confident, unlikely } = ROUTING_LIMITS;
|
|
150
|
+
const along = Math.min(1, Math.max(0, (length - shortTopic) / (longTopic - shortTopic)));
|
|
151
|
+
const between = (short, long) => short * (1 - along) + long * along;
|
|
152
|
+
return { continuing: between(shortTopicBar, confident), change: between(1 - shortTopicBar, unlikely) };
|
|
153
|
+
}
|
|
154
|
+
async function routeTopic(input, dependencies) {
|
|
155
|
+
const start = dependencies.now();
|
|
156
|
+
const envelope = copied(input);
|
|
157
|
+
const baselineDecision = envelope.baselineDecision === "new-topic" || envelope.baselineDecision === "continuation" ? envelope.baselineDecision : null;
|
|
158
|
+
const base = {
|
|
159
|
+
latestUserMessage: filled(envelope.latestUserMessage) ? envelope.latestUserMessage : null,
|
|
160
|
+
baseline: Object.freeze({ decision: baselineDecision }),
|
|
161
|
+
probabilities: null,
|
|
162
|
+
disagreement: null,
|
|
163
|
+
titleFlag: null
|
|
164
|
+
};
|
|
165
|
+
if (!valid(envelope)) return Object.freeze({ ...base, status: "unavailable", reason: "invalid-input", decision: null, requestCount: 0, latencyMs: 0, disclosedFields: Object.freeze([]) });
|
|
166
|
+
const current = envelope.current;
|
|
167
|
+
const state = Object.freeze({
|
|
168
|
+
current: Object.freeze({ ...current.title === void 0 ? {} : { title: current.title }, messages: Object.freeze([...current.messages]) }),
|
|
169
|
+
latestUserMessage: envelope.latestUserMessage
|
|
170
|
+
});
|
|
171
|
+
const fallback = baselineDecision === "continuation" ? "continue-current" : "create-new";
|
|
172
|
+
const evidence = { ...base, requestCount: 1, disclosedFields: fieldPaths(state) };
|
|
173
|
+
const answers = await askQuestions(state, questionsFor(state), dependencies.ask, dependencies.timeoutMs ?? ROUTING_LIMITS.timeoutMs);
|
|
174
|
+
const latencyMs = Math.max(0, dependencies.now() - start);
|
|
175
|
+
if (typeof answers === "string") return Object.freeze({ ...evidence, status: "unavailable", reason: answers, decision: fallback, latencyMs });
|
|
176
|
+
const continuing = answers.continueCurrent;
|
|
177
|
+
const bars = continuationBars(envelope.currentLength ?? current.messages.length);
|
|
178
|
+
const decision = continuing >= bars.continuing || answers.significantChange <= bars.change && continuing >= ROUTING_LIMITS.unlikely ? "continue-current" : "create-new";
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
...evidence,
|
|
181
|
+
status: "available",
|
|
182
|
+
decision,
|
|
183
|
+
probabilities: answers,
|
|
184
|
+
latencyMs,
|
|
185
|
+
disagreement: (decision === "continue-current" ? "continuation" : "new-topic") !== baselineDecision,
|
|
186
|
+
titleFlag: answers.titleAdequate === void 0 ? null : answers.titleAdequate <= ROUTING_LIMITS.staleTitle
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/source-packages/cx-topics/box/core/titling.ts
|
|
191
|
+
var TITLE_SYSTEM = [
|
|
192
|
+
"You write the title of one topic in a seller's History: the piece of work the seller's messages ask for.",
|
|
193
|
+
'Write at most eight words, in the language the seller writes in, such as "Competitor price history" or "\u745C\u4F3D\u57AB\u8865\u8D27\u68C0\u67E5".',
|
|
194
|
+
"Reply with the title alone: no quotes, no punctuation at its end, no explanation."
|
|
195
|
+
].join("\n");
|
|
196
|
+
function titleLanguage(newest) {
|
|
197
|
+
if (/\p{Script=Han}/u.test(newest)) return "Write the title in Chinese.";
|
|
198
|
+
if (/^[\x00-\x7f]*$/.test(newest)) return "Write the title in English.";
|
|
199
|
+
return "Write the title in the language of the last [seller] line.";
|
|
200
|
+
}
|
|
201
|
+
function titlePrompt(request) {
|
|
202
|
+
const { sellerMessages, messageChars, latestChars } = ROUTING_LIMITS;
|
|
203
|
+
const shown = request.messages.slice(-sellerMessages);
|
|
204
|
+
const lines = shown.map((text2, at) => `[seller] ${cut(text2, at === shown.length - 1 ? latestChars : messageChars)}`);
|
|
205
|
+
return [
|
|
206
|
+
...request.title === void 0 ? ["Title this new topic from the seller's message."] : [`The topic is titled "${request.title}", which no longer names its work. Write a title that names the work as it stands now.`],
|
|
207
|
+
"",
|
|
208
|
+
...lines,
|
|
209
|
+
"",
|
|
210
|
+
titleLanguage(shown.at(-1) ?? "")
|
|
211
|
+
].join("\n");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/source-packages/cx-topics/box/extensions/judge.ts
|
|
215
|
+
var JEV = Object.freeze({ provider: "typesafe", model: "jev-latest" });
|
|
216
|
+
var TITLE_MODEL = Object.freeze({ provider: "openrouter", model: "minimax/minimax-m3" });
|
|
217
|
+
var CALL_MS = 2e4;
|
|
218
|
+
var isSellers = (entry) => entry.type === "message" && entry.message.role === "user";
|
|
219
|
+
var QUOTE = /<quote\b[^>]*>[\s\S]*?<\/quote>/g;
|
|
220
|
+
function words(entry) {
|
|
221
|
+
const content = "content" in entry.message ? entry.message.content : void 0;
|
|
222
|
+
const text2 = typeof content === "string" ? content : Array.isArray(content) ? content.flatMap((block) => block?.type === "text" && typeof block.text === "string" ? [block.text] : []).join("\n") : "";
|
|
223
|
+
return text2.replace(QUOTE, "").trim();
|
|
224
|
+
}
|
|
225
|
+
function topicBefore(branch, label, id) {
|
|
226
|
+
const at = branch.findIndex((entry) => entry.id === id);
|
|
227
|
+
if (at < 0) return void 0;
|
|
228
|
+
let topic;
|
|
229
|
+
for (const entry of branch.slice(0, at)) {
|
|
230
|
+
if (!isSellers(entry)) continue;
|
|
231
|
+
const named = label(entry.id);
|
|
232
|
+
if (named !== void 0) topic = { start: entry.id, title: named, said: [], length: 0 };
|
|
233
|
+
if (topic === void 0) continue;
|
|
234
|
+
topic.length += 1;
|
|
235
|
+
const text2 = words(entry);
|
|
236
|
+
if (text2 !== "") topic.said.push(text2);
|
|
237
|
+
}
|
|
238
|
+
return topic && Object.freeze({ ...topic, said: Object.freeze(topic.said) });
|
|
239
|
+
}
|
|
240
|
+
function projection(branch, label, id) {
|
|
241
|
+
const latest = branch.find((entry) => entry.id === id);
|
|
242
|
+
const topic = latest !== void 0 && isSellers(latest) ? topicBefore(branch, label, id) : void 0;
|
|
243
|
+
if (topic === void 0) return void 0;
|
|
244
|
+
return Object.freeze({
|
|
245
|
+
current: Object.freeze({ title: topic.title, messages: Object.freeze(topic.said.slice(-ROUTING_LIMITS.sellerMessages)) }),
|
|
246
|
+
// Without a verdict a message stays in the topic in progress.
|
|
247
|
+
latestUserMessage: words(latest),
|
|
248
|
+
baselineDecision: "continuation",
|
|
249
|
+
currentLength: topic.length
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function verdictOf(advisory) {
|
|
253
|
+
if (advisory.status !== "available") return void 0;
|
|
254
|
+
return Object.freeze(advisory.decision === "continue-current" ? { decision: "continue", stale: advisory.titleFlag === true } : { decision: "new" });
|
|
255
|
+
}
|
|
256
|
+
var asker = (ctx) => async (state, questions, signal) => {
|
|
257
|
+
const jev = ctx.modelRegistry.findOfType("classifier", JEV.provider, JEV.model);
|
|
258
|
+
if (jev === void 0) return void 0;
|
|
259
|
+
return ctx.modelRegistry.classify(jev, { state, questions }, { maxRetries: 0, timeoutMs: CALL_MS, signal });
|
|
260
|
+
};
|
|
261
|
+
var titler = (ctx) => async (request) => {
|
|
262
|
+
const model = ctx.modelRegistry.find(TITLE_MODEL.provider, TITLE_MODEL.model);
|
|
263
|
+
if (model === void 0) return { failure: "no-model" };
|
|
264
|
+
try {
|
|
265
|
+
const answer = await ctx.modelRegistry.complete(model, {
|
|
266
|
+
systemPrompt: TITLE_SYSTEM,
|
|
267
|
+
messages: [{ role: "user", content: [{ type: "text", text: titlePrompt(request) }], timestamp: Date.now() }]
|
|
268
|
+
}, { maxTokens: ROUTING_LIMITS.titleTokens, signal: AbortSignal.timeout(ROUTING_LIMITS.titleMs), cacheRetention: "none" });
|
|
269
|
+
if (answer.stopReason !== "stop") return { failure: failureOf(answer) };
|
|
270
|
+
return { text: answer.content.flatMap((block) => block.type === "text" ? [block.text] : []).join(" ") };
|
|
271
|
+
} catch {
|
|
272
|
+
return { failure: "refused" };
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
var ended = (failure) => failure !== void 0 && STOPPING.has(failure) ? "stopped" : "judged";
|
|
276
|
+
function createJudge(title2, setLabel, live = () => {
|
|
277
|
+
}) {
|
|
278
|
+
const labelOf = (ctx) => (id) => {
|
|
279
|
+
const raw = ctx.sessionManager.getLabel(id);
|
|
280
|
+
return raw === void 0 ? void 0 : title2(raw);
|
|
281
|
+
};
|
|
282
|
+
async function name(ctx, target, request) {
|
|
283
|
+
live();
|
|
284
|
+
const titled = await titler(ctx)(request);
|
|
285
|
+
if ("failure" in titled) return ended(titled.failure);
|
|
286
|
+
const kept = title2(titled.text);
|
|
287
|
+
if (kept === void 0 || kept === request.title || ctx.sessionManager.getEntry(target) === void 0 || labelOf(ctx)(target) !== request.title) return "judged";
|
|
288
|
+
setLabel(target, kept);
|
|
289
|
+
return "judged";
|
|
290
|
+
}
|
|
291
|
+
return async (ctx, id) => {
|
|
292
|
+
const branch = ctx.sessionManager.getBranch();
|
|
293
|
+
const latest = branch.find((entry) => entry.id === id);
|
|
294
|
+
if (latest === void 0 || !isSellers(latest)) return "judged";
|
|
295
|
+
const said = words(latest);
|
|
296
|
+
const topic = topicBefore(branch, labelOf(ctx), id);
|
|
297
|
+
if (topic === void 0) return said === "" ? "judged" : name(ctx, id, { messages: [said] });
|
|
298
|
+
const advisory = await routeTopic(projection(branch, labelOf(ctx), id), { ask: asker(ctx), now: Date.now, timeoutMs: CALL_MS });
|
|
299
|
+
const verdict = verdictOf(advisory);
|
|
300
|
+
if (verdict === void 0) return ended(advisory.reason === "invalid-input" ? void 0 : advisory.reason);
|
|
301
|
+
if (verdict.decision === "new") return name(ctx, id, { messages: [said] });
|
|
302
|
+
if (verdict.stale) return name(ctx, topic.start, { title: topic.title, messages: [...topic.said, said] });
|
|
303
|
+
return "judged";
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/source-packages/cx-topics/box/extensions/pass.ts
|
|
308
|
+
var PROGRESS = "cx-topics";
|
|
309
|
+
var INSTALLS_FILE = ".pi/cx-installs.json";
|
|
310
|
+
function installKey(source) {
|
|
311
|
+
if (!source.startsWith("npm:")) return source;
|
|
312
|
+
const spec = source.slice("npm:".length);
|
|
313
|
+
const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
|
|
314
|
+
return `npm:${at < 0 ? spec : spec.slice(0, at)}`;
|
|
315
|
+
}
|
|
316
|
+
function tokenIn(text2, key) {
|
|
317
|
+
if (text2 === void 0 || key === void 0) return void 0;
|
|
318
|
+
try {
|
|
319
|
+
const tokens = JSON.parse(text2).tokens;
|
|
320
|
+
const token = typeof tokens === "object" && tokens !== null && !Array.isArray(tokens) ? tokens[key] : void 0;
|
|
321
|
+
return typeof token === "string" && token !== "" ? token : void 0;
|
|
322
|
+
} catch {
|
|
323
|
+
return void 0;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
var progressOf = (entries) => entries.flatMap((entry) => {
|
|
327
|
+
if (entry.type !== "custom" || entry.customType !== PROGRESS) return [];
|
|
328
|
+
const data = entry.data;
|
|
329
|
+
return [{ ...typeof data?.install === "string" ? { install: data.install } : {}, ...typeof data?.through === "string" ? { through: data.through } : {} }];
|
|
330
|
+
});
|
|
331
|
+
var Stopped = class extends Error {
|
|
332
|
+
};
|
|
333
|
+
function createPass(ports) {
|
|
334
|
+
let arrivals = 0;
|
|
335
|
+
let over = false;
|
|
336
|
+
let wanted;
|
|
337
|
+
let running;
|
|
338
|
+
const live = () => {
|
|
339
|
+
if (over) throw new Stopped();
|
|
340
|
+
};
|
|
341
|
+
const judge = createJudge(ports.title, (id, label) => {
|
|
342
|
+
live();
|
|
343
|
+
ports.setLabel(id, label);
|
|
344
|
+
}, live);
|
|
345
|
+
const keep = (progress) => {
|
|
346
|
+
live();
|
|
347
|
+
ports.keep(progress);
|
|
348
|
+
return progress;
|
|
349
|
+
};
|
|
350
|
+
async function walk(ctx) {
|
|
351
|
+
const session = ctx.sessionManager;
|
|
352
|
+
const kept = progressOf(session.getEntries());
|
|
353
|
+
const token = ports.token(ctx);
|
|
354
|
+
let last = kept.at(-1);
|
|
355
|
+
if (token !== void 0 && !kept.some((progress) => progress.install === token)) {
|
|
356
|
+
for (const entry of session.getEntries()) if (isSellers(entry) && session.getLabel(entry.id) !== void 0) {
|
|
357
|
+
live();
|
|
358
|
+
ports.setLabel(entry.id, void 0);
|
|
359
|
+
}
|
|
360
|
+
last = keep({ install: token });
|
|
361
|
+
} else if (last === void 0) {
|
|
362
|
+
const newest = session.getBranch().filter(isSellers).at(-1);
|
|
363
|
+
last = keep(newest === void 0 ? {} : { through: newest.id });
|
|
364
|
+
}
|
|
365
|
+
for (; ; ) {
|
|
366
|
+
const sellers = session.getBranch().filter(isSellers);
|
|
367
|
+
const at = last.through === void 0 ? -1 : sellers.findIndex((entry) => entry.id === last.through);
|
|
368
|
+
if (last.through !== void 0 && at < 0) {
|
|
369
|
+
const newest = sellers.at(-1);
|
|
370
|
+
if (newest === void 0) return;
|
|
371
|
+
last = keep({ through: newest.id });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const next = sellers[at + 1];
|
|
375
|
+
if (next === void 0) return;
|
|
376
|
+
if (await judge(ctx, next.id) === "stopped") return;
|
|
377
|
+
last = keep({ through: next.id });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function poke(ctx) {
|
|
381
|
+
if (over) return;
|
|
382
|
+
wanted = ctx;
|
|
383
|
+
if (running !== void 0) return;
|
|
384
|
+
running = (async () => {
|
|
385
|
+
while (wanted !== void 0 && !over) {
|
|
386
|
+
const now = wanted;
|
|
387
|
+
wanted = void 0;
|
|
388
|
+
try {
|
|
389
|
+
await walk(now);
|
|
390
|
+
} catch {
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
running = void 0;
|
|
394
|
+
})();
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
arrived: () => {
|
|
398
|
+
arrivals += 1;
|
|
399
|
+
},
|
|
400
|
+
context(ctx) {
|
|
401
|
+
if (arrivals === 0) return;
|
|
402
|
+
arrivals = 0;
|
|
403
|
+
poke(ctx);
|
|
404
|
+
},
|
|
405
|
+
started: poke,
|
|
406
|
+
stopped: () => {
|
|
407
|
+
over = true;
|
|
408
|
+
},
|
|
409
|
+
async idle() {
|
|
410
|
+
while (running !== void 0) await running;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/source-packages/cx-topics/box/extensions/topics.ts
|
|
416
|
+
var TITLE_WORDS = 8;
|
|
417
|
+
var TITLE_CHARS = 80;
|
|
418
|
+
var QUOTED_CHARS = 8e3;
|
|
419
|
+
var AROUND_CHARS = 1e3;
|
|
420
|
+
var AROUND = 2;
|
|
421
|
+
function title(raw) {
|
|
422
|
+
const words2 = raw.replace(/\s+/g, " ").trim().split(" ").slice(0, TITLE_WORDS).join(" ");
|
|
423
|
+
const cut3 = [...words2].slice(0, TITLE_CHARS).join("").trim();
|
|
424
|
+
return cut3 === "" ? void 0 : cut3;
|
|
425
|
+
}
|
|
426
|
+
var blocksOf = (content) => typeof content === "string" ? [{ type: "text", text: content }] : Array.isArray(content) ? content : [];
|
|
427
|
+
var cut2 = (text2, chars) => text2.length <= chars ? text2 : `${text2.slice(0, chars)} [${text2.length - chars} more characters]`;
|
|
428
|
+
var when = (timestamp) => {
|
|
429
|
+
const at = new Date(timestamp);
|
|
430
|
+
return Number.isNaN(at.getTime()) ? "" : ` \xB7 ${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
431
|
+
};
|
|
432
|
+
var UNDRAWN = "topic";
|
|
433
|
+
function parts(entry) {
|
|
434
|
+
const blocks = blocksOf("content" in entry.message ? entry.message.content : void 0);
|
|
435
|
+
const words2 = blocks.filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n").trim();
|
|
436
|
+
const steps = blocks.filter((block) => block.type === "toolCall" && block.name !== UNDRAWN).map((block) => `(step: ${block.name ?? "tool"})`);
|
|
437
|
+
return { words: words2, steps };
|
|
438
|
+
}
|
|
439
|
+
function line(entry, chars) {
|
|
440
|
+
const who = entry.message.role === "user" ? "seller" : "cx";
|
|
441
|
+
const { words: words2, steps } = parts(entry);
|
|
442
|
+
return [`[${who}${when(entry.timestamp)}]`, ...words2 === "" ? [] : [cut2(words2, chars)], ...steps].join("\n");
|
|
443
|
+
}
|
|
444
|
+
var isSaid = (entry) => entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant");
|
|
445
|
+
var isShown = (entry) => {
|
|
446
|
+
const { words: words2, steps } = parts(entry);
|
|
447
|
+
return words2 !== "" || steps.length > 0;
|
|
448
|
+
};
|
|
449
|
+
function quoted(session, id) {
|
|
450
|
+
const said = session.entries().filter(isSaid).filter((entry) => entry.id === id || isShown(entry));
|
|
451
|
+
const at = said.findIndex((entry) => entry.id === id);
|
|
452
|
+
if (at < 0) return "This message is from an earlier conversation and can't be read here.";
|
|
453
|
+
const around = (from, to) => said.slice(Math.max(0, from), Math.max(0, to)).map((entry) => line(entry, AROUND_CHARS));
|
|
454
|
+
return [
|
|
455
|
+
...around(at - AROUND, at),
|
|
456
|
+
`>>> The quoted message:
|
|
457
|
+
${line(said[at], QUOTED_CHARS)}
|
|
458
|
+
<<<`,
|
|
459
|
+
...around(at + 1, at + 1 + AROUND)
|
|
460
|
+
].join("\n\n");
|
|
461
|
+
}
|
|
462
|
+
var text = (words2) => ({ content: [{ type: "text", text: words2 }], details: {} });
|
|
463
|
+
var TOOL = "quoted";
|
|
464
|
+
function ownKey(pi, file) {
|
|
465
|
+
const own = pi.getAllTools().find((tool) => tool.name === TOOL && typeof tool.sourceInfo?.path === "string" && resolve(tool.sourceInfo.path) === resolve(file));
|
|
466
|
+
return own === void 0 || typeof own.sourceInfo.source !== "string" ? void 0 : installKey(own.sourceInfo.source);
|
|
467
|
+
}
|
|
468
|
+
var readText = (file) => {
|
|
469
|
+
try {
|
|
470
|
+
return readFileSync(file, "utf8");
|
|
471
|
+
} catch {
|
|
472
|
+
return void 0;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
function topics(pi) {
|
|
476
|
+
const file = fileURLToPath(import.meta.url);
|
|
477
|
+
const pass = createPass({
|
|
478
|
+
title,
|
|
479
|
+
setLabel: (id, label) => pi.setLabel(id, label),
|
|
480
|
+
keep: (progress) => pi.appendEntry(PROGRESS, progress),
|
|
481
|
+
token: (ctx) => tokenIn(readText(join(ctx.cwd, INSTALLS_FILE)), ownKey(pi, file))
|
|
482
|
+
});
|
|
483
|
+
pi.on("message_end", (event) => {
|
|
484
|
+
if (event.message.role === "user") pass.arrived();
|
|
485
|
+
});
|
|
486
|
+
pi.on("context", (_event, ctx) => {
|
|
487
|
+
pass.context(ctx);
|
|
488
|
+
});
|
|
489
|
+
pi.on("session_start", (_event, ctx) => {
|
|
490
|
+
pass.started(ctx);
|
|
491
|
+
});
|
|
492
|
+
pi.on("session_shutdown", () => {
|
|
493
|
+
pass.stopped();
|
|
494
|
+
});
|
|
495
|
+
pi.registerTool({
|
|
496
|
+
name: TOOL,
|
|
497
|
+
label: "Read the quoted message",
|
|
498
|
+
description: "Read a message the seller quoted, by its entry id, with the messages around it.",
|
|
499
|
+
promptSnippet: "Read a message the seller quoted, by the `entry` attribute of its `<quote>` tag, with the messages around it",
|
|
500
|
+
promptGuidelines: ['When the seller\'s message holds `<quote entry="\u2026">`, call `quoted` with that entry before answering; the words inside are what the seller selected.'],
|
|
501
|
+
parameters: { type: "object", properties: { entry: { type: "string" } }, required: ["entry"] },
|
|
502
|
+
async execute(_call, params, _signal, _update, ctx) {
|
|
503
|
+
return text(quoted({ entries: () => ctx.sessionManager.getEntries() }, params.entry));
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
export {
|
|
508
|
+
AROUND,
|
|
509
|
+
AROUND_CHARS,
|
|
510
|
+
QUOTED_CHARS,
|
|
511
|
+
TITLE_CHARS,
|
|
512
|
+
TITLE_WORDS,
|
|
513
|
+
topics as default,
|
|
514
|
+
ownKey,
|
|
515
|
+
quoted,
|
|
516
|
+
title
|
|
517
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comchatx/cx-topics",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/HS-Jack-YZY/ComChatX.git",
|
|
8
|
+
"directory": "src/source-packages/cx-topics"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package"
|
|
12
|
+
],
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": [
|
|
15
|
+
"./extensions"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/README.md
DELETED