dsh-nextsteps 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 klaus-2
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,54 @@
1
- # Temporary Holding Version
1
+ # dsh-nextsteps
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/dsh-nextsteps)](https://www.npmjs.com/package/dsh-nextsteps)
4
+ [![license](https://img.shields.io/github/license/klaus-2/dsh-nextsteps)](LICENSE)
5
+ [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-plugin-e5484d)](https://github.com/deepseek-ai/deepseek-harness)
6
+
7
+ **Every finished agent turn ends with ready-to-click next steps.**
8
+
9
+ `dsh-nextsteps` is a [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that prompts the model to suggest about three followup prompts at the end of every turn, renders them as clickable cards under the completed turn, and sends the clicked prompt as your next message — one click, zero typing.
10
+
11
+ ```
12
+ dsh plugin --profile web add dsh-nextsteps
13
+ ```
14
+
15
+ ## How it works
16
+
17
+ 1. The plugin registers a native `suggest_followups` agent tool with a strict JSON Schema.
18
+ 2. At the end of its turn, the model calls it with about three followup prompts.
19
+ 3. Each completed turn renders its own suggestion cards — attributed to that turn, never repeated across messages.
20
+ 4. Click a card: the prompt is sent verbatim as your next queued message and the card flips to a sent state.
21
+
22
+ ## Features
23
+
24
+ - Per-turn attribution — suggestions stay attached to the turn that produced them, across reloads.
25
+ - One-click send through the session's own conversation controller, with a composer fallback.
26
+ - Clicked cards persist (content-addressed keys) and stay marked after reloads.
27
+ - Model-supplied labels, with automatic truncation when absent; strict payload validation with corrective model feedback.
28
+ - Settings page inside the dsh settings — language, followups per turn, header visibility, updates.
29
+ - Followups generated in the selected language (Auto follows the conversation).
30
+ - In-app updater — version check, one-click install, release notes, restart prompt.
31
+ - UI in English, Português (Brasil) and 简体中文.
32
+
33
+ ## Settings
34
+
35
+ Open the dsh settings and find the **Nextsteps** section:
36
+
37
+ | Setting | What it does |
38
+ | --- | --- |
39
+ | Language | The language the model writes followups in. `Auto` follows the conversation. |
40
+ | Followups per turn | How many suggested prompts the model produces (1–6). |
41
+ | Show header | The small label above the cards. |
42
+
43
+ ## Updates
44
+
45
+ The same settings page checks GitHub for new releases. When a newer version is published on npm, **Update now** installs it through the dsh plugin manager and asks you to restart. Updates only ever install the `dsh-nextsteps` npm package — a renamed or development copy is detected and left alone.
46
+
47
+ ## Requirements
48
+
49
+ - DeepSeek Harness 0.2.x (desktop, web profile).
50
+ - Node.js 20+.
51
+
52
+ ## License
53
+
54
+ [MIT](LICENSE)
package/client.js ADDED
@@ -0,0 +1,624 @@
1
+ // SPDX-License-Identifier: MIT
2
+ window.__ModuleLoader__.load({
3
+ id: 'dsh-nextsteps',
4
+ factory(require) {
5
+ const React = require('react');
6
+ const h = React.createElement;
7
+
8
+ const NS = 'dsh-nextsteps';
9
+ const TOOL_NAME = 'suggest_followups';
10
+ const MAX_FOLLOWUPS = 6;
11
+ const PROMPT_TRUNCATE = 60;
12
+ const CLICKED_KEY = 'dsh.suggest-followups.clicked';
13
+
14
+
15
+ const CSS = `
16
+ .dsh-sf-root { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
17
+ .dsh-sf-header { font-size: 11px; letter-spacing: 0.02em; color: var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); }
18
+ .dsh-sf-row { display: flex; flex-wrap: wrap; gap: 8px; }
19
+ .dsh-sf-card { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; font: inherit; font-size: 12.5px; line-height: 1.45; padding: 6px 12px; border-radius: var(--dsw-radius-md, 10px); border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); cursor: pointer; text-align: left; transition: border-color 120ms ease, background 120ms ease, transform 60ms ease; }
20
+ .dsh-sf-card:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-brand-primary); }
21
+ .dsh-sf-card:hover:not(:disabled) .dsh-sf-icon { color: var(--dsw-alias-brand-primary); }
22
+ .dsh-sf-card:active:not(:disabled) { transform: translateY(1px); }
23
+ .dsh-sf-card:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
24
+ .dsh-sf-card:disabled { cursor: default; opacity: 0.6; }
25
+ .dsh-sf-icon { flex: none; color: var(--dsw-alias-label-secondary); font-size: 12px; transition: color 120ms ease; }
26
+ .dsh-sf-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
27
+ .dsh-sf-card.is-sent { color: var(--dsw-alias-state-success-primary); border-color: var(--dsw-alias-state-success-primary); background: transparent; }
28
+ .dsh-sf-card.is-sent .dsh-sf-icon { color: var(--dsw-alias-state-success-primary); }
29
+ .dsh-sf-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
30
+ .dsh-sf-error { max-width: 100%; color: var(--dsw-alias-state-error-primary); font-size: 12px; }
31
+ `;
32
+
33
+ function installStyles(ctx) {
34
+ try {
35
+ const styles = ctx.get('styles');
36
+ if (styles !== undefined && styles !== null && typeof styles.insert === 'function') {
37
+ styles.insert(CSS);
38
+ return;
39
+ }
40
+ } catch (error) {
41
+ console.warn('dsh-nextsteps: styles service unavailable:', error);
42
+ }
43
+ try {
44
+ const tag = document.createElement('style');
45
+ tag.textContent = CSS;
46
+ document.head.appendChild(tag);
47
+ } catch (error) {
48
+ console.warn('dsh-nextsteps: could not install styles:', error);
49
+ }
50
+ }
51
+
52
+
53
+
54
+ function turnIdOf(value) {
55
+ if (typeof value === 'string' && value !== '') return value;
56
+ if (typeof value === 'number') return String(value);
57
+ if (value !== null && typeof value === 'object' && typeof value.turn === 'number') return String(value.turn);
58
+ return undefined;
59
+ }
60
+
61
+ function readClickedMap() {
62
+ try {
63
+ const raw = window.localStorage.getItem(CLICKED_KEY);
64
+ const parsed = raw === null ? {} : JSON.parse(raw);
65
+ return parsed !== null && typeof parsed === 'object' ? parsed : {};
66
+ } catch (error) {
67
+ console.warn('dsh-nextsteps: could not read clicked state:', error);
68
+ return {};
69
+ }
70
+ }
71
+
72
+ function markClicked(clickedId) {
73
+ try {
74
+ const clicked = readClickedMap();
75
+ clicked[clickedId] = true;
76
+ window.localStorage.setItem(CLICKED_KEY, JSON.stringify(clicked));
77
+ } catch (error) {
78
+ console.warn('dsh-nextsteps: could not persist clicked state:', error);
79
+ }
80
+ }
81
+
82
+
83
+ function contentHash(text) {
84
+ let hash = 5381;
85
+ for (let index = 0; index < text.length; index++) {
86
+ hash = ((hash << 5) + hash + text.charCodeAt(index)) | 0;
87
+ }
88
+ return (hash >>> 0).toString(36);
89
+ }
90
+
91
+ function validateFollowups(list) {
92
+ if (!Array.isArray(list)) return null;
93
+ const followups = [];
94
+ for (const item of list) {
95
+ if (item === null || typeof item !== 'object') continue;
96
+ if (typeof item.prompt !== 'string' || item.prompt.trim() === '') continue;
97
+ followups.push({
98
+ prompt: item.prompt,
99
+ label: typeof item.label === 'string' && item.label.trim() !== '' ? item.label : null
100
+ });
101
+ if (followups.length === MAX_FOLLOWUPS) break;
102
+ }
103
+ return followups.length === 0 ? null : followups;
104
+ }
105
+
106
+
107
+
108
+ function followupsFromText(text) {
109
+ if (typeof text !== 'string' || text === '') return null;
110
+ let parsed;
111
+ try {
112
+ parsed = JSON.parse(text);
113
+ } catch (error) {
114
+ return null;
115
+ }
116
+ if (typeof parsed === 'string') {
117
+ try {
118
+ parsed = JSON.parse(parsed);
119
+ } catch (error) {
120
+ return null;
121
+ }
122
+ }
123
+ if (Array.isArray(parsed)) return validateFollowups(parsed);
124
+ if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.followups)) return null;
125
+ return validateFollowups(parsed.followups);
126
+ }
127
+
128
+
129
+ function followupsFromContent(content) {
130
+ if (!Array.isArray(content)) return null;
131
+ let best = null;
132
+ for (const entry of content) {
133
+ const parts = Array.isArray(entry?.content) ? entry.content : [entry];
134
+ for (const part of parts) {
135
+ if (typeof part?.text !== 'string') continue;
136
+ const followups = followupsFromText(part.text);
137
+ if (followups !== null) best = followups;
138
+ }
139
+ }
140
+ return best;
141
+ }
142
+
143
+
144
+
145
+ const captures = new Map();
146
+ const captureListeners = new Set();
147
+ const CAPTURES_KEY = 'dsh.nextsteps.captures.v1';
148
+ const CAPTURE_LIMIT = 400;
149
+
150
+ function readStoredCaptures() {
151
+ try {
152
+ const raw = window.localStorage.getItem(CAPTURES_KEY);
153
+ const parsed = raw === null ? {} : JSON.parse(raw);
154
+ return parsed !== null && typeof parsed === 'object' ? parsed : {};
155
+ } catch (error) {
156
+ console.warn('dsh-nextsteps: could not read stored captures:', error);
157
+ return {};
158
+ }
159
+ }
160
+
161
+ function writeStoredCaptures(store) {
162
+ try {
163
+ window.localStorage.setItem(CAPTURES_KEY, JSON.stringify(store));
164
+ } catch (error) {
165
+ console.warn('dsh-nextsteps: could not persist captures:', error);
166
+ }
167
+ }
168
+
169
+ function rehydrateCaptures() {
170
+ const store = readStoredCaptures();
171
+ for (const [sessionId, sessionEntries] of Object.entries(store)) {
172
+ if (sessionEntries === null || typeof sessionEntries !== 'object') continue;
173
+ const entries = sessionCaptures(sessionId);
174
+ for (const [callId, entry] of Object.entries(sessionEntries)) {
175
+ if (entry === null || typeof entry !== 'object' || !Array.isArray(entry.followups) || entry.followups.length === 0) continue;
176
+ if (entries.has(callId)) continue;
177
+ entries.set(callId, { turn: entry.turn, followups: entry.followups, clickedId: entry.clickedId });
178
+ }
179
+ }
180
+ }
181
+
182
+ function pruneStoredCaptures(store) {
183
+ let total = 0;
184
+ for (const sessionEntries of Object.values(store)) total += Object.keys(sessionEntries ?? {}).length;
185
+ if (total <= CAPTURE_LIMIT) return;
186
+ const sessionIds = Object.keys(store);
187
+ for (const sessionId of sessionIds) {
188
+ if (total <= CAPTURE_LIMIT) break;
189
+ const keys = Object.keys(store[sessionId]);
190
+ for (const callId of keys) {
191
+ if (total <= CAPTURE_LIMIT) break;
192
+ delete store[sessionId][callId];
193
+ total -= 1;
194
+ }
195
+ if (Object.keys(store[sessionId]).length === 0) delete store[sessionId];
196
+ }
197
+ }
198
+
199
+ function sessionCaptures(sessionId) {
200
+ let entries = captures.get(sessionId);
201
+ if (entries === undefined) {
202
+ entries = new Map();
203
+ captures.set(sessionId, entries);
204
+ }
205
+ return entries;
206
+ }
207
+
208
+ function rememberCapture(sessionId, callId, turn, followups) {
209
+ if (followups === null) return;
210
+ const entries = sessionCaptures(sessionId);
211
+ const previous = entries.get(callId);
212
+ const mergedTurn = turnIdOf(turn) ?? previous?.turn;
213
+ const clickedId = previous?.clickedId
214
+ ?? `sf-${contentHash(`${sessionId}|${mergedTurn ?? callId}|${followups.map((entry) => entry.prompt).join('|')}`)}`;
215
+ entries.set(callId, { turn: mergedTurn, followups, clickedId });
216
+ const store = readStoredCaptures();
217
+ const sessionEntries = store[sessionId] !== null && typeof store[sessionId] === 'object' ? store[sessionId] : {};
218
+ sessionEntries[callId] = { turn: mergedTurn, followups, clickedId };
219
+ store[sessionId] = sessionEntries;
220
+ pruneStoredCaptures(store);
221
+ writeStoredCaptures(store);
222
+ for (const listener of captureListeners) listener(sessionId);
223
+ }
224
+
225
+
226
+ function captureForTurn(sessionId, turn) {
227
+ const entries = captures.get(sessionId);
228
+ if (entries === undefined) return null;
229
+ let best = null;
230
+ for (const entry of entries.values()) {
231
+ if (entry.turn === turn) best = entry;
232
+ }
233
+ return best;
234
+ }
235
+
236
+
237
+
238
+ function newestCapture(sessionId) {
239
+ const entries = captures.get(sessionId);
240
+ if (entries === undefined || entries.size === 0) return null;
241
+ let best = null;
242
+ for (const entry of entries.values()) {
243
+ if (best === null || (entry.turn ?? -1) > (best.turn ?? -1)) best = entry;
244
+ }
245
+ return best;
246
+ }
247
+
248
+ const adoptedCallIds = new Set();
249
+
250
+ function unclaimedCaptures(sessionId, turn) {
251
+ const entries = captures.get(sessionId);
252
+ if (entries === undefined) return null;
253
+ const candidates = [];
254
+ for (const [callId, entry] of entries.entries()) {
255
+ if (entry.turn === undefined && !adoptedCallIds.has(callId)) candidates.push([callId, entry]);
256
+ }
257
+ if (candidates.length === 0) return null;
258
+ candidates.sort((a, b) => String(a[0]).localeCompare(String(b[0])));
259
+ const [callId, entry] = candidates[0];
260
+ adoptedCallIds.add(callId);
261
+ entries.set(callId, { ...entry, turn });
262
+ const store = readStoredCaptures();
263
+ const sessionEntries = store[sessionId] !== null && typeof store[sessionId] === 'object' ? store[sessionId] : {};
264
+ sessionEntries[callId] = { turn, followups: entry.followups, clickedId: entry.clickedId };
265
+ store[sessionId] = sessionEntries;
266
+ writeStoredCaptures(store);
267
+ return entry;
268
+ }
269
+
270
+ function displayLabel(followup) {
271
+ if (followup.label !== null) return followup.label;
272
+ const prompt = followup.prompt.trim();
273
+ return prompt.length > PROMPT_TRUNCATE ? `${prompt.slice(0, PROMPT_TRUNCATE)}…` : prompt;
274
+ }
275
+
276
+ function errorMessage(error) {
277
+ return error !== null && typeof error === 'object' && typeof error.message === 'string' && error.message !== ''
278
+ ? error.message
279
+ : String(error);
280
+ }
281
+
282
+
283
+ function resolveSend(ctx, sessionId) {
284
+ const sessions = ctx.get('sessions');
285
+ const conversation = ctx.get('conversation');
286
+ if (sessions === undefined || sessions === null || conversation === undefined || conversation === null) {
287
+ throw new Error('session services are not available yet');
288
+ }
289
+ const binding = sessions.binding(sessionId);
290
+ if (binding === undefined) throw new Error(`unknown session "${sessionId}"`);
291
+
292
+ const controller = typeof conversation.binding === 'function' ? conversation.binding(binding) : undefined;
293
+ if (controller !== undefined && controller !== null && typeof controller.send === 'function') {
294
+ return (text) => controller.send(text);
295
+ }
296
+
297
+ const input = typeof conversation.input === 'object' && conversation.input !== null ? conversation.input : undefined;
298
+ const shell = input !== undefined && typeof input.shell === 'function' ? input.shell(sessionId) : undefined;
299
+ const actions = shell !== undefined && shell !== null && shell.actions !== undefined && shell.actions !== null ? shell.actions : undefined;
300
+ if (shell === undefined || actions === undefined || typeof actions.setDraft !== 'function' || typeof actions.submit !== 'function') {
301
+ throw new Error('the conversation service exposes no send path');
302
+ }
303
+ return (text) => {
304
+ actions.setDraft(text);
305
+ let tries = 0;
306
+ const settle = () => {
307
+ let draft;
308
+ try {
309
+ draft = shell.state.getSnapshot().draft;
310
+ } catch (error) {
311
+ draft = undefined;
312
+ }
313
+ if (draft === text || tries >= 12) {
314
+ try {
315
+ actions.submit();
316
+ } catch (error) {
317
+ console.warn('dsh-nextsteps: composer submit failed:', error);
318
+ }
319
+ return;
320
+ }
321
+ tries += 1;
322
+ window.setTimeout(settle, 25);
323
+ };
324
+ window.setTimeout(settle, 0);
325
+ };
326
+ }
327
+
328
+ const LANGUAGE_OPTIONS = [
329
+ { value: "en", label: "English" },
330
+ { value: "pt-BR", label: "Português (Brasil)" },
331
+ { value: "zh-CN", label: "简体中文" },
332
+ { value: "es", label: "Español" },
333
+ { value: "fr", label: "Français" },
334
+ { value: "de", label: "Deutsch" },
335
+ { value: "it", label: "Italiano" },
336
+ { value: "ja", label: "日本語" },
337
+ { value: "ko", label: "한국어" },
338
+ { value: "ru", label: "Русский" },
339
+ { value: "ar", label: "العربية" },
340
+ { value: "hi", label: "हिन्दी" },
341
+ { value: "id", label: "Bahasa Indonesia" },
342
+ { value: "tr", label: "Türkçe" },
343
+ { value: "vi", label: "Tiếng Việt" }
344
+ ];
345
+ const NPM_NAME = 'dsh-nextsteps';
346
+ const ICON_DATAURI = 'data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20256%20256%22%20role%3D%22img%22%20aria-label%3D%22Nextsteps%20icon%22%3E%0A%20%20%3Crect%20width%3D%22256%22%20height%3D%22256%22%20rx%3D%2256%22%20fill%3D%22%230d0d10%22%2F%3E%0A%20%20%3Crect%20x%3D%2252%22%20y%3D%2264%22%20width%3D%22152%22%20height%3D%2244%22%20rx%3D%2210%22%20fill%3D%22%231f242b%22%20stroke%3D%22%234b5563%22%20stroke-width%3D%223%22%2F%3E%0A%20%20%3Crect%20x%3D%2252%22%20y%3D%22124%22%20width%3D%22152%22%20height%3D%2244%22%20rx%3D%2210%22%20fill%3D%22%231f242b%22%20stroke%3D%22%234b5563%22%20stroke-width%3D%223%22%2F%3E%0A%20%20%3Crect%20x%3D%2252%22%20y%3D%22184%22%20width%3D%22152%22%20height%3D%2244%22%20rx%3D%2210%22%20fill%3D%22%23e5484d%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M84%20206h72M136%20194l14%2012-14%2012%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%228%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20fill%3D%22none%22%2F%3E%0A%20%20%3Cpath%20d%3D%22M76%2086h96M76%20146h96%22%20stroke%3D%22%236b7280%22%20stroke-width%3D%227%22%20stroke-linecap%3D%22round%22%2F%3E%0A%3C%2Fsvg%3E%0A';
347
+ const SETTINGS_ROUTE = '/api/dsh-nextsteps.settings';
348
+ const UPDATE_ROUTE = '/api/dsh-nextsteps.update';
349
+ const DICTIONARIES = {
350
+ en: {"header":"Suggested followups","showEarlier":"Show earlier suggestions","hideEarlier":"Hide earlier suggestions","srFollowupSent":"Followup sent","sendFailed":"Could not send: ","intro":"Suggested next-step prompts under every finished agent turn — one click sends the followup.","setLanguage":"Language","setLanguageTip":"Followups are generated in this language. Auto follows the conversation.","optAuto":"Auto (follow the conversation)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setMax":"Followups per turn","setMaxTip":"How many suggested prompts the model should produce (1-6).","setHeader":"Show header","setHeaderTip":"The small “Suggested followups” label above the cards.","updates":"Updates","current":"Version","checking":"Checking…","upToDate":"up to date","available":"{v} is available","manualCopy":"Development or renamed copy — update it manually.","installing":"Installing {v}…","restartMsg":"Version {v} was installed. Restart the dsh to use it.","noReleases":"No releases published yet","checkFailed":"Update check failed ({e})","updateNow":"Update now","check":"Check for updates","openRelease":"Open release page","releaseNotes":"Release notes","repo":"GitHub repository","issues":"Report an issue"},
351
+ 'pt-BR': {"header":"Sugestões de acompanhamento","showEarlier":"Mostrar sugestões anteriores","hideEarlier":"Ocultar sugestões anteriores","srFollowupSent":"Follow-up enviado","sendFailed":"Não consegui enviar: ","intro":"Sugestões de próximos passos sob cada turno concluído do agente — um clique envia o followup.","setLanguage":"Idioma","setLanguageTip":"Os followups são gerados neste idioma. Automático segue a conversa.","optAuto":"Automático (segue a conversa)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setMax":"Followups por turno","setMaxTip":"Quantas sugestões o modelo deve produzir (1-6).","setHeader":"Mostrar cabeçalho","setHeaderTip":"O rótulo pequeno “Sugestões de acompanhamento” acima dos cartões.","updates":"Atualizações","current":"Versão","checking":"Verificando…","upToDate":"em dia","available":"{v} disponível","manualCopy":"Cópia de desenvolvimento ou renomeada — atualize manualmente.","installing":"Instalando {v}…","restartMsg":"Versão {v} instalada. Reinicie o dsh para usá-la.","noReleases":"Nenhum release publicado ainda","checkFailed":"Falha ao verificar atualizações ({e})","updateNow":"Atualizar agora","check":"Verificar atualizações","openRelease":"Abrir página do release","releaseNotes":"Notas do release","repo":"Repositório no GitHub","issues":"Reportar um problema"},
352
+ 'zh-CN': {"header":"建议的后续操作","showEarlier":"显示较早的建议","hideEarlier":"隐藏较早的建议","srFollowupSent":"后续操作已发送","sendFailed":"发送失败:","intro":"在每个智能体回合完成后的下方推荐下一步提示——单击即可发送。","setLanguage":"语言","setLanguageTip":"后续操作提示将以此语言生成。“自动”会跟随对话语言。","optAuto":"自动(跟随对话)","optEn":"English","optPt":"Português (Brasil)","optZh":"简体中文","setMax":"每回合提示数","setMaxTip":"模型应生成的建议提示数量(1-6)。","setHeader":"显示标题","setHeaderTip":"卡片上方的小标题“建议的后续操作”。","updates":"更新","current":"版本","checking":"检查中…","upToDate":"已是最新","available":"{v} 可用","manualCopy":"开发或重命名的副本——请手动更新。","installing":"正在安装 {v}…","restartMsg":"版本 {v} 已安装。请重启 dsh 以使用。","noReleases":"尚未发布任何版本","checkFailed":"检查更新失败({e})","updateNow":"立即更新","check":"检查更新","openRelease":"打开发布页","releaseNotes":"发布说明","repo":"GitHub 仓库","issues":"反馈问题"}
353
+ };
354
+ let pluginSettings = { lang: 'auto', maxFollowups: 3, showHeader: true };
355
+ let pluginVersion;
356
+
357
+ async function refreshPluginSettings() {
358
+ try {
359
+ const response = await fetch(SETTINGS_ROUTE, { cache: 'no-store' });
360
+ const data = await response.json();
361
+ if (data !== null && typeof data === 'object' && data.ok === true && data.settings !== null && typeof data.settings === 'object') {
362
+ pluginSettings = data.settings;
363
+ if (typeof data.version === 'string') pluginVersion = data.version;
364
+ }
365
+ } catch (error) {
366
+ console.warn('dsh-nextsteps: settings unavailable:', error);
367
+ }
368
+ return pluginSettings;
369
+ }
370
+
371
+ function FollowupsCard({ entry, sessionId, ctx, t }) {
372
+ const [sentLocally, setSentLocally] = React.useState(() => new Set());
373
+ const [failure, setFailure] = React.useState(null);
374
+ const busyRef = React.useRef(false);
375
+
376
+ if (entry === null) return null;
377
+ const clicked = readClickedMap();
378
+
379
+ const send = (followup, index) => {
380
+ if (busyRef.current) return;
381
+ busyRef.current = true;
382
+ let sendFollowup;
383
+ try {
384
+ sendFollowup = resolveSend(ctx, sessionId);
385
+ } catch (error) {
386
+ console.warn('dsh-nextsteps: send unavailable:', error);
387
+ setFailure(errorMessage(error));
388
+ busyRef.current = false;
389
+ return;
390
+ }
391
+ Promise.resolve()
392
+ .then(() => sendFollowup(followup.prompt))
393
+ .then(() => {
394
+ markClicked(`${entry.clickedId}:${index}`);
395
+ setSentLocally((previous) => new Set(previous).add(`${entry.clickedId}:${index}`));
396
+ setFailure(null);
397
+ })
398
+ .catch((error) => {
399
+ console.warn('dsh-nextsteps: send failed:', error);
400
+ setFailure(errorMessage(error));
401
+ })
402
+ .finally(() => {
403
+ busyRef.current = false;
404
+ });
405
+ };
406
+
407
+ const rows = entry.followups.map((followup, index) => {
408
+ const key = `${entry.clickedId}:${index}`;
409
+ const sent = sentLocally.has(key) || clicked[key] === true;
410
+ return h('button', {
411
+ key,
412
+ type: 'button',
413
+ className: 'dsh-sf-card' + (sent ? ' is-sent' : ''),
414
+ disabled: sent,
415
+ title: followup.prompt,
416
+ 'aria-label': followup.prompt,
417
+ onClick: () => {
418
+ if (sent) return;
419
+ send(followup, index);
420
+ }
421
+ },
422
+ h('span', { className: 'dsh-sf-icon', 'aria-hidden': true }, sent ? '✓' : '→'),
423
+ h('span', { className: 'dsh-sf-label' }, displayLabel(followup)),
424
+ sent ? h('span', { className: 'dsh-sf-sr' }, t('srFollowupSent')) : null
425
+ );
426
+ });
427
+
428
+ return h('div', { className: 'dsh-sf-root' },
429
+ pluginSettings.showHeader ? h('div', { className: 'dsh-sf-header' }, t('header')) : null,
430
+ h('div', { className: 'dsh-sf-row' }, rows),
431
+ failure === null ? null : h('div', { className: 'dsh-sf-error' }, `${t('sendFailed')}${failure}`)
432
+ );
433
+ }
434
+
435
+
436
+
437
+ function createCapture() {
438
+ return function SuggestFollowupsCapture(props) {
439
+ const sessionId = typeof props.sessionId === 'string' ? props.sessionId : '';
440
+ if (sessionId !== '') {
441
+ const block = props.block;
442
+ const callId = typeof props.callId === 'string' && props.callId !== ''
443
+ ? props.callId
444
+ : typeof block?.callId === 'string' ? block.callId : '';
445
+ const turn = turnIdOf(block?.turn);
446
+ const followups = followupsFromText(block?.argsRaw)
447
+ ?? followupsFromText(block?.call?.argsRaw)
448
+ ?? followupsFromContent(block?.content);
449
+ if (followups !== null) rememberCapture(sessionId, callId, turn, followups);
450
+ }
451
+ return null;
452
+ };
453
+ }
454
+
455
+ function createTail(ctx) {
456
+ return function SuggestFollowupsTail(props) {
457
+ const sessionId = typeof props.sessionId === 'string' ? props.sessionId : '';
458
+ const turn = turnIdOf(props.turn);
459
+ const [, refresh] = React.useReducer((count) => count + 1, 0);
460
+ React.useEffect(() => {
461
+ captureListeners.add(refresh);
462
+ return () => {
463
+ captureListeners.delete(refresh);
464
+ };
465
+ }, []);
466
+
467
+
468
+
469
+ let entry = turn !== undefined ? captureForTurn(sessionId, turn) : newestCapture(sessionId);
470
+ if (entry === null) {
471
+ const unclaimed = unclaimedCaptures(sessionId, turn);
472
+ if (unclaimed !== null) entry = unclaimed;
473
+ }
474
+ return h(FollowupsCard, { entry, sessionId, ctx, t: props.t });
475
+ };
476
+ }
477
+
478
+ function createSettings(ctx) {
479
+ return function NextstepsSettings(props) {
480
+ let t;
481
+ try {
482
+ t = typeof props.t === 'function' ? props.t : ctx.locale.bind(NS);
483
+ } catch {
484
+ t = (key) => DICTIONARIES.en[key] ?? key;
485
+ }
486
+ const [settings, setSettings] = React.useState(pluginSettings);
487
+ const [update, setUpdate] = React.useState({ status: 'idle' });
488
+ const [notesOpen, setNotesOpen] = React.useState(false);
489
+ React.useEffect(() => {
490
+ refreshPluginSettings().then((loaded) => setSettings({ ...loaded }));
491
+ check();
492
+ }, []);
493
+ const save = async (patch) => {
494
+ const merged = { ...settings, ...patch };
495
+ setSettings(merged);
496
+ try {
497
+ const response = await fetch(SETTINGS_ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) });
498
+ const data = await response.json();
499
+ if (data !== null && typeof data === 'object' && data.ok === true && data.settings !== null && typeof data.settings === 'object') setSettings(data.settings);
500
+ } catch (error) {
501
+ console.warn('dsh-nextsteps: settings save failed:', error);
502
+ }
503
+ };
504
+ const check = async () => {
505
+ setUpdate((previous) => ({ ...previous, status: 'checking' }));
506
+ try {
507
+ const response = await fetch(UPDATE_ROUTE, { cache: 'no-store' });
508
+ const info = await response.json();
509
+ if (!response.ok || info === null || typeof info !== 'object' || info.error !== undefined) throw new Error(info?.error ?? 'HTTP ' + response.status);
510
+ setUpdate({ status: info.newer === true ? (info.managed ? 'available' : 'manual') : 'current', info });
511
+ } catch (error) {
512
+ setUpdate((previous) => previous.status === 'restart' ? previous : { status: 'error', info: previous.info, error: String(error?.message ?? error) });
513
+ }
514
+ };
515
+ const install = async () => {
516
+ const { info } = update;
517
+ let pluginManager;
518
+ try { pluginManager = ctx.get('remote')?.pluginManager; } catch { pluginManager = undefined; }
519
+ if (info === undefined || pluginManager === undefined || typeof pluginManager.installBundle !== 'function') return;
520
+ setUpdate({ status: 'installing', info });
521
+ try {
522
+ const attempt = async (spec) => {
523
+ const reply = await pluginManager.installBundle(spec, { requestId: NS + '-update-' + info.latest + '-' + Date.now() });
524
+ if (reply === null || typeof reply !== 'object' || reply.ok !== true) throw new Error(reply?.error?.message ?? 'plugin manager did not answer');
525
+ return reply.value;
526
+ };
527
+ let result = await attempt(NPM_NAME + '@' + info.latest);
528
+ if (result !== null && typeof result === 'object' && result.error?.code === 'already-installed') result = await attempt('https://registry.npmjs.org/' + NPM_NAME + '/-/' + NPM_NAME + '-' + info.latest + '.tgz');
529
+ const applied = result !== null && typeof result === 'object' && (result.application === 'applied' || result.application === 'restart-required' || result.version === info.latest);
530
+ if (!applied) {
531
+ const diagnostic = typeof result?.error?.diagnostic === 'string' ? result.error.diagnostic.split('\n').find((entry) => entry.trim() !== '') : undefined;
532
+ throw new Error(diagnostic ?? result?.error?.code ?? 'install failed');
533
+ }
534
+ setUpdate(result.application === 'applied'
535
+ ? { status: 'current', info: { ...info, current: info.latest, newer: false } }
536
+ : { status: 'restart', info });
537
+ } catch (error) {
538
+ setUpdate({ status: 'error', info, error: String(error?.message ?? error) });
539
+ }
540
+ };
541
+ const info = update.info ?? {};
542
+ const successColor = 'var(--dsw-alias-state-success-primary, #22c55e)';
543
+ let statusText;
544
+ let statusColor = 'var(--dsw-alias-label-tertiary)';
545
+ if (update.status === 'checking') statusText = t('checking');
546
+ else if (update.status === 'restart') { statusText = t('restartMsg').replace('{v}', info.latest ?? ''); statusColor = successColor; }
547
+ else if (update.status === 'installing') statusText = t('installing').replace('{v}', info.latest ?? '');
548
+ else if (update.status === 'available') { statusText = t('available').replace('{v}', info.latest ?? ''); statusColor = successColor; }
549
+ else if (update.status === 'manual') statusText = t('manualCopy');
550
+ else if (update.status === 'current') statusText = t('upToDate');
551
+ else if (update.status === 'error') statusText = t('checkFailed').replace('{e}', update.error ?? '?');
552
+ else statusText = t('noReleases');
553
+ const controlStyle = { background: 'var(--dsw-alias-bg-base)', color: 'var(--dsw-alias-label-primary)', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: 8, padding: '5px 8px', fontSize: 13 };
554
+ const linkStyle = { background: 'none', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: 8, padding: '5px 10px', color: 'var(--dsw-alias-label-primary)', fontSize: 12, cursor: 'pointer', textDecoration: 'none' };
555
+ const row = (label, tip, control, key) => h('div', { key, title: tip, style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 24, padding: '14px 0', borderBottom: '.5px solid var(--dsw-alias-border-l1)', flexWrap: 'wrap' } },
556
+ h('div', { style: { minWidth: 0 } },
557
+ h('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-primary)' } }, label),
558
+ tip === '' ? null : h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary)', marginTop: 2 } }, tip)),
559
+ h('div', { style: { flex: 'none', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' } }, control));
560
+ return h('div', { 'data-dsh-nextsteps-settings': '' },
561
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, paddingBottom: 8 } },
562
+ h('img', { src: ICON_DATAURI, width: 96, height: 54, alt: '', style: { borderRadius: 6, border: '1px solid var(--dsw-alias-border-l1)', flex: 'none' } }),
563
+ h('div', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', lineHeight: '18px' } }, t('intro'))),
564
+ row(t('setLanguage'), t('setLanguageTip'), h('select', { value: settings.lang, onChange: (event) => save({ lang: event.target.value }), style: controlStyle, 'aria-label': t('setLanguage') },
565
+ h('option', { value: 'auto' }, t('optAuto')),
566
+ ...LANGUAGE_OPTIONS.map((option) => h('option', { key: option.value, value: option.value }, option.label))), 'lang'),
567
+ row(t('setMax'), t('setMaxTip'), h('input', { type: 'number', min: 1, max: 6, value: settings.maxFollowups, onChange: (event) => save({ maxFollowups: Number(event.target.value) }), style: { ...controlStyle, width: 64 }, 'aria-label': t('setMax') }), 'max'),
568
+ row(t('setHeader'), t('setHeaderTip'), h('input', { type: 'checkbox', checked: settings.showHeader, onChange: (event) => save({ showHeader: event.target.checked }), 'aria-label': t('setHeader') }), 'header'),
569
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 24, padding: '14px 0', borderBottom: '.5px solid var(--dsw-alias-border-l1)', flexWrap: 'wrap' } },
570
+ h('div', { style: { minWidth: 0 } },
571
+ h('div', { style: { fontSize: 13, color: 'var(--dsw-alias-label-primary)' } }, t('updates')),
572
+ h('div', { style: { fontSize: 12, color: statusColor, marginTop: 2, display: 'flex', flexWrap: 'wrap', gap: '2px 8px', alignItems: 'center' } },
573
+ h('span', null, t('current') + ': ' + (info.current ?? pluginVersion ?? '?')),
574
+ h('span', null, statusText),
575
+ typeof info.notes === 'string' && info.notes !== '' ? h('button', { onClick: () => setNotesOpen(!notesOpen), style: { background: 'none', border: 'none', padding: 0, color: 'var(--dsw-alias-label-tertiary)', fontSize: 12, cursor: 'pointer', textDecoration: 'underline' } }, t('releaseNotes')) : null)),
576
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' } },
577
+ update.status === 'available' ? h('button', { onClick: install, style: { ...linkStyle, fontWeight: 600 } }, t('updateNow')) : null,
578
+ h('button', { onClick: check, style: linkStyle }, t('check')),
579
+ typeof info.url === 'string' ? h('a', { href: info.url, target: '_blank', rel: 'noreferrer', style: linkStyle }, t('openRelease')) : null)),
580
+ update.status === 'available' && info.notes && notesOpen ? h('pre', { style: { background: 'var(--dsw-alias-bg-base)', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: 8, padding: 10, fontSize: 12, whiteSpace: 'pre-wrap', maxHeight: 180, overflow: 'auto' } }, info.notes) : null,
581
+ h('div', { style: { display: 'flex', gap: 8, padding: '14px 0', flexWrap: 'wrap' } },
582
+ h('a', { href: 'https://github.com/klaus-2/dsh-nextsteps', target: '_blank', rel: 'noreferrer', style: linkStyle }, t('repo')),
583
+ h('a', { href: 'https://github.com/klaus-2/dsh-nextsteps/issues', target: '_blank', rel: 'noreferrer', style: linkStyle }, t('issues'))));
584
+ };
585
+ }
586
+ return {
587
+ inject: ['slots', 'locale', 'remote'],
588
+ apply(ctx) {
589
+ installStyles(ctx);
590
+ rehydrateCaptures();
591
+ refreshPluginSettings();
592
+ for (const [code, dictionary] of Object.entries(DICTIONARIES)) {
593
+ ctx.effect(() => {
594
+ try {
595
+ return ctx.locale.register(NS, code, dictionary);
596
+ } catch (error) {
597
+ console.warn('dsh-nextsteps: ' + code + ' dictionary was not accepted by the locale service:', error);
598
+ return undefined;
599
+ }
600
+ }, 'dsh-nextsteps: ' + code + ' dictionary');
601
+ }
602
+
603
+ ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
604
+ name: 'tool.call.toolview',
605
+ key: TOOL_NAME
606
+ }, createCapture()));
607
+
608
+ ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
609
+ name: 'conversation.chat.turnTail',
610
+ id: 'dsh-nextsteps',
611
+ locale: NS
612
+ }, createTail(ctx)));
613
+
614
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
615
+ name: 'settings.section',
616
+ id: 'dsh-nextsteps',
617
+ order: 94,
618
+ label: () => 'Nextsteps',
619
+ locale: NS
620
+ }, createSettings(ctx)));
621
+ }
622
+ };
623
+ }
624
+ });
Binary file
package/index.js ADDED
@@ -0,0 +1,231 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
3
+ import { readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ const TOOL_NAME = 'suggest_followups';
8
+ const name = 'dsh-nextsteps';
9
+ const inject = ['tools', 'connection'];
10
+ const NPM_NAME = 'dsh-nextsteps';
11
+ const RELEASES_URL = 'https://api.github.com/repos/klaus-2/dsh-nextsteps/releases/latest';
12
+ const SETTINGS_FILE = path.join(homedir(), '.dsh', 'dsh-nextsteps', 'settings.json');
13
+ const PACKAGE = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
14
+
15
+ const LANGUAGE_NAMES = {
16
+ auto: "the same language as the conversation",
17
+ 'en': "English",
18
+ 'pt-BR': "Brazilian Portuguese",
19
+ 'zh-CN': "Simplified Chinese",
20
+ 'es': "Spanish",
21
+ 'fr': "French",
22
+ 'de': "German",
23
+ 'it': "Italian",
24
+ 'ja': "Japanese",
25
+ 'ko': "Korean",
26
+ 'ru': "Russian",
27
+ 'ar': "Arabic",
28
+ 'hi': "Hindi",
29
+ 'id': "Indonesian",
30
+ 'tr': "Turkish",
31
+ 'vi': "Vietnamese"
32
+ };
33
+
34
+ function settingsLoad() {
35
+ try {
36
+ const stored = JSON.parse(readFileSync(SETTINGS_FILE, 'utf8')) ?? {};
37
+ return {
38
+ lang: ["auto","en","pt-BR","zh-CN","es","fr","de","it","ja","ko","ru","ar","hi","id","tr","vi"].includes(stored.lang) ? stored.lang : 'auto',
39
+ maxFollowups: Number.isInteger(stored.maxFollowups) && stored.maxFollowups >= 1 && stored.maxFollowups <= 6 ? stored.maxFollowups : 3,
40
+ showHeader: stored.showHeader !== false
41
+ };
42
+ } catch {
43
+ return { lang: 'auto', maxFollowups: 3, showHeader: true };
44
+ }
45
+ }
46
+
47
+ async function settingsSave(next) {
48
+ await mkdir(path.dirname(SETTINGS_FILE), { recursive: true });
49
+ await writeFile(SETTINGS_FILE, JSON.stringify(next, null, 2));
50
+ }
51
+
52
+ function coerceFollowups(raw) {
53
+ if (typeof raw === 'string') {
54
+ try {
55
+ raw = JSON.parse(raw);
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw) && Array.isArray(raw.followups)) {
61
+ raw = raw.followups;
62
+ }
63
+ if (raw === undefined || raw === null) return undefined;
64
+ if (!Array.isArray(raw)) raw = [raw];
65
+ return raw;
66
+ }
67
+
68
+ function validationProblem(followups) {
69
+ const shape = followups === undefined || followups === null ? 'nothing usable'
70
+ : Array.isArray(followups) ? `array(${followups.length})`
71
+ : typeof followups;
72
+ if (!Array.isArray(followups) || followups.length === 0) {
73
+ return `Error: \`followups\` must be a non-empty array (aim for ${settingsLoad().maxFollowups}) of objects with a non-empty \`prompt\` string and an optional short \`label\` string. Received ${shape}. Retry with valid followups.`;
74
+ }
75
+ for (const followup of followups) {
76
+ if (followup === null || typeof followup !== 'object') {
77
+ return 'Error: each followup must be an object with a non-empty `prompt` string. Retry with valid followups.';
78
+ }
79
+ if (typeof followup.prompt !== 'string' || followup.prompt.trim() === '') {
80
+ return 'Error: each followup needs a non-empty `prompt` string (the message sent when the user clicks). Retry with valid followups.';
81
+ }
82
+ if (followup.label !== undefined && (typeof followup.label !== 'string' || followup.label.trim() === '')) {
83
+ return 'Error: `label` must be a short non-empty string when present. Retry with valid followups.';
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
89
+ const PARAMETER_SCHEMA = {
90
+ type: 'object',
91
+ properties: {
92
+ followups: {
93
+ type: 'array',
94
+ description: 'List of suggested followup prompts the user can click to send.',
95
+ items: {
96
+ type: 'object',
97
+ additionalProperties: false,
98
+ description: 'One suggested followup card.',
99
+ properties: {
100
+ prompt: {
101
+ type: 'string',
102
+ description: 'The message sent verbatim when the user clicks; short, goal-oriented, first-person.'
103
+ },
104
+ label: {
105
+ type: 'string',
106
+ description: 'Short display label for the card, 2-5 words; defaults to a truncated prompt.'
107
+ }
108
+ },
109
+ required: ['prompt']
110
+ }
111
+ }
112
+ },
113
+ required: ['followups']
114
+ };
115
+
116
+ function toolDescription(settings) {
117
+ const language = LANGUAGE_NAMES[settings.lang] ?? LANGUAGE_NAMES.auto;
118
+ return [
119
+ 'MANDATORY in every turn: once all real work and other tool calls are done, call this tool BEFORE writing your final answer — the answer must come after this tool returns, so the turn ends cleanly right after it. Never write your final answer before calling it.',
120
+ `Suggest exactly ${settings.maxFollowups} followup prompts the user is likely to want next.`,
121
+ 'Good suggestions include alternatives to the latest implementation, related features, cleanup opportunities, tests to add, or verification steps you could run yourself; if plan steps remain, one suggestion may be continuing with the next step.',
122
+ `Each prompt must be short (usually one sentence), goal-oriented — name the outcome, not the steps — stand alone without conversation context, and be phrased in the user's first-person voice.`,
123
+ `Write every prompt in ${language}.`,
124
+ 'Never suggest actions the user would carry out themselves outside this session (e.g. "commit these changes"); judge by the tools you actually have.',
125
+ 'This never waits for an answer: its result only confirms the suggestions were recorded.',
126
+ 'Example input:',
127
+ '{"followups":[{"prompt":"Show me how to run the test suite","label":"Run tests"},{"prompt":"Add error handling for empty uploads","label":"Handle empty uploads"},{"prompt":"What about handling streaming updates?","label":"Streaming updates"}]}'
128
+ ].join('\n');
129
+ }
130
+
131
+ const PLUGIN_REV = 'sf-2';
132
+
133
+ function toolSpec(settings) {
134
+ return {
135
+ name: TOOL_NAME,
136
+ description: toolDescription(settings),
137
+ parameters: PARAMETER_SCHEMA,
138
+ output: {
139
+ schema: { type: 'string' },
140
+ render: (args, value) => [
141
+ { type: 'text', text: value },
142
+ { type: 'text', text: JSON.stringify({ rev: PLUGIN_REV, followups: coerceFollowups(args.followups) ?? [] }) }
143
+ ]
144
+ },
145
+ async execute(args) {
146
+ const followups = coerceFollowups(args.followups);
147
+ const problem = validationProblem(followups);
148
+ if (problem !== null) return problem;
149
+ return 'Followups recorded. Now write your final answer for this turn (do not call this tool again).';
150
+ }
151
+ };
152
+ }
153
+
154
+ function parseVersion(value) {
155
+ const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(value ?? ''));
156
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
157
+ }
158
+
159
+ function newerVersion(latest, current) {
160
+ for (let index = 0; index < 3; index += 1) {
161
+ if ((latest[index] ?? 0) !== (current[index] ?? 0)) return (latest[index] ?? 0) > (current[index] ?? 0);
162
+ }
163
+ return false;
164
+ }
165
+
166
+ async function checkUpdate(fetchImpl = fetch, pkg = PACKAGE, signal) {
167
+ const current = parseVersion(pkg.version);
168
+ const managed = pkg.name === NPM_NAME && current !== undefined;
169
+ const response = await fetchImpl(RELEASES_URL, { signal: signal === undefined ? AbortSignal.timeout(10000) : AbortSignal.any([signal, AbortSignal.timeout(10000)]), headers: { accept: 'application/vnd.github+json', 'user-agent': NPM_NAME } });
170
+ if (!response.ok) throw new Error(`GitHub answered ${response.status}`);
171
+ const release = await response.json();
172
+ const latest = parseVersion(release?.tag_name);
173
+ if (latest === undefined) throw new Error('latest release has no x.y.z tag');
174
+ const info = { name: NPM_NAME, current: pkg.version, latest: latest.join('.'), managed, newer: false, url: typeof release.html_url === 'string' && release.html_url.startsWith('https://github.com/') ? release.html_url : undefined };
175
+ const notes = typeof release.body === 'string' ? release.body.slice(0, 4000) : undefined;
176
+ if (notes !== undefined) info.notes = notes;
177
+ if (current === undefined || !newerVersion(latest, current)) return info;
178
+ const registry = await fetchImpl(`https://registry.npmjs.org/${NPM_NAME}/${info.latest}`, { signal: AbortSignal.any([signal ?? AbortSignal.timeout(10000), AbortSignal.timeout(10000)]) });
179
+ return { ...info, newer: registry.ok };
180
+ }
181
+
182
+ async function apply(ctx) {
183
+ let settings = settingsLoad();
184
+ try {
185
+ ctx.tools.register(toolSpec(settings));
186
+ } catch (error) {
187
+ console.error('dsh-nextsteps: tool registration failed:', error);
188
+ }
189
+ const reapplyTool = (next) => {
190
+ settings = next;
191
+ try {
192
+ ctx.tools.register(toolSpec(settings));
193
+ } catch (error) {
194
+ console.error('dsh-nextsteps: tool re-registration failed:', error);
195
+ }
196
+ };
197
+ const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' } });
198
+ ctx.connection?.fetch?.register({
199
+ path: '/api/dsh-nextsteps.settings',
200
+ methods: ['GET', 'POST'],
201
+ requestBody: 'buffered',
202
+ fetch: async (request) => {
203
+ if (request.method === 'GET') return json({ ok: true, settings: settingsLoad(), version: PACKAGE.version });
204
+ let body;
205
+ try { body = await request.json(); } catch { return json({ ok: false, error: 'bad request' }, 400); }
206
+ const current = settingsLoad();
207
+ const next = {
208
+ lang: ["auto","en","pt-BR","zh-CN","es","fr","de","it","ja","ko","ru","ar","hi","id","tr","vi"].includes(body?.lang) ? body.lang : current.lang,
209
+ maxFollowups: Number.isInteger(body?.maxFollowups) && body.maxFollowups >= 1 && body.maxFollowups <= 6 ? body.maxFollowups : current.maxFollowups,
210
+ showHeader: typeof body?.showHeader === 'boolean' ? body.showHeader : current.showHeader
211
+ };
212
+ await settingsSave(next);
213
+ reapplyTool(next);
214
+ return json({ ok: true, settings: next });
215
+ }
216
+ });
217
+ ctx.connection?.fetch?.register({
218
+ path: '/api/dsh-nextsteps.update',
219
+ methods: ['GET'],
220
+ fetch: async (request) => {
221
+ try {
222
+ const info = await checkUpdate(fetch, PACKAGE, request.signal);
223
+ return json({ ...info, installed: undefined });
224
+ } catch (error) {
225
+ return json({ error: String(error?.message ?? error) }, 502);
226
+ }
227
+ }
228
+ });
229
+ }
230
+
231
+ export { name, inject, apply, checkUpdate };
@@ -0,0 +1,38 @@
1
+ {
2
+ "header": "Suggested followups",
3
+ "showEarlier": "Show earlier suggestions",
4
+ "hideEarlier": "Hide earlier suggestions",
5
+ "srFollowupSent": "Followup sent",
6
+ "sendFailed": "Could not send: ",
7
+ "meta": {
8
+ "title": "Nextsteps",
9
+ "description": "Suggested next-step prompts under every finished agent turn — one click sends the followup."
10
+ },
11
+ "intro": "Suggested next-step prompts under every finished agent turn — one click sends the followup.",
12
+ "setLanguage": "Language",
13
+ "setLanguageTip": "Followups are generated in this language. Auto follows the conversation.",
14
+ "optAuto": "Auto (follow the conversation)",
15
+ "optEn": "English",
16
+ "optPt": "Português (Brasil)",
17
+ "optZh": "简体中文",
18
+ "setMax": "Followups per turn",
19
+ "setMaxTip": "How many suggested prompts the model should produce (1-6).",
20
+ "setHeader": "Show header",
21
+ "setHeaderTip": "The small “Suggested followups” label above the cards.",
22
+ "updates": "Updates",
23
+ "current": "Version",
24
+ "checking": "Checking…",
25
+ "upToDate": "up to date",
26
+ "available": "{v} is available",
27
+ "manualCopy": "Development or renamed copy — update it manually.",
28
+ "installing": "Installing {v}…",
29
+ "restartMsg": "Version {v} was installed. Restart the dsh to use it.",
30
+ "noReleases": "No releases published yet",
31
+ "checkFailed": "Update check failed ({e})",
32
+ "updateNow": "Update now",
33
+ "check": "Check for updates",
34
+ "openRelease": "Open release page",
35
+ "releaseNotes": "Release notes",
36
+ "repo": "GitHub repository",
37
+ "issues": "Report an issue"
38
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "header": "Sugestões de acompanhamento",
3
+ "showEarlier": "Mostrar sugestões anteriores",
4
+ "hideEarlier": "Ocultar sugestões anteriores",
5
+ "srFollowupSent": "Follow-up enviado",
6
+ "sendFailed": "Não consegui enviar: ",
7
+ "meta": {
8
+ "title": "Nextsteps",
9
+ "description": "Sugestões de próximos passos sob cada turno do agente — um clique envia o followup."
10
+ },
11
+ "intro": "Sugestões de próximos passos sob cada turno concluído do agente — um clique envia o followup.",
12
+ "setLanguage": "Idioma",
13
+ "setLanguageTip": "Os followups são gerados neste idioma. Automático segue a conversa.",
14
+ "optAuto": "Automático (segue a conversa)",
15
+ "optEn": "English",
16
+ "optPt": "Português (Brasil)",
17
+ "optZh": "简体中文",
18
+ "setMax": "Followups por turno",
19
+ "setMaxTip": "Quantas sugestões o modelo deve produzir (1-6).",
20
+ "setHeader": "Mostrar cabeçalho",
21
+ "setHeaderTip": "O rótulo pequeno “Sugestões de acompanhamento” acima dos cartões.",
22
+ "updates": "Atualizações",
23
+ "current": "Versão",
24
+ "checking": "Verificando…",
25
+ "upToDate": "em dia",
26
+ "available": "{v} disponível",
27
+ "manualCopy": "Cópia de desenvolvimento ou renomeada — atualize manualmente.",
28
+ "installing": "Instalando {v}…",
29
+ "restartMsg": "Versão {v} instalada. Reinicie o dsh para usá-la.",
30
+ "noReleases": "Nenhum release publicado ainda",
31
+ "checkFailed": "Falha ao verificar atualizações ({e})",
32
+ "updateNow": "Atualizar agora",
33
+ "check": "Verificar atualizações",
34
+ "openRelease": "Abrir página do release",
35
+ "releaseNotes": "Notas do release",
36
+ "repo": "Repositório no GitHub",
37
+ "issues": "Reportar um problema"
38
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "header": "建议的后续操作",
3
+ "showEarlier": "显示较早的建议",
4
+ "hideEarlier": "隐藏较早的建议",
5
+ "srFollowupSent": "后续操作已发送",
6
+ "sendFailed": "发送失败:",
7
+ "meta": {
8
+ "title": "Nextsteps",
9
+ "description": "在每个智能体回合下方推荐后续步骤提示——单击即可发送。"
10
+ },
11
+ "intro": "在每个智能体回合完成后的下方推荐下一步提示——单击即可发送。",
12
+ "setLanguage": "语言",
13
+ "setLanguageTip": "后续操作提示将以此语言生成。“自动”会跟随对话语言。",
14
+ "optAuto": "自动(跟随对话)",
15
+ "optEn": "English",
16
+ "optPt": "Português (Brasil)",
17
+ "optZh": "简体中文",
18
+ "setMax": "每回合提示数",
19
+ "setMaxTip": "模型应生成的建议提示数量(1-6)。",
20
+ "setHeader": "显示标题",
21
+ "setHeaderTip": "卡片上方的小标题“建议的后续操作”。",
22
+ "updates": "更新",
23
+ "current": "版本",
24
+ "checking": "检查中…",
25
+ "upToDate": "已是最新",
26
+ "available": "{v} 可用",
27
+ "manualCopy": "开发或重命名的副本——请手动更新。",
28
+ "installing": "正在安装 {v}…",
29
+ "restartMsg": "版本 {v} 已安装。请重启 dsh 以使用。",
30
+ "noReleases": "尚未发布任何版本",
31
+ "checkFailed": "检查更新失败({e})",
32
+ "updateNow": "立即更新",
33
+ "check": "检查更新",
34
+ "openRelease": "打开发布页",
35
+ "releaseNotes": "发布说明",
36
+ "repo": "GitHub 仓库",
37
+ "issues": "反馈问题"
38
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "dsh-nextsteps",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./index.js",
7
+ "./client": "./client.js",
8
+ "./locale/*.json": "./lib/locale/*.json",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "client.js",
14
+ "lib/locale",
15
+ "docs/assets/icon.png",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "dsh": {
20
+ "bundle": {
21
+ "patch": "./cordis.patch.yml"
22
+ },
23
+ "client": {
24
+ "platform": "web",
25
+ "inject": [
26
+ "@deepseek-ai/dsh-client-locale"
27
+ ]
28
+ }
29
+ },
30
+ "description": "Suggested next-step prompts under every finished agent turn in the DeepSeek Harness chat — one click sends the followup.",
31
+ "keywords": [
32
+ "deepseek-harness",
33
+ "dsh-plugin",
34
+ "dsh",
35
+ "followups",
36
+ "next-steps",
37
+ "suggestions",
38
+ "ai-agents",
39
+ "human-in-the-loop"
40
+ ],
41
+ "license": "MIT",
42
+ "homepage": "https://github.com/klaus-2/dsh-nextsteps#readme",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/klaus-2/dsh-nextsteps.git"
46
+ },
47
+ "bugs": {
48
+ "url": "https://github.com/klaus-2/dsh-nextsteps/issues"
49
+ },
50
+ "icon": "./docs/assets/icon.png"
51
+ }