@lsfcy/dsh-composer-predictions 0.0.0-stage → 0.1.1

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