dsh-superinput 0.0.0-stage → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * dsh-superinput · prompts 模块(提示词模板库)
3
+ *
4
+ * prompts 是手动保存的常用模板(划选正文可一键存入)。
5
+ * 支持 {{变量}} 占位符,插入时由前端弹窗填值;服务端只存原文,不做替换。
6
+ *
7
+ * 存储 ~/.dsh/storages/dsh-superinput/prompts.json(结构 { items: [...] })。
8
+ */
9
+ import Schema from '@deepseek-ai/schemastery'
10
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
11
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, appendFileSync } from 'node:fs'
12
+ import { randomUUID } from 'node:crypto'
13
+ import { homedir } from 'node:os'
14
+ import { join, dirname } from 'node:path'
15
+
16
+ export const inject = []
17
+
18
+ const STORE_DIR = join(homedir(), '.dsh', 'storages', 'dsh-superinput')
19
+ const STORE_PATH = join(STORE_DIR, 'prompts.json')
20
+ const CLIENT_DIAG = join(homedir(), '.dsh', 'superinput-debug.log')
21
+
22
+ /* 划选上下文:客户端把「选段 + 评论」暂存到这里,下一轮 pre-step 一次性
23
+ * 注入成一条 sourced UserMessage,随后立即删除(只影响紧接着的一次发送)。 */
24
+ const CONTEXT_MAX_ITEMS = 8
25
+ const CONTEXT_TEXT_MAX = 4000
26
+ const CONTEXT_COMMENT_MAX = 600
27
+ const CONTEXT_TTL_MS = 10 * 60_000
28
+ const pendingContext = new Map()
29
+
30
+ function renderSelectionContext(items) {
31
+ const lines = [
32
+ '以下是我从对话中选中的上下文,按顺序附带我的评论。回答时请优先参考这些内容,并针对评论中的问题作答。',
33
+ ]
34
+ items.forEach((item, index) => {
35
+ lines.push(`【选段 ${index + 1}】\n${item.text}${item.comment ? `\n我的评论:${item.comment}` : ''}`)
36
+ })
37
+ return lines.join('\n\n')
38
+ }
39
+
40
+ export const Config = Schema.object({
41
+ enabled: Schema.boolean().default(true).description('启用提示词模板库'),
42
+ maxItems: Schema.number().default(500).description('最多保存多少条模板(收藏不计入上限)'),
43
+ })
44
+
45
+ function createStore(maxItems) {
46
+ let state = { items: [] }
47
+ let dirty = false
48
+ let timer = null
49
+
50
+ const load = () => {
51
+ try { state = JSON.parse(readFileSync(STORE_PATH, 'utf8')) } catch { /* 首次或损坏:空库 */ }
52
+ if (!state || !Array.isArray(state.items)) state = { items: [] }
53
+ }
54
+
55
+ const flush = () => {
56
+ if (!dirty) return
57
+ try {
58
+ mkdirSync(STORE_DIR, { recursive: true })
59
+ const tmp = `${STORE_PATH}.${process.pid}.tmp`
60
+ writeFileSync(tmp, JSON.stringify(state), 'utf8')
61
+ renameSync(tmp, STORE_PATH)
62
+ dirty = false
63
+ } catch (error) {
64
+ console.error('[dsh-superinput] prompts flush failed:', error?.message ?? error)
65
+ }
66
+ }
67
+
68
+ const schedule = () => {
69
+ dirty = true
70
+ if (timer) return
71
+ timer = setTimeout(() => { timer = null; flush() }, 500)
72
+ timer.unref?.()
73
+ }
74
+
75
+ return {
76
+ load,
77
+ dispose() { if (timer) { clearTimeout(timer); timer = null } flush() },
78
+ list() { return state.items },
79
+
80
+ /** 新建或更新。title 为空时用正文前 40 字兜底,避免列表里出现无标题条目。 */
81
+ save({ id, title, body, tags }) {
82
+ const cleanBody = String(body ?? '').replace(/\0/g, '')
83
+ if (!cleanBody.trim()) return { ok: false, reason: 'empty-body' }
84
+ const cleanTitle = String(title ?? '').trim().slice(0, 80) || cleanBody.trim().slice(0, 40)
85
+ const cleanTags = Array.isArray(tags)
86
+ ? tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 8)
87
+ : []
88
+
89
+ if (id) {
90
+ const item = state.items.find((entry) => entry.id === id)
91
+ if (!item) return { ok: false, reason: 'not-found' }
92
+ item.title = cleanTitle
93
+ item.body = cleanBody
94
+ item.tags = cleanTags
95
+ item.at = Date.now()
96
+ schedule()
97
+ return { ok: true, id, created: false }
98
+ }
99
+
100
+ const newId = randomUUID()
101
+ state.items.unshift({
102
+ id: newId,
103
+ title: cleanTitle,
104
+ body: cleanBody,
105
+ tags: cleanTags,
106
+ at: Date.now(),
107
+ pinned: false,
108
+ })
109
+ // 收藏条目不占上限
110
+ const overflow = state.items.filter((item) => !item.pinned).length - maxItems
111
+ if (overflow > 0) {
112
+ const drop = new Set(state.items.filter((item) => !item.pinned).slice(-overflow).map((item) => item.id))
113
+ state.items = state.items.filter((item) => !drop.has(item.id))
114
+ }
115
+ schedule()
116
+ return { ok: true, id: newId, created: true }
117
+ },
118
+
119
+ remove(id) {
120
+ const before = state.items.length
121
+ state.items = state.items.filter((item) => item.id !== id)
122
+ if (state.items.length !== before) schedule()
123
+ return state.items.length !== before
124
+ },
125
+
126
+ pin(id, pinned) {
127
+ const item = state.items.find((entry) => entry.id === id)
128
+ if (!item) return false
129
+ item.pinned = !!pinned
130
+ schedule()
131
+ return true
132
+ },
133
+
134
+ /** 使用计数:面板点插入时调用,列表按最近使用排序。 */
135
+ use(id) {
136
+ const item = state.items.find((entry) => entry.id === id)
137
+ if (!item) return false
138
+ item.usedAt = Date.now()
139
+ item.uses = (item.uses ?? 0) + 1
140
+ schedule()
141
+ return true
142
+ },
143
+
144
+ /** 是否注册为 / 斜杠命令(客户端按此标记同步 commandUi)。 */
145
+ slash(id, on) {
146
+ const item = state.items.find((entry) => entry.id === id)
147
+ if (!item) return false
148
+ item.slash = !!on
149
+ schedule()
150
+ return true
151
+ },
152
+
153
+ /**
154
+ * 导入:按 id 合并;同 id 冲突保留 at(更新时间)较新的一方。
155
+ * id 不在库中则原 id 收编(跨机重复导入可去重),缺 id 才新分配。
156
+ */
157
+ import(items) {
158
+ let added = 0
159
+ let updated = 0
160
+ for (const raw of items.slice(0, 5000)) {
161
+ const body = String(raw?.body ?? '').replace(/\0/g, '')
162
+ if (!body.trim()) continue
163
+ const incoming = {
164
+ title: String(raw?.title ?? '').trim().slice(0, 80) || body.trim().slice(0, 40),
165
+ body,
166
+ tags: Array.isArray(raw?.tags)
167
+ ? raw.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 8)
168
+ : [],
169
+ at: Number.isFinite(raw?.at) ? raw.at : Date.now(),
170
+ pinned: raw?.pinned === true,
171
+ slash: raw?.slash === true,
172
+ usedAt: Number.isFinite(raw?.usedAt) ? raw.usedAt : undefined,
173
+ uses: Number.isFinite(raw?.uses) ? Math.max(0, Math.round(raw.uses)) : 0,
174
+ }
175
+ const existing = raw?.id ? state.items.find((entry) => entry.id === raw.id) : null
176
+ if (existing) {
177
+ if (incoming.at > (existing.at ?? 0)) {
178
+ Object.assign(existing, incoming)
179
+ updated++
180
+ }
181
+ continue
182
+ }
183
+ state.items.unshift({ id: raw?.id ? String(raw.id).slice(0, 64) : randomUUID(), ...incoming })
184
+ added++
185
+ }
186
+ const overflow = state.items.filter((item) => !item.pinned).length - maxItems
187
+ if (overflow > 0) {
188
+ const drop = new Set(state.items.filter((item) => !item.pinned).slice(-overflow).map((item) => item.id))
189
+ state.items = state.items.filter((item) => !drop.has(item.id))
190
+ }
191
+ if (added + updated > 0) schedule()
192
+ return { added, updated }
193
+ },
194
+ }
195
+ }
196
+
197
+ export function apply(ctx, config) {
198
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
199
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。回放必须在
200
+ // createStore 之前,重启后上次的 maxItems 才对裁剪生效。
201
+ const live = { ...config }
202
+ if (!live.enabled) return
203
+
204
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
205
+ const SETTINGS_KEYS = ['maxItems']
206
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-superinput', 'prompts.settings.json')
207
+ try {
208
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
209
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
210
+ } catch { /* 无存档:全部跟随配置 */ }
211
+ let settingsTimer = null
212
+ const persistSettings = () => {
213
+ if (settingsTimer) return
214
+ settingsTimer = setTimeout(() => {
215
+ settingsTimer = null
216
+ try {
217
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
218
+ const out = {}
219
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
220
+ const tmp = `${SETTINGS_PATH}.tmp`
221
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
222
+ renameSync(tmp, SETTINGS_PATH)
223
+ } catch { /* ignore */ }
224
+ }, 400)
225
+ }
226
+
227
+ const store = createStore(Math.max(20, Math.round(live.maxItems ?? 500)))
228
+ store.load()
229
+ ctx.effect(() => store.dispose(), 'dsh-superinput: prompts store')
230
+
231
+ // 划选上下文注入:挂在 pre-step 最前面,先让下游(含提示词增强)处理完,
232
+ // 再把上下文作为一条独立的 sourced 用户消息追加到本轮送模消息末尾。
233
+ ctx.on('agent/pre-step', async ({ agent, step, signal }, next) => {
234
+ const decision = await next()
235
+ if (decision.kind === 'reject' || signal?.aborted) return decision
236
+ const rawSession = agent?.session
237
+ const sessionId = typeof rawSession === 'string'
238
+ ? rawSession
239
+ : rawSession?.id === undefined ? '' : String(rawSession.id)
240
+ const pending = sessionId ? pendingContext.get(sessionId) : undefined
241
+ if (step !== 1 || !pending) return decision
242
+ pendingContext.delete(sessionId)
243
+ if (pending.items.length === 0 || Date.now() - pending.at > CONTEXT_TTL_MS) return decision
244
+ const text = renderSelectionContext(pending.items)
245
+ try {
246
+ appendFileSync(CLIENT_DIAG, `${new Date().toISOString()} context injected session=${sessionId.slice(0, 50)} items=${pending.items.length}\n`, 'utf8')
247
+ } catch { /* 日志失败不影响注入 */ }
248
+ return {
249
+ ...decision,
250
+ messages: [...decision.messages, createUserMessage({
251
+ content: [{ type: 'text', text }],
252
+ source: {
253
+ kind: 'dsh-superinput-selection',
254
+ form: 'snapshot',
255
+ sections: [{ name: 'selection-context', text }],
256
+ },
257
+ })],
258
+ }
259
+ }, { prepend: true })
260
+
261
+ ctx.inject(['webServer'], (webCtx) => {
262
+ webCtx.effect(() => webCtx.webServer.register({
263
+ kind: 'prefix',
264
+ path: '/api/dsh-superinput/prompts',
265
+ handler: async (req, res) => {
266
+ const send = (status, body) => {
267
+ res.statusCode = status
268
+ res.setHeader('content-type', 'application/json')
269
+ res.end(JSON.stringify(body))
270
+ }
271
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-superinput\/prompts/, '')
272
+ const readBody = async () => {
273
+ let body = ''
274
+ for await (const chunk of req) body += chunk
275
+ try { return JSON.parse(body) } catch { return {} }
276
+ }
277
+
278
+ if (path === '/settings' && req.method === 'GET') {
279
+ send(200, { ...live })
280
+ return
281
+ }
282
+ // 运行时改参数:只接受白名单字段,其余忽略(provider/model 这类
283
+ // 改了要重建路由,运行时改无效,不如不给入口)。
284
+ if (path === '/settings' && req.method === 'POST') {
285
+ let raw = ''
286
+ for await (const chunk of req) raw += chunk
287
+ let body
288
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
289
+ if (body.maxItems !== undefined) { const v = Number(body.maxItems); if (Number.isFinite(v)) live.maxItems = Math.min(5000, Math.max(20, Math.round(v))) }
290
+ persistSettings()
291
+ send(200, { ...live })
292
+ return
293
+ }
294
+ // 套件客户端统一诊断通道(0.4.0 起从 snippets 迁来):落
295
+ // ~/.dsh/superinput-debug.log,与服务端 trace 同文件便于一次排查。
296
+ if (path === '/diag' && req.method === 'POST') {
297
+ let raw = ''
298
+ for await (const chunk of req) raw += chunk
299
+ try {
300
+ let message = raw
301
+ try { message = JSON.parse(raw).message ?? raw } catch { /* 纯文本也收 */ }
302
+ mkdirSync(dirname(CLIENT_DIAG), { recursive: true })
303
+ appendFileSync(CLIENT_DIAG, `${new Date().toISOString()} ${String(message).slice(0, 500)}\n`, 'utf8')
304
+ } catch { /* 落盘失败不影响应答 */ }
305
+ send(200, { ok: true })
306
+ return
307
+ }
308
+ // 划选上下文暂存:items 为空表示清除;真正的注入在 agent/pre-step。
309
+ if (path === '/context' && req.method === 'POST') {
310
+ const body = await readBody()
311
+ const sessionId = String(body.sessionId ?? '').trim().slice(0, 200)
312
+ if (!sessionId) { send(400, { error: 'missing-session' }); return }
313
+ const items = Array.isArray(body.items)
314
+ ? body.items.slice(0, CONTEXT_MAX_ITEMS).map((item) => ({
315
+ text: String(item?.text ?? '').replace(/\0/g, '').trim().slice(0, CONTEXT_TEXT_MAX),
316
+ comment: String(item?.comment ?? '').replace(/\0/g, '').trim().slice(0, CONTEXT_COMMENT_MAX),
317
+ })).filter((item) => item.text.length > 0)
318
+ : []
319
+ if (items.length === 0) pendingContext.delete(sessionId)
320
+ else pendingContext.set(sessionId, { items, at: Date.now() })
321
+ send(200, { ok: true, count: items.length })
322
+ return
323
+ }
324
+ if (path === '/list' && req.method === 'GET') {
325
+ send(200, { items: store.list() })
326
+ return
327
+ }
328
+ if (path === '/save' && req.method === 'POST') {
329
+ send(200, store.save(await readBody()))
330
+ return
331
+ }
332
+ if (path === '/remove' && req.method === 'POST') {
333
+ const body = await readBody()
334
+ send(200, { ok: store.remove(String(body.id ?? '')) })
335
+ return
336
+ }
337
+ if (path === '/pin' && req.method === 'POST') {
338
+ const body = await readBody()
339
+ send(200, { ok: store.pin(String(body.id ?? ''), body.pinned === true) })
340
+ return
341
+ }
342
+ if (path === '/use' && req.method === 'POST') {
343
+ const body = await readBody()
344
+ send(200, { ok: store.use(String(body.id ?? '')) })
345
+ return
346
+ }
347
+ if (path === '/slash' && req.method === 'POST') {
348
+ const body = await readBody()
349
+ send(200, { ok: store.slash(String(body.id ?? ''), body.slash === true) })
350
+ return
351
+ }
352
+ if (path === '/export' && req.method === 'GET') {
353
+ send(200, { version: 1, exportedAt: Date.now(), items: store.list() })
354
+ return
355
+ }
356
+ // 兼容两种载荷:导出文件原样({items:[...]})和裸数组。
357
+ if (path === '/import' && req.method === 'POST') {
358
+ const body = await readBody()
359
+ const items = Array.isArray(body) ? body : Array.isArray(body?.items) ? body.items : null
360
+ if (!items) { send(400, { error: 'invalid-payload' }); return }
361
+ send(200, { ok: true, ...store.import(items) })
362
+ return
363
+ }
364
+ send(404, { error: 'not-found' })
365
+ },
366
+ }), 'dsh-superinput: prompts endpoints')
367
+ })
368
+ }
369
+
370
+ export default { Config, apply }
@@ -0,0 +1,17 @@
1
+ /* 由 scripts/build-suite.mjs 从 dsh-siri-voice/index.js 机械生成;勿手改,重跑脚本再生成。 */
2
+ /**
3
+ * dsh-siri-voice — 服务端半(无逻辑)。
4
+ *
5
+ * 这个插件只做 web 客户端的视觉层,但 dsh 的客户端模块扫描是从 Host 的
6
+ * loader entries 出发的,所以包仍然需要一个可被加载的服务端入口:
7
+ * 声明 `dsh.bundle.patch` 把一行插件插进 profile,`dsh.client.platform = 'web'`
8
+ * 让 web 宿主把 client.js 挂到页面里。
9
+ */
10
+
11
+ const name = 'siri-voice'
12
+
13
+ /** 不注册任何服务;所有效果都在客户端。 */
14
+ export function apply() {}
15
+
16
+ import Schema from '@deepseek-ai/schemastery'
17
+ export const Config = Schema.object({ enabled: Schema.boolean().default(true).description('启用') })
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "dsh-superinput",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.12.1",
4
+ "description": "Super input box suite for the DeepSeek Harness (dsh): model & effort picker, prompt enhancement, follow-up suggestions, full cost engine (billing, backfill, price sync, forecast, usage heatmap, Coding Plan quotas, bilingual UI), voice input, prompt template manager, inline selection reference chips with comments, a compaction ring and a settings hub — one plugin, eight modules with per-module failure isolation.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "server.js",
14
+ "client.js",
15
+ "modules/*.js",
16
+ "scripts",
17
+ "cordis.patch.yml",
18
+ "README.md",
19
+ "CHANGELOG.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "node scripts/build-suite.mjs",
23
+ "check": "node --check index.js && node --check server.js && node --check client.js",
24
+ "test": "node scripts/smoke.mjs"
25
+ },
26
+ "keywords": [
27
+ "dsh",
28
+ "dsh-plugin",
29
+ "deepseek-harness",
30
+ "deepseek",
31
+ "composer",
32
+ "cost",
33
+ "picker",
34
+ "suite"
35
+ ],
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/saul-dsh/dsh-superinput.git"
40
+ },
41
+ "homepage": "https://github.com/saul-dsh/dsh-superinput#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/saul-dsh/dsh-superinput/issues"
44
+ },
45
+ "engines": {
46
+ "node": ">=18"
47
+ },
48
+ "peerDependencies": {
49
+ "@deepseek-ai/cordis": "*",
50
+ "@deepseek-ai/dsh-llm": ">=0.2.0-0 <0.3.0-0",
51
+ "@deepseek-ai/dsh-tools": ">=0.2.0-0 <0.3.0-0",
52
+ "@deepseek-ai/schemastery": "*"
53
+ },
54
+ "dsh": {
55
+ "bundle": {
56
+ "patch": "./cordis.patch.yml"
57
+ },
58
+ "client": {
59
+ "platform": "web"
60
+ }
61
+ }
62
+ }