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