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/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 }