dsh-plan-completion 0.0.0-stage → 0.1.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/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>