dsh-superinput 0.0.0-stage → 0.7.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.
@@ -0,0 +1,305 @@
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 { readFileSync, writeFileSync, renameSync, mkdirSync, appendFileSync } from 'node:fs'
11
+ import { randomUUID } from 'node:crypto'
12
+ import { homedir } from 'node:os'
13
+ import { join, dirname } from 'node:path'
14
+
15
+ export const inject = []
16
+
17
+ const STORE_DIR = join(homedir(), '.dsh', 'storages', 'dsh-superinput')
18
+ const STORE_PATH = join(STORE_DIR, 'prompts.json')
19
+ const CLIENT_DIAG = join(homedir(), '.dsh', 'superinput-debug.log')
20
+
21
+ export const Config = Schema.object({
22
+ enabled: Schema.boolean().default(true).description('启用提示词模板库'),
23
+ maxItems: Schema.number().default(500).description('最多保存多少条模板(收藏不计入上限)'),
24
+ })
25
+
26
+ function createStore(maxItems) {
27
+ let state = { items: [] }
28
+ let dirty = false
29
+ let timer = null
30
+
31
+ const load = () => {
32
+ try { state = JSON.parse(readFileSync(STORE_PATH, 'utf8')) } catch { /* 首次或损坏:空库 */ }
33
+ if (!state || !Array.isArray(state.items)) state = { items: [] }
34
+ }
35
+
36
+ const flush = () => {
37
+ if (!dirty) return
38
+ try {
39
+ mkdirSync(STORE_DIR, { recursive: true })
40
+ const tmp = `${STORE_PATH}.${process.pid}.tmp`
41
+ writeFileSync(tmp, JSON.stringify(state), 'utf8')
42
+ renameSync(tmp, STORE_PATH)
43
+ dirty = false
44
+ } catch (error) {
45
+ console.error('[dsh-superinput] prompts flush failed:', error?.message ?? error)
46
+ }
47
+ }
48
+
49
+ const schedule = () => {
50
+ dirty = true
51
+ if (timer) return
52
+ timer = setTimeout(() => { timer = null; flush() }, 500)
53
+ timer.unref?.()
54
+ }
55
+
56
+ return {
57
+ load,
58
+ dispose() { if (timer) { clearTimeout(timer); timer = null } flush() },
59
+ list() { return state.items },
60
+
61
+ /** 新建或更新。title 为空时用正文前 40 字兜底,避免列表里出现无标题条目。 */
62
+ save({ id, title, body, tags }) {
63
+ const cleanBody = String(body ?? '').replace(/\0/g, '')
64
+ if (!cleanBody.trim()) return { ok: false, reason: 'empty-body' }
65
+ const cleanTitle = String(title ?? '').trim().slice(0, 80) || cleanBody.trim().slice(0, 40)
66
+ const cleanTags = Array.isArray(tags)
67
+ ? tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 8)
68
+ : []
69
+
70
+ if (id) {
71
+ const item = state.items.find((entry) => entry.id === id)
72
+ if (!item) return { ok: false, reason: 'not-found' }
73
+ item.title = cleanTitle
74
+ item.body = cleanBody
75
+ item.tags = cleanTags
76
+ item.at = Date.now()
77
+ schedule()
78
+ return { ok: true, id, created: false }
79
+ }
80
+
81
+ const newId = randomUUID()
82
+ state.items.unshift({
83
+ id: newId,
84
+ title: cleanTitle,
85
+ body: cleanBody,
86
+ tags: cleanTags,
87
+ at: Date.now(),
88
+ pinned: false,
89
+ })
90
+ // 收藏条目不占上限
91
+ const overflow = state.items.filter((item) => !item.pinned).length - maxItems
92
+ if (overflow > 0) {
93
+ const drop = new Set(state.items.filter((item) => !item.pinned).slice(-overflow).map((item) => item.id))
94
+ state.items = state.items.filter((item) => !drop.has(item.id))
95
+ }
96
+ schedule()
97
+ return { ok: true, id: newId, created: true }
98
+ },
99
+
100
+ remove(id) {
101
+ const before = state.items.length
102
+ state.items = state.items.filter((item) => item.id !== id)
103
+ if (state.items.length !== before) schedule()
104
+ return state.items.length !== before
105
+ },
106
+
107
+ pin(id, pinned) {
108
+ const item = state.items.find((entry) => entry.id === id)
109
+ if (!item) return false
110
+ item.pinned = !!pinned
111
+ schedule()
112
+ return true
113
+ },
114
+
115
+ /** 使用计数:面板点插入时调用,列表按最近使用排序。 */
116
+ use(id) {
117
+ const item = state.items.find((entry) => entry.id === id)
118
+ if (!item) return false
119
+ item.usedAt = Date.now()
120
+ item.uses = (item.uses ?? 0) + 1
121
+ schedule()
122
+ return true
123
+ },
124
+
125
+ /** 是否注册为 / 斜杠命令(客户端按此标记同步 commandUi)。 */
126
+ slash(id, on) {
127
+ const item = state.items.find((entry) => entry.id === id)
128
+ if (!item) return false
129
+ item.slash = !!on
130
+ schedule()
131
+ return true
132
+ },
133
+
134
+ /**
135
+ * 导入:按 id 合并;同 id 冲突保留 at(更新时间)较新的一方。
136
+ * id 不在库中则原 id 收编(跨机重复导入可去重),缺 id 才新分配。
137
+ */
138
+ import(items) {
139
+ let added = 0
140
+ let updated = 0
141
+ for (const raw of items.slice(0, 5000)) {
142
+ const body = String(raw?.body ?? '').replace(/\0/g, '')
143
+ if (!body.trim()) continue
144
+ const incoming = {
145
+ title: String(raw?.title ?? '').trim().slice(0, 80) || body.trim().slice(0, 40),
146
+ body,
147
+ tags: Array.isArray(raw?.tags)
148
+ ? raw.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 8)
149
+ : [],
150
+ at: Number.isFinite(raw?.at) ? raw.at : Date.now(),
151
+ pinned: raw?.pinned === true,
152
+ slash: raw?.slash === true,
153
+ usedAt: Number.isFinite(raw?.usedAt) ? raw.usedAt : undefined,
154
+ uses: Number.isFinite(raw?.uses) ? Math.max(0, Math.round(raw.uses)) : 0,
155
+ }
156
+ const existing = raw?.id ? state.items.find((entry) => entry.id === raw.id) : null
157
+ if (existing) {
158
+ if (incoming.at > (existing.at ?? 0)) {
159
+ Object.assign(existing, incoming)
160
+ updated++
161
+ }
162
+ continue
163
+ }
164
+ state.items.unshift({ id: raw?.id ? String(raw.id).slice(0, 64) : randomUUID(), ...incoming })
165
+ added++
166
+ }
167
+ const overflow = state.items.filter((item) => !item.pinned).length - maxItems
168
+ if (overflow > 0) {
169
+ const drop = new Set(state.items.filter((item) => !item.pinned).slice(-overflow).map((item) => item.id))
170
+ state.items = state.items.filter((item) => !drop.has(item.id))
171
+ }
172
+ if (added + updated > 0) schedule()
173
+ return { added, updated }
174
+ },
175
+ }
176
+ }
177
+
178
+ export function apply(ctx, config) {
179
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
180
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。回放必须在
181
+ // createStore 之前,重启后上次的 maxItems 才对裁剪生效。
182
+ const live = { ...config }
183
+ if (!live.enabled) return
184
+
185
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
186
+ const SETTINGS_KEYS = ['maxItems']
187
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-superinput', 'prompts.settings.json')
188
+ try {
189
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
190
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
191
+ } catch { /* 无存档:全部跟随配置 */ }
192
+ let settingsTimer = null
193
+ const persistSettings = () => {
194
+ if (settingsTimer) return
195
+ settingsTimer = setTimeout(() => {
196
+ settingsTimer = null
197
+ try {
198
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
199
+ const out = {}
200
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
201
+ const tmp = `${SETTINGS_PATH}.tmp`
202
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
203
+ renameSync(tmp, SETTINGS_PATH)
204
+ } catch { /* ignore */ }
205
+ }, 400)
206
+ }
207
+
208
+ const store = createStore(Math.max(20, Math.round(live.maxItems ?? 500)))
209
+ store.load()
210
+ ctx.effect(() => store.dispose(), 'dsh-superinput: prompts store')
211
+
212
+ ctx.inject(['webServer'], (webCtx) => {
213
+ webCtx.effect(() => webCtx.webServer.register({
214
+ kind: 'prefix',
215
+ path: '/api/dsh-superinput/prompts',
216
+ handler: async (req, res) => {
217
+ const send = (status, body) => {
218
+ res.statusCode = status
219
+ res.setHeader('content-type', 'application/json')
220
+ res.end(JSON.stringify(body))
221
+ }
222
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-superinput\/prompts/, '')
223
+ const readBody = async () => {
224
+ let body = ''
225
+ for await (const chunk of req) body += chunk
226
+ try { return JSON.parse(body) } catch { return {} }
227
+ }
228
+
229
+ if (path === '/settings' && req.method === 'GET') {
230
+ send(200, { ...live })
231
+ return
232
+ }
233
+ // 运行时改参数:只接受白名单字段,其余忽略(provider/model 这类
234
+ // 改了要重建路由,运行时改无效,不如不给入口)。
235
+ if (path === '/settings' && req.method === 'POST') {
236
+ let raw = ''
237
+ for await (const chunk of req) raw += chunk
238
+ let body
239
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
240
+ if (body.maxItems !== undefined) { const v = Number(body.maxItems); if (Number.isFinite(v)) live.maxItems = Math.min(5000, Math.max(20, Math.round(v))) }
241
+ persistSettings()
242
+ send(200, { ...live })
243
+ return
244
+ }
245
+ // 套件客户端统一诊断通道(0.4.0 起从 snippets 迁来):落
246
+ // ~/.dsh/superinput-debug.log,与服务端 trace 同文件便于一次排查。
247
+ if (path === '/diag' && req.method === 'POST') {
248
+ let raw = ''
249
+ for await (const chunk of req) raw += chunk
250
+ try {
251
+ let message = raw
252
+ try { message = JSON.parse(raw).message ?? raw } catch { /* 纯文本也收 */ }
253
+ mkdirSync(dirname(CLIENT_DIAG), { recursive: true })
254
+ appendFileSync(CLIENT_DIAG, `${new Date().toISOString()} ${String(message).slice(0, 500)}\n`, 'utf8')
255
+ } catch { /* 落盘失败不影响应答 */ }
256
+ send(200, { ok: true })
257
+ return
258
+ }
259
+ if (path === '/list' && req.method === 'GET') {
260
+ send(200, { items: store.list() })
261
+ return
262
+ }
263
+ if (path === '/save' && req.method === 'POST') {
264
+ send(200, store.save(await readBody()))
265
+ return
266
+ }
267
+ if (path === '/remove' && req.method === 'POST') {
268
+ const body = await readBody()
269
+ send(200, { ok: store.remove(String(body.id ?? '')) })
270
+ return
271
+ }
272
+ if (path === '/pin' && req.method === 'POST') {
273
+ const body = await readBody()
274
+ send(200, { ok: store.pin(String(body.id ?? ''), body.pinned === true) })
275
+ return
276
+ }
277
+ if (path === '/use' && req.method === 'POST') {
278
+ const body = await readBody()
279
+ send(200, { ok: store.use(String(body.id ?? '')) })
280
+ return
281
+ }
282
+ if (path === '/slash' && req.method === 'POST') {
283
+ const body = await readBody()
284
+ send(200, { ok: store.slash(String(body.id ?? ''), body.slash === true) })
285
+ return
286
+ }
287
+ if (path === '/export' && req.method === 'GET') {
288
+ send(200, { version: 1, exportedAt: Date.now(), items: store.list() })
289
+ return
290
+ }
291
+ // 兼容两种载荷:导出文件原样({items:[...]})和裸数组。
292
+ if (path === '/import' && req.method === 'POST') {
293
+ const body = await readBody()
294
+ const items = Array.isArray(body) ? body : Array.isArray(body?.items) ? body.items : null
295
+ if (!items) { send(400, { error: 'invalid-payload' }); return }
296
+ send(200, { ok: true, ...store.import(items) })
297
+ return
298
+ }
299
+ send(404, { error: 'not-found' })
300
+ },
301
+ }), 'dsh-superinput: prompts endpoints')
302
+ })
303
+ }
304
+
305
+ 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,63 @@
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.7.0",
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), voice input, prompt template manager and a settings hub — one plugin, seven input-area 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
+ "snippets",
35
+ "suite"
36
+ ],
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/saul-dsh/dsh-superinput.git"
41
+ },
42
+ "homepage": "https://github.com/saul-dsh/dsh-superinput#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/saul-dsh/dsh-superinput/issues"
45
+ },
46
+ "engines": {
47
+ "node": ">=18"
48
+ },
49
+ "peerDependencies": {
50
+ "@deepseek-ai/cordis": "*",
51
+ "@deepseek-ai/dsh-llm": ">=0.2.0-0 <0.3.0-0",
52
+ "@deepseek-ai/dsh-tools": ">=0.2.0-0 <0.3.0-0",
53
+ "@deepseek-ai/schemastery": "*"
54
+ },
55
+ "dsh": {
56
+ "bundle": {
57
+ "patch": "./cordis.patch.yml"
58
+ },
59
+ "client": {
60
+ "platform": "web"
61
+ }
62
+ }
63
+ }