dsh-composer-predictions 1.2.1 → 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 +20 -0
- package/README.md +18 -12
- package/client.js +6 -4
- package/index.js +32 -22
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ 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
|
+
|
|
9
29
|
## [1.2.1] - 2026-10-25
|
|
10
30
|
|
|
11
31
|
### Changed
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
AI autocomplete and ghost-text suggestions for the composer in **DeepSeek
|
|
8
8
|
Harness desktop and the Web GUI**.
|
|
9
9
|
|
|
10
|
-
**Current release: 1.2.
|
|
10
|
+
**Current release: 1.2.2** · [Install or update](#install) · [中文说明](#中文说明)
|
|
11
11
|
|
|
12
12
|
- **Next message** — after the agent replies, the empty composer shows a
|
|
13
13
|
streamed suggestion for your likely next message. **Tab** accepts it as an
|
|
@@ -53,6 +53,9 @@ on this device. Other browser origins and devices keep their own preference.
|
|
|
53
53
|
|
|
54
54
|
## Install
|
|
55
55
|
|
|
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
|
+
|
|
56
59
|
In the **DeepSeek Harness desktop app or Web GUI**: **Plugins → Install**, then enter
|
|
57
60
|
|
|
58
61
|
```
|
|
@@ -97,12 +100,12 @@ https://raw.githubusercontent.com/Stijnus/dsh-composer-predictions/main/market-i
|
|
|
97
100
|
`Tab` 采纳已完成的候选为可编辑草稿,不会自动发送;`Alt+↓/↑` 切换最多三个候选,
|
|
98
101
|
`Esc` 关闭。设置 → 通用 → 输入预测可开关,设置保存在当前设备的浏览器中。
|
|
99
102
|
|
|
100
|
-
安装或更新:**插件 → 安装 → `dsh-composer-predictions@1.2.
|
|
101
|
-
CLI 用户可运行 `dsh plugin --profile web add dsh-composer-predictions@1.2.
|
|
103
|
+
安装或更新:**插件 → 安装 → `dsh-composer-predictions@1.2.2`**,然后重启 Harness。
|
|
104
|
+
CLI 用户可运行 `dsh plugin --profile web add dsh-composer-predictions@1.2.2`,
|
|
102
105
|
把 `web` 换成自己的 profile。预测会调用会话模型并消耗 token,虽然不会出现在会话日志和 token 统计中。
|
|
103
106
|
|
|
104
107
|
也可以把上方 [Install](#install) 中的市场索引添加到「插件 → 发现」。
|
|
105
|
-
演示、完整配置与限制见本页;[1.2.
|
|
108
|
+
演示、完整配置与限制见本页;[1.2.2 发布说明](https://github.com/Stijnus/dsh-composer-predictions/releases/tag/v1.2.2)。
|
|
106
109
|
|
|
107
110
|
## How it works
|
|
108
111
|
|
|
@@ -124,8 +127,9 @@ CLI 用户可运行 `dsh plugin --profile web add dsh-composer-predictions@1.2.1
|
|
|
124
127
|
model restated are trimmed. A continuation keeps the model's leading
|
|
125
128
|
separator space; the Client adds one only when a non-CJK draft and
|
|
126
129
|
continuation would otherwise glue two words together.
|
|
127
|
-
-
|
|
128
|
-
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.
|
|
129
133
|
- The **Client half** registers into `conversation.input.overlay`, debounces
|
|
130
134
|
requests (600 ms after a turn settles, 500 ms after a typing pause), aborts
|
|
131
135
|
superseded requests, and renders the ghost over the empty composer or at the
|
|
@@ -170,8 +174,8 @@ A suggestion longer than 240 characters is cut at a word boundary.
|
|
|
170
174
|
- A continuation is hidden when there is too little room after the caret; it
|
|
171
175
|
never shifts left over your draft.
|
|
172
176
|
- Final suggestions are announced through an accessible status message.
|
|
173
|
-
- Requires a Harness build
|
|
174
|
-
`ctx.llm
|
|
177
|
+
- Requires a compatible Harness build with `conversation.input.overlay`,
|
|
178
|
+
`settings.general.item`, `connection.admit()`, `ctx.llm` and `session.deriveMessages()`.
|
|
175
179
|
|
|
176
180
|
## Test
|
|
177
181
|
|
|
@@ -179,16 +183,18 @@ A suggestion longer than 240 characters is cut at a word boundary.
|
|
|
179
183
|
node --test
|
|
180
184
|
```
|
|
181
185
|
|
|
182
|
-
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.
|
|
183
189
|
|
|
184
190
|
```bash
|
|
185
|
-
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"
|
|
186
192
|
```
|
|
187
193
|
|
|
188
|
-
The
|
|
194
|
+
The 36 cases drive both Host endpoints against a fake Session and LLM service:
|
|
189
195
|
prompt contents (including tool activity), candidate normalization and spacing,
|
|
190
196
|
streaming events, per-turn and per-draft caching, route and effort selection,
|
|
191
|
-
failure reporting, and request validation. Client helpers (continuation
|
|
197
|
+
failure reporting, HTTP authentication/owner checks, cancellation and request validation. Client helpers (continuation
|
|
192
198
|
joining, SSE parsing, streaming candidates) are unit-tested through the bundle's
|
|
193
199
|
internal `__testing` export. Component regression tests cover streaming acceptance,
|
|
194
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-composer-predictions",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
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",
|
|
@@ -58,5 +58,8 @@
|
|
|
58
58
|
"homepage": "https://github.com/Stijnus/dsh-composer-predictions#readme",
|
|
59
59
|
"bugs": {
|
|
60
60
|
"url": "https://github.com/Stijnus/dsh-composer-predictions/issues"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"dsh": ">=0.2.1-rc.3 <0.3.0-0"
|
|
61
64
|
}
|
|
62
65
|
}
|