dsh-composer-predictions 1.2.0 → 1.2.2

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 CHANGED
@@ -6,6 +6,36 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.2.2] - 2026-10-25
10
+
11
+ ### Fixed
12
+
13
+ - Both prediction routes require Harness connection admission and owner access,
14
+ including the app's Host/Origin checks, before reading sessions or using tokens.
15
+ - Reject non-JSON, malformed, oversized and invalid-mode requests without model work.
16
+ - Simultaneous requests from different views no longer cancel each other; disabling
17
+ the host plugin cancels outstanding model calls and clears its cache.
18
+ - Cache draft text exactly, preventing one draft from reusing a hash collision's suggestion.
19
+ - Disconnected or cancelled calls never cache partial output; token-limited output
20
+ offers only complete lines.
21
+ - Clear a refused insertion's ghost and resume predictions after IME composition ends.
22
+
23
+ ### Changed
24
+
25
+ - Declare Harness compatibility `>=0.2.1-rc.3 <0.3.0-0` (tested on `0.2.1-rc.3`).
26
+ - Add HTTP admission, input validation, cancellation, cache and IME regressions.
27
+ - The manual probe now requires an authenticated cookie and fails on HTTP errors.
28
+
29
+ ## [1.2.1] - 2026-10-25
30
+
31
+ ### Changed
32
+
33
+ - Publish desktop/web installation guidance and Chinese usage instructions on npm.
34
+ - Expand package search terms for ghost text and inline autocomplete.
35
+ - Release verification waits for npm's asynchronous publication to become visible.
36
+ - Validate GitHub Actions trusted publishing with provenance. Runtime behavior is
37
+ unchanged from 1.2.0.
38
+
9
39
  ## [1.2.0] - 2026-10-24
10
40
 
11
41
  ### Added
package/README.md CHANGED
@@ -4,7 +4,10 @@
4
4
  [![license](https://img.shields.io/npm/l/dsh-composer-predictions)](LICENSE)
5
5
  [![topic](https://img.shields.io/badge/topic-dsh--plugin-blue)](https://github.com/topics/dsh-plugin)
6
6
 
7
- AI ghost text for the message box in the DeepSeek Harness Web GUI.
7
+ AI autocomplete and ghost-text suggestions for the composer in **DeepSeek
8
+ Harness desktop and the Web GUI**.
9
+
10
+ **Current release: 1.2.2** · [Install or update](#install) · [中文说明](#中文说明)
8
11
 
9
12
  - **Next message** — after the agent replies, the empty composer shows a
10
13
  streamed suggestion for your likely next message. **Tab** accepts it as an
@@ -50,7 +53,10 @@ on this device. Other browser origins and devices keep their own preference.
50
53
 
51
54
  ## Install
52
55
 
53
- Inside the DeepSeek Harness Web GUI: **Plugins → Install**, then enter
56
+ Requires Harness `>=0.2.1-rc.3 <0.3.0-0`; desktop and web are tested on
57
+ `0.2.1-rc.3`. Predictions use the same authenticated owner access as the app.
58
+
59
+ In the **DeepSeek Harness desktop app or Web GUI**: **Plugins → Install**, then enter
54
60
 
55
61
  ```
56
62
  dsh-composer-predictions
@@ -87,6 +93,20 @@ https://raw.githubusercontent.com/Stijnus/dsh-composer-predictions/main/market-i
87
93
 
88
94
  ![Inline continuation ghost](https://raw.githubusercontent.com/Stijnus/dsh-composer-predictions/main/docs/continuation-ghost.png)
89
95
 
96
+ ## 中文说明
97
+
98
+ **DeepSeek Harness 桌面端 / Web 的 Tab 自动补全与输入预测。** 回复结束后,
99
+ 空输入框显示下一条消息的灰字建议;输入停顿时,续写光标末尾的草稿。
100
+ `Tab` 采纳已完成的候选为可编辑草稿,不会自动发送;`Alt+↓/↑` 切换最多三个候选,
101
+ `Esc` 关闭。设置 → 通用 → 输入预测可开关,设置保存在当前设备的浏览器中。
102
+
103
+ 安装或更新:**插件 → 安装 → `dsh-composer-predictions@1.2.2`**,然后重启 Harness。
104
+ CLI 用户可运行 `dsh plugin --profile web add dsh-composer-predictions@1.2.2`,
105
+ 把 `web` 换成自己的 profile。预测会调用会话模型并消耗 token,虽然不会出现在会话日志和 token 统计中。
106
+
107
+ 也可以把上方 [Install](#install) 中的市场索引添加到「插件 → 发现」。
108
+ 演示、完整配置与限制见本页;[1.2.2 发布说明](https://github.com/Stijnus/dsh-composer-predictions/releases/tag/v1.2.2)。
109
+
90
110
  ## How it works
91
111
 
92
112
  - The **Host half** registers two routes on the Harness web server:
@@ -107,8 +127,9 @@ https://raw.githubusercontent.com/Stijnus/dsh-composer-predictions/main/market-i
107
127
  model restated are trimmed. A continuation keeps the model's leading
108
128
  separator space; the Client adds one only when a non-CJK draft and
109
129
  continuation would otherwise glue two words together.
110
- - Results are cached per Session sequence (and per draft for continuations), so
111
- a repeated request for unchanged context costs no model call.
130
+ - Completed results are cached per Session sequence (and per draft for
131
+ continuations), so a repeated request reuses the cached result. Simultaneous
132
+ requests from different views have independent cancellation and may each call the model.
112
133
  - The **Client half** registers into `conversation.input.overlay`, debounces
113
134
  requests (600 ms after a turn settles, 500 ms after a typing pause), aborts
114
135
  superseded requests, and renders the ghost over the empty composer or at the
@@ -153,8 +174,8 @@ A suggestion longer than 240 characters is cut at a word boundary.
153
174
  - A continuation is hidden when there is too little room after the caret; it
154
175
  never shifts left over your draft.
155
176
  - Final suggestions are announced through an accessible status message.
156
- - Requires a Harness build that provides `conversation.input.overlay`,
157
- `ctx.llm`, and `sessions.deriveMessages()`.
177
+ - Requires a compatible Harness build with `conversation.input.overlay`,
178
+ `settings.general.item`, `connection.admit()`, `ctx.llm` and `session.deriveMessages()`.
158
179
 
159
180
  ## Test
160
181
 
@@ -162,16 +183,18 @@ A suggestion longer than 240 characters is cut at a word boundary.
162
183
  node --test
163
184
  ```
164
185
 
165
- Probe a running GUI to watch the stream and cache timing:
186
+ Probe a running GUI to watch the stream and cache timing. First set `DSH_COOKIE`
187
+ to an authenticated Harness cookie in your environment; treat it as a secret
188
+ and never commit or include it in logs.
166
189
 
167
190
  ```bash
168
- node tools/live-probe.mjs <sessionId> continue "Can you also"
191
+ DSH_URL='http://127.0.0.1:<port>' node tools/live-probe.mjs <sessionId> continue "Can you also"
169
192
  ```
170
193
 
171
- The 30 cases drive both Host endpoints against a fake Session and LLM service:
194
+ The 36 cases drive both Host endpoints against a fake Session and LLM service:
172
195
  prompt contents (including tool activity), candidate normalization and spacing,
173
196
  streaming events, per-turn and per-draft caching, route and effort selection,
174
- failure reporting, and request validation. Client helpers (continuation
197
+ failure reporting, HTTP authentication/owner checks, cancellation and request validation. Client helpers (continuation
175
198
  joining, SSE parsing, streaming candidates) are unit-tested through the bundle's
176
199
  internal `__testing` export. Component regression tests cover streaming acceptance,
177
200
  refused insertions, right-edge placement, truncated streams and accessible copy.
package/client.js CHANGED
@@ -362,6 +362,7 @@ window.__ModuleLoader__.load({
362
362
  const running = useSessionStatus((state) => (state === undefined ? false : state.get(sessionId)?.running === true))
363
363
  const [ghost, setGhost] = React.useState(NO_GHOST)
364
364
  const [box, setBox] = React.useState(null)
365
+ const [compositionEnd, setCompositionEnd] = React.useState(0)
365
366
 
366
367
  const abortRef = React.useRef(null)
367
368
  const composingRef = React.useRef(false)
@@ -468,6 +469,7 @@ window.__ModuleLoader__.load({
468
469
  }
469
470
  const onCompositionEnd = () => {
470
471
  composingRef.current = false
472
+ setCompositionEnd(value => value + 1)
471
473
  }
472
474
  document.addEventListener('compositionstart', onCompositionStart, true)
473
475
  document.addEventListener('compositionend', onCompositionEnd, true)
@@ -512,7 +514,7 @@ window.__ModuleLoader__.load({
512
514
  void request('next', '')
513
515
  }, SETTLE_MS)
514
516
  return () => clearTimeout(timer)
515
- }, [sessionId, running, draft, request, predictionsEnabled])
517
+ }, [sessionId, running, draft, request, predictionsEnabled, compositionEnd])
516
518
 
517
519
  // `continue`: ask after a pause, only with the caret at the draft's end.
518
520
  React.useEffect(() => {
@@ -528,7 +530,7 @@ window.__ModuleLoader__.load({
528
530
  void request('continue', draft.slice(-MAX_DRAFT_CHARS))
529
531
  }, TYPE_PAUSE_MS)
530
532
  return () => clearTimeout(timer)
531
- }, [sessionId, draft, request, predictionsEnabled])
533
+ }, [sessionId, draft, request, predictionsEnabled, compositionEnd])
532
534
 
533
535
  // A prediction skipped while the page was hidden is retried on return.
534
536
  React.useEffect(() => {
@@ -682,14 +684,14 @@ window.__ModuleLoader__.load({
682
684
  const expected = ghost.mode === 'continue' ? draft + value : value
683
685
  dismissedRef.current = { draft: expected }
684
686
  abortRef.current?.abort()
687
+ setGhost(NO_GHOST)
688
+ setBox(null)
685
689
  try {
686
690
  // A refused insertion must never be bypassed by replacing the draft.
687
691
  if (inputActions.insertText(value, inputActions.captureInsertion()) !== true) return
688
692
  } catch {
689
693
  return
690
694
  }
691
- setGhost(NO_GHOST)
692
- setBox(null)
693
695
  // Accepting leaves an editable draft: hand the keyboard back to the editor.
694
696
  try {
695
697
  const found = composerElements()
package/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  export const name = 'composer-predictions'
16
16
 
17
- export const inject = ['llm', 'sessions', 'webServer']
17
+ export const inject = ['connection', 'llm', 'sessions', 'webServer']
18
18
 
19
19
  /** Same-origin endpoint the Client half posts to (JSON, kept for compatibility). */
20
20
  const ENDPOINT = '/composer-prediction'
@@ -85,7 +85,13 @@ const CONTINUE_SYSTEM_PROMPT = [
85
85
  export function apply(ctx, config) {
86
86
  const options = resolveOptions(config)
87
87
  const cache = new Map()
88
- const inflight = new Map()
88
+ const inflight = new Set()
89
+
90
+ ctx.effect(() => () => {
91
+ for (const controller of inflight) controller.abort()
92
+ inflight.clear()
93
+ cache.clear()
94
+ })
89
95
 
90
96
  ctx.effect(() =>
91
97
  ctx.webServer.register({
@@ -206,17 +212,30 @@ function describeError(error) {
206
212
  * @returns `null` when the response was already written, a plan, or an error.
207
213
  */
208
214
  async function planRequest(ctx, request, response) {
215
+ const admission = ctx.connection.admit(request)
216
+ if ('rejection' in admission) {
217
+ return { status: admission.rejection, error: admission.rejection === 401 ? 'unauthorized' : 'forbidden' }
218
+ }
219
+ // Predictions read the owner's sessions and spend the owner's model tokens.
220
+ if (admission.peer.principal.kind !== 'owner') return { status: 403, error: 'forbidden' }
221
+
209
222
  if (request.method !== 'POST') {
210
223
  respond(response, 405, { suggestion: null, suggestions: [], reason: 'method-not-allowed' })
211
224
  return null
212
225
  }
213
226
 
227
+ if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
228
+ return { status: 415, error: 'unsupported-media-type' }
229
+ }
230
+
214
231
  let body
215
232
  try {
216
233
  body = await readJsonBody(request)
217
234
  } catch {
218
235
  return { status: 400, error: 'invalid-body' }
219
236
  }
237
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return { status: 400, error: 'invalid-body' }
238
+ if (body.mode !== undefined && body.mode !== 'next' && body.mode !== 'continue') return { status: 400, error: 'invalid-mode' }
220
239
 
221
240
  const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
222
241
  if (sessionId === '') return { status: 400, error: 'missing-session' }
@@ -229,10 +248,7 @@ async function planRequest(ctx, request, response) {
229
248
  if (mode === 'continue' && draft.trim() === '') return { status: 200, error: 'no-draft' }
230
249
 
231
250
  const seq = Number(session.seq)
232
- const key =
233
- mode === 'next'
234
- ? `${sessionId}:${seq}`
235
- : `${sessionId}:${seq}:d${hashText(draft)}`
251
+ const key = JSON.stringify([sessionId, seq, mode, draft])
236
252
 
237
253
  return { sessionId, session, mode, draft, seq, key }
238
254
  }
@@ -247,17 +263,17 @@ async function runPrediction(ctx, options, cache, inflight, plan, signals, onDel
247
263
  return { items: remembered.items, reason: undefined, cached: true }
248
264
  }
249
265
 
250
- // Supersede only an identical request; another view or the JSON endpoint on
251
- // the same Session must not cancel this one.
252
- inflight.get(plan.key)?.abort()
266
+ // Each view owns its cancellation; an identical request must not cancel it.
253
267
  const controller = new AbortController()
254
- inflight.set(plan.key, controller)
268
+ inflight.add(controller)
255
269
  try {
256
270
  const result = await predict(ctx, options, plan.session, plan.mode, plan.draft, joinSignals([controller.signal, ...signals]), onDelta)
257
- if (result.items.length > 0) remember(cache, plan.key, { items: result.items })
271
+ if (result.items.length > 0 && !controller.signal.aborted && !signals.some(signal => signal.aborted)) {
272
+ remember(cache, plan.key, { items: result.items })
273
+ }
258
274
  return result
259
275
  } finally {
260
- if (inflight.get(plan.key) === controller) inflight.delete(plan.key)
276
+ inflight.delete(controller)
261
277
  }
262
278
  }
263
279
 
@@ -280,6 +296,7 @@ async function predict(ctx, options, session, mode, draft, signal, onDelta) {
280
296
  let text = ''
281
297
  let finish
282
298
  try {
299
+ deadline.signal.throwIfAborted()
283
300
  const call = await ctx.llm.prepareCall(
284
301
  { provider: route.provider, model: route.model, maxTokens: options.maxOutputTokens },
285
302
  deadline.signal,
@@ -290,6 +307,7 @@ async function predict(ctx, options, session, mode, draft, signal, onDelta) {
290
307
  )
291
308
  const messages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }]
292
309
  for await (const chunk of call.stream({ ...call.config, messages, system, signal: deadline.signal })) {
310
+ deadline.signal.throwIfAborted()
293
311
  if (chunk.type === 'text-delta') {
294
312
  text += chunk.text
295
313
  onDelta?.(chunk.text)
@@ -297,6 +315,7 @@ async function predict(ctx, options, session, mode, draft, signal, onDelta) {
297
315
  finish = chunk.reason
298
316
  }
299
317
  }
318
+ deadline.signal.throwIfAborted()
300
319
  } finally {
301
320
  deadline.dispose()
302
321
  }
@@ -305,6 +324,7 @@ async function predict(ctx, options, session, mode, draft, signal, onDelta) {
305
324
  // outcome reports its own failure instead of looking like a missing stream.
306
325
  const failure = finishFailure(finish)
307
326
  if (failure !== undefined) return { items: [], reason: failure }
327
+ if (finish.kind === 'max-tokens') text = text.slice(0, text.lastIndexOf('\n') + 1)
308
328
 
309
329
  const cue = mode === 'continue' ? CONTINUE_CUE : NEXT_CUE
310
330
  const items = normalizeCandidates(text, options.maxSuggestions, mode === 'continue' ? draft : '', cue)
@@ -519,16 +539,6 @@ function boundSuggestion(line) {
519
539
  return space > 120 ? cut.slice(0, space) : cut
520
540
  }
521
541
 
522
- /** FNV-1a over the draft: a stable, cheap cache identity. */
523
- function hashText(text) {
524
- let hash = 0x811c9dc5
525
- for (let index = 0; index < text.length; index += 1) {
526
- hash ^= text.charCodeAt(index)
527
- hash = Math.imul(hash, 0x01000193)
528
- }
529
- return (hash >>> 0).toString(36)
530
- }
531
-
532
542
  function readJsonBody(request) {
533
543
  return new Promise((resolve, reject) => {
534
544
  const chunks = []
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-composer-predictions",
3
- "version": "1.2.0",
4
- "description": "Tab-completion for the DeepSeek Harness composer: next-message ghost text plus inline continuation of your draft, with keyboard candidate cycling",
3
+ "version": "1.2.2",
4
+ "description": "Tab autocomplete for the DeepSeek Harness desktop and web composer: next-message ghost text, inline draft completion and keyboard candidate cycling",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "scripts": {
@@ -14,7 +14,12 @@
14
14
  "deepseek-harness",
15
15
  "composer",
16
16
  "autocomplete",
17
- "tab-completion"
17
+ "tab-completion",
18
+ "ghost-text",
19
+ "inline-completion",
20
+ "prediction",
21
+ "desktop",
22
+ "input-completion"
18
23
  ],
19
24
  "publishConfig": {
20
25
  "access": "public"
@@ -53,5 +58,8 @@
53
58
  "homepage": "https://github.com/Stijnus/dsh-composer-predictions#readme",
54
59
  "bugs": {
55
60
  "url": "https://github.com/Stijnus/dsh-composer-predictions/issues"
61
+ },
62
+ "engines": {
63
+ "dsh": ">=0.2.1-rc.3 <0.3.0-0"
56
64
  }
57
65
  }