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 +30 -0
- package/README.md +33 -10
- package/client.js +6 -4
- package/index.js +32 -22
- package/package.json +11 -3
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)
|
|
5
5
|
[](https://github.com/topics/dsh-plugin)
|
|
6
6
|
|
|
7
|
-
AI ghost
|
|
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
|
-
|
|
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
|

|
|
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
|
-
-
|
|
111
|
-
a repeated request
|
|
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
|
|
157
|
-
`ctx.llm
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Tab
|
|
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
|
}
|