@lsfcy/dsh-composer-predictions 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/index.js ADDED
@@ -0,0 +1,812 @@
1
+ // Host half of the composer-predictions plugin.
2
+ //
3
+ // It owns exactly one thing: POST /composer-predictions/predict, a loopback
4
+ // route the browser half calls after a turn settles. The route reads the
5
+ // session's own derived messages, asks the configured model for ONE plausible
6
+ // next user message, and answers { text }. Nothing is written to the session
7
+ // log — a prediction is not history, it is a hint, so it must never enter the
8
+ // context the agent sees.
9
+ //
10
+ // Two details are easy to get wrong and are handled here instead of in the UI:
11
+ //
12
+ // * A session transcript contains synthetic `user` messages (runtime context,
13
+ // skill catalogue, plan/PTC mode, subagent notices). Only messages the human
14
+ // actually sent may feed a prediction, otherwise the model reads a wall of
15
+ // harness boilerplate and predicts harness instructions.
16
+ // * The selected model may be a reasoning model. Predictions are a tiny,
17
+ // latency-sensitive call, so the host asks the adapter for the cheapest
18
+ // supported reasoning effort ("off" when it exists) and prefers a cheap
19
+ // model on the user's provider.
20
+ //
21
+ // Loaded by the bundle row in cordis.patch.yml.
22
+
23
+ import z from '@deepseek-ai/schemastery'
24
+
25
+ export const name = 'composer-predictions'
26
+ export const GENERATOR_VERSION = '0.1.0'
27
+ // webServer: the route; sessions: the transcript; llm: the model call.
28
+ export const inject = ['webServer', 'sessions', 'llm']
29
+
30
+ const PREDICT_PATH = '/composer-predictions/predict'
31
+ const CONFIG_PATH = '/composer-predictions/config'
32
+ const BODY_LIMIT_BYTES = 8 * 1024
33
+
34
+ const CHEAP_MODEL = /\b(flash|mini|small|lite|fast|haiku|nano)\b/i
35
+ const EFFORT_PREFERENCE = ['off', 'none', 'minimal', 'low']
36
+
37
+ // Codex keeps the suggestion with the thread, so switching threads shows the
38
+ // suggestion that thread already earned instead of an empty composer. Mirror
39
+ // that: one entry per session, valid only while the session's last seq is the
40
+ // one the suggestion was generated from. In memory only — a hint is not
41
+ // history, and restarting the process should not resurrect a stale line.
42
+ const CACHE_LIMIT = 40
43
+ const CACHE_TTL_MS = 30 * 60 * 1000
44
+ const predictionCache = new Map()
45
+
46
+ export const DEFAULTS = Object.freeze({
47
+ // Master switch. false stops every request and hides the ghost.
48
+ enabled: true,
49
+ // Reasoning models spend these tokens before any text exists; below ~256 the
50
+ // answer is often an empty string even though the call "succeeded".
51
+ maxTokens: 512,
52
+ historyMessages: 12,
53
+ timeoutMs: 20_000,
54
+ perMessageChars: 1_600,
55
+ totalChars: 6_000,
56
+ maxPredictionChars: 160,
57
+ })
58
+
59
+ const SYSTEM_PROMPT = [
60
+ 'You suggest the next message a developer will type in a coding-assistant chat.',
61
+ '',
62
+ "The excerpt ends with the assistant's last reply. Predict the one message the user is most likely to send next, as if they were typing it themselves.",
63
+ '',
64
+ 'Rules:',
65
+ "- Write in the user's language and register, with a similar amount of detail.",
66
+ '- Be concrete: reuse the file names, functions, commands, error text, or numbers that appear in the excerpt.',
67
+ "- Write the user's own request or question. Never reply as the assistant, never mention predicting, and never add commentary about the request.",
68
+ "- Ground every claim in the context. Do not invent that the user has already observed a result, approved or sent anything, or that a fix is confirmed: an assistant's claim is not proof the user accepted it.",
69
+ '- A suggestion is one message, not an agenda: respond to what just happened or continue the task naturally. Do not append extra next steps the user never asked for.',
70
+ '- One line of plain text. Stay under roughly 60 characters for Chinese, Japanese, or Korean, and 120 characters otherwise.',
71
+ '- No greeting, no preamble, no quotes, no markdown, no trailing ellipsis.',
72
+ '- If no concrete next message is supported by the excerpt, output exactly: SKIP',
73
+ ].join('\n')
74
+
75
+ const PROMPT_HEADER = 'Conversation excerpt (oldest first):\n\n'
76
+ const PROMPT_FOOTER = "\n\nWrite the user's next message."
77
+
78
+ /** Concatenate the text blocks of one message; non-text blocks are ignored. */
79
+ export function textOf(message) {
80
+ const content = message && message.content
81
+ if (typeof content === 'string') return content.trim()
82
+ if (!Array.isArray(content)) return ''
83
+ const parts = []
84
+ for (const block of content) {
85
+ if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text)
86
+ }
87
+ return parts.join('\n').trim()
88
+ }
89
+
90
+ /**
91
+ * Whether one `user`-role message was actually written by the human.
92
+ *
93
+ * `Session.deriveMessages()` replays every synthetic prompt input as a
94
+ * user-role message: runtime context, agent instructions, the skill catalogue,
95
+ * plan/PTC mode notices, time context, and subagent relays all carry their own
96
+ * `source.kind`. Only `kind: 'user'` (or a hand-built message with no source,
97
+ * which is what tests use) is the user's own voice.
98
+ */
99
+ export function isUserAuthored(message) {
100
+ if (!message || message.role !== 'user') return false
101
+ const source = message.source
102
+ if (source === undefined || source === null) return true
103
+ return source.kind === 'user'
104
+ }
105
+
106
+ /** Keep only the messages a prediction should read, oldest first. */
107
+ function conversationMessages(messages) {
108
+ const rows = []
109
+ for (const message of Array.isArray(messages) ? messages : []) {
110
+ const role = message && message.role
111
+ if (role !== 'user' && role !== 'assistant') continue
112
+ if (role === 'user' && !isUserAuthored(message)) continue
113
+ const text = textOf(message)
114
+ if (!text) continue
115
+ rows.push({ role, text })
116
+ }
117
+ return rows
118
+ }
119
+
120
+ /**
121
+ * One message clipped to at most `limit` characters.
122
+ *
123
+ * A long assistant reply carries its conclusion at the END ("fixed", "tests
124
+ * pass", "press Cmd+R"). Head-only clipping used to hide exactly that part and
125
+ * leave the model mid-debugging, so it kept predicting another bug report, so
126
+ * assistant text keeps both ends. User requests keep their head. The result
127
+ * never exceeds `limit`: the ellipsis is counted inside the budget.
128
+ */
129
+ function clipMessage(text, limit, role) {
130
+ const max = Math.floor(Math.max(0, Number(limit)))
131
+ if (max <= 0) return ''
132
+ if (text.length <= max) return text
133
+ if (role !== 'assistant') {
134
+ return max <= 1 ? text.slice(0, max) : text.slice(0, max - 1) + '…'
135
+ }
136
+ const sep = ' … '
137
+ if (max <= sep.length) return text.slice(0, max)
138
+ const head = Math.max(0, Math.round(max * 0.35))
139
+ const tail = max - head - sep.length
140
+ return tail > 0
141
+ ? text.slice(0, head) + sep + text.slice(text.length - tail)
142
+ : text.slice(0, max)
143
+ }
144
+
145
+ /**
146
+ * Render the model-visible history as a plain transcript, newest last.
147
+ *
148
+ * The current turn — the last user request and the assistant reply that just
149
+ * answered it — is mandatory and budgeted first: a prediction without the
150
+ * request that earned the reply, or without the reply's conclusion, is noise.
151
+ * Older context only uses leftover budget. The newest assistant reply gets a
152
+ * larger per-message budget than the rest.
153
+ */
154
+ export function buildTranscript(messages, options = {}) {
155
+ const limit = options.historyMessages ?? DEFAULTS.historyMessages
156
+ const perMessage = options.perMessageChars ?? DEFAULTS.perMessageChars
157
+ const total = options.totalChars ?? DEFAULTS.totalChars
158
+ const rows = conversationMessages(messages)
159
+ if (rows.length === 0) return ''
160
+
161
+ const label = (role) => (role === 'user' ? 'User: ' : 'Assistant: ')
162
+ const line = (role, body) => label(role) + body
163
+
164
+ // Locate the current turn: it begins at the last user-authored message.
165
+ let lastUser = -1
166
+ for (let index = rows.length - 1; index >= 0 && lastUser === -1; index -= 1) {
167
+ if (rows[index].role === 'user') lastUser = index
168
+ }
169
+ const newestAssistant = rows[rows.length - 1].role === 'assistant'
170
+ const paired = newestAssistant && lastUser >= 0 && lastUser < rows.length - 1
171
+
172
+ // --- paired path: reserve budget for [last user, newest assistant] ---
173
+ if (paired) {
174
+ const userRow = rows[lastUser]
175
+ const assistantRow = rows[rows.length - 1]
176
+ const overhead = label('user').length + label('assistant').length + 2 // '\n\n'
177
+ let assistantBudget = Math.round(perMessage * 1.5)
178
+ let userBudget = perMessage
179
+ const available = total - overhead
180
+ // Shrink both bodies until they fit, keeping them proportional.
181
+ const want = assistantBudget + userBudget
182
+ if (want > available) {
183
+ const factor = available / want
184
+ assistantBudget = Math.max(0, Math.floor(assistantBudget * factor))
185
+ userBudget = Math.max(0, Math.floor(userBudget * factor))
186
+ }
187
+ const pairText = line('user', clipMessage(userRow.text, userBudget, 'user')) +
188
+ '\n\n' +
189
+ line('assistant', clipMessage(assistantRow.text, assistantBudget, 'assistant'))
190
+
191
+ // Older context, newest first, within the leftover budget after the pair.
192
+ const olderPieces = []
193
+ let olderUsed = 2 // the '\n\n' that would join older text to the pair
194
+ for (let index = lastUser - 1; index >= 0; index -= 1) {
195
+ const row = rows[index]
196
+ const room = total - pairText.length - olderUsed - label(row.role).length
197
+ if (room < 1) break
198
+ olderPieces.push(line(row.role, clipMessage(row.text, Math.min(room, perMessage), row.role)))
199
+ olderUsed += olderPieces[olderPieces.length - 1].length + 2
200
+ if (olderPieces.length >= Math.max(0, limit - 2)) break
201
+ }
202
+ olderPieces.reverse()
203
+ const olderText = olderPieces.join('\n\n')
204
+ return olderText ? olderText + '\n\n' + pairText : pairText
205
+ }
206
+
207
+ // --- uniform path: no user/assistant pairing to preserve ---
208
+ const selected = rows.slice(-Math.max(1, limit))
209
+ const pieces = []
210
+ let used = 0
211
+ for (let index = selected.length - 1; index >= 0; index -= 1) {
212
+ const row = selected[index]
213
+ const room = total - used - (pieces.length ? 2 : 0) - label(row.role).length
214
+ if (room < 1 && pieces.length) break
215
+ pieces.push(line(row.role, clipMessage(row.text, Math.min(Math.max(0, room), perMessage), row.role)))
216
+ used += pieces[pieces.length - 1].length + (pieces.length > 1 ? 2 : 0)
217
+ if (used >= total) break
218
+ }
219
+ pieces.reverse()
220
+ return pieces.join('\n\n')
221
+ }
222
+
223
+ /**
224
+ * The role of the newest message a human would recognise. Used to refuse a
225
+ * prediction when the turn did not actually end on an assistant reply (an
226
+ * aborted turn, a queued message, or a failed request).
227
+ */
228
+ export function lastConversationRole(messages) {
229
+ const rows = conversationMessages(messages)
230
+ return rows.length > 0 ? rows[rows.length - 1].role : undefined
231
+ }
232
+
233
+ /** One display line, or '' when the model declined (SKIP) or said nothing usable. */
234
+ export function normalizePrediction(raw, maxLength = DEFAULTS.maxPredictionChars) {
235
+ const limit = Number.isFinite(Number(maxLength)) ? Math.max(40, Math.round(Number(maxLength))) : DEFAULTS.maxPredictionChars
236
+ let text = String(raw == null ? '' : raw).replace(/\r/g, '').trim()
237
+ if (!text || /^skip\b/i.test(text)) return ''
238
+ // A small model occasionally wraps the line in a code fence or a label.
239
+ text = text.replace(/^```[a-z]*\s*/i, '').replace(/```\s*$/, '').trim()
240
+ const firstLine = text.split('\n').map((line) => line.trim()).find((line) => line !== '') || ''
241
+ const line = firstLine
242
+ .replace(/^[-*•]\s+/, '')
243
+ .replace(/^(?:user|用户)\s*[::]\s*/i, '')
244
+ .replace(/^(?:suggestion|prediction|next message|建议|预测)\s*[::]\s*/i, '')
245
+ .replace(/^[`"'“”「」『』]+/, '')
246
+ .replace(/[`"'“”「」『』]+$/, '')
247
+ .trim()
248
+ if (!line || /^skip\b/i.test(line)) return ''
249
+ // The model sometimes answers as the assistant ("Sure, I've updated…") instead
250
+ // of writing the user's own message. Borrowed from the sibling npm plugin's
251
+ // output guard: reject English assistant openers, keep Chinese ones (a user
252
+ // legitimately starts with "好的/已…").
253
+ if (/^(?:sure|certainly|of course|absolutely|done|here(?:'s| is| are)|i(?:'ve| have)? (?:updated|fixed|added|changed|created|removed|refactored))\b/i.test(line)) {
254
+ return ''
255
+ }
256
+ if (line.length <= limit) return line
257
+ return line.slice(0, limit).replace(/[\s,,、;;::.。]+$/, '')
258
+ }
259
+
260
+ /**
261
+ * The model the user already selected for this app, when that service is
262
+ * mounted (`ctx.get` is the optional-service convention: absent service, no
263
+ * throw). A prediction should sound like the agent the user is talking to.
264
+ */
265
+ export function defaultSelection(ctx) {
266
+ const service = ctx && typeof ctx.get === 'function' ? ctx.get('agentDefaultModel') : undefined
267
+ if (!service || typeof service.currentSelection !== 'function') return undefined
268
+ try {
269
+ const selection = service.currentSelection()
270
+ if (!selection || !selection.provider || !selection.model) return undefined
271
+ return { provider: String(selection.provider), model: String(selection.model) }
272
+ } catch {
273
+ return undefined
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Pick the model route. Explicit config wins. Otherwise stay on the user's
279
+ * provider but prefer a cheap model there: a prediction is a latency-sensitive
280
+ * side call, not a coding turn. The user's own model is kept when it is already
281
+ * a cheap one, and is the fallback when discovery names no cheap sibling.
282
+ */
283
+ export async function resolveRoute(llm, config = {}, signal) {
284
+ const providers = llm && typeof llm.listProviders === 'function' ? llm.listProviders() : []
285
+ const offered = (Array.isArray(providers) ? providers : [])
286
+ .map((provider) => (provider && provider.id ? String(provider.id) : ''))
287
+ .filter(Boolean)
288
+ const provider = config.provider ? String(config.provider) : offered[0]
289
+ if (!provider) throw new Error('no LLM provider is registered')
290
+ if (config.model) return { provider, model: String(config.model) }
291
+
292
+ const preferred = config.preferredModel ? String(config.preferredModel) : ''
293
+ let advertised = []
294
+ try {
295
+ const models = await llm.listModels(provider, signal)
296
+ advertised = (Array.isArray(models) ? models : [])
297
+ .map((model) => (model && model.id != null ? String(model.id) : ''))
298
+ .filter(Boolean)
299
+ } catch (error) {
300
+ // Per-request discovery can fail on providers that only list models at
301
+ // configuration time; the user's own selection is still a valid route.
302
+ if (preferred) return { provider, model: preferred }
303
+ throw error
304
+ }
305
+ const named = advertised.includes(preferred) ? preferred : undefined
306
+ const model =
307
+ (named && CHEAP_MODEL.test(named) ? named : undefined) ||
308
+ advertised.find((id) => CHEAP_MODEL.test(id)) ||
309
+ named ||
310
+ advertised[0]
311
+ if (!model) throw new Error(`provider "${provider}" advertises no models`)
312
+ return { provider, model }
313
+ }
314
+
315
+ /**
316
+ * The cheapest reasoning effort the adapter supports, or undefined when the
317
+ * model does not reason (or cannot be inspected). Explicit config always wins.
318
+ */
319
+ export async function resolveEffort(llm, route, signal) {
320
+ if (!llm || typeof llm.resolveModelInfo !== 'function') return undefined
321
+ let info
322
+ try {
323
+ info = await llm.resolveModelInfo(route.provider, route.model, signal)
324
+ } catch {
325
+ return undefined
326
+ }
327
+ const efforts = info && info.reasoning && Array.isArray(info.reasoning.efforts) ? info.reasoning.efforts : []
328
+ const ids = efforts
329
+ .map((effort) => (effort && effort.id != null ? String(effort.id) : ''))
330
+ .filter(Boolean)
331
+ for (const candidate of EFFORT_PREFERENCE) {
332
+ if (ids.includes(candidate)) return candidate
333
+ }
334
+ return undefined
335
+ }
336
+
337
+ /** Row config is user-edited YAML: clamp it instead of trusting it. */
338
+ export function normalizeConfig(input) {
339
+ const raw = input && typeof input === 'object' && !Array.isArray(input) ? input : {}
340
+ const integer = (value, fallback, min, max) => {
341
+ const number = Number(value)
342
+ if (!Number.isFinite(number)) return fallback
343
+ return Math.min(max, Math.max(min, Math.round(number)))
344
+ }
345
+ const string = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined)
346
+ return {
347
+ enabled: raw.enabled === undefined ? DEFAULTS.enabled : raw.enabled !== false,
348
+ provider: string(raw.provider),
349
+ model: string(raw.model),
350
+ reasoningEffort: string(raw.reasoningEffort),
351
+ maxTokens: integer(raw.maxTokens, DEFAULTS.maxTokens, 64, 4_096),
352
+ historyMessages: integer(raw.historyMessages, DEFAULTS.historyMessages, 2, 50),
353
+ timeoutMs: integer(raw.timeoutMs, DEFAULTS.timeoutMs, 1_000, 120_000),
354
+ perMessageChars: integer(raw.perMessageChars, DEFAULTS.perMessageChars, 200, 8_000),
355
+ totalChars: integer(raw.totalChars, DEFAULTS.totalChars, 1_000, 40_000),
356
+ maxPredictionChars: integer(raw.maxPredictionChars, DEFAULTS.maxPredictionChars, 40, 500),
357
+ }
358
+ }
359
+
360
+ /**
361
+ * A stable fingerprint of the message surface. `session.seq` cannot serve:
362
+ * persistence receipts (for example `session-log-deepseek/delivery-accepted`) are appended to the
363
+ * same log after every flush and bump it continuously. Surface nodes change
364
+ * only when a message is actually appended or replaced.
365
+ */
366
+ export function sessionTail(session) {
367
+ const surface = session && session.surface
368
+ const nodes = surface && Array.isArray(surface.nodes) ? surface.nodes : []
369
+ return nodes.length > 0 ? Number(nodes[nodes.length - 1]) : 0
370
+ }
371
+
372
+ /** Read a cached suggestion only when it still matches the session's input. */
373
+ function cachedPrediction(session, fingerprint) {
374
+ const key = String(session.id)
375
+ const entry = predictionCache.get(key)
376
+ if (!entry) return undefined
377
+ if (entry.fingerprint !== fingerprint || Date.now() - entry.at > CACHE_TTL_MS) {
378
+ predictionCache.delete(key)
379
+ return undefined
380
+ }
381
+ return entry
382
+ }
383
+
384
+ function rememberPrediction(session, fingerprint, payload) {
385
+ const key = String(session.id)
386
+ predictionCache.delete(key)
387
+ predictionCache.set(key, { fingerprint, payload, at: Date.now() })
388
+ while (predictionCache.size > CACHE_LIMIT) {
389
+ const oldest = predictionCache.keys().next().value
390
+ predictionCache.delete(oldest)
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Deterministic fingerprint of everything that changes the generated line: the
396
+ * transcript itself, the generator version, and the config knobs that affect
397
+ * output. Reusing a suggestion across a changed transcript, model, or budget
398
+ * would show a stale line, so the cache key cannot be the session's seq alone.
399
+ */
400
+ function predictionFingerprint(config, transcript) {
401
+ const effectors = {
402
+ provider: config.provider ?? '',
403
+ model: config.model ?? '',
404
+ reasoningEffort: config.reasoningEffort ?? '',
405
+ maxTokens: config.maxTokens,
406
+ maxPredictionChars: config.maxPredictionChars,
407
+ historyMessages: config.historyMessages,
408
+ perMessageChars: config.perMessageChars,
409
+ totalChars: config.totalChars,
410
+ }
411
+ return hashString(GENERATOR_VERSION + '|' + transcript + '|' + JSON.stringify(effectors))
412
+ }
413
+
414
+ function hashString(input) {
415
+ let hash = 0x811c9dc5
416
+ for (let index = 0; index < input.length; index += 1) {
417
+ hash ^= input.charCodeAt(index)
418
+ hash = Math.imul(hash, 0x01000193) >>> 0
419
+ }
420
+ return hash.toString(36)
421
+ }
422
+
423
+ /** Test seam: the cache must never leak across cases. */
424
+ export function clearPredictionCache() {
425
+ predictionCache.clear()
426
+ }
427
+
428
+ /**
429
+ * Declared schema so the Loader validates the row config and the settings UI
430
+ * can edit it as a form. Every knob is optional: absent fields fall back to
431
+ * the defaults above (and the host's cheapest-model pick when provider/model
432
+ * are unset).
433
+ */
434
+ export const Config = z.object({
435
+ enabled: z.boolean().default(DEFAULTS.enabled).description('总开关:关闭后不再请求或显示预测。'),
436
+ provider: z.string().role('text').description('模型 provider;默认用用户当前选中的 provider。'),
437
+ model: z.string().role('text').description('模型 id;默认用同 provider 下最便宜的型号,没有便宜型号则退回当前选中模型。'),
438
+ reasoningEffort: z.string().role('text').description('推理档;默认自动选 off > none > minimal > low。'),
439
+ maxTokens: z.number().role('slider').min(64).max(4096).step(64).default(DEFAULTS.maxTokens).description('一次预测的输出上限;推理模型会把预算先花在思考上,太小会返回空。'),
440
+ historyMessages: z.number().role('slider').min(2).max(50).step(1).default(DEFAULTS.historyMessages).description('喂给模型的历史消息条数(只含真人 user + assistant 文本)。'),
441
+ timeoutMs: z.number().role('slider').min(1000).max(120000).step(1000).default(DEFAULTS.timeoutMs).description('单次预测的最长等待时间。'),
442
+ perMessageChars: z.number().role('slider').min(200).max(8000).step(100).default(DEFAULTS.perMessageChars).description('单条消息截断上限(最新一条 assistant 用 1.5 倍)。'),
443
+ totalChars: z.number().role('slider').min(1000).max(40000).step(500).default(DEFAULTS.totalChars).description('整个转录的字符预算。'),
444
+ maxPredictionChars: z.number().role('slider').min(40).max(500).step(20).default(DEFAULTS.maxPredictionChars).description('建议的最大显示/接受长度。'),
445
+ })
446
+
447
+ /**
448
+ * The reason of the newest `turn/end`, read by walking back from the session
449
+ * tail. `eventAt` is the cheap public accessor; `snapshotEvents` is the
450
+ * fallback for hosts that expose only the bulk reader.
451
+ */
452
+ export function lastTurnOutcome(session) {
453
+ if (!session) return undefined
454
+ const seq = Number(session.seq)
455
+ if (!Number.isFinite(seq)) return undefined
456
+ if (typeof session.eventAt === 'function') {
457
+ for (let offset = 1; offset <= 64 && seq - offset >= 0; offset += 1) {
458
+ let event
459
+ try {
460
+ event = session.eventAt(seq - offset)
461
+ } catch {
462
+ return undefined
463
+ }
464
+ if (event && event.type === 'turn/end') return event.data && event.data.reason
465
+ }
466
+ return undefined
467
+ }
468
+ if (typeof session.snapshotEvents !== 'function') return undefined
469
+ let events
470
+ try {
471
+ events = session.snapshotEvents(Math.max(0, seq - 64))
472
+ } catch {
473
+ return undefined
474
+ }
475
+ for (let index = events.length - 1; index >= 0; index -= 1) {
476
+ const event = events[index]
477
+ if (event && event.type === 'turn/end') return event.data && event.data.reason
478
+ }
479
+ return undefined
480
+ }
481
+
482
+ /**
483
+ * Codex only suggests a continuation after a SUCCESSFUL turn. The newest
484
+ * assistant text alone cannot tell an abort from a completion (an aborted turn
485
+ * can still leave partial text), so read the last `turn/end` reason. Unknown
486
+ * shapes allow the prediction rather than silently disabling the feature on an
487
+ * event-format change.
488
+ */
489
+ export function lastTurnCompleted(session) {
490
+ const reason = lastTurnOutcome(session)
491
+ if (!reason || !reason.kind) return true
492
+ return reason.kind === 'completed' || reason.kind === 'max-tokens'
493
+ }
494
+
495
+ /**
496
+ * One prediction: transcript in, one line out. Split from the HTTP handler so
497
+ * the prompt, route selection, and stream handling stay testable.
498
+ */
499
+ export async function predict(ctx, config, sessionId, signal) {
500
+ if (config && config.enabled === false) return { text: '', reason: 'disabled' }
501
+ const session =
502
+ ctx && ctx.sessions && typeof ctx.sessions.get === 'function' &&
503
+ (typeof sessionId === 'string' || typeof sessionId === 'number')
504
+ ? ctx.sessions.get(sessionId)
505
+ : undefined
506
+ if (!session || typeof session.deriveMessages !== 'function') {
507
+ throw Object.assign(new Error('unknown session'), { status: 404 })
508
+ }
509
+ if (signal && signal.aborted) throw Object.assign(new Error('aborted'), { status: 499 })
510
+
511
+ // Eligibility is checked before anything is cached: a suggestion belongs to
512
+ // a successful, settled turn, so a cache entry must never survive the turn
513
+ // becoming aborted or the assistant reply vanishing.
514
+ if (!lastTurnCompleted(session)) return { text: '', reason: 'turn-not-completed' }
515
+
516
+ const messages = session.deriveMessages()
517
+ // The turn must have ended on the assistant's reply. A queued submission or
518
+ // an aborted turn leaves a user message last, and there is nothing sensible
519
+ // to predict in that state.
520
+ if (lastConversationRole(messages) !== 'assistant') return { text: '', reason: 'last-message-not-assistant' }
521
+ const transcript = buildTranscript(messages, config)
522
+ if (!transcript) return { text: '', reason: 'empty-transcript' }
523
+
524
+ // Reuse a suggestion only when this exact transcript, generator version, and
525
+ // output-affecting config generated it. Switching back to the session is
526
+ // therefore a cache hit, while any message or config change regenerates.
527
+ const fingerprint = predictionFingerprint(config, transcript)
528
+ const cached = cachedPrediction(session, fingerprint)
529
+ if (cached) return { ...cached.payload, cached: true }
530
+
531
+ const generationTail = sessionTail(session)
532
+
533
+ const selection = defaultSelection(ctx) || {}
534
+ const route = await resolveRoute(
535
+ ctx.llm,
536
+ {
537
+ provider: config.provider || selection.provider,
538
+ model: config.model,
539
+ preferredModel: selection.model,
540
+ },
541
+ signal,
542
+ )
543
+ const reasoningEffort = config.reasoningEffort || (await resolveEffort(ctx.llm, route, signal))
544
+
545
+ let raw = ''
546
+ let finish
547
+ for await (const chunk of ctx.llm.stream({
548
+ ...route,
549
+ ...(reasoningEffort ? { reasoningEffort } : {}),
550
+ messages: [{ role: 'user', content: [{ type: 'text', text: PROMPT_HEADER + transcript + PROMPT_FOOTER }] }],
551
+ system: SYSTEM_PROMPT,
552
+ maxTokens: config.maxTokens,
553
+ sessionId: session.id ?? sessionId,
554
+ signal,
555
+ })) {
556
+ if (!chunk) continue
557
+ if (chunk.type === 'text-delta' && typeof chunk.text === 'string') raw += chunk.text
558
+ else if (chunk.type === 'finish') finish = chunk.reason
559
+ }
560
+ if (finish && (finish.kind === 'error' || finish.kind === 'aborted')) {
561
+ const failure = finish.failure || {}
562
+ throw new Error(`model call failed: ${failure.code || failure.message || finish.kind}`)
563
+ }
564
+ // The session may have moved on while the model was thinking (a queued
565
+ // submission, a new turn). A prediction for the old tail must not be shown.
566
+ if (sessionTail(session) !== generationTail) {
567
+ return { text: '', reason: 'session-moved' }
568
+ }
569
+
570
+ const text = normalizePrediction(raw, config.maxPredictionChars)
571
+ // A declined suggestion (SKIP) is remembered too, so switching back and forth
572
+ // does not re-run the model on the same unchanged tail.
573
+ const payload = {
574
+ text,
575
+ model: route.model,
576
+ provider: route.provider,
577
+ ...(reasoningEffort ? { effort: reasoningEffort } : {}),
578
+ ...(text ? {} : { reason: 'model-empty' }),
579
+ debug: {
580
+ tail: generationTail,
581
+ transcriptChars: transcript.length,
582
+ rawChars: raw.length,
583
+ finish: finish && finish.kind ? finish.kind : null,
584
+ },
585
+ }
586
+ rememberPrediction(session, fingerprint, payload)
587
+ return payload
588
+ }
589
+
590
+ function readBody(request) {
591
+ return new Promise((resolve, reject) => {
592
+ const chunks = []
593
+ let size = 0
594
+ let settled = false
595
+ const fail = (error) => {
596
+ if (settled) return
597
+ settled = true
598
+ reject(error)
599
+ }
600
+ request.on('data', (chunk) => {
601
+ if (settled) return
602
+ size += chunk.length
603
+ if (size > BODY_LIMIT_BYTES) {
604
+ fail(Object.assign(new Error('request body too large'), { status: 413 }))
605
+ // Drain instead of destroying: the handler still owns a JSON reply.
606
+ request.resume()
607
+ return
608
+ }
609
+ chunks.push(chunk)
610
+ })
611
+ request.on('end', () => {
612
+ if (settled) return
613
+ settled = true
614
+ resolve(Buffer.concat(chunks).toString('utf8'))
615
+ })
616
+ request.on('error', fail)
617
+ })
618
+ }
619
+
620
+ function send(response, status, payload) {
621
+ if (response.writableEnded || response.destroyed) return
622
+ const body = JSON.stringify(payload)
623
+ response.writeHead(status, {
624
+ 'content-type': 'application/json; charset=utf-8',
625
+ 'cache-control': 'no-store',
626
+ 'x-content-type-options': 'nosniff',
627
+ 'content-length': Buffer.byteLength(body),
628
+ })
629
+ response.end(body)
630
+ }
631
+
632
+ /**
633
+ * A browser page is the only intended caller. Browsers always send `Origin`
634
+ * for a cross-origin POST (and a preflight for JSON), so rejecting a foreign
635
+ * origin and a non-JSON body is enough to keep a random tab from spending the
636
+ * user's model credentials.
637
+ */
638
+ function requestAllowed(request) {
639
+ const origin = request.headers.origin
640
+ if (typeof origin === 'string' && origin !== '') {
641
+ if (origin === 'null') return false
642
+ try {
643
+ if (new URL(origin).host !== request.headers.host) return false
644
+ } catch {
645
+ return false
646
+ }
647
+ }
648
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
649
+ const contentType = request.headers['content-type']
650
+ if (typeof contentType === 'string' && contentType !== '' && !/^application\/json\b/i.test(contentType)) {
651
+ return false
652
+ }
653
+ return true
654
+ }
655
+
656
+ /** Build the HTTP handler; exported so tests can drive a real server socket. */
657
+ export function createPredictHandler(ctx, config = {}) {
658
+ // `config` is either a plain object (tests) or a getter owned by apply, so a
659
+ // settings save can update the live values without re-registering the route.
660
+ const getOptions = typeof config === 'function' ? config : () => normalizeConfig(config)
661
+ return async function handle(request, response) {
662
+ if (request.method !== 'POST') {
663
+ response.writeHead(405, { allow: 'POST' }).end()
664
+ return
665
+ }
666
+ if (!requestAllowed(request)) {
667
+ send(response, 403, { error: 'forbidden' })
668
+ return
669
+ }
670
+
671
+ const options = getOptions()
672
+ const controller = new AbortController()
673
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs)
674
+ // A page that navigates away (or a cancelled fetch) must not keep a model
675
+ // call alive. A normally finished response is already writableEnded.
676
+ response.on('close', () => {
677
+ if (!response.writableEnded) controller.abort()
678
+ })
679
+
680
+ try {
681
+ const raw = await readBody(request)
682
+ let body
683
+ try {
684
+ body = JSON.parse(raw || '{}')
685
+ } catch (error) {
686
+ throw Object.assign(new Error('invalid JSON body'), { status: 400, cause: error })
687
+ }
688
+ const prediction = await predict(ctx, options, body && body.sessionId, controller.signal)
689
+ send(response, 200, prediction)
690
+ } catch (error) {
691
+ const status = error && error.status
692
+ ? error.status
693
+ : controller.signal.aborted
694
+ ? 504
695
+ : 500
696
+ const message = error && error.message ? error.message : String(error)
697
+ if (status >= 500 && ctx && ctx.logger && typeof ctx.logger.warn === 'function') {
698
+ ctx.logger.warn('[composer-predictions] predict failed: %s', message)
699
+ }
700
+ send(response, status, { error: message })
701
+ } finally {
702
+ clearTimeout(timer)
703
+ }
704
+ }
705
+ }
706
+
707
+ /** The public shape the settings page reads and writes. */
708
+ export function settingsView(options) {
709
+ const config = options || {}
710
+ return {
711
+ enabled: config.enabled !== false,
712
+ provider: config.provider ?? '',
713
+ model: config.model ?? '',
714
+ reasoningEffort: config.reasoningEffort ?? '',
715
+ maxTokens: config.maxTokens,
716
+ historyMessages: config.historyMessages,
717
+ timeoutMs: config.timeoutMs,
718
+ perMessageChars: config.perMessageChars,
719
+ totalChars: config.totalChars,
720
+ maxPredictionChars: config.maxPredictionChars,
721
+ }
722
+ }
723
+
724
+ /** Untrusted page input: keep known keys and primitive types; clamp later. */
725
+ export function sanitizeSettings(input) {
726
+ const raw = input && typeof input === 'object' && !Array.isArray(input) ? input : {}
727
+ const out = {}
728
+ if (typeof raw.enabled === 'boolean') out.enabled = raw.enabled
729
+ if (typeof raw.provider === 'string') out.provider = raw.provider.trim().slice(0, 120)
730
+ if (typeof raw.model === 'string') out.model = raw.model.trim().slice(0, 120)
731
+ if (typeof raw.reasoningEffort === 'string') out.reasoningEffort = raw.reasoningEffort.trim().slice(0, 40)
732
+ for (const key of ['maxTokens', 'historyMessages', 'timeoutMs', 'perMessageChars', 'totalChars', 'maxPredictionChars']) {
733
+ if (raw[key] === undefined) continue
734
+ const number = Number(raw[key])
735
+ if (Number.isFinite(number)) out[key] = Math.round(number)
736
+ }
737
+ return out
738
+ }
739
+
740
+ /**
741
+ * Settings endpoint. Writes go through the platform's configEditor, which
742
+ * validates against the Config schema and persists the override in the
743
+ * profile's patch — one source of truth, no plugin-owned JSON file.
744
+ */
745
+ export function createConfigHandler(ctx, getOptions, setOptions) {
746
+ return async function handle(request, response) {
747
+ if (request.method !== 'GET' && request.method !== 'POST') {
748
+ response.writeHead(405, { allow: 'GET, POST' }).end()
749
+ return
750
+ }
751
+ if (!requestAllowed(request)) {
752
+ send(response, 403, { error: 'forbidden' })
753
+ return
754
+ }
755
+ if (request.method === 'GET') {
756
+ send(response, 200, { config: settingsView(getOptions()) })
757
+ return
758
+ }
759
+ try {
760
+ const raw = await readBody(request)
761
+ let body
762
+ try {
763
+ body = JSON.parse(raw || '{}')
764
+ } catch (error) {
765
+ throw Object.assign(new Error('invalid JSON body'), { status: 400, cause: error })
766
+ }
767
+ const patch = sanitizeSettings(body && body.config !== undefined ? body.config : body)
768
+ const editor = ctx && typeof ctx.get === 'function' ? ctx.get('configEditor') : undefined
769
+ if (!editor || typeof editor.edit !== 'function' || typeof editor.entries !== 'function') {
770
+ throw Object.assign(new Error('config editor is unavailable in this runtime'), { status: 501 })
771
+ }
772
+ const entry = editor.entries().find(
773
+ (candidate) =>
774
+ candidate &&
775
+ candidate.options &&
776
+ (candidate.options.id === 'composer-predictions' || candidate.options.name === '@lsfcy/dsh-composer-predictions'),
777
+ )
778
+ if (!entry) throw Object.assign(new Error('composer-predictions entry not found'), { status: 404 })
779
+ await editor.edit(entry, (current) => ({ ...current, ...patch }))
780
+ const next = normalizeConfig({ ...getOptions(), ...patch })
781
+ setOptions(next)
782
+ send(response, 200, { config: settingsView(next) })
783
+ } catch (error) {
784
+ const status = error && error.status ? error.status : 500
785
+ const message = error && error.message ? error.message : String(error)
786
+ if (status >= 500 && ctx && ctx.logger && typeof ctx.logger.warn === 'function') {
787
+ ctx.logger.warn('[composer-predictions] config failed: %s', message)
788
+ }
789
+ send(response, status, { error: message })
790
+ }
791
+ }
792
+ }
793
+
794
+ export function apply(ctx, config) {
795
+ const state = { options: normalizeConfig(config) }
796
+ const predictHandler = createPredictHandler(ctx, () => state.options)
797
+ const configHandler = createConfigHandler(
798
+ ctx,
799
+ () => state.options,
800
+ (next) => {
801
+ state.options = next
802
+ },
803
+ )
804
+ ctx.effect(
805
+ () => ctx.webServer.register({ kind: 'exact', path: PREDICT_PATH, handler: predictHandler }),
806
+ 'composer-predictions: predict route',
807
+ )
808
+ ctx.effect(
809
+ () => ctx.webServer.register({ kind: 'exact', path: CONFIG_PATH, handler: configHandler }),
810
+ 'composer-predictions: config route',
811
+ )
812
+ }