dsh-plan-completion 0.0.0-stage → 0.1.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.
package/host/store.js ADDED
@@ -0,0 +1,312 @@
1
+ /**
2
+ * host/store.js — 计划存储:内存实现(默认)+ 文件实现(预留、异步、原子写)。
3
+ *
4
+ * 设计依据:v1.2 §3.4(Host 私有存储域,key=sessionId,保留最近 5 版)、§3.5.2(CAS 提交)。
5
+ *
6
+ * 语义(两个 store 一致):
7
+ * get(sessionId) → plan | null(返回副本,改它不影响存储)
8
+ * put(plan, {cas}) → boolean;cas 省略=无条件写,null=仅新建,{gen,rev}=期望当前版本
9
+ * history(sessionId) → plan[](旧 → 新,含当前版本)
10
+ * prune(n = 5) → 每会话只保留最近 n 版
11
+ * clear(sessionId?) → boolean | number
12
+ * listSessions() → sessionId[]
13
+ *
14
+ * createMemoryStore 同步;createFileStore 的方法全部为 async,但同样「永不 throw」:
15
+ * 出错时返回 null/false/[],并把原因记录到 `store.lastError`(字符串)。
16
+ */
17
+
18
+ import { randomUUID } from 'node:crypto';
19
+ import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+
22
+ import { DEFAULTS } from './config.js';
23
+ import { validVersion, versionEquals } from './plan.js';
24
+
25
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
26
+ const isNonEmptyStr = (v) => typeof v === 'string' && v.trim().length > 0;
27
+
28
+ function clone(value) {
29
+ if (value == null) return value;
30
+ try {
31
+ if (typeof structuredClone === 'function') return structuredClone(value);
32
+ } catch {
33
+ /* fall through */
34
+ }
35
+ try {
36
+ return JSON.parse(JSON.stringify(value));
37
+ } catch {
38
+ return value;
39
+ }
40
+ }
41
+
42
+ function lightValidate(plan) {
43
+ if (!isObj(plan)) return false;
44
+ if (!isNonEmptyStr(plan.sessionId)) return false;
45
+ if (!validVersion(plan.version)) return false;
46
+ return true;
47
+ }
48
+
49
+ function casMatches(cas, state) {
50
+ const current = state && state.plan ? state.plan : null;
51
+ if (cas === undefined) return true;
52
+ if (cas === null) return current == null;
53
+ if (!validVersion(cas)) return false;
54
+ if (!current) return false;
55
+ return versionEquals(cas, current.version);
56
+ }
57
+
58
+ function pushHistory(state, plan) {
59
+ const last = state.history[state.history.length - 1];
60
+ if (last && versionEquals(last.version, plan.version)) {
61
+ state.history[state.history.length - 1] = clone(plan);
62
+ } else {
63
+ state.history.push(clone(plan));
64
+ }
65
+ }
66
+
67
+ /**
68
+ * 创建内存计划存储。
69
+ * @returns {object}
70
+ */
71
+ export function createMemoryStore() {
72
+ /** @type {Map<string, {plan: object, history: object[]}>} */
73
+ const states = new Map();
74
+
75
+ function get(sessionId) {
76
+ if (!isNonEmptyStr(sessionId)) return null;
77
+ const state = states.get(sessionId);
78
+ return state && state.plan ? clone(state.plan) : null;
79
+ }
80
+
81
+ function put(plan, options = {}) {
82
+ if (!lightValidate(plan)) return false;
83
+ const cas = isObj(options) && Object.prototype.hasOwnProperty.call(options, 'cas') ? options.cas : undefined;
84
+ const sessionId = plan.sessionId;
85
+ const state = states.get(sessionId) ?? { plan: null, history: [] };
86
+ if (!casMatches(cas, state)) return false;
87
+ state.plan = clone(plan);
88
+ pushHistory(state, plan);
89
+ states.set(sessionId, state);
90
+ return true;
91
+ }
92
+
93
+ function history(sessionId) {
94
+ if (!isNonEmptyStr(sessionId)) return [];
95
+ const state = states.get(sessionId);
96
+ return state ? state.history.map((plan) => clone(plan)) : [];
97
+ }
98
+
99
+ function prune(n = DEFAULTS.keepVersions) {
100
+ const keep = Math.max(1, Math.floor(Number.isFinite(n) ? n : DEFAULTS.keepVersions));
101
+ let removed = 0;
102
+ let sessions = 0;
103
+ for (const state of states.values()) {
104
+ sessions += 1;
105
+ if (state.history.length > keep) {
106
+ removed += state.history.length - keep;
107
+ state.history = state.history.slice(-keep);
108
+ }
109
+ }
110
+ return { ok: true, removed, sessions, keep };
111
+ }
112
+
113
+ function clear(sessionId) {
114
+ if (sessionId === undefined) {
115
+ const count = states.size;
116
+ states.clear();
117
+ return count;
118
+ }
119
+ if (!isNonEmptyStr(sessionId)) return false;
120
+ return states.delete(sessionId);
121
+ }
122
+
123
+ function listSessions() {
124
+ return [...states.keys()];
125
+ }
126
+
127
+ return { get, put, history, prune, clear, listSessions, size: () => states.size };
128
+ }
129
+
130
+ /* ──────────────────────────────────────────────────────────────
131
+ * 文件存储(预留;运行时不强依赖)
132
+ * ────────────────────────────────────────────────────────────── */
133
+
134
+ const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
135
+
136
+ function encodeSessionId(sessionId) {
137
+ return Buffer.from(sessionId, 'utf8').toString('base64url');
138
+ }
139
+
140
+ function decodeSessionId(name) {
141
+ try {
142
+ return Buffer.from(name, 'base64url').toString('utf8');
143
+ } catch {
144
+ return '';
145
+ }
146
+ }
147
+
148
+ /**
149
+ * 创建文件计划存储(异步)。每个会话一个 JSON 文件,写入走「tmp 文件 + rename」的原子替换。
150
+ *
151
+ * 说明:这是为 P2+ 预留的持久化接口,单进程假设、无跨进程锁;运行时不强依赖。
152
+ *
153
+ * @param {string} dir 存储目录(不存在会自动创建)
154
+ * @returns {object} 所有方法 async 且不 throw;出错返回 null/false/[] 并写 `lastError`
155
+ */
156
+ export function createFileStore(dir) {
157
+ const enabled = isNonEmptyStr(dir);
158
+ const root = enabled ? dir.trim() : '';
159
+ const plansDir = enabled ? join(root, 'plans') : '';
160
+ let lastError = '';
161
+
162
+ const store = {
163
+ kind: 'file',
164
+ dir: root,
165
+ enabled,
166
+ get lastError() {
167
+ return lastError;
168
+ },
169
+ async get(sessionId) {
170
+ if (!enabled || !isNonEmptyStr(sessionId)) return null;
171
+ const data = await readState(sessionId);
172
+ return data && isObj(data.plan) ? clone(data.plan) : null;
173
+ },
174
+ async put(plan, options = {}) {
175
+ if (!enabled || !lightValidate(plan)) return false;
176
+ try {
177
+ const cas = isObj(options) && Object.prototype.hasOwnProperty.call(options, 'cas') ? options.cas : undefined;
178
+ const sessionId = plan.sessionId;
179
+ const data = (await readState(sessionId)) ?? { sessionId, plan: null, history: [] };
180
+ if (!casMatches(cas, data)) return false;
181
+ data.plan = clone(plan);
182
+ data.sessionId = sessionId;
183
+ if (!Array.isArray(data.history)) data.history = [];
184
+ const last = data.history[data.history.length - 1];
185
+ if (last && versionEquals(last.version, plan.version)) data.history[data.history.length - 1] = clone(plan);
186
+ else data.history.push(clone(plan));
187
+ await writeState(sessionId, data);
188
+ return true;
189
+ } catch (err) {
190
+ lastError = errText(err);
191
+ return false;
192
+ }
193
+ },
194
+ async history(sessionId) {
195
+ if (!enabled || !isNonEmptyStr(sessionId)) return [];
196
+ const data = await readState(sessionId);
197
+ return data && Array.isArray(data.history) ? data.history.map((plan) => clone(plan)) : [];
198
+ },
199
+ async prune(n = DEFAULTS.keepVersions) {
200
+ if (!enabled) return { ok: false, removed: 0, sessions: 0, keep: 0 };
201
+ const keep = Math.max(1, Math.floor(Number.isFinite(n) ? n : DEFAULTS.keepVersions));
202
+ let removed = 0;
203
+ let sessions = 0;
204
+ const ids = await listSessions();
205
+ for (const sessionId of ids) {
206
+ const data = await readState(sessionId);
207
+ if (!data || !Array.isArray(data.history)) continue;
208
+ sessions += 1;
209
+ if (data.history.length > keep) {
210
+ removed += data.history.length - keep;
211
+ data.history = data.history.slice(-keep);
212
+ await writeState(sessionId, data);
213
+ }
214
+ }
215
+ return { ok: true, removed, sessions, keep };
216
+ },
217
+ async clear(sessionId) {
218
+ if (!enabled) return false;
219
+ try {
220
+ if (sessionId === undefined) {
221
+ const ids = await listSessions();
222
+ let count = 0;
223
+ for (const id of ids) {
224
+ if (await removeState(id)) count += 1;
225
+ }
226
+ return count;
227
+ }
228
+ if (!isNonEmptyStr(sessionId)) return false;
229
+ return removeState(sessionId);
230
+ } catch (err) {
231
+ lastError = errText(err);
232
+ return false;
233
+ }
234
+ },
235
+ async listSessions() {
236
+ return listSessions();
237
+ },
238
+ async size() {
239
+ return (await listSessions()).length;
240
+ },
241
+ };
242
+
243
+ function errText(err) {
244
+ return err && typeof err.message === 'string' ? err.message : String(err);
245
+ }
246
+
247
+ function fileFor(sessionId) {
248
+ return join(plansDir, `${encodeSessionId(sessionId)}.json`);
249
+ }
250
+
251
+ async function readState(sessionId) {
252
+ if (!enabled || !isNonEmptyStr(sessionId)) return null;
253
+ try {
254
+ const raw = await readFile(fileFor(sessionId), 'utf8');
255
+ const data = JSON.parse(raw);
256
+ return isObj(data) ? data : null;
257
+ } catch (err) {
258
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return null;
259
+ lastError = errText(err);
260
+ return null;
261
+ }
262
+ }
263
+
264
+ async function writeState(sessionId, data) {
265
+ await mkdir(plansDir, { recursive: true });
266
+ const target = fileFor(sessionId);
267
+ const tmp = `${target}.${process.pid}.${randomUUID().replace(/-/g, '').slice(0, 12)}.tmp`;
268
+ try {
269
+ await writeFile(tmp, `${JSON.stringify(data)}\n`, 'utf8');
270
+ await rename(tmp, target);
271
+ } catch (err) {
272
+ try {
273
+ await rm(tmp, { force: true });
274
+ } catch {
275
+ /* ignore */
276
+ }
277
+ throw err;
278
+ }
279
+ }
280
+
281
+ async function removeState(sessionId) {
282
+ try {
283
+ await rm(fileFor(sessionId), { force: true });
284
+ return true;
285
+ } catch (err) {
286
+ lastError = errText(err);
287
+ return false;
288
+ }
289
+ }
290
+
291
+ async function listSessions() {
292
+ if (!enabled) return [];
293
+ try {
294
+ const names = await readdir(plansDir);
295
+ const ids = [];
296
+ for (const name of names) {
297
+ if (!name.endsWith('.json')) continue;
298
+ const base = name.slice(0, -5);
299
+ if (!BASE64URL_RE.test(base)) continue;
300
+ const sessionId = decodeSessionId(base);
301
+ if (sessionId) ids.push(sessionId);
302
+ }
303
+ return ids;
304
+ } catch (err) {
305
+ if (err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')) return [];
306
+ lastError = errText(err);
307
+ return [];
308
+ }
309
+ }
310
+
311
+ return store;
312
+ }
package/host/text.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * host/text.js — 文本清洗与安全过滤工具(纯函数,零依赖)。
3
+ *
4
+ * 主要用途:
5
+ * - suggestion 文本规范化:单行、去 Markdown、≤40 字、可直接发送;
6
+ * - 危险建议过滤:明显破坏性命令一律拦掉(设计 §3.6 护栏 4、§3.8「危险建议过滤」);
7
+ * - 提问回合识别(启发式与证据共用)。
8
+ */
9
+
10
+ import { DEFAULTS } from './config.js';
11
+
12
+ /**
13
+ * 把任意值安全地转成字符串(只接受基础类型,对象/数组返回 '')。
14
+ * @param {unknown} value
15
+ * @returns {string}
16
+ */
17
+ export function toText(value) {
18
+ if (value == null) return '';
19
+ if (typeof value === 'string') return value;
20
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
21
+ return '';
22
+ }
23
+
24
+ /**
25
+ * 按「字符(code point)」截断,不会切断 surrogate pair。
26
+ * @param {unknown} value
27
+ * @param {number} max
28
+ * @returns {string}
29
+ */
30
+ export function clampChars(value, max) {
31
+ const text = toText(value);
32
+ const limit = Number.isFinite(max) ? Math.floor(max) : 0;
33
+ if (limit <= 0) return '';
34
+ const chars = Array.from(text);
35
+ return chars.length <= limit ? text : chars.slice(0, limit).join('');
36
+ }
37
+
38
+ /**
39
+ * 折叠为单行:换行/制表 → 空格,压缩连续空白,trim。
40
+ * @param {unknown} value
41
+ * @returns {string}
42
+ */
43
+ export function oneLine(value) {
44
+ return toText(value)
45
+ .replace(/[\r\n\t]+/g, ' ')
46
+ .replace(/\u00a0/g, ' ')
47
+ .replace(/\s{2,}/g, ' ')
48
+ .trim();
49
+ }
50
+
51
+ const MD_LINE_PREFIX = /^\s{0,3}(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/;
52
+ const MD_INLINE = /(\*\*|__|\*|_|`)/g;
53
+ const MD_LINK = /\[([^\]]*)\]\([^)]*\)/g;
54
+ const WRAPPING_QUOTES = /^["'“”‘’「」『』]+|["'“”‘’「」『』]+$/g;
55
+
56
+ /**
57
+ * 去掉常见 Markdown 痕迹,得到适合直接发送的纯文本。
58
+ * @param {unknown} value
59
+ * @returns {string}
60
+ */
61
+ export function stripMarkdown(value) {
62
+ let text = toText(value);
63
+ text = text.replace(/```[a-zA-Z0-9_+-]*/g, ' ');
64
+ text = text
65
+ .split(/\r?\n/)
66
+ .map((line) => line.replace(MD_LINE_PREFIX, ''))
67
+ .join(' ');
68
+ text = text.replace(MD_LINK, '$1');
69
+ text = text.replace(MD_INLINE, '');
70
+ return oneLine(text).replace(WRAPPING_QUOTES, '').trim();
71
+ }
72
+
73
+ /**
74
+ * 建议文本规范化:单行 + 去 Markdown + 截断到 max 字。
75
+ * @param {unknown} value
76
+ * @param {number} [max]
77
+ * @returns {string}
78
+ */
79
+ export function clampSuggestion(value, max = DEFAULTS.suggestionMaxChars) {
80
+ return clampChars(stripMarkdown(value), max);
81
+ }
82
+
83
+ /**
84
+ * 明显破坏性命令形态(设计 §3.6 护栏 4、§3.8 危险建议过滤)。
85
+ * 只拦「明显」形态,避免误伤正常开发建议。
86
+ * @type {ReadonlyArray<{name: string, re: RegExp}>}
87
+ */
88
+ export const DANGEROUS_PATTERNS = Object.freeze([
89
+ { name: 'rm-recursive-force', re: /(?:^|[\s;|&])rm\s+(?:-[a-zA-Z]*[rf][a-zA-Z]*\s+)+/ },
90
+ { name: 'windows-del-force', re: /\b(?:del|erase)\s+\/[fsq]/i },
91
+ { name: 'windows-rmdir-force', re: /\brmdir\s+\/s/i },
92
+ { name: 'format-drive', re: /\bformat\s+[a-z]:/i },
93
+ { name: 'mkfs', re: /\bmkfs(?:\.\w+)?\b/i },
94
+ { name: 'dd-write', re: /\bdd\s+if=/i },
95
+ { name: 'git-reset-hard', re: /\bgit\s+reset\s+--hard\b/i },
96
+ { name: 'git-clean-force', re: /\bgit\s+clean\s+-[a-z]*f/i },
97
+ { name: 'shutdown-reboot', re: /\b(?:shutdown|reboot)\b/i },
98
+ { name: 'drop-database', re: /\bdrop\s+(?:database|table)\b/i },
99
+ { name: 'curl-pipe-shell', re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|k|d)?sh\b/i },
100
+ { name: 'powershell-remove-recurse', re: /remove-item\b[^\n]*-recurse/i },
101
+ { name: 'powershell-encode', re: /powershell(?:\.exe)?\s+-(?:enc|encodedcommand)/i },
102
+ { name: 'invoke-expression', re: /\b(?:invoke-expression|iex)\b/i },
103
+ { name: 'fork-bomb', re: /:\(\)\s*\{/ },
104
+ { name: 'chmod-root', re: /chmod\s+-R\s+777\s+\//i },
105
+ { name: 'raw-device-write', re: />\s*\/dev\/(?:sd|nvme)/i },
106
+ { name: 'recursive-remove-item', re: /\b(?:Remove-Item|ri|rm)\b[^\n]*(?:-Recurse|-r)\b[^\n]*(?:\/|\*)/i },
107
+ ]);
108
+
109
+ /**
110
+ * 检测文本里是否有明显破坏性命令形态。
111
+ * @param {unknown} value
112
+ * @returns {string|null} 命中的规则名,未命中返回 null
113
+ */
114
+ export function findDangerousCommand(value) {
115
+ const text = oneLine(value);
116
+ if (!text) return null;
117
+ for (const item of DANGEROUS_PATTERNS) {
118
+ if (item.re.test(text)) return item.name;
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /**
124
+ * suggestion 安全清洗:过滤危险形态、单行化、≤max 字。
125
+ * @param {unknown} value
126
+ * @param {number} [max]
127
+ * @returns {{ok: boolean, text: string, filtered: boolean, reason: string}}
128
+ */
129
+ export function sanitizeSuggestion(value, max = DEFAULTS.suggestionMaxChars) {
130
+ if (typeof value !== 'string' || !value.trim()) {
131
+ return { ok: false, text: '', filtered: false, reason: 'empty' };
132
+ }
133
+ const danger = findDangerousCommand(value);
134
+ if (danger) return { ok: false, text: '', filtered: true, reason: `dangerous:${danger}` };
135
+ const text = clampSuggestion(value, max);
136
+ if (!text) return { ok: false, text: '', filtered: false, reason: 'empty' };
137
+ return { ok: true, text, filtered: false, reason: '' };
138
+ }
139
+
140
+ const QUESTION_TAIL = /(?:要不要|是否|选哪|哪个方案|需要我|可以吗|行不行|同意吗|还是说|你觉得|确认一下|要我|需要的话|好不好)/;
141
+
142
+ /**
143
+ * 判断一段 assistant 文本是否是「向用户提问」的回合。
144
+ * 依据:末尾问号,或结尾一段出现典型提问句式。
145
+ * @param {unknown} value
146
+ * @returns {boolean}
147
+ */
148
+ export function looksLikeQuestion(value) {
149
+ const text = oneLine(value);
150
+ if (!text) return false;
151
+ if (/[??]\s*$/.test(text)) return true;
152
+ const tail = Array.from(text).slice(-120).join('');
153
+ return QUESTION_TAIL.test(tail);
154
+ }
155
+
156
+ /**
157
+ * 安全取 JSON 字符串(解析异常返回 fallback),给需要稳定对比的地方用。
158
+ * @param {unknown} value
159
+ * @param {string} [fallback]
160
+ * @returns {string}
161
+ */
162
+ export function stableJson(value, fallback = '') {
163
+ try {
164
+ return JSON.stringify(value);
165
+ } catch {
166
+ return fallback;
167
+ }
168
+ }
package/icon.svg ADDED
@@ -0,0 +1,12 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
3
+ <rect x="2" y="2" width="60" height="60" rx="14" fill="#1d4ed8"/>
4
+ <rect x="12" y="14" width="26" height="4" rx="2" fill="#dbeafe"/>
5
+ <circle cx="16" cy="28" r="3.5" fill="#93c5fd"/>
6
+ <rect x="23" y="24.5" width="17" height="3.5" rx="1.75" fill="#e0ecff"/>
7
+ <circle cx="16" cy="40" r="3.5" fill="#93c5fd"/>
8
+ <rect x="23" y="36.5" width="14" height="3.5" rx="1.75" fill="#e0ecff"/>
9
+ <path d="M14 48.5h9" stroke="#93c5fd" stroke-width="3" stroke-linecap="round"/>
10
+ <path d="M41 43.5c-2.6 3.4-2 8 1.4 10.6 3.4 2.6 8 2 10.6-1.4l6-7.4-12-9.6z" fill="#fbbf24"/>
11
+ <path d="M45.5 38.8l7.2 5.8-3 3.7-7.2-5.8z" fill="#fde68a"/>
12
+ </svg>