dsh-plain-talk 0.1.3 → 0.1.4

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/dsh/rules.js CHANGED
@@ -23,6 +23,7 @@ export const RULES = `# 说人话(回复优化)
23
23
  - 代号、任务号、版本号、提交号、模块名(如 EXP-7、DEV-1K、run-3)第一次出现时写清它是什么:EXP-7(把实例校验加进推理流程的实验编号)。之后可以直接用。
24
24
  - 但用户在本对话里用过的词、项目名、常见术语(arXiv、DOI、API、GPU 等)默认读者已经懂,不要解释,也不要改写成别的说法。
25
25
  - 用户让你"调研 / 研究 / 查一下 X"时,第一遍回复默认读者还不了解 X,先把它讲清楚;这条回复交付之后,X 才进入词汇清单。
26
+ - 内部代号(EXP-7、DEV-1K、run-3 这类)不进入词汇清单:每次出现都按未知处理,第一次出现时解释。
26
27
  - 不出现会话号、子代理、父会话、工具名等内部信息。
27
28
 
28
29
  ## 引用与数字
package/dsh/vocab.js CHANGED
@@ -1,215 +1,229 @@
1
- // The user vocabulary file.
2
- //
3
- // One file for the whole profile, not per Session: "the user already knows this
4
- // word" is a property of the reader, not of one conversation. Sessions only add
5
- // observations; the checker reads the result.
6
- //
7
- // Categories, from broadest to most specific:
8
- // common 常识词汇 — built in, never needs an explanation
9
- // domain 专业词汇 — field terms the user has already met
10
- // project 项目 / 产品名 — repositories, products, workspace names
11
- // internal 内部代号 — experiment ids, task numbers, build codes
12
- // unclassified 待归类 — recorded but not yet classified
13
- //
14
- // Two-phase learning for research requests: when the user says "调研 X", X is
15
- // marked pending for that Session, so the first reply must still explain it;
16
- // the next turn end promotes it into the vocabulary.
17
-
18
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
19
- import { dirname } from 'node:path'
20
-
21
- export const CATEGORIES = {
22
- common: '常识词汇',
23
- domain: '专业词汇',
24
- project: '项目 / 产品名',
25
- internal: '内部代号',
26
- unclassified: '待归类',
27
- }
28
-
29
- const MAX_TERMS = 2000
30
- const MAX_PENDING_PER_SESSION = 20
31
-
32
- const cjkCount = (s) => (s.match(/[\u4e00-\u9fff]/g) ?? []).length
33
-
34
- /** Classify one learned term from its shape alone. */
35
- export function classify(term, cwdName) {
36
- const text = String(term ?? '').trim()
37
- if (!text) return 'unclassified'
38
- if (cwdName && text === cwdName) return 'project'
39
- if (/^[A-Za-z]{1,10}[-_.]?\d{1,6}$/.test(text)) return 'internal'
40
- if (/^[A-Za-z0-9]+(?:_[A-Za-z0-9]+)+$/.test(text) && (text.match(/[A-Z]/g) ?? []).length >= 2) return 'project'
41
- if (/^[A-Z][A-Za-z0-9]+$/.test(text) && /[A-Z]/.test(text.slice(1))) return 'project'
42
- if (cjkCount(text) >= 2) return 'domain'
43
- if (/^[A-Z]{2,8}$/.test(text)) return 'domain'
44
- return 'unclassified'
45
- }
46
-
47
- export function emptyVocab() {
48
- return { version: 1, updatedAt: new Date(0).toISOString(), terms: {}, pending: {} }
49
- }
50
-
51
- export function loadVocab(file) {
52
- try {
53
- const parsed = JSON.parse(readFileSync(file, 'utf8'))
54
- const terms = {}
55
- const raw = parsed && typeof parsed === 'object' && parsed.terms && typeof parsed.terms === 'object' ? parsed.terms : {}
56
- for (const [key, value] of Object.entries(raw)) {
57
- if (!key || key.length > 80) continue
58
- if (!value || typeof value !== 'object') continue
59
- const category = Object.prototype.hasOwnProperty.call(CATEGORIES, value.category) ? value.category : 'unclassified'
60
- terms[key] = {
61
- category,
62
- firstSeen: typeof value.firstSeen === 'string' ? value.firstSeen : new Date(0).toISOString(),
63
- lastSeen: typeof value.lastSeen === 'string' ? value.lastSeen : new Date(0).toISOString(),
64
- count: Number.isFinite(value.count) ? value.count : 1,
65
- sessions: Array.isArray(value.sessions) ? value.sessions.filter((s) => typeof s === 'string').slice(-5) : [],
66
- source: typeof value.source === 'string' ? value.source : 'user',
67
- }
68
- }
69
- const pending = {}
70
- const rawPending = parsed && typeof parsed === 'object' && parsed.pending && typeof parsed.pending === 'object' ? parsed.pending : {}
71
- for (const [sessionId, list] of Object.entries(rawPending)) {
72
- if (!Array.isArray(list)) continue
73
- pending[sessionId] = list
74
- .filter((item) => item && typeof item.term === 'string' && item.term.length > 0 && item.term.length <= 80)
75
- .slice(-MAX_PENDING_PER_SESSION)
76
- .map((item) => ({ term: item.term, at: typeof item.at === 'string' ? item.at : new Date(0).toISOString() }))
77
- }
78
- return { version: 1, updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date(0).toISOString(), terms, pending }
79
- } catch {
80
- return emptyVocab()
81
- }
82
- }
83
-
84
- export function saveVocab(file, vocab) {
85
- const next = { ...vocab, version: 1, updatedAt: new Date().toISOString() }
86
- mkdirSync(dirname(file), { recursive: true })
87
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
88
- writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
89
- renameSync(tmp, file)
90
- return next
91
- }
92
-
93
- function evict(vocab) {
94
- const keys = Object.keys(vocab.terms)
95
- if (keys.length <= MAX_TERMS) return
96
- keys
97
- .sort((a, b) => String(vocab.terms[a].lastSeen).localeCompare(String(vocab.terms[b].lastSeen)))
98
- .slice(0, keys.length - MAX_TERMS)
99
- .forEach((key) => delete vocab.terms[key])
100
- }
101
-
102
- /** Record one term as known. Existing terms keep their category. */
103
- export function addTerm(vocab, term, options = {}) {
104
- const key = String(term ?? '').trim()
105
- if (!key || key.length > 80) return vocab
106
- const now = options.now ?? new Date().toISOString()
107
- const session = typeof options.session === 'string' ? options.session : undefined
108
- const existing = vocab.terms[key]
109
- if (existing) {
110
- existing.lastSeen = now
111
- existing.count += 1
112
- if (session && !existing.sessions.includes(session)) existing.sessions = [...existing.sessions.slice(-4), session]
113
- if (options.category && CATEGORIES[options.category]) existing.category = options.category
114
- } else {
115
- vocab.terms[key] = {
116
- category: options.category && CATEGORIES[options.category] ? options.category : classify(key, options.cwdName),
117
- firstSeen: now,
118
- lastSeen: now,
119
- count: 1,
120
- sessions: session ? [session] : [],
121
- source: options.source ?? 'user',
122
- }
123
- }
124
- evict(vocab)
125
- return vocab
126
- }
127
-
128
- export function removeTerm(vocab, term) {
129
- delete vocab.terms[String(term ?? '').trim()]
130
- return vocab
131
- }
132
-
133
- export function setCategory(vocab, term, category) {
134
- const key = String(term ?? '').trim()
135
- if (!vocab.terms[key] || !CATEGORIES[category]) return vocab
136
- vocab.terms[key].category = category
137
- return vocab
138
- }
139
-
140
- /** Mark a research subject as not-yet-known for this Session. */
141
- export function markPending(vocab, sessionId, term, now = new Date().toISOString()) {
142
- const key = String(term ?? '').trim()
143
- if (!sessionId || !key || key.length > 80) return vocab
144
- const list = vocab.pending[sessionId] ?? []
145
- if (!list.some((item) => item.term === key)) list.push({ term: key, at: now })
146
- vocab.pending[sessionId] = list.slice(-MAX_PENDING_PER_SESSION)
147
- return vocab
148
- }
149
-
150
- export function pendingFor(vocab, sessionId) {
151
- return (vocab.pending?.[sessionId] ?? []).map((item) => item.term)
152
- }
153
-
154
- /** Turn end: the reply has been delivered, so the research subject is now known. */
155
- export function promotePending(vocab, sessionId, options = {}) {
156
- const list = vocab.pending?.[sessionId] ?? []
157
- if (list.length === 0) return []
158
- for (const item of list) addTerm(vocab, item.term, { ...options, session: sessionId, category: options.category ?? 'domain', source: 'research' })
159
- delete vocab.pending[sessionId]
160
- return list.map((item) => item.term)
161
- }
162
-
163
- /**
164
- * Terms the checker may treat as known for one Session.
165
- * Pending research subjects are excluded even when the profile learned them
166
- * earlier: the user asked to research them, so the first reply must not assume.
167
- */
168
- export function knownSet(vocab, sessionId) {
169
- const suspended = new Set(pendingFor(vocab, sessionId))
170
- const known = new Set()
171
- for (const key of Object.keys(vocab.terms)) {
172
- if (!suspended.has(key)) known.add(key)
173
- }
174
- return known
175
- }
176
-
177
- export function listByCategory(vocab) {
178
- const groups = {}
179
- for (const key of Object.keys(CATEGORIES)) groups[key] = []
180
- for (const [term, value] of Object.entries(vocab.terms)) {
181
- const category = CATEGORIES[value.category] ? value.category : 'unclassified'
182
- groups[category].push({ term, count: value.count, lastSeen: value.lastSeen })
183
- }
184
- for (const key of Object.keys(groups)) groups[key].sort((a, b) => b.count - a.count || a.term.localeCompare(b.term))
185
- return groups
186
- }
187
-
188
- /** Extract "research X" subjects from one user message. */
189
- export function researchTargets(text) {
190
- const found = []
191
- const input = String(text ?? '')
192
- // A captured phrase may carry a code inside it ("调研 XR-77 这个实验"):
193
- // the code is the subject, not the surrounding words.
194
- const codePattern = /\b[A-Za-z]{1,10}[-_.]?\d{1,6}[A-Za-z]?\b/g
195
- const push = (phrase) => {
196
- const codes = phrase.match(codePattern) ?? []
197
- if (codes.length > 0) {
198
- for (const code of codes) if (code.length >= 2) found.push(code)
199
- return
200
- }
201
- if (phrase.length >= 2) found.push(phrase)
202
- }
203
- const zh = /(?:调研|研究|调查|了解一下|了解下|查一下|查查|梳理一下|分析一下)\s*([^\n。!?;;]{1,40})/g
204
- let m
205
- while ((m = zh.exec(input))) {
206
- const term = m[1].trim().replace(/^(?:一下|下)\s*/, '').replace(/[,,、\s]+$/, '')
207
- push(term)
208
- }
209
- const en = /\b(?:research|investigate|look into|study)\s+([A-Za-z0-9][A-Za-z0-9 ._/-]{1,40})/gi
210
- while ((m = en.exec(input))) {
211
- const term = m[1].trim().replace(/[.,;:\s]+$/, '')
212
- push(term)
213
- }
214
- return [...new Set(found)]
215
- }
1
+ // The user vocabulary file.
2
+ //
3
+ // One file for the whole profile, not per Session: "the user already knows this
4
+ // word" is a property of the reader, not of one conversation. Sessions only add
5
+ // observations; the checker reads the result.
6
+ //
7
+ // Categories, from broadest to most specific:
8
+ // common 常识词汇 — built in, never needs an explanation
9
+ // domain 专业词汇 — field terms the user has already met
10
+ // project 项目 / 产品名 — repositories, products, workspace names
11
+ // unclassified 待归类 — recorded but not yet classified
12
+ //
13
+ // Codes (EXP-7, DEV-1K, run-3, commit hashes) are never stored: the plugin's
14
+ // job is to make the model explain them, so remembering one would hide the
15
+ // next unexplainable use.
16
+ // Two-phase learning for research requests: when the user says "调研 X", X is
17
+ // marked pending for that Session, so the first reply must still explain it;
18
+ // the next turn end promotes it into the vocabulary.
19
+
20
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
21
+ import { dirname } from 'node:path'
22
+
23
+ export const CATEGORIES = {
24
+ common: '常识词汇',
25
+ domain: '专业词汇',
26
+ project: '项目 / 产品名',
27
+ unclassified: '待归类',
28
+ }
29
+
30
+ const MAX_TERMS = 2000
31
+ const MAX_PENDING_PER_SESSION = 20
32
+
33
+ const cjkCount = (s) => (s.match(/[\u4e00-\u9fff]/g) ?? []).length
34
+
35
+ // Codes never enter the vocabulary: explaining them is the plugin's job, so
36
+ // remembering one would silence the next warning.
37
+ const CODE_LIKE = /^[A-Za-z]{1,10}[-_.]?\d{1,6}[A-Za-z]?$|^\d{1,4}[A-Za-z]{1,6}$|^[0-9a-f]{7,40}$/i
38
+
39
+ export function isCodeLike(term) {
40
+ return CODE_LIKE.test(String(term ?? '').trim())
41
+ }
42
+
43
+ /** Classify one learned term from its shape alone. */
44
+ export function classify(term, cwdName) {
45
+ const text = String(term ?? '').trim()
46
+ if (!text) return 'unclassified'
47
+ if (isCodeLike(text)) return null
48
+ if (cwdName && text === cwdName) return 'project'
49
+ if (/^[A-Za-z0-9]+(?:_[A-Za-z0-9]+)+$/.test(text) && (text.match(/[A-Z]/g) ?? []).length >= 2) return 'project'
50
+ if (/^[A-Z][A-Za-z0-9]+$/.test(text) && /[A-Z]/.test(text.slice(1))) return 'project'
51
+ if (cjkCount(text) >= 2) return 'domain'
52
+ if (/^[A-Z]{2,8}$/.test(text)) return 'domain'
53
+ return 'unclassified'
54
+ }
55
+
56
+ export function emptyVocab() {
57
+ return { version: 1, updatedAt: new Date(0).toISOString(), terms: {}, pending: {} }
58
+ }
59
+
60
+ export function loadVocab(file) {
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
63
+ const terms = {}
64
+ const raw = parsed && typeof parsed === 'object' && parsed.terms && typeof parsed.terms === 'object' ? parsed.terms : {}
65
+ for (const [key, value] of Object.entries(raw)) {
66
+ if (!key || key.length > 80) continue
67
+ if (isCodeLike(key)) continue
68
+ if (!value || typeof value !== 'object') continue
69
+ if (value.category === 'internal') continue
70
+ const category = Object.prototype.hasOwnProperty.call(CATEGORIES, value.category) ? value.category : 'unclassified'
71
+ terms[key] = {
72
+ category,
73
+ firstSeen: typeof value.firstSeen === 'string' ? value.firstSeen : new Date(0).toISOString(),
74
+ lastSeen: typeof value.lastSeen === 'string' ? value.lastSeen : new Date(0).toISOString(),
75
+ count: Number.isFinite(value.count) ? value.count : 1,
76
+ sessions: Array.isArray(value.sessions) ? value.sessions.filter((s) => typeof s === 'string').slice(-5) : [],
77
+ source: typeof value.source === 'string' ? value.source : 'user',
78
+ }
79
+ }
80
+ const pending = {}
81
+ const rawPending = parsed && typeof parsed === 'object' && parsed.pending && typeof parsed.pending === 'object' ? parsed.pending : {}
82
+ for (const [sessionId, list] of Object.entries(rawPending)) {
83
+ if (!Array.isArray(list)) continue
84
+ pending[sessionId] = list
85
+ .filter((item) => item && typeof item.term === 'string' && item.term.length > 0 && item.term.length <= 80)
86
+ .slice(-MAX_PENDING_PER_SESSION)
87
+ .map((item) => ({ term: item.term, at: typeof item.at === 'string' ? item.at : new Date(0).toISOString() }))
88
+ }
89
+ return { version: 1, updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date(0).toISOString(), terms, pending }
90
+ } catch {
91
+ return emptyVocab()
92
+ }
93
+ }
94
+
95
+ export function saveVocab(file, vocab) {
96
+ const next = { ...vocab, version: 1, updatedAt: new Date().toISOString() }
97
+ mkdirSync(dirname(file), { recursive: true })
98
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
99
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8')
100
+ renameSync(tmp, file)
101
+ return next
102
+ }
103
+
104
+ function evict(vocab) {
105
+ const keys = Object.keys(vocab.terms)
106
+ if (keys.length <= MAX_TERMS) return
107
+ keys
108
+ .sort((a, b) => String(vocab.terms[a].lastSeen).localeCompare(String(vocab.terms[b].lastSeen)))
109
+ .slice(0, keys.length - MAX_TERMS)
110
+ .forEach((key) => delete vocab.terms[key])
111
+ }
112
+
113
+ /** Record one term as known. Existing terms keep their category. */
114
+ export function addTerm(vocab, term, options = {}) {
115
+ const key = String(term ?? '').trim()
116
+ if (!key || key.length > 80) return vocab
117
+ if (isCodeLike(key)) return vocab
118
+ const category = options.category && CATEGORIES[options.category] ? options.category : classify(key, options.cwdName)
119
+ if (category === null) return vocab
120
+ const now = options.now ?? new Date().toISOString()
121
+ const session = typeof options.session === 'string' ? options.session : undefined
122
+ const existing = vocab.terms[key]
123
+ if (existing) {
124
+ existing.lastSeen = now
125
+ existing.count += 1
126
+ if (session && !existing.sessions.includes(session)) existing.sessions = [...existing.sessions.slice(-4), session]
127
+ if (options.category && CATEGORIES[options.category]) existing.category = options.category
128
+ } else {
129
+ vocab.terms[key] = {
130
+ category,
131
+ firstSeen: now,
132
+ lastSeen: now,
133
+ count: 1,
134
+ sessions: session ? [session] : [],
135
+ source: options.source ?? 'user',
136
+ }
137
+ }
138
+ evict(vocab)
139
+ return vocab
140
+ }
141
+
142
+ export function removeTerm(vocab, term) {
143
+ delete vocab.terms[String(term ?? '').trim()]
144
+ return vocab
145
+ }
146
+
147
+ export function setCategory(vocab, term, category) {
148
+ const key = String(term ?? '').trim()
149
+ if (!vocab.terms[key] || !CATEGORIES[category] || isCodeLike(key)) return vocab
150
+ vocab.terms[key].category = category
151
+ return vocab
152
+ }
153
+
154
+ /** Mark a research subject as not-yet-known for this Session. */
155
+ export function markPending(vocab, sessionId, term, now = new Date().toISOString()) {
156
+ const key = String(term ?? '').trim()
157
+ if (!sessionId || !key || key.length > 80) return vocab
158
+ const list = vocab.pending[sessionId] ?? []
159
+ if (!list.some((item) => item.term === key)) list.push({ term: key, at: now })
160
+ vocab.pending[sessionId] = list.slice(-MAX_PENDING_PER_SESSION)
161
+ return vocab
162
+ }
163
+
164
+ export function pendingFor(vocab, sessionId) {
165
+ return (vocab.pending?.[sessionId] ?? []).map((item) => item.term)
166
+ }
167
+
168
+ /** Turn end: the reply has been delivered, so the research subject is now known. */
169
+ export function promotePending(vocab, sessionId, options = {}) {
170
+ const list = vocab.pending?.[sessionId] ?? []
171
+ if (list.length === 0) return []
172
+ for (const item of list) addTerm(vocab, item.term, { ...options, session: sessionId, category: options.category ?? 'domain', source: 'research' })
173
+ delete vocab.pending[sessionId]
174
+ return list.map((item) => item.term)
175
+ }
176
+
177
+ /**
178
+ * Terms the checker may treat as known for one Session.
179
+ * Pending research subjects are excluded even when the profile learned them
180
+ * earlier: the user asked to research them, so the first reply must not assume.
181
+ */
182
+ export function knownSet(vocab, sessionId) {
183
+ const suspended = new Set(pendingFor(vocab, sessionId))
184
+ const known = new Set()
185
+ for (const key of Object.keys(vocab.terms)) {
186
+ if (!suspended.has(key)) known.add(key)
187
+ }
188
+ return known
189
+ }
190
+
191
+ export function listByCategory(vocab) {
192
+ const groups = {}
193
+ for (const key of Object.keys(CATEGORIES)) groups[key] = []
194
+ for (const [term, value] of Object.entries(vocab.terms)) {
195
+ const category = CATEGORIES[value.category] ? value.category : 'unclassified'
196
+ groups[category].push({ term, count: value.count, lastSeen: value.lastSeen })
197
+ }
198
+ for (const key of Object.keys(groups)) groups[key].sort((a, b) => b.count - a.count || a.term.localeCompare(b.term))
199
+ return groups
200
+ }
201
+
202
+ /** Extract "research X" subjects from one user message. */
203
+ export function researchTargets(text) {
204
+ const found = []
205
+ const input = String(text ?? '')
206
+ // A captured phrase may carry a code inside it ("调研 XR-77 这个实验"):
207
+ // the code is the subject, not the surrounding words.
208
+ const codePattern = /\b[A-Za-z]{1,10}[-_.]?\d{1,6}[A-Za-z]?\b/g
209
+ const push = (phrase) => {
210
+ const codes = phrase.match(codePattern) ?? []
211
+ if (codes.length > 0) {
212
+ for (const code of codes) if (code.length >= 2) found.push(code)
213
+ return
214
+ }
215
+ if (phrase.length >= 2) found.push(phrase)
216
+ }
217
+ const zh = /(?:调研|研究|调查|了解一下|了解下|查一下|查查|梳理一下|分析一下)\s*([^\n。!?;;]{1,40})/g
218
+ let m
219
+ while ((m = zh.exec(input))) {
220
+ const term = m[1].trim().replace(/^(?:一下|下)\s*/, '').replace(/[,,、\s]+$/, '')
221
+ push(term)
222
+ }
223
+ const en = /\b(?:research|investigate|look into|study)\s+([A-Za-z0-9][A-Za-z0-9 ._/-]{1,40})/gi
224
+ while ((m = en.exec(input))) {
225
+ const term = m[1].trim().replace(/[.,;:\s]+$/, '')
226
+ push(term)
227
+ }
228
+ return [...new Set(found)]
229
+ }
package/icon.png CHANGED
Binary file