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/CHANGELOG.md +7 -0
- package/README.en.md +28 -53
- package/README.md +27 -52
- package/dsh/index.js +480 -473
- package/dsh/rules.js +1 -0
- package/dsh/vocab.js +229 -215
- package/icon.png +0 -0
- package/lib/client.js +2 -6
- package/package.json +1 -1
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
.
|
|
76
|
-
.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
export function
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|