dsh-plain-talk 0.1.2 → 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 +11 -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 -3
- package/package.json +1 -1
package/dsh/index.js
CHANGED
|
@@ -1,473 +1,480 @@
|
|
|
1
|
-
// dsh-plain-talk — host half.
|
|
2
|
-
//
|
|
3
|
-
// Four jobs:
|
|
4
|
-
// - one persisted switch per Session (`$DSH_HOME/plain-talk.json`) that the
|
|
5
|
-
// composer button flips over the loopback state route;
|
|
6
|
-
// - one system-prompt section whose text appears only while that Session's
|
|
7
|
-
// switch is on;
|
|
8
|
-
// - one user vocabulary (`$DSH_HOME/plain-talk-vocab.json`) shared by every
|
|
9
|
-
// Session, so "the user already knows this word" survives restarts;
|
|
10
|
-
// - two optional tools: a reply self-check and vocabulary maintenance.
|
|
11
|
-
//
|
|
12
|
-
// Research requests are two-phase: "调研 X" marks X pending for that Session,
|
|
13
|
-
// so the first reply must still explain it; the next turn end promotes X into
|
|
14
|
-
// the vocabulary.
|
|
15
|
-
//
|
|
16
|
-
// The plugin writes no session events: switches and vocabulary are sidecar
|
|
17
|
-
// files, and the prompt section is derived state.
|
|
18
|
-
|
|
19
|
-
import { readFileSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
20
|
-
import { homedir } from 'node:os'
|
|
21
|
-
import { dirname, join } from 'node:path'
|
|
22
|
-
import { RULES } from './rules.js'
|
|
23
|
-
import { analyze, scanIdentifiers, isCommonTerm } from './check.js'
|
|
24
|
-
import {
|
|
25
|
-
CATEGORIES,
|
|
26
|
-
addTerm,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
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
|
-
const
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
sessions
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (typeof
|
|
126
|
-
return
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
res.
|
|
174
|
-
res.setHeader('
|
|
175
|
-
res.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
let
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
entry
|
|
191
|
-
entry.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
'
|
|
314
|
-
'
|
|
315
|
-
'
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
'
|
|
375
|
-
'
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
|
|
1
|
+
// dsh-plain-talk — host half.
|
|
2
|
+
//
|
|
3
|
+
// Four jobs:
|
|
4
|
+
// - one persisted switch per Session (`$DSH_HOME/plain-talk.json`) that the
|
|
5
|
+
// composer button flips over the loopback state route;
|
|
6
|
+
// - one system-prompt section whose text appears only while that Session's
|
|
7
|
+
// switch is on;
|
|
8
|
+
// - one user vocabulary (`$DSH_HOME/plain-talk-vocab.json`) shared by every
|
|
9
|
+
// Session, so "the user already knows this word" survives restarts;
|
|
10
|
+
// - two optional tools: a reply self-check and vocabulary maintenance.
|
|
11
|
+
//
|
|
12
|
+
// Research requests are two-phase: "调研 X" marks X pending for that Session,
|
|
13
|
+
// so the first reply must still explain it; the next turn end promotes X into
|
|
14
|
+
// the vocabulary.
|
|
15
|
+
//
|
|
16
|
+
// The plugin writes no session events: switches and vocabulary are sidecar
|
|
17
|
+
// files, and the prompt section is derived state.
|
|
18
|
+
|
|
19
|
+
import { readFileSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { RULES } from './rules.js'
|
|
23
|
+
import { analyze, scanIdentifiers, isCommonTerm } from './check.js'
|
|
24
|
+
import {
|
|
25
|
+
CATEGORIES,
|
|
26
|
+
addTerm,
|
|
27
|
+
isCodeLike,
|
|
28
|
+
knownSet,
|
|
29
|
+
listByCategory,
|
|
30
|
+
loadVocab,
|
|
31
|
+
markPending,
|
|
32
|
+
pendingFor,
|
|
33
|
+
promotePending,
|
|
34
|
+
removeTerm,
|
|
35
|
+
saveVocab,
|
|
36
|
+
setCategory,
|
|
37
|
+
researchTargets,
|
|
38
|
+
} from './vocab.js'
|
|
39
|
+
|
|
40
|
+
export const name = 'plain-talk'
|
|
41
|
+
|
|
42
|
+
// systemPrompt and tools are required; webServer is reached through ctx.inject
|
|
43
|
+
// so the plugin still mounts in profiles without the Web surface.
|
|
44
|
+
export const inject = ['systemPrompt', 'tools']
|
|
45
|
+
|
|
46
|
+
const SECTION_NAME = 'plain-talk'
|
|
47
|
+
// After the persona (0) and next to the other reply-style sections, before
|
|
48
|
+
// tool guidance (100+).
|
|
49
|
+
const SECTION_ORDER = 55
|
|
50
|
+
|
|
51
|
+
const ROUTE_STATE = '/plain-talk/state'
|
|
52
|
+
const MAX_BODY = 8 * 1024
|
|
53
|
+
const MAX_SESSIONS = 256
|
|
54
|
+
|
|
55
|
+
/** New Sessions start with the mode on; a click records an explicit choice.
|
|
56
|
+
* PLAIN_TALK_DEFAULT=off makes unseen Sessions start off (benchmarks and
|
|
57
|
+
* opt-in setups); any click still records an explicit per-Session choice. */
|
|
58
|
+
const DEFAULT_ON = String(process.env.PLAIN_TALK_DEFAULT ?? 'on').toLowerCase() !== 'off'
|
|
59
|
+
|
|
60
|
+
function dshHome() {
|
|
61
|
+
return process.env.DSH_HOME && process.env.DSH_HOME.length > 0
|
|
62
|
+
? process.env.DSH_HOME
|
|
63
|
+
: join(homedir(), '.dsh')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function statePath() {
|
|
67
|
+
return join(dshHome(), 'plain-talk.json')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function vocabPath() {
|
|
71
|
+
return join(dshHome(), 'plain-talk-vocab.json')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function emptyState() {
|
|
75
|
+
return { version: 1, sessions: {} }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readState() {
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(readFileSync(statePath(), 'utf8'))
|
|
81
|
+
const raw = parsed && typeof parsed === 'object' && parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {}
|
|
82
|
+
const sessions = {}
|
|
83
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
84
|
+
if (typeof id === 'string' && id.length > 0 && typeof value === 'boolean') sessions[id] = value
|
|
85
|
+
}
|
|
86
|
+
return { version: 1, sessions }
|
|
87
|
+
} catch {
|
|
88
|
+
// A missing, unreadable or malformed file reads as "no choices recorded";
|
|
89
|
+
// DEFAULT_ON then applies, and a broken file can never lock the mode on.
|
|
90
|
+
return emptyState()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sessionEnabled(state, sessionId) {
|
|
95
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return false
|
|
96
|
+
if (Object.prototype.hasOwnProperty.call(state.sessions, sessionId)) return state.sessions[sessionId] === true
|
|
97
|
+
return DEFAULT_ON
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function persistJson(file, value) {
|
|
101
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
102
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
103
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
|
104
|
+
renameSync(tmp, file)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function setSessionEnabled(state, sessionId, enabled) {
|
|
108
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) throw new Error('sessionId is required')
|
|
109
|
+
const sessions = { ...state.sessions }
|
|
110
|
+
sessions[sessionId] = enabled === true
|
|
111
|
+
while (Object.keys(sessions).length > MAX_SESSIONS) {
|
|
112
|
+
const oldest = Object.keys(sessions)[0]
|
|
113
|
+
if (oldest === undefined) break
|
|
114
|
+
delete sessions[oldest]
|
|
115
|
+
}
|
|
116
|
+
const next = { version: 1, sessions }
|
|
117
|
+
persistJson(statePath(), next)
|
|
118
|
+
return next
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sessionIdOf(context) {
|
|
122
|
+
const agent = context === undefined || context === null ? undefined : context.agent
|
|
123
|
+
if (agent === undefined || agent === null) return undefined
|
|
124
|
+
const direct = agent.session !== undefined && agent.session !== null ? agent.session.id : undefined
|
|
125
|
+
if (typeof direct === 'string' && direct.length > 0) return direct
|
|
126
|
+
if (typeof agent.id === 'string' && agent.id.length > 0) return agent.id
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The Session's directory, when the runtime exposes it. */
|
|
131
|
+
function cwdOf(agent) {
|
|
132
|
+
const session = agent && agent.session
|
|
133
|
+
const header = session && session.header
|
|
134
|
+
const value = (header && header.cwd) || (session && session.cwd) || (agent && agent.cwd)
|
|
135
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function projectName(cwd) {
|
|
139
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return undefined
|
|
140
|
+
const parts = cwd.split(/[\\/]/).filter(Boolean)
|
|
141
|
+
const last = parts[parts.length - 1]
|
|
142
|
+
return last && last.length >= 3 ? last : undefined
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function textOf(message) {
|
|
146
|
+
if (!message || !Array.isArray(message.content)) return ''
|
|
147
|
+
return message.content.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isLoopback(req) {
|
|
151
|
+
const address = req && req.socket ? req.socket.remoteAddress : undefined
|
|
152
|
+
if (typeof address !== 'string') return false
|
|
153
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function readJson(req) {
|
|
157
|
+
const chunks = []
|
|
158
|
+
let size = 0
|
|
159
|
+
for await (const chunk of req) {
|
|
160
|
+
size += chunk.length
|
|
161
|
+
if (size > MAX_BODY) return undefined
|
|
162
|
+
chunks.push(chunk)
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
166
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sendJson(res, status, body) {
|
|
173
|
+
res.statusCode = status
|
|
174
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
175
|
+
res.setHeader('cache-control', 'no-store')
|
|
176
|
+
res.end(JSON.stringify(body))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const VALID_SESSION_ID = /^[A-Za-z0-9_.:-]{1,200}$/
|
|
180
|
+
|
|
181
|
+
export function apply(ctx) {
|
|
182
|
+
let state = readState()
|
|
183
|
+
let userVocab = loadVocab(vocabPath())
|
|
184
|
+
|
|
185
|
+
// Language mix per Session. Vocabulary is profile-wide; this is not.
|
|
186
|
+
const language = new Map()
|
|
187
|
+
|
|
188
|
+
const rememberUserText = (sessionId, text) => {
|
|
189
|
+
if (!sessionId || !text) return
|
|
190
|
+
const entry = language.get(sessionId) ?? { zh: 0, latin: 0 }
|
|
191
|
+
entry.zh += (text.match(/[\u4e00-\u9fff]/g) ?? []).length
|
|
192
|
+
entry.latin += (text.match(/[A-Za-z]/g) ?? []).length
|
|
193
|
+
language.set(sessionId, entry)
|
|
194
|
+
while (language.size > MAX_SESSIONS) {
|
|
195
|
+
const oldest = language.keys().next().value
|
|
196
|
+
if (oldest === undefined) break
|
|
197
|
+
language.delete(oldest)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Research subjects stay unknown until this turn's reply is delivered.
|
|
201
|
+
const targets = researchTargets(text).filter((term) => !isCommonTerm(term))
|
|
202
|
+
for (const term of targets) markPending(userVocab, sessionId, term)
|
|
203
|
+
|
|
204
|
+
// Everything else the user typed is vocabulary they already hold.
|
|
205
|
+
const pending = new Set(targets)
|
|
206
|
+
for (const found of scanIdentifiers(text)) {
|
|
207
|
+
if (found.token.includes('/') || found.token.includes('\\')) continue
|
|
208
|
+
if (pending.has(found.token)) continue
|
|
209
|
+
addTerm(userVocab, found.token, { session: sessionId, source: 'user' })
|
|
210
|
+
}
|
|
211
|
+
saveVocab(vocabPath(), userVocab)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const knownFor = (sessionId, agent) => {
|
|
215
|
+
const tokens = new Set(knownSet(userVocab, sessionId))
|
|
216
|
+
const project = projectName(cwdOf(agent))
|
|
217
|
+
if (project) tokens.add(project)
|
|
218
|
+
return [...tokens]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const languageFor = (sessionId) => {
|
|
222
|
+
const entry = language.get(sessionId)
|
|
223
|
+
if (!entry) return 'unknown'
|
|
224
|
+
const total = entry.zh + entry.latin
|
|
225
|
+
if (total === 0) return 'unknown'
|
|
226
|
+
return entry.zh / total >= 0.3 ? 'zh' : 'en'
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
ctx.effect(() => ctx.on('session/event', (session, event) => {
|
|
230
|
+
if (!event || event.type !== 'user/message') return
|
|
231
|
+
const sessionId = session?.id ?? session?.sessionId
|
|
232
|
+
const text = textOf(event.data)
|
|
233
|
+
if (!sessionId || !text) return
|
|
234
|
+
// Only the human's own words count as vocabulary. Approval notices,
|
|
235
|
+
// runtime reminders and time-sampled injects are not the user's language.
|
|
236
|
+
const source = event.data?.source
|
|
237
|
+
if (source && source.kind !== 'user') return
|
|
238
|
+
if (/^(?:Time sampled|Elapsed since|Current runtime context|Current working directory|<system-reminder>)/i.test(text.trim())) return
|
|
239
|
+
rememberUserText(String(sessionId), text)
|
|
240
|
+
}))
|
|
241
|
+
|
|
242
|
+
// Turn end: the reply has been sent, so a research subject is now known.
|
|
243
|
+
ctx.effect(() => ctx.on('agent/turn-stopping', (payload) => {
|
|
244
|
+
const sessionId = payload?.agent?.id
|
|
245
|
+
if (!sessionId) return
|
|
246
|
+
const promoted = promotePending(userVocab, String(sessionId), { cwdName: projectName(cwdOf(payload.agent)) })
|
|
247
|
+
if (promoted.length > 0) saveVocab(vocabPath(), userVocab)
|
|
248
|
+
}))
|
|
249
|
+
|
|
250
|
+
// Prompt section: empty string when the Session has the switch off.
|
|
251
|
+
ctx.effect(() => ctx.systemPrompt.section({
|
|
252
|
+
name: SECTION_NAME,
|
|
253
|
+
order: SECTION_ORDER,
|
|
254
|
+
text: (context) => {
|
|
255
|
+
try {
|
|
256
|
+
const sessionId = sessionIdOf(context)
|
|
257
|
+
if (!sessionId) return ''
|
|
258
|
+
return sessionEnabled(state, sessionId) ? RULES : ''
|
|
259
|
+
} catch {
|
|
260
|
+
return ''
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
}))
|
|
264
|
+
|
|
265
|
+
// Loopback state route used by the composer button.
|
|
266
|
+
ctx.inject(['webServer'], (scope) => {
|
|
267
|
+
const server = scope.webServer
|
|
268
|
+
if (!server || typeof server.register !== 'function') return
|
|
269
|
+
scope.effect(() => server.register({
|
|
270
|
+
kind: 'exact',
|
|
271
|
+
path: ROUTE_STATE,
|
|
272
|
+
handler: async (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
if (!isLoopback(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' })
|
|
275
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
276
|
+
if (req.method === 'GET') {
|
|
277
|
+
const sessionId = url.searchParams.get('sessionId')
|
|
278
|
+
if (!sessionId || !VALID_SESSION_ID.test(sessionId)) {
|
|
279
|
+
return sendJson(res, 400, { ok: false, error: 'sessionId is required' })
|
|
280
|
+
}
|
|
281
|
+
return sendJson(res, 200, {
|
|
282
|
+
ok: true,
|
|
283
|
+
sessionId,
|
|
284
|
+
enabled: sessionEnabled(state, sessionId),
|
|
285
|
+
defaultOn: DEFAULT_ON,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
if (req.method === 'POST') {
|
|
289
|
+
const body = await readJson(req)
|
|
290
|
+
if (!body) return sendJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
291
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
|
|
292
|
+
if (!VALID_SESSION_ID.test(sessionId)) {
|
|
293
|
+
return sendJson(res, 400, { ok: false, error: 'sessionId is required' })
|
|
294
|
+
}
|
|
295
|
+
if (typeof body.enabled !== 'boolean') {
|
|
296
|
+
return sendJson(res, 400, { ok: false, error: 'enabled must be a boolean' })
|
|
297
|
+
}
|
|
298
|
+
state = setSessionEnabled(state, sessionId, body.enabled)
|
|
299
|
+
return sendJson(res, 200, { ok: true, sessionId, enabled: body.enabled, defaultOn: DEFAULT_ON })
|
|
300
|
+
}
|
|
301
|
+
return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
302
|
+
} catch (error) {
|
|
303
|
+
return sendJson(res, 500, { ok: false, error: error?.message ?? 'state route failed' })
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
}))
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// Optional self-check tool.
|
|
310
|
+
ctx.effect(() => ctx.tools.register({
|
|
311
|
+
name: 'plain_talk_check',
|
|
312
|
+
description:
|
|
313
|
+
'Self-check one reply draft against the plain-talk rules before sending it. ' +
|
|
314
|
+
'Call it for a completion report or any reply longer than about 12 lines. ' +
|
|
315
|
+
'It reports cross-turn wording, internal identifiers, pointer-only citations, ' +
|
|
316
|
+
'unexplained opaque codes, and length/shape problems. All user-facing strings are Chinese.',
|
|
317
|
+
parameters: {
|
|
318
|
+
type: 'object',
|
|
319
|
+
properties: {
|
|
320
|
+
draft: { type: 'string', description: 'The full reply text you are about to send.' },
|
|
321
|
+
},
|
|
322
|
+
required: ['draft'],
|
|
323
|
+
additionalProperties: false,
|
|
324
|
+
},
|
|
325
|
+
output: {
|
|
326
|
+
schema: {
|
|
327
|
+
type: 'object',
|
|
328
|
+
properties: {
|
|
329
|
+
ok: { type: 'boolean' },
|
|
330
|
+
issues: {
|
|
331
|
+
type: 'array',
|
|
332
|
+
items: {
|
|
333
|
+
type: 'object',
|
|
334
|
+
properties: {
|
|
335
|
+
severity: { type: 'string' },
|
|
336
|
+
rule: { type: 'string' },
|
|
337
|
+
message: { type: 'string' },
|
|
338
|
+
fix: { type: 'string' },
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
stats: {
|
|
343
|
+
type: 'object',
|
|
344
|
+
properties: {
|
|
345
|
+
chars: { type: 'number' },
|
|
346
|
+
cjk: { type: 'number' },
|
|
347
|
+
lines: { type: 'number' },
|
|
348
|
+
cjkRatio: { type: 'number' },
|
|
349
|
+
identifiers: { type: 'number' },
|
|
350
|
+
backrefs: { type: 'number' },
|
|
351
|
+
maxListRun: { type: 'number' },
|
|
352
|
+
hasNext: { type: 'boolean' },
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
render: (_args, value) => [{ type: 'text', text: renderCheck(value) }],
|
|
358
|
+
},
|
|
359
|
+
async execute(args, exec) {
|
|
360
|
+
const draft = typeof args?.draft === 'string' ? args.draft : ''
|
|
361
|
+
const sessionId = exec?.agent?.id ? String(exec.agent.id) : ''
|
|
362
|
+
const result = analyze(draft, {
|
|
363
|
+
knownTokens: knownFor(sessionId, exec?.agent),
|
|
364
|
+
language: languageFor(sessionId),
|
|
365
|
+
})
|
|
366
|
+
return result
|
|
367
|
+
},
|
|
368
|
+
}))
|
|
369
|
+
|
|
370
|
+
// Vocabulary maintenance.
|
|
371
|
+
ctx.effect(() => ctx.tools.register({
|
|
372
|
+
name: 'plain_talk_vocab',
|
|
373
|
+
description:
|
|
374
|
+
'Read or maintain the user vocabulary that decides which terms no longer need an explanation. ' +
|
|
375
|
+
'Categories: common (built in), domain, project, internal, unclassified. ' +
|
|
376
|
+
'Use action=list to show it, add/remove/classify to correct it. All user-facing strings are Chinese.',
|
|
377
|
+
parameters: {
|
|
378
|
+
type: 'object',
|
|
379
|
+
properties: {
|
|
380
|
+
action: { type: 'string', enum: ['list', 'add', 'remove', 'classify'] },
|
|
381
|
+
terms: { type: 'array', items: { type: 'string' }, description: 'Terms for add/remove.' },
|
|
382
|
+
term: { type: 'string', description: 'One term for classify.' },
|
|
383
|
+
category: { type: 'string', enum: Object.keys(CATEGORIES) },
|
|
384
|
+
},
|
|
385
|
+
required: ['action'],
|
|
386
|
+
additionalProperties: false,
|
|
387
|
+
},
|
|
388
|
+
output: {
|
|
389
|
+
schema: {
|
|
390
|
+
type: 'object',
|
|
391
|
+
properties: {
|
|
392
|
+
ok: { type: 'boolean' },
|
|
393
|
+
text: { type: 'string' },
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
render: (_args, value) => [{ type: 'text', text: String(value?.text ?? '') }],
|
|
397
|
+
},
|
|
398
|
+
async execute(args, exec) {
|
|
399
|
+
const action = String(args?.action ?? '')
|
|
400
|
+
const sessionId = exec?.agent?.id ? String(exec.agent.id) : ''
|
|
401
|
+
const cwdName = projectName(cwdOf(exec?.agent))
|
|
402
|
+
|
|
403
|
+
if (action === 'list') {
|
|
404
|
+
const groups = listByCategory(userVocab)
|
|
405
|
+
const total = Object.keys(userVocab.terms).length
|
|
406
|
+
const lines = [`词汇清单:已记录 ${total} 条;本会话待确认 ${pendingFor(userVocab, sessionId).length} 条。`]
|
|
407
|
+
for (const [key, label] of Object.entries(CATEGORIES)) {
|
|
408
|
+
const items = groups[key] ?? []
|
|
409
|
+
if (key === 'common') {
|
|
410
|
+
lines.push(`${label}:内置常用词(不占文件),例如 arXiv、DOI、API。`)
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
if (items.length === 0) {
|
|
414
|
+
lines.push(`${label}:0 条`)
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
const shown = items.slice(0, 12).map((item) => `${item.term}(${item.count})`).join('、')
|
|
418
|
+
lines.push(`${label}:${items.length} 条 — ${shown}${items.length > 12 ? ' …' : ''}`)
|
|
419
|
+
}
|
|
420
|
+
const pending = pendingFor(userVocab, sessionId)
|
|
421
|
+
if (pending.length) lines.push(`本次调研、尚未转为已知:${pending.join('、')}`)
|
|
422
|
+
return { ok: true, text: lines.join('\n') }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (action === 'add') {
|
|
426
|
+
const terms = (Array.isArray(args?.terms) ? args.terms : []).map((t) => String(t).trim()).filter(Boolean)
|
|
427
|
+
if (terms.length === 0) return { ok: false, text: '没有要加入的词:给 terms 一个非空数组。' }
|
|
428
|
+
const kept = terms.filter((term) => !isCodeLike(term))
|
|
429
|
+
const skipped = terms.filter((t) => !kept.includes(t))
|
|
430
|
+
for (const term of kept) addTerm(userVocab, term, { session: sessionId, cwdName, source: 'manual' })
|
|
431
|
+
saveVocab(vocabPath(), userVocab)
|
|
432
|
+
const note = skipped.length ? `;跳过代号 ${skipped.join('、')}(代号不进词表)` : ''
|
|
433
|
+
return { ok: kept.length > 0, text: `已加入 ${kept.length} 条:${kept.join('、') || '无'}${note}` }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (action === 'remove') {
|
|
437
|
+
const terms = (Array.isArray(args?.terms) ? args.terms : []).map((t) => String(t).trim()).filter(Boolean)
|
|
438
|
+
if (terms.length === 0) return { ok: false, text: '没有要删除的词:给 terms 一个非空数组。' }
|
|
439
|
+
for (const term of terms) removeTerm(userVocab, term)
|
|
440
|
+
saveVocab(vocabPath(), userVocab)
|
|
441
|
+
return { ok: true, text: `已删除 ${terms.length} 条:${terms.join('、')}` }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (action === 'classify') {
|
|
445
|
+
const term = String(args?.term ?? '').trim()
|
|
446
|
+
const category = String(args?.category ?? '')
|
|
447
|
+
if (!term || !CATEGORIES[category]) {
|
|
448
|
+
return { ok: false, text: `需要 term 和有效 category(${Object.keys(CATEGORIES).join(' / ')})。` }
|
|
449
|
+
}
|
|
450
|
+
if (isCodeLike(term)) {
|
|
451
|
+
return { ok: false, text: `「${term}」是内部代号,不进入词汇清单;它每次出现都应按未知处理并解释。` }
|
|
452
|
+
}
|
|
453
|
+
if (!userVocab.terms[term]) addTerm(userVocab, term, { session: sessionId, cwdName, category, source: 'manual' })
|
|
454
|
+
else setCategory(userVocab, term, category)
|
|
455
|
+
saveVocab(vocabPath(), userVocab)
|
|
456
|
+
return { ok: true, text: `已把「${term}」归到「${CATEGORIES[category]}」。` }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return { ok: false, text: `未知 action「${action}」。用 list / add / remove / classify。` }
|
|
460
|
+
},
|
|
461
|
+
}))
|
|
462
|
+
|
|
463
|
+
console.log(`[plain-talk] host active (default ${DEFAULT_ON ? 'on' : 'off'}; section ${SECTION_NAME}@${SECTION_ORDER}; vocab ${Object.keys(userVocab.terms).length} terms)`)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function renderCheck(value) {
|
|
467
|
+
const issues = Array.isArray(value?.issues) ? value.issues : []
|
|
468
|
+
const stats = value?.stats ?? {}
|
|
469
|
+
if (issues.length === 0) {
|
|
470
|
+
return `说人话自检:通过。\n字数 ${stats.chars ?? 0},行数 ${stats.lines ?? 0},未解释代号 ${stats.identifiers ?? 0},回指 ${stats.backrefs ?? 0}。`
|
|
471
|
+
}
|
|
472
|
+
const errors = issues.filter((i) => i.severity === 'error').length
|
|
473
|
+
const warns = issues.length - errors
|
|
474
|
+
const lines = [`说人话自检:${errors} 个必须改,${warns} 个建议改。`, '']
|
|
475
|
+
for (const issue of issues) {
|
|
476
|
+
lines.push(`[${issue.severity === 'error' ? '必须改' : '建议改'}] ${issue.message}`)
|
|
477
|
+
if (issue.fix) lines.push(` 改法:${issue.fix}`)
|
|
478
|
+
}
|
|
479
|
+
return lines.join('\n')
|
|
480
|
+
}
|