@operatingtag/dsh-unclesam 0.0.0-stage → 1.0.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/CHANGELOG.md +893 -0
- package/LICENSE +142 -0
- package/README.md +354 -2
- package/client.js +1099 -0
- package/cordis.patch.yml +48 -0
- package/icon.svg +17 -0
- package/index.js +1411 -0
- package/install.cmd +43 -0
- package/install.ps1 +311 -0
- package/lib/cdp.mjs +247 -0
- package/lib/engine.mjs +1412 -0
- package/lib/i18n.mjs +141 -0
- package/lib/prefs.mjs +101 -0
- package/lib/prompt.mjs +518 -0
- package/lib/savings.mjs +131 -0
- package/locale/ar.json +98 -0
- package/locale/az.json +98 -0
- package/locale/bg.json +98 -0
- package/locale/bn.json +98 -0
- package/locale/cs.json +98 -0
- package/locale/da.json +98 -0
- package/locale/de.json +98 -0
- package/locale/el.json +98 -0
- package/locale/en.json +98 -0
- package/locale/es.json +98 -0
- package/locale/fa.json +98 -0
- package/locale/fi.json +98 -0
- package/locale/fr.json +98 -0
- package/locale/he.json +98 -0
- package/locale/hi.json +98 -0
- package/locale/hr.json +98 -0
- package/locale/hu.json +98 -0
- package/locale/id.json +98 -0
- package/locale/it.json +98 -0
- package/locale/ja.json +98 -0
- package/locale/ka.json +98 -0
- package/locale/kk.json +98 -0
- package/locale/km.json +98 -0
- package/locale/ko.json +98 -0
- package/locale/lt.json +98 -0
- package/locale/mr.json +98 -0
- package/locale/ms.json +98 -0
- package/locale/my.json +98 -0
- package/locale/ne.json +98 -0
- package/locale/nl.json +98 -0
- package/locale/no.json +98 -0
- package/locale/pl.json +98 -0
- package/locale/pt.json +98 -0
- package/locale/ro.json +98 -0
- package/locale/ru.json +98 -0
- package/locale/si.json +98 -0
- package/locale/sk.json +98 -0
- package/locale/sl.json +98 -0
- package/locale/sr.json +98 -0
- package/locale/sv.json +98 -0
- package/locale/sw.json +98 -0
- package/locale/ta.json +98 -0
- package/locale/te.json +98 -0
- package/locale/th.json +98 -0
- package/locale/tl.json +98 -0
- package/locale/tr.json +98 -0
- package/locale/uk.json +98 -0
- package/locale/ur.json +98 -0
- package/locale/uz.json +98 -0
- package/locale/vi.json +98 -0
- package/locale/zh.json +98 -0
- package/package.json +73 -4
package/lib/prompt.mjs
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn harness request messages into the single block of prose a web chat can accept.
|
|
3
|
+
*
|
|
4
|
+
* @file prompt.mjs
|
|
5
|
+
* @version 3.9.11
|
|
6
|
+
* @author Operating Tag (@operatingtag)
|
|
7
|
+
* @license CC-BY-NC-ND-4.0
|
|
8
|
+
*
|
|
9
|
+
* THE CONSTRAINT THAT SHAPES THIS FILE
|
|
10
|
+
* chat.deepseek.com has ONE text box. It has no notion of a system role, a tool result, or a
|
|
11
|
+
* structured tool call. Everything the harness wants to say has to be flattened into one message,
|
|
12
|
+
* and everything the model wants to do has to be recovered from plain text.
|
|
13
|
+
*
|
|
14
|
+
* WHY THE FLATTENING IS NOT JUST CONCATENATION
|
|
15
|
+
* A transcript glued together with newlines loses the boundary between "the user said this" and
|
|
16
|
+
* "you said that". Speakers are therefore labelled explicitly whenever there is more than one turn,
|
|
17
|
+
* because a model that cannot tell its own earlier answer from the user's request will happily
|
|
18
|
+
* continue the wrong voice.
|
|
19
|
+
*
|
|
20
|
+
* WHY A SINGLE-TURN REQUEST IS SENT RAW
|
|
21
|
+
* The common case is one user message and nothing else. Wrapping that in speaker labels and a
|
|
22
|
+
* preamble costs tokens, changes the answer, and makes short prompts feel unlike the web product.
|
|
23
|
+
* Only requests that actually carry history or a system prompt get the scaffolding.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Pull readable text out of a message whose content may be a string or a list of blocks. */
|
|
27
|
+
export function messageText(message) { if (!message) return '';
|
|
28
|
+
const content = message.content;
|
|
29
|
+
if (typeof content === 'string') return content;
|
|
30
|
+
if (!Array.isArray(content)) return '';
|
|
31
|
+
const parts = [];
|
|
32
|
+
for (const block of content) {
|
|
33
|
+
if (!block || typeof block !== 'object') continue;
|
|
34
|
+
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
|
|
35
|
+
else if (block.type === 'reasoning' && typeof block.text === 'string') continue;
|
|
36
|
+
else if (block.type === 'tool-call') {
|
|
37
|
+
parts.push(`[called ${block.name}(${block.arguments ?? ''})]`);
|
|
38
|
+
} else if (block.type === 'image') parts.push('[image attached]');
|
|
39
|
+
else if (block.type === 'file') parts.push(`[file: ${block.attachment?.name ?? 'attached'}]`);
|
|
40
|
+
}
|
|
41
|
+
return parts.join('\n').trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Render the tool catalogue as instructions the model can follow in plain text.
|
|
46
|
+
*
|
|
47
|
+
* The protocol is one JSON object and nothing else. Anything more elaborate - XML tags, a custom
|
|
48
|
+
* grammar, a sentinel language - competes with the model's instinct to be helpful in prose, and in
|
|
49
|
+
* practice it answers the question instead of calling the tool. A bare JSON object is the one shape it
|
|
50
|
+
* will reliably produce when asked and will not accidentally produce when not asked.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* Hard limits on what is sent to the chat box.
|
|
54
|
+
*
|
|
55
|
+
* MEASURED, NOT GUESSED
|
|
56
|
+
* A one-word "hello" once became a 234,530-character prompt because every tool was described in full
|
|
57
|
+
* on every message. chat.deepseek.com silently ignores Enter on text that large, so the message was
|
|
58
|
+
* never sent and the caller waited for an answer to a question that was never asked.
|
|
59
|
+
*
|
|
60
|
+
* Each tool cost roughly 1,600 characters - description plus a JSON schema - and a working harness can
|
|
61
|
+
* carry a hundred or more of them. So the budget is set per tool AND in total, and whichever is hit
|
|
62
|
+
* first truncates. A truncated list that still sends beats a complete list that never leaves the box.
|
|
63
|
+
*
|
|
64
|
+
* WHY THESE NUMBERS
|
|
65
|
+
* 8,000 characters of tool description is about five detailed tools, which is the most a person can
|
|
66
|
+
* usefully ask a chat model to choose between in one turn. 24,000 for the whole prompt leaves room for
|
|
67
|
+
* a real conversation on top of it. Both sit far below the size that failed.
|
|
68
|
+
*/
|
|
69
|
+
export const MAX_TOOL_PREAMBLE_CHARS = 8000;
|
|
70
|
+
export const MAX_PROMPT_CHARS = 24000;
|
|
71
|
+
/** Per-tool caps, so one enormous schema cannot consume the whole budget. */
|
|
72
|
+
const MAX_TOOL_DESCRIPTION = 220;
|
|
73
|
+
const MAX_TOOL_SCHEMA = 420;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Is this message worth answering, or is it just the person being friendly?
|
|
77
|
+
*
|
|
78
|
+
* WHY
|
|
79
|
+
* Every message carried the tool preamble, because tools were offered on every call. On a greeting that
|
|
80
|
+
* turned a hello into "What would you like me to do? I have a broad toolset available…" - recorded
|
|
81
|
+
* twice, and it reads as a machine waiting for orders rather than a model saying hello back.
|
|
82
|
+
*
|
|
83
|
+
* A greeting carries no task, so no tool can be the answer. Dropping the preamble for one leaves the
|
|
84
|
+
* model free to reply like a person, and costs nothing: nothing needed calling.
|
|
85
|
+
*
|
|
86
|
+
* HOW
|
|
87
|
+
* Short AND made only of greeting-ish words. Both conditions matter - "hola, arregla el bug del
|
|
88
|
+
* proveedor" starts with a greeting and is obviously a task, so length alone disqualifies it.
|
|
89
|
+
*/
|
|
90
|
+
export function isOnlyGreeting(text) {
|
|
91
|
+
const s = String(text ?? '').trim().toLowerCase();
|
|
92
|
+
if (!s) return false;
|
|
93
|
+
if (s.length > 40) return false;
|
|
94
|
+
const words = s.split(/[\s,!?.¿¡·-]+/).filter(Boolean);
|
|
95
|
+
if (words.length > 4) return false;
|
|
96
|
+
const GREETING = new Set([
|
|
97
|
+
'hi', 'hello', 'hey', 'yo', 'sup', 'morning', 'evening', 'howdy', 'greetings',
|
|
98
|
+
'hola', 'buenas', 'buenos', 'dias', 'días', 'tardes', 'noches', 'que', 'qué', 'tal', 'estas', 'estás',
|
|
99
|
+
'bonjour', 'salut', 'ça', 'va', 'hallo', 'guten', 'tag', 'ciao', 'buongiorno', 'olá', 'oi', 'tudo', 'bem',
|
|
100
|
+
'hallo', 'hoi', 'hej', 'hei', 'terve', 'привет', 'здравствуйте', 'مرحبا', 'السلام', 'عليكم',
|
|
101
|
+
'こんにちは', 'こんばんは', 'おはよう', '你好', '您好', '안녕하세요', '안녕', 'नमस्ते', 'नमस्कार',
|
|
102
|
+
'สวัสดี', 'xin', 'chào', 'merhaba', 'selam', 'σου', 'γεια', 'szia', 'ahoj', 'czesc', 'cześć',
|
|
103
|
+
'unutbud', 'dobre', 'god', 'dag', 'there', 'amigo', 'friend', 'mi', 'gran', 'sam', 'uncle',
|
|
104
|
+
]);
|
|
105
|
+
return words.every((w) => GREETING.has(w) || /^[\p{Emoji_Presentation}\p{Extended_Pictographic}]$/u.test(w));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Describe the available tools, within a fixed budget.
|
|
110
|
+
*
|
|
111
|
+
* Tools are taken in the order given, because the harness lists the most relevant first; when the budget
|
|
112
|
+
* runs out the rest are named but not described, so the model still knows they exist without paying for
|
|
113
|
+
* every schema. The truncation is stated in the preamble itself rather than hidden, so the model can say
|
|
114
|
+
* "I could not see the details of that tool" instead of inventing an argument shape.
|
|
115
|
+
*/
|
|
116
|
+
export function toolPreamble(tools) {
|
|
117
|
+
if (!tools?.length) return '';
|
|
118
|
+
/**
|
|
119
|
+
* Names only, and hard-capped.
|
|
120
|
+
*
|
|
121
|
+
* The first version carried a "NEVER do this" list plus a schema dump per tool. The second cut that to
|
|
122
|
+
* descriptions - which was fine for three tools and produced ONE ENORMOUS LINE for this session's thirty,
|
|
123
|
+
* because every description was still there. Recorded on screen: a paragraph of tool names and blurbs
|
|
124
|
+
* sitting above the person's own message, which is exactly the "gobbly goop" complaint.
|
|
125
|
+
*
|
|
126
|
+
* So: names only. A model does not need the description to call a tool - it needs to know the tool
|
|
127
|
+
* exists and what it is called. Arguments come from understanding, and the harness validates them.
|
|
128
|
+
*/
|
|
129
|
+
const names = tools.map((t) => t.name);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* One line, not a paraffle.
|
|
133
|
+
*
|
|
134
|
+
* The first version printed a dozen names AND a two-line call format above every single message. What
|
|
135
|
+
* the person saw in their own chat box was a wall of tool names before their own sentence - reported,
|
|
136
|
+
* correctly, as looking broken.
|
|
137
|
+
*
|
|
138
|
+
* The names have to stay: a model cannot call a tool it was never told exists. The call format has to
|
|
139
|
+
* stay too, because without it the model answers in prose instead of calling anything. What goes is the
|
|
140
|
+
* repetition - the format is one short clause on the same line, not a second block.
|
|
141
|
+
*
|
|
142
|
+
* The full text is still visible in the panel's "last thing sent" view, which is where a person looks
|
|
143
|
+
* when something goes wrong.
|
|
144
|
+
*/
|
|
145
|
+
const HEAD = 'Tools: ';
|
|
146
|
+
const TAIL = ' · to call one reply only with {"tool":"name","arguments":{}}';
|
|
147
|
+
const MAX_VISIBLE_NAMES = 12;
|
|
148
|
+
const budget = MAX_TOOL_PREAMBLE_CHARS - HEAD.length - TAIL.length;
|
|
149
|
+
const kept = [];
|
|
150
|
+
let used = 0;
|
|
151
|
+
for (const name of names) {
|
|
152
|
+
if (kept.length >= MAX_VISIBLE_NAMES) break;
|
|
153
|
+
const add = (kept.length ? 2 : 0) + name.length;
|
|
154
|
+
if (used + add > budget) break;
|
|
155
|
+
kept.push(name);
|
|
156
|
+
used += add;
|
|
157
|
+
}
|
|
158
|
+
const omitted = names.length - kept.length;
|
|
159
|
+
const list = kept.join(', ') + (omitted > 0 ? `, +${omitted} more` : '');
|
|
160
|
+
|
|
161
|
+
return `${HEAD}${list}${TAIL}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build the single prompt to type into the web composer.
|
|
166
|
+
*
|
|
167
|
+
* Returns `{ text, expectsToolCall }`, where `expectsToolCall` tells the caller whether a JSON reply
|
|
168
|
+
* would be a tool call rather than an answer that happens to look like JSON.
|
|
169
|
+
*
|
|
170
|
+
* WHAT THE PERSON SEES IN THE BROWSER
|
|
171
|
+
* The chat box has one field and no system-prompt channel, so anything the harness wants the model to
|
|
172
|
+
* know must be typed in as visible text. The browser tab is the same page the model answers on, so
|
|
173
|
+
* every instruction becomes something a human can read there - and scaffolding in a chat box looks
|
|
174
|
+
* like something broke.
|
|
175
|
+
*
|
|
176
|
+
* So plain conversation sends ONLY the person's own words. No headings, no tool list, no marker of any
|
|
177
|
+
* kind. Tools are described only on turns that actually carry tools, and that is the one case where
|
|
178
|
+
* visible scaffolding is unavoidable: the model cannot call a tool it was never told about.
|
|
179
|
+
*/
|
|
180
|
+
export function buildPrompt(options) {
|
|
181
|
+
const system = typeof options.system === 'string' ? options.system.trim() : '';
|
|
182
|
+
const tools = Array.isArray(options.tools) ? options.tools : [];
|
|
183
|
+
|
|
184
|
+
/*
|
|
185
|
+
* A greeting carries no task, so the tool list would only invite "what would you like me to do?".
|
|
186
|
+
* Judged from the person's newest message, which is what this call is answering.
|
|
187
|
+
*/
|
|
188
|
+
const newest = [...(options.messages ?? [])]
|
|
189
|
+
.reverse()
|
|
190
|
+
.find((m) => {
|
|
191
|
+
if (m?.role !== 'user') return false;
|
|
192
|
+
const kind = m?.source?.kind;
|
|
193
|
+
return typeof kind !== 'string' || kind === 'user';
|
|
194
|
+
});
|
|
195
|
+
const greetingOnly = isOnlyGreeting(messageText(newest));
|
|
196
|
+
const preamble = greetingOnly ? '' : toolPreamble(tools);
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Tell the model which language to answer in.
|
|
200
|
+
*
|
|
201
|
+
* WHY THIS EXISTS
|
|
202
|
+
* The panel can be in Spanish while the message reaching the page is a single word - "hola". The web
|
|
203
|
+
* model has nothing to infer from and answers in English, which looks like the plugin ignored the
|
|
204
|
+
* reader's choice. It can only answer in their language if it is told, and it has to be told the same
|
|
205
|
+
* way a person would: one short line in the message.
|
|
206
|
+
*
|
|
207
|
+
* WHY IT IS SENT IN ENGLISH
|
|
208
|
+
* The instruction itself is English, because that is the language the page reasons in. The language
|
|
209
|
+
* NAME comes from each locale's `languageNameEn`, so it is always unambiguous - "Spanish", not
|
|
210
|
+
* "Español", "Japanese" not "日本語".
|
|
211
|
+
*
|
|
212
|
+
* WHY IT IS OMITTED FOR ENGLISH
|
|
213
|
+
* An English reader asking in English needs no instruction, and adding one would put a line of
|
|
214
|
+
* scaffolding in front of every message for no reason.
|
|
215
|
+
*
|
|
216
|
+
* WHY IT COMES AFTER THE TOOL LINE
|
|
217
|
+
* The tool list is machinery; the language is a request about the answer. Instructions about the
|
|
218
|
+
* reply belong next to the words being replied to, which is where a person would put them.
|
|
219
|
+
*/
|
|
220
|
+
const answerIn = typeof options.answerIn === 'string' ? options.answerIn.trim() : '';
|
|
221
|
+
const languageLine = answerIn && answerIn.toLowerCase() !== 'english' ? `[Answer in ${answerIn}]` : '';
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* When the browser chat continues, it already holds every earlier turn.
|
|
225
|
+
*
|
|
226
|
+
* Re-sending the transcript on top of a chat that remembers it duplicates the whole conversation, and
|
|
227
|
+
* the model then answers a question it was asked twice. So a continuing call sends only the newest user
|
|
228
|
+
* message, plus any system instructions the browser has no way to know about.
|
|
229
|
+
*/
|
|
230
|
+
const continuing = options.continueChat === true;
|
|
231
|
+
|
|
232
|
+
const turns = [];
|
|
233
|
+
for (const message of options.messages ?? []) {
|
|
234
|
+
const role = message?.role;
|
|
235
|
+
const text = messageText(message);
|
|
236
|
+
if (!text) continue;
|
|
237
|
+
if (role === 'user') {
|
|
238
|
+
/*
|
|
239
|
+
* ONLY WHAT THE PERSON TYPED.
|
|
240
|
+
*
|
|
241
|
+
* The harness marks a message the person wrote with `source.kind === 'user'`. It also files a dozen
|
|
242
|
+
* kinds of its own machinery under `role: 'user'` - time context, runtime context, the skill catalog,
|
|
243
|
+
* goal notes, tool-job notices and, most damagingly, the "[model changed: …]" line written when the
|
|
244
|
+
* model is switched mid-conversation.
|
|
245
|
+
*
|
|
246
|
+
* Measured in one real transcript: of 1112 `role: 'user'` entries, 770 were the person and 342 were
|
|
247
|
+
* machinery. Treating the machinery as conversation meant a mid-chat switch could REPLACE the
|
|
248
|
+
* question - recorded happening: the search query became "[model changed: assistant turns above this
|
|
249
|
+
* point were generated by …]" and the answer was written from memory because the search it ran was
|
|
250
|
+
* meaningless.
|
|
251
|
+
*
|
|
252
|
+
* A message with no `source` at all is kept: the API path and older transcripts do not carry one, and
|
|
253
|
+
* dropping those would break ordinary use. Only an explicitly non-user source is excluded.
|
|
254
|
+
*/
|
|
255
|
+
const kind = message?.source?.kind;
|
|
256
|
+
if (typeof kind === 'string' && kind !== 'user') continue;
|
|
257
|
+
turns.push({ role: 'user', text });
|
|
258
|
+
} else if (role === 'assistant') turns.push({ role: 'assistant', text });
|
|
259
|
+
else if (role === 'tool') turns.push({ role: 'tool', text });
|
|
260
|
+
else if (role === 'system' || role === 'developer') turns.push({ role: 'system', text });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Plain conversation: send the words, nothing else.
|
|
265
|
+
*
|
|
266
|
+
* This is the common case and the one the person actually watches happen in the browser. The harness's
|
|
267
|
+
* system prompt and tool list are deliberately dropped here - they exist to steer an API model, and
|
|
268
|
+
* pasting them into a chat box produces exactly the wall of instructions that looked like a malfunction.
|
|
269
|
+
* The model still answers the question; it simply is not told about machinery it will not use.
|
|
270
|
+
*/
|
|
271
|
+
const hasTools = Boolean(preamble);
|
|
272
|
+
const onlyOneUserTurn = turns.length === 1 && turns[0].role === 'user';
|
|
273
|
+
|
|
274
|
+
if (!hasTools && (!system || onlyOneUserTurn)) {
|
|
275
|
+
const lastUser = [...turns].reverse().find((t) => t.role === 'user');
|
|
276
|
+
if (lastUser) {
|
|
277
|
+
/*
|
|
278
|
+
* The clean path: their words, and the language line only when it is needed.
|
|
279
|
+
*
|
|
280
|
+
* A continuing chat already has the history, so only this turn is typed. A fresh one gets the same
|
|
281
|
+
* treatment when there is nothing else to send, which is the pristine case. Even here the language
|
|
282
|
+
* line is kept, because it is the case where the answer is most likely to come back in the wrong
|
|
283
|
+
* language - one short message with nothing to infer from.
|
|
284
|
+
*/
|
|
285
|
+
const text = languageLine ? `${languageLine}\n\n${lastUser.text}` : lastUser.text;
|
|
286
|
+
return finish(text, false, continuing);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (continuing) {
|
|
291
|
+
const lastUser = [...turns].reverse().find((t) => t.role === 'user');
|
|
292
|
+
const sections = [];
|
|
293
|
+
if (preamble) sections.push(preamble);
|
|
294
|
+
// An instruction about the answer sits next to the question it is about.
|
|
295
|
+
if (languageLine) sections.push(languageLine);
|
|
296
|
+
// The harness system prompt is NOT sent here. It exists to steer an API model, and typing it into a
|
|
297
|
+
// chat box puts a wall of instructions in front of the person using that browser tab. The web model
|
|
298
|
+
// answers the question perfectly well without being told it is a coding agent with a shell.
|
|
299
|
+
if (lastUser) sections.push(lastUser.text);
|
|
300
|
+
return finish(sections.join('\n\n'), Boolean(preamble), true);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (onlyOneUserTurn && !system && !preamble) {
|
|
304
|
+
const text = languageLine ? `${languageLine}\n\n${turns[0].text}` : turns[0].text;
|
|
305
|
+
return finish(text, false, false);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const sections = [];
|
|
309
|
+
// Same reasoning as the continuing branch: no harness system prompt in the visible text.
|
|
310
|
+
if (preamble) sections.push(preamble);
|
|
311
|
+
if (languageLine) sections.push(languageLine);
|
|
312
|
+
|
|
313
|
+
if (turns.length === 1) {
|
|
314
|
+
sections.push(turns[0].text);
|
|
315
|
+
} else {
|
|
316
|
+
const lines = ['# Conversation so far'];
|
|
317
|
+
for (const turn of turns) {
|
|
318
|
+
const label =
|
|
319
|
+
turn.role === 'user'
|
|
320
|
+
? 'User'
|
|
321
|
+
: turn.role === 'assistant'
|
|
322
|
+
? 'Assistant'
|
|
323
|
+
: turn.role === 'tool'
|
|
324
|
+
? 'Tool result'
|
|
325
|
+
: 'System note';
|
|
326
|
+
lines.push(`${label}: ${turn.text}`);
|
|
327
|
+
}
|
|
328
|
+
lines.push('Assistant (reply now, continuing the conversation):');
|
|
329
|
+
sections.push(lines.join('\n\n'));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return finish(sections.join('\n\n'), Boolean(preamble), false);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Enforce the total budget, keeping the end of the conversation.
|
|
337
|
+
*
|
|
338
|
+
* If the assembled prompt is over the limit the oldest material goes first, because the newest turns are
|
|
339
|
+
* what the person is actually asking about. Trimming is done at a turn boundary where possible and stated
|
|
340
|
+
* in the text, so the model knows it is missing context rather than silently answering a half-question.
|
|
341
|
+
*
|
|
342
|
+
* This is a backstop: the tool budget above should normally keep the prompt well under the limit, but a
|
|
343
|
+
* long conversation or an unusually large system prompt could still exceed it, and the failure mode is
|
|
344
|
+
* silence from the site, which is the worst kind.
|
|
345
|
+
*/
|
|
346
|
+
function finish(text, expectsToolCall, continuing) {
|
|
347
|
+
if (text.length <= MAX_PROMPT_CHARS) return { text, expectsToolCall, continuing, truncated: false };
|
|
348
|
+
|
|
349
|
+
const room = MAX_PROMPT_CHARS - 200;
|
|
350
|
+
const cut = text.length - room;
|
|
351
|
+
const head = text.slice(0, 400);
|
|
352
|
+
const tail = text.slice(text.length - (room - 400));
|
|
353
|
+
const notice =
|
|
354
|
+
`\n\n[Earlier part of this conversation omitted - ${cut} characters - to fit the message limit.]\n\n`;
|
|
355
|
+
return {
|
|
356
|
+
text: head + notice + tail,
|
|
357
|
+
expectsToolCall,
|
|
358
|
+
continuing,
|
|
359
|
+
truncated: true,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Recover a tool call from a plain-text reply, if the model made one.
|
|
365
|
+
*
|
|
366
|
+
* The reply may be bare JSON, fenced JSON, or prose with JSON inside it. Fenced blocks are searched
|
|
367
|
+
* last-first because a model that explains itself tends to put the final answer at the end. A reply
|
|
368
|
+
* that merely CONTAINS the word tool but no valid object is treated as prose, because treating it as a
|
|
369
|
+
* malformed call would throw away a perfectly good answer.
|
|
370
|
+
*/
|
|
371
|
+
export function parseToolCall(reply) {
|
|
372
|
+
const text = String(reply ?? '').trim();
|
|
373
|
+
if (!text) return null;
|
|
374
|
+
|
|
375
|
+
const candidates = [];
|
|
376
|
+
// fenced blocks first
|
|
377
|
+
const fence = /```(?:json)?\s*([\s\S]*?)```/gi;
|
|
378
|
+
let m;
|
|
379
|
+
while ((m = fence.exec(text)) !== null) candidates.push(m[1].trim());
|
|
380
|
+
// then the whole reply, then any balanced-looking object
|
|
381
|
+
candidates.push(text);
|
|
382
|
+
const brace = text.indexOf('{');
|
|
383
|
+
const lastBrace = text.lastIndexOf('}');
|
|
384
|
+
if (brace >= 0 && lastBrace > brace) candidates.push(text.slice(brace, lastBrace + 1));
|
|
385
|
+
|
|
386
|
+
for (const raw of candidates.reverse()) {
|
|
387
|
+
try {
|
|
388
|
+
const parsed = JSON.parse(raw);
|
|
389
|
+
if (!parsed || typeof parsed !== 'object') continue;
|
|
390
|
+
const name = parsed.tool ?? parsed.name ?? parsed.function;
|
|
391
|
+
if (typeof name !== 'string' || !name) continue;
|
|
392
|
+
let args = parsed.arguments ?? parsed.args ?? parsed.parameters ?? {};
|
|
393
|
+
if (typeof args === 'string') {
|
|
394
|
+
try {
|
|
395
|
+
args = JSON.parse(args);
|
|
396
|
+
} catch {
|
|
397
|
+
/* leave as the raw string */
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { name, arguments: typeof args === 'string' ? args : JSON.stringify(args) };
|
|
401
|
+
} catch {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Strip a tool-call JSON blob out of a reply that also contained prose, for the text channel.
|
|
410
|
+
*
|
|
411
|
+
* ASKING IS NOT ENOUGH
|
|
412
|
+
* The preamble tells the model to emit only the object, and it leaked anyway: a call appeared as plain
|
|
413
|
+
* words in the reply, followed by prose. What the person sees in that case is a raw {"tool": ...}
|
|
414
|
+
* sitting in their answer, which looks like the plugin broke.
|
|
415
|
+
*
|
|
416
|
+
* The old pattern also only matched a call at the END of the text with the anchor `\{\s*"tool"...$`,
|
|
417
|
+
* so a call emitted FIRST survived scrubbing. This removes the object wherever it appears, with or
|
|
418
|
+
* without a fence, with or without a closing brace, plus a stray narrator line a describing model
|
|
419
|
+
* leaves behind.
|
|
420
|
+
*/
|
|
421
|
+
/**
|
|
422
|
+
* How much of a growing reply is safe to show while it is still arriving.
|
|
423
|
+
*
|
|
424
|
+
* THE BUG THIS EXISTS FOR
|
|
425
|
+
* A reply arrived as prose followed by a call: `I'll test the reply speed… {"tool":"x","arguments":{}}`.
|
|
426
|
+
* The old code decided how to handle a reply by looking only at its FIRST character - a reply opening
|
|
427
|
+
* with a brace was buffered, everything else streamed straight through. This one opened with prose, so
|
|
428
|
+
* the whole thing was streamed live, JSON included, and scrubbing afterwards could only fix a copy
|
|
429
|
+
* nobody would see. Recorded on screen as a raw tool envelope inside an answer.
|
|
430
|
+
*
|
|
431
|
+
* WHAT IT DOES
|
|
432
|
+
* Returns the index beyond which the text must be held: the first brace that could still turn out to be
|
|
433
|
+
* the start of a tool object. `{"tool"` is certainly one. A brace whose following characters could
|
|
434
|
+
* still grow into `"tool"` is *unresolved*, so it is held; a brace that has already diverged (`{a`) can
|
|
435
|
+
* never become a call and is released immediately. That keeps streaming for ordinary prose, including
|
|
436
|
+
* prose that merely contains a brace.
|
|
437
|
+
*
|
|
438
|
+
* WHAT IT IS NOT
|
|
439
|
+
* It does not decide whether a call exists - `parseToolCall` does that at the end. It only answers
|
|
440
|
+
* "how far can I safely show right now", which is the question a live stream needs.
|
|
441
|
+
*/
|
|
442
|
+
export function streamSafeCut(text) {
|
|
443
|
+
const s = String(text ?? '');
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Could the brace at `i` still turn out to be the start of a tool object?
|
|
447
|
+
*
|
|
448
|
+
* Returns 'hold' when it might (`{`, `{"`, `{"t`, `{"tool`, `{"tool"`, `{"tool":`), and 'no' when the
|
|
449
|
+
* characters after it have already ruled it out (`{a`, `{"name"`). Only 'hold' stops the stream.
|
|
450
|
+
*/
|
|
451
|
+
const candidate = (i) => {
|
|
452
|
+
let j = i + 1;
|
|
453
|
+
while (j < s.length && /\s/.test(s[j])) j += 1;
|
|
454
|
+
if (j >= s.length) return 'hold'; // `{` alone - the next character decides
|
|
455
|
+
if (s[j] !== '"') return 'no'; // can never become `{"tool"`
|
|
456
|
+
|
|
457
|
+
j += 1;
|
|
458
|
+
let word = '';
|
|
459
|
+
while (j < s.length && /[a-zA-Z]/.test(s[j])) {
|
|
460
|
+
word += s[j];
|
|
461
|
+
j += 1;
|
|
462
|
+
}
|
|
463
|
+
if (!'tool'.startsWith(word.toLowerCase())) return 'no';
|
|
464
|
+
if (word.length < 4) return 'hold'; // `{"t` - still growing
|
|
465
|
+
if (j >= s.length) return 'hold'; // `{"tool` - the closing quote may be coming
|
|
466
|
+
return s[j] === '"' ? 'hold' : 'no'; // `{"tool"` is a key; `{"toolx` is not
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
for (let i = s.indexOf('{'); i !== -1; i = s.indexOf('{', i + 1)) {
|
|
470
|
+
if (candidate(i) === 'hold') return i;
|
|
471
|
+
}
|
|
472
|
+
return s.length;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function stripToolCall(reply) {
|
|
476
|
+
let text = String(reply ?? '');
|
|
477
|
+
|
|
478
|
+
// Fenced calls first: the fence marks the whole block, so no brace counting is needed.
|
|
479
|
+
text = text.replace(/```(?:json)?\s*\{\s*"tool"[\s\S]*?\}\s*```/gi, '');
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Remove bare tool objects by counting braces rather than matching them with a pattern.
|
|
483
|
+
*
|
|
484
|
+
* A regex cannot do this job. The call `{"tool":"x","arguments":{}}` contains TWO closing braces, and
|
|
485
|
+
* every lazy pattern stops at the first one - which deleted most of the object and left `}I'll start by…`
|
|
486
|
+
* behind, exactly as recorded. Counting depth is unambiguous and handles nested argument objects too.
|
|
487
|
+
*/
|
|
488
|
+
const OPEN = /\{\s*"tool"\s*:/i;
|
|
489
|
+
for (let guard = 0; guard < 20; guard += 1) {
|
|
490
|
+
const match = OPEN.exec(text);
|
|
491
|
+
if (!match) break;
|
|
492
|
+
const start = match.index;
|
|
493
|
+
let depth = 0;
|
|
494
|
+
let end = -1;
|
|
495
|
+
for (let i = start; i < text.length; i += 1) {
|
|
496
|
+
if (text[i] === '{') depth += 1;
|
|
497
|
+
else if (text[i] === '}') {
|
|
498
|
+
depth -= 1;
|
|
499
|
+
if (depth === 0) {
|
|
500
|
+
end = i;
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (end === -1) {
|
|
506
|
+
// Unterminated: the stream was cut mid-call. Drop everything from the opening brace onward.
|
|
507
|
+
text = text.slice(0, start);
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
text = text.slice(0, start) + text.slice(end + 1);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return text
|
|
514
|
+
// A narrator line a describing model leaves behind.
|
|
515
|
+
.replace(/^\s*(?:tool|function)\s*:\s*[\w.-]+\s*$/gim, '')
|
|
516
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
517
|
+
.trim();
|
|
518
|
+
}
|
package/lib/savings.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session savings meter.
|
|
3
|
+
*
|
|
4
|
+
* Copyright (c) 2026 Operating Tag. All rights reserved. Released under the CC BY-NC-ND 4.0 licence.
|
|
5
|
+
*
|
|
6
|
+
* @file savings.mjs
|
|
7
|
+
* @version 3.9.11
|
|
8
|
+
* @author Operating Tag (@operatingtag)
|
|
9
|
+
* @license CC-BY-NC-ND-4.0
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS EXISTS
|
|
12
|
+
* The whole reason to use Uncle Sam is that web replies do not cost money per token. Until now that
|
|
13
|
+
* was a claim the user had to take on trust. This counts what actually went through the web route and
|
|
14
|
+
* prices it at the published API rate, so the saving is a number that climbs in front of them.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IS COUNTED, AND WHAT IS HONEST ABOUT IT
|
|
17
|
+
* Tokens are ESTIMATED from character counts, not measured - the web page does not report usage. The
|
|
18
|
+
* estimate is the standard four-characters-per-token rule, which is close for English and English
|
|
19
|
+
* code and drifts for other scripts. So the figure is labelled as an estimate everywhere it appears,
|
|
20
|
+
* and the price is a configurable rate rather than a promise.
|
|
21
|
+
*
|
|
22
|
+
* Only calls that really went through the page are counted. A call that failed, or that the API
|
|
23
|
+
* served, contributes nothing - counting those would inflate the number the product is sold on.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Published DeepSeek API prices, in US dollars per one million tokens.
|
|
28
|
+
*
|
|
29
|
+
* These are the rates a person would actually pay for the same work through the API, which is the number
|
|
30
|
+
* the saving is measured against. They change; the caller may override them from configuration, and the
|
|
31
|
+
* panel states which rate it used.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_PRICES = {
|
|
34
|
+
inputPerMTok: 0.27,
|
|
35
|
+
outputPerMTok: 1.10,
|
|
36
|
+
currency: 'USD',
|
|
37
|
+
label: 'DeepSeek API list price',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Four characters per token, the same rule the rest of the plugin uses for usage reporting. */
|
|
41
|
+
export function estimateTokens(text) {
|
|
42
|
+
return Math.max(1, Math.round(String(text ?? '').length / 4));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function money(tokens, perMTok) {
|
|
46
|
+
return (tokens / 1_000_000) * perMTok;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Running totals for one plugin lifetime.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately in memory only: a figure that survives restarts invites people to treat it as billed
|
|
53
|
+
* truth, and it is an estimate. A session is a session.
|
|
54
|
+
*/
|
|
55
|
+
export class SavingsMeter {
|
|
56
|
+
#calls = 0;
|
|
57
|
+
#inputTokens = 0;
|
|
58
|
+
#outputTokens = 0;
|
|
59
|
+
#startedAt = Date.now();
|
|
60
|
+
#lastCallAt = null;
|
|
61
|
+
#prices;
|
|
62
|
+
|
|
63
|
+
constructor(prices = {}) {
|
|
64
|
+
/*
|
|
65
|
+
* Only override a default with a number that really is a number.
|
|
66
|
+
*
|
|
67
|
+
* The prices arrive from plugin configuration, where an unset field is not absent - it is `undefined`,
|
|
68
|
+
* and an explicit `undefined` still overwrites the default when spread. That silently priced output at
|
|
69
|
+
* `undefined`, which turns the whole figure into NaN and arrives at the panel as null. A non-numeric
|
|
70
|
+
* rate now falls back to the published default instead of poisoning the total.
|
|
71
|
+
*/
|
|
72
|
+
const given = prices && typeof prices === 'object' ? prices : {};
|
|
73
|
+
const pick = (value, fallback) => {
|
|
74
|
+
const n = Number(value);
|
|
75
|
+
return value !== undefined && value !== null && Number.isFinite(n) ? n : fallback;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
this.#prices = {
|
|
79
|
+
...DEFAULT_PRICES,
|
|
80
|
+
inputPerMTok: pick(given.inputPerMTok, DEFAULT_PRICES.inputPerMTok),
|
|
81
|
+
outputPerMTok: pick(given.outputPerMTok, DEFAULT_PRICES.outputPerMTok),
|
|
82
|
+
currency:
|
|
83
|
+
typeof given.currency === 'string' && given.currency.trim()
|
|
84
|
+
? given.currency.trim().toUpperCase()
|
|
85
|
+
: DEFAULT_PRICES.currency,
|
|
86
|
+
label:
|
|
87
|
+
typeof given.label === 'string' && given.label.trim() ? given.label.trim() : DEFAULT_PRICES.label,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get prices() {
|
|
92
|
+
return { ...this.#prices };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Record one completed web call. Called only when the reply actually came back. */
|
|
96
|
+
record({ inputTokens = 0, outputTokens = 0 } = {}) {
|
|
97
|
+
this.#calls += 1;
|
|
98
|
+
this.#inputTokens += Math.max(0, Number(inputTokens) || 0);
|
|
99
|
+
this.#outputTokens += Math.max(0, Number(outputTokens) || 0);
|
|
100
|
+
this.#lastCallAt = Date.now();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Everything the panel needs, with the money already worked out. */
|
|
104
|
+
snapshot() {
|
|
105
|
+
const input = this.#inputTokens;
|
|
106
|
+
const output = this.#outputTokens;
|
|
107
|
+
const total = input + output;
|
|
108
|
+
const inputValue = money(input, this.#prices.inputPerMTok);
|
|
109
|
+
const outputValue = money(output, this.#prices.outputPerMTok);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
calls: this.#calls,
|
|
113
|
+
inputTokens: input,
|
|
114
|
+
outputTokens: output,
|
|
115
|
+
totalTokens: total,
|
|
116
|
+
/** What the same tokens would have cost through the API. */
|
|
117
|
+
saved: Number((inputValue + outputValue).toFixed(4)),
|
|
118
|
+
currency: this.#prices.currency,
|
|
119
|
+
priceLabel: this.#prices.label,
|
|
120
|
+
estimated: true,
|
|
121
|
+
startedAt: this.#startedAt,
|
|
122
|
+
lastCallAt: this.#lastCallAt,
|
|
123
|
+
// A human-readable line the panel can show without formatting anything itself.
|
|
124
|
+
summary:
|
|
125
|
+
this.#calls === 0
|
|
126
|
+
? 'No web calls yet this session'
|
|
127
|
+
: `${this.#calls} call${this.#calls === 1 ? '' : 's'} · ~${total.toLocaleString()} tokens · ` +
|
|
128
|
+
`~${this.#prices.currency} ${(inputValue + outputValue).toFixed(2)} saved`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|