dsh-spell-check 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +125 -2
- package/README.zh-CN.md +110 -0
- package/assets/banner-dark.png +0 -0
- package/assets/banner-light.png +0 -0
- package/assets/icon.png +0 -0
- package/assets/menu.png +0 -0
- package/assets/strip.gif +0 -0
- package/assets/strip.png +0 -0
- package/assets/words.txt +20000 -0
- package/cordis.patch.yml +13 -0
- package/dsh.plugin.json +9 -0
- package/package.json +88 -4
- package/screenshots.json +4 -0
- package/scripts/build-client.mjs +102 -0
- package/scripts/check-styles.mjs +70 -0
- package/scripts/make-banner.py +191 -0
- package/scripts/make-media.py +120 -0
- package/scripts/test-client.mjs +402 -0
- package/scripts/test-engine.mjs +101 -0
- package/scripts/test-host.mjs +82 -0
- package/src/client.js +2327 -0
- package/src/engine.js +981 -0
- package/src/index.js +221 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-spell-check — the host half.
|
|
3
|
+
*
|
|
4
|
+
* Owns ONE correction engine (src/engine.js) and answers the composer's
|
|
5
|
+
* questions from it. The browser half holds no word list and no matching code:
|
|
6
|
+
* it asks this service for the suggestions to draw, so the two halves cannot
|
|
7
|
+
* disagree about what a word means.
|
|
8
|
+
*
|
|
9
|
+
* WHY THE DICTIONARY IS NOT IN THE BROWSER
|
|
10
|
+
*
|
|
11
|
+
* The list comes from a corpus (20k frequency-ranked English words, built from
|
|
12
|
+
* the local nltk corpora — see assets/words.txt). Shipping it inside the client
|
|
13
|
+
* bundle would mean re-sending ~160 KB on every page load AND a second copy of
|
|
14
|
+
* the matching rules to keep in sync. Read once here, cached for the life of
|
|
15
|
+
* the process, and asked over the existing remote bridge.
|
|
16
|
+
*
|
|
17
|
+
* WHAT IT NEVER DOES
|
|
18
|
+
*
|
|
19
|
+
* No network requests, no telemetry, no LLM. Everything is a local array and a
|
|
20
|
+
* bounded edit-distance scan.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-spell-check
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
26
|
+
import { dirname, join } from 'node:path'
|
|
27
|
+
import { fileURLToPath } from 'node:url'
|
|
28
|
+
|
|
29
|
+
import { createEngine, TYPO_MAP } from './engine.js'
|
|
30
|
+
|
|
31
|
+
export const name = 'dsh-spell-check'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Nothing is required to mount. The service is answered from files this package
|
|
35
|
+
* ships, so a missing registry can never take the plugin down with it.
|
|
36
|
+
*/
|
|
37
|
+
export const inject = []
|
|
38
|
+
|
|
39
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
40
|
+
|
|
41
|
+
/** The ranked word list, shipped with the package. */
|
|
42
|
+
const WORDS_PATH = join(here, '..', 'assets', 'words.txt')
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Words the user has taught the plugin, keyed by nothing but themselves.
|
|
46
|
+
*
|
|
47
|
+
* Kept next to dsh's own storages rather than in the package, so an upgrade
|
|
48
|
+
* (which replaces the package directory) cannot throw away their corrections.
|
|
49
|
+
*/
|
|
50
|
+
const STORAGE_PATH = join(
|
|
51
|
+
process.env.DSH_HOME ?? join(process.env.USERPROFILE ?? process.env.HOME ?? '.', '.dsh'),
|
|
52
|
+
'storages',
|
|
53
|
+
'spell-check.json',
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
/** The engine is built once and reused for the process lifetime. */
|
|
57
|
+
let enginePromise
|
|
58
|
+
|
|
59
|
+
async function readJson(path) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
62
|
+
} catch {
|
|
63
|
+
// Missing or half-written. Both mean "nothing stored yet".
|
|
64
|
+
return undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load the word list, the user's own words, and build the engine.
|
|
70
|
+
*
|
|
71
|
+
* Concurrent callers share one promise: the first keystroke after a restart
|
|
72
|
+
* must not read a 160 KB file several times over.
|
|
73
|
+
*/
|
|
74
|
+
function getEngine() {
|
|
75
|
+
if (enginePromise === undefined) {
|
|
76
|
+
enginePromise = (async () => {
|
|
77
|
+
let words = []
|
|
78
|
+
try {
|
|
79
|
+
const raw = await readFile(WORDS_PATH, 'utf8')
|
|
80
|
+
words = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
|
81
|
+
} catch {
|
|
82
|
+
// No word list: the typo map alone still gives the useful behaviour.
|
|
83
|
+
words = []
|
|
84
|
+
}
|
|
85
|
+
const engine = createEngine(words, TYPO_MAP)
|
|
86
|
+
const stored = await readJson(STORAGE_PATH)
|
|
87
|
+
const learned = Array.isArray(stored?.words) ? stored.words : []
|
|
88
|
+
for (const word of learned) engine.addWord(word)
|
|
89
|
+
return engine
|
|
90
|
+
})()
|
|
91
|
+
}
|
|
92
|
+
return enginePromise
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Write the learned words back, atomically. */
|
|
96
|
+
async function saveLearned(engine) {
|
|
97
|
+
try {
|
|
98
|
+
await mkdir(dirname(STORAGE_PATH), { recursive: true })
|
|
99
|
+
const temporary = `${STORAGE_PATH}.${process.pid}.tmp`
|
|
100
|
+
const body = { version: 1, words: engine.learned() }
|
|
101
|
+
await writeFile(temporary, JSON.stringify(body, null, 2), 'utf8')
|
|
102
|
+
await rename(temporary, STORAGE_PATH)
|
|
103
|
+
} catch {
|
|
104
|
+
// A lost dictionary entry is a nuisance, never worth breaking a keystroke.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ------------------------------------------------------------------ *
|
|
109
|
+
* The service body, as plain functions.
|
|
110
|
+
*
|
|
111
|
+
* Split out from the Remote class on purpose: these are the unit under test
|
|
112
|
+
* (`scripts/test-host.mjs`), and they must not need a cordis Context to run.
|
|
113
|
+
* The class below is the transport, not the logic.
|
|
114
|
+
* ------------------------------------------------------------------ */
|
|
115
|
+
|
|
116
|
+
/** Ranked corrections for one typed token. */
|
|
117
|
+
export async function suggestWord(word) {
|
|
118
|
+
const engine = await getEngine()
|
|
119
|
+
return { word: String(word ?? ''), suggestions: engine.suggest(word) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether a token is a word we know. */
|
|
123
|
+
export async function checkWord(word) {
|
|
124
|
+
const engine = await getEngine()
|
|
125
|
+
const lower = String(word ?? '').toLowerCase()
|
|
126
|
+
return { word: lower, known: engine.isWord(lower) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Teach the dictionary a word, and persist it. */
|
|
130
|
+
export async function learnWord(word) {
|
|
131
|
+
const engine = await getEngine()
|
|
132
|
+
const added = engine.addWord(word)
|
|
133
|
+
if (added) await saveLearned(engine)
|
|
134
|
+
return { word: String(word ?? '').toLowerCase(), added }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The Remote service, built on a DYNAMIC import.
|
|
139
|
+
*
|
|
140
|
+
* `@deepseek-ai/dsh-typert-protocol` is provided by the host, not vendored here,
|
|
141
|
+
* so importing it at module scope makes the whole file unloadable anywhere the
|
|
142
|
+
* host's packages are not on the resolution path — including a clean `git clone`
|
|
143
|
+
* running `npm test`, which is exactly how a broken tarball ships unnoticed.
|
|
144
|
+
*
|
|
145
|
+
* The browser half already does every suggestion locally, so a host without the
|
|
146
|
+
* protocol package loses only the learned-word persistence. Failing softly is
|
|
147
|
+
* the right trade: a plugin that still corrects, rather than one that does not
|
|
148
|
+
* load at all.
|
|
149
|
+
*/
|
|
150
|
+
async function mountRemoteService(ctx) {
|
|
151
|
+
let protocol
|
|
152
|
+
try {
|
|
153
|
+
protocol = await import('@deepseek-ai/dsh-typert-protocol')
|
|
154
|
+
} catch {
|
|
155
|
+
ctx.logger?.warn?.('dsh-spell-check: host protocol unavailable; learned words will not persist')
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { Remote, TypertRemoteService } = protocol
|
|
160
|
+
|
|
161
|
+
class SpellCheckRemote extends TypertRemoteService {
|
|
162
|
+
constructor(ownerCtx) {
|
|
163
|
+
super(ownerCtx, 'spellCheck')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Ranked corrections for one typed token.
|
|
168
|
+
*
|
|
169
|
+
* Answers `{ suggestions: [] }` for a correctly spelled word — that is the
|
|
170
|
+
* common case, and it is what stops the popup nagging about real words.
|
|
171
|
+
*/
|
|
172
|
+
suggest(word) {
|
|
173
|
+
return suggestWord(word)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Whether a token is a word we know, so the caller can skip the round trip. */
|
|
177
|
+
check(word) {
|
|
178
|
+
return checkWord(word)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Teach the dictionary a word. Persisted under dsh's storages directory. */
|
|
182
|
+
learn(word) {
|
|
183
|
+
return learnWord(word)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Emulate the @Remote decorator without decorator syntax. */
|
|
188
|
+
const markRemoteMethod = (prototype, method) => {
|
|
189
|
+
Remote(method)(prototype[method], {
|
|
190
|
+
kind: 'method',
|
|
191
|
+
name: method,
|
|
192
|
+
static: false,
|
|
193
|
+
private: false,
|
|
194
|
+
access: {
|
|
195
|
+
has() {
|
|
196
|
+
return false
|
|
197
|
+
},
|
|
198
|
+
get() {
|
|
199
|
+
return undefined
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
addInitializer(fn) {
|
|
203
|
+
fn.call(Object.create(prototype))
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const method of ['suggest', 'check', 'learn']) markRemoteMethod(SpellCheckRemote.prototype, method)
|
|
209
|
+
|
|
210
|
+
return new SpellCheckRemote(ctx)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function apply(ctx) {
|
|
214
|
+
void mountRemoteService(ctx).catch(() => {})
|
|
215
|
+
// Warm the engine while the session starts, so the first keystroke is not
|
|
216
|
+
// waiting on a 160 KB read.
|
|
217
|
+
void getEngine().catch(() => {})
|
|
218
|
+
ctx.logger?.info?.('dsh-spell-check: ready')
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export default { apply, inject, name }
|