dsh-instruction-memory 1.4.2 → 1.4.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/lib/index.js CHANGED
@@ -30,7 +30,7 @@ import { readFileSync, watch } from 'node:fs'
30
30
  import { randomUUID } from 'node:crypto'
31
31
  import { isIP } from 'node:net'
32
32
  import { homedir, hostname } from 'node:os'
33
- import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
33
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
35
  import {
36
36
  FEEDBACK_ENV,
@@ -108,6 +108,15 @@ const MAX_HISTORY_BYTES = 32 * 1024 * 1024
108
108
  * overflows Node's timer range.
109
109
  */
110
110
  const MAX_MUTE_MS = 7 * 24 * 60 * 60 * 1000
111
+ /**
112
+ * The most event streams one host will hold open at once.
113
+ *
114
+ * Each open stream keeps a Map entry, a socket and listeners until the client
115
+ * disconnects; the panel opens one per window and browsers retry on their own,
116
+ * so a handful is normal. The ceiling exists so a misbehaving client (or a
117
+ * duplicated tab left open for weeks) cannot grow the set without bound.
118
+ */
119
+ const MAX_SSE_STREAMS = 32
111
120
  const HISTORY_DIR_NAME = 'history'
112
121
  /** Name of the JSON Schema shipped with the package and copied next to the store. */
113
122
  const SCHEMA_FILE_NAME = 'memory.schema.json'
@@ -185,6 +194,32 @@ export function resolveStorePath(env = process.env, home = homedir()) {
185
194
  return join(root, STORE_DIR_NAME, STORE_FILE_NAME)
186
195
  }
187
196
 
197
+ /**
198
+ * Fold the user's home directory to `~` for every path published in the
199
+ * snapshot. The settings page only needs to show WHICH file is in use; the
200
+ * absolute path adds nothing there but does put the account name on screen
201
+ * (and into any screenshot or support paste). The real paths stay untouched
202
+ * everywhere else — writes, backups and the watcher all use the absolute form.
203
+ */
204
+ export function displayPath(value, home = homedir()) {
205
+ if (typeof value !== 'string' || value === '') return value
206
+ const base = home.endsWith(sep) ? home.slice(0, -sep.length) : home
207
+ if (base === '') return value
208
+ if (value === base) return '~'
209
+ if (value.startsWith(base + sep)) return '~' + value.slice(base.length)
210
+ return value
211
+ }
212
+
213
+ /**
214
+ * Error code for "the store file exists but holds nothing usable".
215
+ *
216
+ * It is a distinct code (not a SyntaxError) because the recovery path needs to
217
+ * tell the three states apart: file missing (first run), file present but
218
+ * unparsable, file present and EMPTY. Only the last two are recoverable from
219
+ * .bak — and only the last two may never be treated as "no memory yet".
220
+ */
221
+ const EMPTY_STORE_CODE = 'EEMPTYSTORE'
222
+
188
223
  const DEFAULT_BUDGET = 4000
189
224
  const MIN_BUDGET = 800
190
225
  const MAX_BUDGET = 40000
@@ -201,12 +236,19 @@ const MAX_TAG_LENGTH = 24
201
236
  * preferences, personality, goals) used to tilt how answers are framed. They
202
237
  * are NOT roles the model is asked to play, and they are injected ahead of the
203
238
  * instruction block under a header that demotes them to "tilt only, never
204
- * override correctness or the question itself". They do not take part in the
205
- * per-entry budget trimming: an identity must never be silently dropped.
239
+ * override correctness or the question itself".
240
+ *
241
+ * They are deliberately kept OUT of the per-entry priority trimming — an
242
+ * identity must never be silently dropped for a low-priority instruction — but
243
+ * they are NOT exempt from the injection ceiling: MAX_PROFILE_INJECT_CHARS caps
244
+ * the whole persona section (and planInjection gives it at most the remaining
245
+ * share of budgetChars), so the number the panel calls 注入字数上限 applies to
246
+ * everything that reaches the prompt.
206
247
  */
207
248
  const MAX_PROFILES = 50
208
249
  const MAX_PROFILE_NAME = 40
209
250
  const MAX_PROFILE_CONTENT = 4000
251
+ const MAX_PROFILE_INJECT_CHARS = 4000
210
252
 
211
253
  /**
212
254
  * The batch operations the `batch` route accepts. Kept here so the command and
@@ -278,10 +320,32 @@ function clampBudget(value) {
278
320
  return n
279
321
  }
280
322
 
323
+ /**
324
+ * Cut a string to `max` UTF-16 units without ever ending on half a surrogate
325
+ * pair.
326
+ *
327
+ * `String.prototype.slice` counts UTF-16 units, so cutting at `max` can land
328
+ * between the high and low half of an emoji or a CJK ext-B character. The
329
+ * orphaned high surrogate is then written to memory.json verbatim (JSON
330
+ * escapes it as \ud83d), renders as U+FFFD in the panel, and is injected into
331
+ * every later prompt. The injection path already backed off one unit; this is
332
+ * the same rule, in ONE place, for every other caller (storage sanitising,
333
+ * single-line titles, history keys).
334
+ */
335
+ export function cutText(value, max) {
336
+ if (typeof value !== 'string') return ''
337
+ if (!Number.isFinite(max) || max <= 0) return ''
338
+ if (value.length <= max) return value
339
+ let head = value.slice(0, max)
340
+ const last = head.charCodeAt(head.length - 1)
341
+ if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1)
342
+ return head
343
+ }
344
+
281
345
  function cleanText(value, max) {
282
346
  if (typeof value !== 'string') return ''
283
347
  const trimmed = value.split('\r\n').join('\n').split('\r').join('\n').trim()
284
- return trimmed.length > max ? trimmed.slice(0, max) : trimmed
348
+ return cutText(trimmed, max)
285
349
  }
286
350
 
287
351
  /**
@@ -907,14 +971,20 @@ export function hostnameOf(value) {
907
971
  if (typeof value !== 'string') return ''
908
972
  const trimmed = value.trim().toLowerCase()
909
973
  if (trimmed === '') return ''
974
+ // A single trailing dot is the FQDN spelling of the SAME name (`localhost.`,
975
+ // `127.0.0.1.`). Browsers and some proxies emit it, and leaving it in made
976
+ // the loopback check fail closed — the panel 403'd against its own host. The
977
+ // dot is not a bypass either way (the guard is an allowlist), so normalising
978
+ // it is strictly a compatibility fix.
979
+ const stripTrailingDot = (name) => (name.endsWith('.') ? name.slice(0, -1) : name)
910
980
  if (trimmed.startsWith('[')) {
911
981
  const end = trimmed.indexOf(']')
912
- return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
982
+ return stripTrailingDot(end === -1 ? trimmed.slice(1) : trimmed.slice(1, end))
913
983
  }
914
984
  // Unbracketed IPv6 literal: the colons are part of the address, not a port.
915
- if (trimmed.indexOf(':') !== trimmed.lastIndexOf(':')) return trimmed
985
+ if (trimmed.indexOf(':') !== trimmed.lastIndexOf(':')) return stripTrailingDot(trimmed)
916
986
  const colon = trimmed.lastIndexOf(':')
917
- return colon === -1 ? trimmed : trimmed.slice(0, colon)
987
+ return stripTrailingDot(colon === -1 ? trimmed : trimmed.slice(0, colon))
918
988
  }
919
989
 
920
990
  /**
@@ -1029,11 +1099,18 @@ const isLoopbackName = (name) => {
1029
1099
  * and pre-2020 browsers, which is exactly why it can only ADD rejections, never
1030
1100
  * replace the checks above): a modern browser stamps every request with it, so
1031
1101
  * a page at evil.com talking to this route — even through tricks that drop the
1032
- * `Origin` header — still says `cross-site`. Only `cross-site` is refused;
1033
- * `same-site` must pass because schemeful same-site ignores ports (the GUI and
1034
- * the route live on the same host but different ports), and local non-browser
1035
- * clients never send the header at all, so they are governed by the Host and
1036
- * content-type checks alone.
1102
+ * `Origin` header — still says `cross-site`.
1103
+ *
1104
+ * `same-site` is refused too, but ONLY on a guarded (loopback-bound) server.
1105
+ * Schemeful same-site ignores the port, so `same-site` is also what a page at
1106
+ * 127.0.0.1:5555 — any other local app, or a rebinding page that landed on a
1107
+ * different port — sends when it talks to this route. The hostname comparison
1108
+ * below cannot see that difference (it deliberately ignores ports), so
1109
+ * `same-site` used to sail straight through and let any local origin rewrite
1110
+ * long-lived instructions. On loopback the GUI and the route are always the
1111
+ * same origin, so nothing legitimate is lost. On a non-loopback bind the server
1112
+ * may sit behind a proxy that legitimately rewrites ports, so the same-site
1113
+ * rule is left off there and the README documents the trade-off.
1037
1114
  */
1038
1115
  export function requestOriginVerdict(headers, guarded) {
1039
1116
  const read = (name) => {
@@ -1052,15 +1129,21 @@ export function requestOriginVerdict(headers, guarded) {
1052
1129
  if (fetchSite === 'cross-site') {
1053
1130
  return { ok: false, reason: '请求被浏览器标记为跨站(Sec-Fetch-Site: cross-site)' }
1054
1131
  }
1132
+ if (guarded && fetchSite === 'same-site') {
1133
+ return { ok: false, reason: '请求来自同一站点但不同源(Sec-Fetch-Site: same-site),回环服务只接受同源请求' }
1134
+ }
1055
1135
 
1056
1136
  const origin = read('origin').trim()
1057
1137
  if (origin !== '') {
1058
1138
  let originHostname = ''
1139
+ let originPort = null
1059
1140
  try {
1060
1141
  // URL.hostname keeps the brackets on an IPv6 literal; normalizeHostname
1061
1142
  // takes them off so it can meet the bracket-free Host parsing on equal
1062
1143
  // terms.
1063
- originHostname = normalizeHostname(new URL(origin).hostname.toLowerCase())
1144
+ const parsed = new URL(origin)
1145
+ originHostname = normalizeHostname(parsed.hostname.toLowerCase())
1146
+ originPort = parsed.port
1064
1147
  } catch {
1065
1148
  originHostname = ''
1066
1149
  }
@@ -1072,11 +1155,63 @@ export function requestOriginVerdict(headers, guarded) {
1072
1155
  if (originHostname === '' || !sameHostname(originHostname, requestHostname)) {
1073
1156
  return { ok: false, reason: 'Origin 与 Host 不同源:' + origin }
1074
1157
  }
1158
+ // On loopback the port IS part of the origin, and a different local port is
1159
+ // a different application: refuse it when both sides state one. Skipped on
1160
+ // a non-loopback bind, where a proxy may legitimately translate the port,
1161
+ // and skipped when the Host states none — an absent port is unknown, not
1162
+ // port 80, and refusing on that guess would break a loopback proxy that
1163
+ // strips it. Default ports compare equal to no port (URL.port omits them).
1164
+ const originPortValue = originPort === '80' || originPort === '443' ? '' : originPort
1165
+ const hostPortValue = requestPortOf(requestHost)
1166
+ if (guarded && originPortValue !== null && originPortValue !== ''
1167
+ && hostPortValue !== '' && originPortValue !== hostPortValue) {
1168
+ return { ok: false, reason: 'Origin 端口与 Host 端口不一致:' + origin }
1169
+ }
1075
1170
  }
1076
1171
 
1077
1172
  return { ok: true, reason: null }
1078
1173
  }
1079
1174
 
1175
+ /**
1176
+ * The media type of a Content-Type header, lowercased, without parameters.
1177
+ *
1178
+ * `text/plain; note=application/json` has media type `text/plain`; a substring
1179
+ * test would have called it JSON.
1180
+ *
1181
+ * Node joins repeated headers with `, `, so a gateway that forwards its own
1182
+ * `Content-Type` alongside the client's produces
1183
+ * `application/json, text/plain`. The first value is the effective one, and
1184
+ * taking it keeps a duplicated header from being refused as a wrong type.
1185
+ */
1186
+ function mediaTypeOf(headerValue) {
1187
+ if (typeof headerValue !== 'string') return ''
1188
+ const first = headerValue.indexOf(',')
1189
+ const withoutList = first === -1 ? headerValue : headerValue.slice(0, first)
1190
+ const semicolon = withoutList.indexOf(';')
1191
+ const mediaType = semicolon === -1 ? withoutList : withoutList.slice(0, semicolon)
1192
+ return mediaType.trim().toLowerCase()
1193
+ }
1194
+
1195
+ /**
1196
+ * The port stated by a `Host` header, or '' when none is (which is also what
1197
+ * URL.port reports for a default port, so the two compare equal).
1198
+ */
1199
+ function requestPortOf(hostHeader) {
1200
+ const value = typeof hostHeader === 'string' ? hostHeader.trim() : ''
1201
+ const closing = value.lastIndexOf(']')
1202
+ const rest = closing === -1 ? value : value.slice(closing + 1)
1203
+ // A bare IPv6 literal (`::1`) carries no brackets and several colons: its
1204
+ // last colon belongs to the address, not to a port. RFC 7230 requires the
1205
+ // brackets, but this tolerant reader must not invent port "1" out of `::1`
1206
+ // and then refuse an Origin that states no port at all.
1207
+ if (closing === -1 && (rest.match(/:/g) || []).length > 1) return ''
1208
+ const index = rest.lastIndexOf(':')
1209
+ const port = index === -1 ? '' : rest.slice(index + 1).trim()
1210
+ // URL.port is '' for a scheme's default port, so `Host: x:80` must compare
1211
+ // equal to `Origin: http://x`.
1212
+ return port === '80' || port === '443' ? '' : port
1213
+ }
1214
+
1080
1215
  function formatEntry(entry, tag) {
1081
1216
  const lines = ['[' + tag + '|优先级 ' + priorityLabel(entry.priority) + '] ' + entry.title]
1082
1217
  if (entry.mode === 'auto' && entry.when !== '') lines.push('适用场景:' + entry.when)
@@ -1131,26 +1266,86 @@ const PERSONA_HEADER = [
1131
1266
  * no timestamps, no counts, identical profiles in produce byte-identical
1132
1267
  * text. An empty/disabled set renders to '' so callers can join unconditionally.
1133
1268
  */
1134
- function buildProfilesBlock(profiles) {
1269
+ function buildProfilesBlock(profiles, budget) {
1135
1270
  const active = (Array.isArray(profiles) ? profiles : [])
1136
1271
  .filter((profile) => profile !== null && typeof profile === 'object'
1137
1272
  && profile.enabled === true
1138
1273
  && typeof profile.content === 'string' && profile.content !== '')
1139
1274
  if (active.length === 0) return { text: '', profiles: [], chars: 0 }
1275
+ /**
1276
+ * The persona section is BOUNDED, and it is bounded by the same number the
1277
+ * panel calls 注入字数上限.
1278
+ *
1279
+ * It used to be unbounded: `content` is sanitised at 4000 chars per profile
1280
+ * but nothing capped how many profiles, or their total, could reach the
1281
+ * prompt. A dozen filled profiles therefore injected ~50k chars before the
1282
+ * entry budget was even consulted — the one number the user was told to tune
1283
+ * did not apply to the largest part of the block. `budget` is what is LEFT
1284
+ * after the persona section in planInjection, so the two together never
1285
+ * exceed the configured ceiling.
1286
+ */
1287
+ const cap = Math.min(
1288
+ MAX_PROFILE_INJECT_CHARS,
1289
+ Number.isFinite(budget) && budget > 0 ? Math.floor(budget) : MAX_PROFILE_INJECT_CHARS,
1290
+ )
1291
+ const footerFor = (count) => (count === 0
1292
+ ? ''
1293
+ : '\n\n(另有 ' + count + ' 个形象因注入字数上限未包含,可在「设置 → 指令记忆」中精简其内容。)')
1140
1294
  const blocks = []
1141
1295
  const items = []
1296
+ let used = PERSONA_HEADER.length + 1
1297
+ let omitted = 0
1142
1298
  for (const profile of active) {
1143
1299
  const name = typeof profile.name === 'string' && profile.name !== ''
1144
1300
  ? profile.name
1145
1301
  : singleLine(profile.content, 20)
1302
+ const id = typeof profile.id === 'string' ? profile.id : ''
1146
1303
  const block = '### ' + name + '\n' + profile.content
1147
- blocks.push(block)
1148
- items.push({ id: typeof profile.id === 'string' ? profile.id : '', name, chars: block.length })
1304
+ const sep = blocks.length === 0 ? 0 : 2
1305
+ // Reserve the omission footer up front: the disclosure must never be the
1306
+ // thing squeezed out by the profiles that caused it. Reserve the WORST
1307
+ // CASE (every profile omitted), not `omitted + 1`: the footer grows with
1308
+ // the omitted count, so reserving one-more-than-now under-reserves by one
1309
+ // character per extra digit and the finished section can exceed `cap`.
1310
+ const reserve = footerFor(active.length).length
1311
+ const room = cap - used - sep - reserve
1312
+ if (block.length <= room) {
1313
+ blocks.push(block)
1314
+ items.push({ id, name, chars: block.length })
1315
+ used += sep + block.length
1316
+ continue
1317
+ }
1318
+ // Too big for the room left: keep a truncated head (never splitting a
1319
+ // surrogate pair) and drop the rest, so the section stays informative
1320
+ // instead of silently vanishing.
1321
+ const note = '\n…(此形象因注入字数上限被截断)'
1322
+ const fit = room - note.length
1323
+ if (fit > 40) {
1324
+ const head = cutText(block, fit)
1325
+ blocks.push(head + note)
1326
+ items.push({ id, name, chars: head.length + note.length, truncated: true })
1327
+ used += sep + head.length + note.length
1328
+ }
1329
+ omitted = active.length - blocks.length
1330
+ break
1149
1331
  }
1150
- const text = PERSONA_HEADER + '\n' + blocks.join('\n\n')
1332
+ const text = PERSONA_HEADER + '\n' + blocks.join('\n\n') + footerFor(Math.max(0, active.length - blocks.length))
1151
1333
  return { text, profiles: items, chars: text.length }
1152
1334
  }
1153
1335
 
1336
+ /**
1337
+ * The disclosure line for entries the budget left out. Shared by the entry
1338
+ * plan and by `planInjection`, because a persona section can consume the whole
1339
+ * budget and leave the entries with nothing: in that case the entry plan's own
1340
+ * text is empty, so its footer would never reach the prompt and the model would
1341
+ * silently lose the user's instructions while the persona block still rendered.
1342
+ */
1343
+ export function entryOmittedFooter(count) {
1344
+ return count === 0
1345
+ ? ''
1346
+ : '\n\n(另有 ' + count + ' 条指令因注入字数上限未包含,可在「设置 → 指令记忆」中提高上限或调低其优先级。)'
1347
+ }
1348
+
1154
1349
  /**
1155
1350
  * The entry half of the plan: the existing instruction block with its budget
1156
1351
  * trimming. Kept as the inner function so planInjection can prepend the
@@ -1183,10 +1378,7 @@ function planEntries(data, budget) {
1183
1378
  ].join('\n'),
1184
1379
  '## 用户长期指令记忆\n',
1185
1380
  ]
1186
- const footerFor = (count) =>
1187
- count === 0
1188
- ? ''
1189
- : '\n\n(另有 ' + count + ' 条指令因注入字数上限未包含,可在「设置 → 指令记忆」中提高上限或调低其优先级。)'
1381
+ const footerFor = entryOmittedFooter
1190
1382
 
1191
1383
  const MIN_ROOM = 80
1192
1384
  let header = HEADERS[HEADERS.length - 1]
@@ -1206,8 +1398,8 @@ function planEntries(data, budget) {
1206
1398
  const consider = (entry, tag) => {
1207
1399
  const block = formatEntry(entry, tag)
1208
1400
  // Reserve the footer up front so a disclosure can never be squeezed out by
1209
- // the very entries that caused it.
1210
- const reserve = footerFor(omitted + 1).length
1401
+ // the very entries that caused it. Worst case again: every entry omitted.
1402
+ const reserve = footerFor(active.length).length
1211
1403
  if (used + block.length + 2 + reserve <= budget) {
1212
1404
  used += block.length + 2
1213
1405
  blocks.push(block)
@@ -1220,12 +1412,10 @@ function planEntries(data, budget) {
1220
1412
  const room = budget - used - 2 - reserve
1221
1413
  const note = '\n…(本条因注入字数上限被截断)'
1222
1414
  if (room > note.length + 20) {
1223
- // Cut in UTF-16 units but back off one char when that lands between a
1415
+ // cutText backs off one UTF-16 unit when the cut would land between a
1224
1416
  // surrogate pair, so the truncation point can never end on the broken
1225
1417
  // half of an emoji or CJK ext-B character.
1226
- let head = block.slice(0, room - note.length)
1227
- const last = head.charCodeAt(head.length - 1)
1228
- if (last >= 0xd800 && last <= 0xdbff) head = head.slice(0, -1)
1418
+ const head = cutText(block, room - note.length)
1229
1419
  const body = head + note
1230
1420
  blocks.push(body)
1231
1421
  used += body.length + 2
@@ -1320,11 +1510,25 @@ export function planInjection(data, now = Date.now()) {
1320
1510
  entries: (Array.isArray(data.entries) ? data.entries : []).filter((entry) => inScope(entry, scope)),
1321
1511
  profiles: (Array.isArray(data.profiles) ? data.profiles : []).filter((profile) => inScope(profile, scope)),
1322
1512
  }
1323
- const persona = buildProfilesBlock(scoped.profiles)
1324
- const entryPlan = planEntries(scoped, budget)
1513
+ /**
1514
+ * Persona first, then entries with whatever budget is LEFT. The persona
1515
+ * section used to be prepended outside the budget entirely, so the rendered
1516
+ * block could exceed 注入字数上限 by the size of the whole persona section.
1517
+ * Giving it the first claim and the entries the remainder keeps the total
1518
+ * honest: text.length <= budget for every store shape.
1519
+ */
1520
+ // Worst-case entry disclosure, plus the 2-char separator the two halves are
1521
+ // joined with: if the persona section took the whole budget, every entry
1522
+ // would be dropped and its footer still has to fit.
1523
+ const JOIN = '\n\n'.length
1524
+ const activeEntryCount = (scoped.entries || []).filter((entry) => entry && entry.enabled === true && entry.content !== '').length
1525
+ const entryReserve = activeEntryCount > 0 ? entryOmittedFooter(activeEntryCount).length + JOIN : 0
1526
+ const persona = buildProfilesBlock(scoped.profiles, Math.max(0, budget - entryReserve))
1527
+ const entryPlan = planEntries(scoped, Math.max(0, budget - persona.chars))
1325
1528
  const parts = []
1326
1529
  if (persona.text !== '') parts.push(persona.text)
1327
1530
  if (entryPlan.text !== '') parts.push(entryPlan.text)
1531
+ else if (parts.length > 0 && entryPlan.omitted.length > 0) parts.push(entryOmittedFooter(entryPlan.omitted.length))
1328
1532
  return {
1329
1533
  text: parts.join('\n\n'),
1330
1534
  budget,
@@ -1458,6 +1662,28 @@ export function apply(ctx) {
1458
1662
  return String(err)
1459
1663
  }
1460
1664
 
1665
+ /**
1666
+ * describeError for text that LEAVES the process (HTTP responses).
1667
+ *
1668
+ * A raw fs message names the absolute path it failed on (ENOENT, EACCES,
1669
+ * ENOTDIR), which hands any caller the user's home directory and store
1670
+ * location for free. The full text still reaches the log; the response gets
1671
+ * the same sentence with the home folded to `~` and the store directory to a
1672
+ * stable placeholder.
1673
+ */
1674
+ const describeErrorSafe = (err) => {
1675
+ let text = describeError(err)
1676
+ const storeDir = dirname(storePath)
1677
+ if (storeDir !== '' && storeDir !== '.') text = text.split(storeDir).join('<记忆目录>')
1678
+ try {
1679
+ const home = homedir()
1680
+ if (typeof home === 'string' && home !== '') text = text.split(home).join('~')
1681
+ } catch {
1682
+ // No home to redact: keep the text as it is.
1683
+ }
1684
+ return text
1685
+ }
1686
+
1461
1687
  /* ---------------- diagnostics ---------------- */
1462
1688
 
1463
1689
  /**
@@ -1612,7 +1838,7 @@ export function apply(ctx) {
1612
1838
  } catch (err) {
1613
1839
  sectionDispose = null
1614
1840
  state.sectionRegistered = false
1615
- state.sectionError = '注入注册失败:' + describeError(err)
1841
+ state.sectionError = '注入注册失败:' + describeErrorSafe(err)
1616
1842
  console.error('[instruction-memory] section registration failed:', err)
1617
1843
  }
1618
1844
  } else if (!wanted) {
@@ -1720,8 +1946,21 @@ export function apply(ctx) {
1720
1946
  await readFromDisk()
1721
1947
  syncSection()
1722
1948
  pushSnapshot()
1723
- } catch {
1949
+ } catch (err) {
1724
1950
  // Transient: the next event, or the panel's 重新载入, catches up.
1951
+ // An EMPTY file is the one exception — a save in progress looks like
1952
+ // that for a moment, but a file that stays empty is data loss waiting
1953
+ // to happen, so route it through the recovery path that knows about
1954
+ // .bak instead of silently serving stale memory forever.
1955
+ if (err !== null && err !== undefined && err.code === EMPTY_STORE_CODE) {
1956
+ try {
1957
+ await readFromDisk()
1958
+ syncSection()
1959
+ pushSnapshot()
1960
+ } catch {
1961
+ // Still transient; the panel's 重新载入 remains available.
1962
+ }
1963
+ }
1725
1964
  }
1726
1965
  })
1727
1966
  }, WATCH_DEBOUNCE_MS)
@@ -1916,7 +2155,7 @@ export function apply(ctx) {
1916
2155
  await unlink(probe).catch(() => {})
1917
2156
  return null
1918
2157
  } catch (err) {
1919
- return '目录不可写:' + describeError(err)
2158
+ return '目录不可写:' + describeErrorSafe(err)
1920
2159
  }
1921
2160
  }
1922
2161
 
@@ -1932,10 +2171,24 @@ export function apply(ctx) {
1932
2171
  })())
1933
2172
 
1934
2173
  /**
1935
- * The bytes of the most recent snapshot, so two writes that produce the same
1936
- * file (a no-op-ish save, a repeat restore) do not each mint a "version".
2174
+ * Identity of the last snapshot's CONTENT, so two writes that produce the
2175
+ * same store (a no-op-ish save, a repeat restore) do not each mint a
2176
+ * "version".
2177
+ *
2178
+ * This used to compare the serialised file byte-for-byte, which never
2179
+ * matched twice: `rev` is bumped on every save, so the "identical" comparison
2180
+ * could not fire and every no-op save still appended a version. The key is
2181
+ * the serialised store with `rev` normalised away — the field that exists
2182
+ * only to differ.
2183
+ */
2184
+ let lastHistoryKey = null
2185
+
2186
+ /**
2187
+ * Content identity of the store as last PERSISTED (rev normalised away), so a
2188
+ * save that changes nothing is skipped instead of bumping rev and appending a
2189
+ * version-history file.
1937
2190
  */
1938
- let lastHistoryText = null
2191
+ let lastPersistedKey = null
1939
2192
 
1940
2193
  /**
1941
2194
  * Enforce the per-device retention policy: the count and byte ceilings from
@@ -1958,6 +2211,13 @@ export function apply(ctx) {
1958
2211
  }
1959
2212
  let removed = 0
1960
2213
  let kept = 0
2214
+ // Survivors of the per-device plan, kept with their size so a GLOBAL byte
2215
+ // ceiling can be enforced afterwards: the plan bounds each device on its
2216
+ // own, so N devices multiply the budget (ten machines = ten × 32 MB). The
2217
+ // total is capped below by deleting the oldest snapshots first, whichever
2218
+ // device they came from — the fixed-width stamp makes name order
2219
+ // chronological across devices too.
2220
+ const survivors = []
1961
2221
  for (const bucket of byDevice.values()) {
1962
2222
  // Fixed-width UTC stamp first => lexicographic order is chronological.
1963
2223
  bucket.sort()
@@ -1971,7 +2231,22 @@ export function apply(ctx) {
1971
2231
  await unlink(join(dir, name)).catch(() => {})
1972
2232
  removed += 1
1973
2233
  }
2234
+ const sizeOf = new Map(sized.map((item) => [item.name, item.size]))
1974
2235
  kept += plan.keep.length
2236
+ // plan.keep holds NAMES (newest first), so re-attach the sizes here: the
2237
+ // global ceiling below sums bytes, not file counts.
2238
+ for (const name of plan.keep) survivors.push({ name, size: sizeOf.get(name) || 0 })
2239
+ }
2240
+ let total = survivors.reduce((sum, item) => sum + item.size, 0)
2241
+ if (total > MAX_HISTORY_BYTES) {
2242
+ survivors.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
2243
+ for (const item of survivors) {
2244
+ if (total <= MAX_HISTORY_BYTES) break
2245
+ await unlink(join(dir, item.name)).catch(() => {})
2246
+ total -= item.size
2247
+ removed += 1
2248
+ kept -= 1
2249
+ }
1975
2250
  }
1976
2251
  return { removed, kept }
1977
2252
  }
@@ -1983,8 +2258,9 @@ export function apply(ctx) {
1983
2258
  * really was committed; a failure here is disclosed and never fails the save,
1984
2259
  * because the store itself is already safely on disk.
1985
2260
  */
1986
- const writeHistorySnapshot = async (text) => {
1987
- if (typeof text !== 'string' || text === '' || text === lastHistoryText) return
2261
+ const writeHistorySnapshot = async (text, key) => {
2262
+ const identity = typeof key === 'string' && key !== '' ? key : text
2263
+ if (typeof text !== 'string' || text === '' || identity === lastHistoryKey) return
1988
2264
  try {
1989
2265
  const dir = historyDirPath()
1990
2266
  await mkdir(dir, { recursive: true })
@@ -1994,19 +2270,19 @@ export function apply(ctx) {
1994
2270
  // bump the stamp by a millisecond instead of losing one of the two.
1995
2271
  for (let attempt = 0; attempt < 5 && !written; attempt += 1) {
1996
2272
  try {
1997
- await writeFile(join(dir, historyFileName(epoch + attempt, state.data.rev || 0, DEVICE)), text, { encoding: 'utf8', flag: 'wx' })
2273
+ await writeFile(join(dir, historyFileName(epoch + attempt, state.data.rev || 0, DEVICE)), text, { encoding: 'utf8', flag: 'wx', mode: 0o600 })
1998
2274
  written = true
1999
2275
  } catch (err) {
2000
2276
  if (err.code !== 'EEXIST') throw err
2001
2277
  }
2002
2278
  }
2003
2279
  if (!written) throw new Error('快照文件名重复')
2004
- lastHistoryText = text
2280
+ lastHistoryKey = identity
2005
2281
  const { kept } = await pruneHistory(dir)
2006
2282
  state.historyCount = kept
2007
2283
  state.historyError = null
2008
2284
  } catch (err) {
2009
- state.historyError = '版本历史快照未能写入(' + describeError(err) + ');本次保存本身已完成'
2285
+ state.historyError = '版本历史快照未能写入(' + describeErrorSafe(err) + ');本次保存本身已完成'
2010
2286
  }
2011
2287
  }
2012
2288
 
@@ -2064,31 +2340,92 @@ export function apply(ctx) {
2064
2340
  await mkdir(dirname(storePath), { recursive: true })
2065
2341
  lock = await acquireStoreLock()
2066
2342
  if (lock === null) {
2067
- logDebug('store lock still held by another process; writing anyway (last write wins)')
2343
+ // The lock could not be taken within the wait. That is either a stale
2344
+ // lock left by a crashed process (the file on disk is still exactly
2345
+ // what we loaded, so writing is safe) or a LIVE one, in which case the
2346
+ // other process may have committed since we loaded and writing our
2347
+ // whole-file snapshot would silently drop their change. Only the first
2348
+ // case may proceed: compare the on-disk rev with the one we hold.
2349
+ const diskRev = await readDiskRev()
2350
+ if (diskRev > (state.data.rev || 0)) {
2351
+ state.storageError = '另一个 DSH 进程刚刚保存了记忆(磁盘 rev ' + diskRev
2352
+ + ',本进程 rev ' + (state.data.rev || 0) + '),本次未写入以免覆盖,请重试'
2353
+ return false
2354
+ }
2355
+ logDebug('store lock held by a stale owner and the disk rev is unchanged; writing anyway')
2356
+ }
2357
+ // The lock is ours, but the DISK may have moved on since this process
2358
+ // loaded it: another DSH window committed while our watcher had not yet
2359
+ // refreshed. Bumping the rev from max(ours, disk) kept the NUMBER
2360
+ // monotonic while the CONTENT of their save was dropped without an error
2361
+ // — "rev 3" looked healthy and the entry was simply gone. Refuse instead,
2362
+ // so the caller retries against the newer store.
2363
+ const diskRevNow = await readDiskRev()
2364
+ if (diskRevNow > (state.data.rev || 0)) {
2365
+ state.storageError = '另一个 DSH 进程刚刚保存了记忆(磁盘 rev ' + diskRevNow
2366
+ + ',本进程 rev ' + (state.data.rev || 0) + '),本次未写入以免覆盖,请重试'
2367
+ return false
2368
+ }
2369
+ // A save that changes NOTHING must not bump the rev, rewrite the file or
2370
+ // mint a history snapshot. The mutation still counts as committed (the
2371
+ // in-memory state already matches), so callers keep their success path.
2372
+ //
2373
+ // The identity is the serialised store with `rev` normalised away,
2374
+ // because rev is bumped on every save and would make the comparison
2375
+ // always differ.
2376
+ const contentKey = JSON.stringify({ ...persistableData(), rev: 0 })
2377
+ if (state.storeExists && lastPersistedKey !== null && contentKey === lastPersistedKey) {
2378
+ logDebug('save skipped: the store content did not change')
2379
+ return true
2068
2380
  }
2069
2381
  // The rev is bumped before serialising so the file on disk and the state
2070
2382
  // in memory agree on it; a failed write restores the old value.
2071
- previousRev = state.data.rev || 0
2383
+ //
2384
+ // It is bumped from max(ours, the one on disk) so it stays monotonic
2385
+ // ACROSS processes: two windows that loaded rev 5 would otherwise both
2386
+ // write rev 6, and anything that watches the rev for change (the lite
2387
+ // SSE stream) cannot tell the second save from a repeat of the first.
2388
+ const diskRev = await readDiskRev()
2389
+ previousRev = Math.max(state.data.rev || 0, diskRev)
2072
2390
  state.data.rev = previousRev + 1
2073
2391
  const text = serialize()
2074
- await writeFile(tmpPath, text, 'utf8')
2392
+ // mode 0600: the store holds the user's private instructions and (with a
2393
+ // user profile) personal notes, so on a multi-user POSIX host it must not
2394
+ // land world-readable the way a bare writeFile (0666 & ~umask = 0644)
2395
+ // would. Windows ignores the bit and inherits the directory ACL instead.
2396
+ await writeFile(tmpPath, text, { encoding: 'utf8', mode: 0o600 })
2075
2397
  // Both rolling copies stand for the PREVIOUS good version, so both are
2076
2398
  // taken before the atomic replace, and neither may ever be renamed into
2077
2399
  // place (see the long note above).
2400
+ //
2401
+ // The copy is GUARDED against an UNUSABLE primary, not merely an empty
2402
+ // one: the states the backup exists to survive are a 0-byte file, a
2403
+ // whitespace stub, and a truncated or hand-mangled JSON — all of which
2404
+ // parse to nothing, and only the first is zero bytes. Copying any of them
2405
+ // over a good .bak destroys the only remaining copy at the exact moment
2406
+ // the user needs it, so the test is "does this file read as a store?".
2407
+ // An unusable primary therefore keeps the existing backup untouched and
2408
+ // discloses it (the .bak may be stale, and the panel says so).
2078
2409
  let hadPrevious = true
2079
2410
  try {
2411
+ await readStoreFile(storePath)
2080
2412
  await copyFile(storePath, localBackupPath())
2081
2413
  state.backupError = null
2082
2414
  } catch (err) {
2083
- // ENOENT is simply the very first save: there is no previous version
2084
- // to roll. Any other failure means the promised rolling backup did not
2085
- // happen — non-fatal for this save, because the primary is still intact
2086
- // and the replace below is the real gate, but it must be disclosed
2087
- // rather than swallowed.
2088
- hadPrevious = err.code !== 'ENOENT'
2089
- state.backupError = err.code === 'ENOENT'
2090
- ? null
2091
- : '上一版未能保留为 ' + localBackupPath() + ':' + describeError(err)
2415
+ if (err !== null && err !== undefined && err.code === 'ENOENT') {
2416
+ // The very first save: there is no previous version to roll, and
2417
+ // writing a .bak here would leave a 0-byte copy that a later recovery
2418
+ // would adopt as an "empty store" — the trap this guard exists for.
2419
+ hadPrevious = false
2420
+ state.backupError = null
2421
+ } else {
2422
+ hadPrevious = false
2423
+ const previous = await stat(localBackupPath()).catch(() => null)
2424
+ state.backupError = previous !== null && previous.size > 0
2425
+ ? 'memory.json 当前不可用(' + describeErrorSafe(err) + '),已保留上一版备份 '
2426
+ + localBackupPath() + ' 不被覆盖'
2427
+ : 'memory.json 当前不可用,且没有可用的上一版备份(' + describeErrorSafe(err) + ')'
2428
+ }
2092
2429
  }
2093
2430
  // The user-facing copy under a custom backup location (F18). The LOCAL
2094
2431
  // copy above stays the one crash recovery reads, so a failure here
@@ -2099,7 +2436,7 @@ export function apply(ctx) {
2099
2436
  await copyFile(storePath, customBak)
2100
2437
  } catch (err) {
2101
2438
  if (state.backupError === null) {
2102
- state.backupError = '备份位置未能保留上一版(' + customBak + '):' + describeError(err)
2439
+ state.backupError = '备份位置未能保留上一版(' + customBak + '):' + describeErrorSafe(err)
2103
2440
  }
2104
2441
  }
2105
2442
  }
@@ -2123,7 +2460,8 @@ export function apply(ctx) {
2123
2460
  // Best-effort side files, each guarded inside itself: a failure in either
2124
2461
  // must never be mistaken for "the save failed" — the save already
2125
2462
  // committed, and the store is on disk whatever happens next.
2126
- await writeHistorySnapshot(text)
2463
+ await writeHistorySnapshot(text, contentKey)
2464
+ lastPersistedKey = contentKey
2127
2465
  await ensureSchemaFile()
2128
2466
  logDebug('store written: ' + storePath + ' bytes=' + text.length
2129
2467
  + ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
@@ -2133,16 +2471,51 @@ export function apply(ctx) {
2133
2471
  return true
2134
2472
  } catch (err) {
2135
2473
  if (previousRev !== null) state.data.rev = previousRev
2136
- state.storageError = '保存失败:' + describeError(err)
2474
+ state.storageError = '保存失败:' + describeErrorSafe(err)
2137
2475
  return false
2138
2476
  } finally {
2139
2477
  await releaseStoreLock(lock)
2140
2478
  }
2141
2479
  }
2142
2480
 
2481
+ /**
2482
+ * Read + parse one store file.
2483
+ *
2484
+ * An EMPTY file is NOT "no file". Returning null for it made the loader treat
2485
+ * a 0-byte memory.json — a truncated write, an editor that saved blank, a
2486
+ * quarantine stub — as a first run, so the plugin materialised an empty store
2487
+ * and the very next save copied that empty file over memory.json.bak,
2488
+ * destroying the last good copy. It now throws a tagged error, which sends it
2489
+ * down the corrupt-file recovery path (the one that knows about .bak).
2490
+ */
2143
2491
  const readStoreFile = async (path) => {
2144
2492
  const text = await readFile(path, 'utf8')
2145
- return text.trim() === '' ? null : JSON.parse(text)
2493
+ if (text.trim() === '') {
2494
+ const empty = new Error('store file is empty (0 bytes or whitespace only)')
2495
+ empty.code = EMPTY_STORE_CODE
2496
+ throw empty
2497
+ }
2498
+ return JSON.parse(text)
2499
+ }
2500
+
2501
+ /**
2502
+ * The `rev` currently on disk, or 0 when there is no readable store.
2503
+ *
2504
+ * writeToDisk needs this for two questions the in-memory copy cannot answer:
2505
+ * (a) keeping the rev monotonic ACROSS processes — two DSH windows each doing
2506
+ * their own `data.rev + 1` from the same loaded rev mint the SAME number, and
2507
+ * a consumer that detects change by "the rev differs" (the lite SSE stream)
2508
+ * then silently misses the second save; and (b) deciding whether a save made
2509
+ * while another process holds the lock would clobber a newer store.
2510
+ */
2511
+ const readDiskRev = async () => {
2512
+ try {
2513
+ const parsed = await readStoreFile(storePath)
2514
+ const rev = parsed !== null && typeof parsed === 'object' ? parsed.rev : null
2515
+ return typeof rev === 'number' && Number.isFinite(rev) && rev > 0 ? Math.round(rev) : 0
2516
+ } catch {
2517
+ return 0
2518
+ }
2146
2519
  }
2147
2520
 
2148
2521
  /**
@@ -2205,6 +2578,11 @@ export function apply(ctx) {
2205
2578
  needsMaterialize = false
2206
2579
  }
2207
2580
  state.storageError = null
2581
+ // The primary read cleanly, so a "memory.json 当前不可用" note from an
2582
+ // earlier save describes a state that no longer exists. The panel reports
2583
+ // current state, not a log; keeping the stale sentence would leave the
2584
+ // user reading about a broken file they just fixed.
2585
+ state.backupError = null
2208
2586
  return false
2209
2587
  } catch (err) {
2210
2588
  if (err === null || err === undefined || err.code !== 'ENOENT') {
@@ -2213,14 +2591,23 @@ export function apply(ctx) {
2213
2591
  // presenting an empty memory, but say so loudly. The corrupt file
2214
2592
  // itself stays untouched for hand repair; it gets overwritten on the
2215
2593
  // next save (the pre-existing and disclosed behaviour).
2216
- if (err instanceof SyntaxError) {
2217
- // The file was READ and only failed to PARSE, so a store file is
2218
- // physically present on disk. Saying onDisk=no here contradicted
2219
- // the branch's own story ("损坏文件已保留") and produced a mounted
2220
- // log line reading "from=memory.json.bak (main file corrupt)
2221
- // onDisk=no". (Other non-ENOENT errors stay no: ENOTDIR, say, means
2222
- // nothing exists at the store path at all.)
2594
+ //
2595
+ // "Corrupt" covers BOTH a file that was read but would not parse and a
2596
+ // file that parsed to nothing at all (0 bytes / whitespace only). The
2597
+ // empty case used to be silently treated as "no file yet", which is the
2598
+ // exact shape that loses data: see readStoreFile.
2599
+ const unreadable = err instanceof SyntaxError || err.code === EMPTY_STORE_CODE
2600
+ if (unreadable) {
2601
+ // The file was READ, so a store file is physically present on disk.
2602
+ // Saying onDisk=no here contradicted the branch's own story ("损坏
2603
+ // 文件已保留") and produced a mounted log line reading
2604
+ // "from=memory.json.bak (main file corrupt) onDisk=no". (Other
2605
+ // non-ENOENT errors stay no: ENOTDIR, say, means nothing exists at
2606
+ // the store path at all.)
2223
2607
  state.storeExists = true
2608
+ const reason = err.code === EMPTY_STORE_CODE
2609
+ ? 'memory.json 为空(0 字节或只有空白)'
2610
+ : 'memory.json 解析失败(' + describeErrorSafe(err) + ')'
2224
2611
  try {
2225
2612
  const backup = await readStoreFile(storePath + '.bak')
2226
2613
  if (backup !== null) {
@@ -2232,7 +2619,7 @@ export function apply(ctx) {
2232
2619
  state.data = loaded.data
2233
2620
  const warnings = loadWarnings(loaded)
2234
2621
  state.storageWarning = (warnings !== null ? warnings + ';' : '')
2235
- + 'memory.json 解析失败(' + describeError(err) + '),已临时回退到最近备份 memory.json.bak;损坏文件已保留,下次保存时会覆盖它'
2622
+ + reason + ',已临时回退到最近备份 memory.json.bak;原文件已保留,下次保存时会覆盖它'
2236
2623
  state.loadedFrom = 'memory.json.bak (main file corrupt)'
2237
2624
  return false
2238
2625
  }
@@ -2241,9 +2628,11 @@ export function apply(ctx) {
2241
2628
  }
2242
2629
  }
2243
2630
  // A file we cannot understand must never be silently overwritten.
2244
- state.storageError = err instanceof SyntaxError
2245
- ? '配置文件解析失败(' + describeError(err) + '),原文件已保留;你下次保存时会覆盖它'
2246
- : '读取失败:' + describeError(err)
2631
+ state.storageError = unreadable
2632
+ ? (err.code === EMPTY_STORE_CODE
2633
+ ? '配置文件为空(0 字节或只有空白),原文件已保留;你下次保存时会覆盖它'
2634
+ : '配置文件解析失败(' + describeErrorSafe(err) + '),原文件已保留;你下次保存时会覆盖它')
2635
+ : '读取失败:' + describeErrorSafe(err)
2247
2636
  return false
2248
2637
  }
2249
2638
  }
@@ -2302,7 +2691,7 @@ export function apply(ctx) {
2302
2691
  }
2303
2692
  } catch (err) {
2304
2693
  // A missing or unreadable legacy file must never block a fresh store.
2305
- if (err instanceof SyntaxError) state.storageError = '旧位置文件解析失败,已忽略:' + describeError(err)
2694
+ if (err instanceof SyntaxError) state.storageError = '旧位置文件解析失败,已忽略:' + describeErrorSafe(err)
2306
2695
  }
2307
2696
 
2308
2697
  // 3. First run: there is no store anywhere. Deliberately write NOTHING.
@@ -2376,7 +2765,7 @@ export function apply(ctx) {
2376
2765
  let bootSettled = false
2377
2766
  const boot = readFromDisk()
2378
2767
  .catch((err) => {
2379
- state.storageError = '读取失败:' + describeError(err)
2768
+ state.storageError = '读取失败:' + describeErrorSafe(err)
2380
2769
  })
2381
2770
  .then(async () => {
2382
2771
  bootSettled = true
@@ -2490,7 +2879,7 @@ export function apply(ctx) {
2490
2879
  rev: state.data.rev || 0,
2491
2880
  },
2492
2881
  storage: {
2493
- path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
2882
+ path: displayPath(state.storageDisplay !== null ? state.storageDisplay : state.storagePath),
2494
2883
  error: state.storageError,
2495
2884
  warning: state.storageWarning !== null ? state.storageWarning : state.backupError,
2496
2885
  savedAt: state.savedAt,
@@ -2498,19 +2887,22 @@ export function apply(ctx) {
2498
2887
  /** false until the store file has actually been written (see readFromDisk step 3). */
2499
2888
  onDisk: state.storeExists,
2500
2889
  /** Backup location (F18): '' means "next to the store file". */
2501
- backupDir: backupDirOf(),
2890
+ backupDir: displayPath(backupDirOf()),
2502
2891
  isDefaultBackupDir: backupDirOf() === '',
2503
2892
  /** Where the rolling pre-write copy actually sits (diagnostics). */
2504
- backupPath: localBackupPath(),
2893
+ backupPath: displayPath(localBackupPath()),
2505
2894
  /** Version history (F14). */
2506
- historyDir: historyDirPath(),
2895
+ historyDir: displayPath(historyDirPath()),
2507
2896
  historyCount: state.historyCount,
2508
2897
  historyError: state.historyError,
2509
- schemaFile: join(dirname(storePath), SCHEMA_FILE_NAME),
2898
+ schemaFile: displayPath(join(dirname(storePath), SCHEMA_FILE_NAME)),
2510
2899
  },
2511
2900
  injection: {
2512
2901
  registered: state.sectionRegistered,
2513
- available: true,
2902
+ // `available` used to be hardcoded true, so a failed section()
2903
+ // registration published "available" next to an `error` string and the
2904
+ // panel had to guess. It now mirrors the real state.
2905
+ available: state.sectionError === null,
2514
2906
  error: state.sectionError,
2515
2907
  /**
2516
2908
  * F7: the pause is decided by the HOST clock (isMuted on the live
@@ -2825,6 +3217,16 @@ export function apply(ctx) {
2825
3217
  // is a de-escalation, refusing it would be user-hostile.
2826
3218
  let muteTarget = null
2827
3219
  let muteClamped = false
3220
+ // `disabledUntil: 0` (or any past instant) is "not paused", not a pause
3221
+ // request. Treating it as one made `{ enabled: true, disabledUntil: 0 }` —
3222
+ // the exact shape a caller sends to say "on, and make sure nothing is
3223
+ // paused" — hit the ambiguity guard below and be refused, even though both
3224
+ // halves agree.
3225
+ let muteClear = false
3226
+ // Whether the caller mentioned a pause at all, for the reply wording: a
3227
+ // past instant and an explicit 0 both mean "resume", which is reported only
3228
+ // when there was something to resume.
3229
+ let muteRequested = false
2828
3230
  if (patch !== null && typeof patch === 'object' && 'disabledUntil' in patch) {
2829
3231
  const rawMute = patch.disabledUntil
2830
3232
  if (typeof rawMute !== 'number' || !Number.isFinite(rawMute) || rawMute < 0) {
@@ -2835,6 +3237,7 @@ export function apply(ctx) {
2835
3237
  snapshot: snapshot(),
2836
3238
  }
2837
3239
  }
3240
+ muteRequested = true
2838
3241
  let wanted = Math.round(rawMute)
2839
3242
  if (wanted <= Date.now()) {
2840
3243
  wanted = 0
@@ -2842,7 +3245,21 @@ export function apply(ctx) {
2842
3245
  wanted = Date.now() + MAX_MUTE_MS
2843
3246
  muteClamped = true
2844
3247
  }
2845
- muteTarget = wanted
3248
+ if (wanted > 0) muteTarget = wanted
3249
+ else muteClear = true
3250
+ }
3251
+ // A patch carrying BOTH the master switch and a mute instant is ambiguous:
3252
+ // the switch is documented to clear any pause, so the commit below silently
3253
+ // dropped the pause the caller had just asked for and answered "saved".
3254
+ // Refuse the combination instead of guessing which half to honour.
3255
+ if (muteTarget !== null && patch !== null && typeof patch === 'object'
3256
+ && typeof patch.enabled === 'boolean') {
3257
+ return {
3258
+ ok: false,
3259
+ saved: false,
3260
+ message: '同一请求里不能同时设置总开关与暂停时间:请分开提交(切换总开关会自动清除暂停状态)',
3261
+ snapshot: snapshot(),
3262
+ }
2846
3263
  }
2847
3264
  const muteWasActive = isMuted(state.data)
2848
3265
  const saved = await commit(() => {
@@ -2857,6 +3274,7 @@ export function apply(ctx) {
2857
3274
  state.data.budgetChars = clampBudget(patch.budgetChars)
2858
3275
  }
2859
3276
  if (typeof patch.backupDir === 'string') state.data.backupDir = patch.backupDir.trim()
3277
+ if (muteClear) state.data.disabledUntil = 0
2860
3278
  if (muteTarget !== null && typeof patch.enabled !== 'boolean') {
2861
3279
  state.data.disabledUntil = muteTarget
2862
3280
  }
@@ -2871,8 +3289,8 @@ export function apply(ctx) {
2871
3289
  ? '已恢复默认备份位置(版本历史与导出与存储文件同目录)'
2872
3290
  : '备份位置已更新:' + wanted + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)')
2873
3291
  }
2874
- if (muteTarget !== null && !(patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean')) {
2875
- if (muteTarget === 0) {
3292
+ if (muteRequested && !(patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean')) {
3293
+ if (muteTarget === null) {
2876
3294
  if (muteWasActive) notes.push('已恢复注入。')
2877
3295
  } else {
2878
3296
  notes.push('已暂停注入至 ' + new Date(muteTarget).toLocaleString()
@@ -3166,8 +3584,32 @@ export function apply(ctx) {
3166
3584
  if (state.data.sets.length >= MAX_SETS) {
3167
3585
  return { ok: false, saved: false, message: '最多 ' + MAX_SETS + ' 个配置集', snapshot: snapshot() }
3168
3586
  }
3169
- const wanted = singleLine(typeof name === 'string' ? name : '', MAX_SET_NAME)
3170
- || singleLine(source.name + ' 副本', MAX_SET_NAME)
3587
+ // The derived name must not lose its " 副本" suffix to truncation: a
3588
+ // MAX_SET_NAME-long source name used to be cut AFTER the suffix was
3589
+ // appended, so the copy came back with the source's own name and the
3590
+ // same-name check below refused it with "已存在同名配置集" — a copy that
3591
+ // cannot be made and a message that does not explain why. Reserve the
3592
+ // suffix's room first, then number the copies when that name is taken.
3593
+ const derivedName = () => {
3594
+ const plain = ' 副本'
3595
+ const first = singleLine(source.name, MAX_SET_NAME - plain.length) + plain
3596
+ if (!state.data.sets.some((set) => set.name === first)) return first
3597
+ for (let index = 2; index <= 99; index += 1) {
3598
+ const tag = ' 副本' + index
3599
+ const candidate = singleLine(source.name, MAX_SET_NAME - tag.length) + tag
3600
+ if (!state.data.sets.some((set) => set.name === candidate)) return candidate
3601
+ }
3602
+ return null
3603
+ }
3604
+ const wanted = singleLine(typeof name === 'string' ? name : '', MAX_SET_NAME) || derivedName()
3605
+ if (wanted === null) {
3606
+ return {
3607
+ ok: false,
3608
+ saved: false,
3609
+ message: '无法为新配置集生成不重复的名称(同名「副本」已达 99 个):请手动指定名称',
3610
+ snapshot: snapshot(),
3611
+ }
3612
+ }
3171
3613
  if (state.data.sets.some((set) => set.name === wanted)) {
3172
3614
  return { ok: false, saved: false, message: '已存在同名配置集「' + wanted + '」', snapshot: snapshot() }
3173
3615
  }
@@ -3214,7 +3656,7 @@ export function apply(ctx) {
3214
3656
  } catch (err) {
3215
3657
  // A missing directory is not an error: nothing has been written yet.
3216
3658
  if (err === null || err === undefined || err.code !== 'ENOENT') {
3217
- return { ok: false, saved: false, message: '无法读取版本历史目录:' + describeError(err), snapshot: snapshot(), history: { dir, items: [] } }
3659
+ return { ok: false, saved: false, message: '无法读取版本历史目录:' + describeErrorSafe(err), snapshot: snapshot(), history: { dir, items: [] } }
3218
3660
  }
3219
3661
  }
3220
3662
  const items = []
@@ -3271,7 +3713,7 @@ export function apply(ctx) {
3271
3713
  try {
3272
3714
  parsed = JSON.parse(await readFile(join(historyDirPath(), name), 'utf8'))
3273
3715
  } catch (err) {
3274
- return { ok: false, saved: false, message: '无法读取该历史快照:' + describeError(err), snapshot: snapshot() }
3716
+ return { ok: false, saved: false, message: '无法读取该历史快照:' + describeErrorSafe(err), snapshot: snapshot() }
3275
3717
  }
3276
3718
  const adopted = adoptParsedStore(parsed)
3277
3719
  if (adopted.error !== undefined) {
@@ -3312,7 +3754,7 @@ export function apply(ctx) {
3312
3754
  try {
3313
3755
  await unlink(join(historyDirPath(), name))
3314
3756
  } catch (err) {
3315
- return { ok: false, saved: false, message: '删除历史快照失败:' + describeError(err), snapshot: snapshot() }
3757
+ return { ok: false, saved: false, message: '删除历史快照失败:' + describeErrorSafe(err), snapshot: snapshot() }
3316
3758
  }
3317
3759
  await refreshHistoryCount()
3318
3760
  return { ok: true, saved: false, message: '已删除该历史快照', snapshot: snapshot() }
@@ -3360,9 +3802,9 @@ export function apply(ctx) {
3360
3802
  const target = join(dir, exportFileName(Date.now()))
3361
3803
  try {
3362
3804
  await mkdir(dir, { recursive: true })
3363
- await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', 'utf8')
3805
+ await writeFile(target, JSON.stringify(exportEnvelope(), null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
3364
3806
  } catch (err) {
3365
- return { ok: false, saved: false, message: '写入备份目录失败:' + describeError(err), snapshot: snapshot() }
3807
+ return { ok: false, saved: false, message: '写入备份目录失败:' + describeErrorSafe(err), snapshot: snapshot() }
3366
3808
  }
3367
3809
  return { ok: true, saved: false, message: '已写入 ' + target, snapshot: snapshot() }
3368
3810
  })
@@ -3450,7 +3892,7 @@ export function apply(ctx) {
3450
3892
  try {
3451
3893
  parsed = JSON.parse(text)
3452
3894
  } catch (err) {
3453
- return { ok: false, saved: false, message: '源码不是有效的 JSON:' + describeError(err), snapshot: snapshot() }
3895
+ return { ok: false, saved: false, message: '源码不是有效的 JSON:' + describeErrorSafe(err), snapshot: snapshot() }
3454
3896
  }
3455
3897
  return commitAdoptedStore(parsed, '已按源码保存:')
3456
3898
  })
@@ -3767,12 +4209,12 @@ export function apply(ctx) {
3767
4209
  text = await readFile(path, 'utf8')
3768
4210
  } catch (err) {
3769
4211
  if (err !== null && typeof err === 'object' && err.code === 'ENOENT') return null
3770
- throw new Error('无法读取反馈配置文件 ' + path + ':' + describeError(err))
4212
+ throw new Error('无法读取反馈配置文件 ' + path + ':' + describeErrorSafe(err))
3771
4213
  }
3772
4214
  try {
3773
4215
  return JSON.parse(text)
3774
4216
  } catch (err) {
3775
- throw new Error('反馈配置文件不是有效的 JSON(' + path + '):' + describeError(err))
4217
+ throw new Error('反馈配置文件不是有效的 JSON(' + path + '):' + describeErrorSafe(err))
3776
4218
  }
3777
4219
  }
3778
4220
 
@@ -3830,8 +4272,8 @@ export function apply(ctx) {
3830
4272
  file = await readFeedbackConfigFile(configPath)
3831
4273
  } catch (err) {
3832
4274
  releaseReservation()
3833
- logInfo('feedback config unusable: ' + describeError(err))
3834
- return { ok: false, message: describeError(err) }
4275
+ logInfo('feedback config unusable: ' + describeErrorSafe(err))
4276
+ return { ok: false, message: describeErrorSafe(err) }
3835
4277
  }
3836
4278
  const readiness = feedbackConfigFrom({ env: process.env, file, configPath })
3837
4279
  if (!readiness.ok) {
@@ -3880,7 +4322,7 @@ export function apply(ctx) {
3880
4322
  // on; it never contains the password (see mailer.js), and the configured
3881
4323
  // mailboxes are masked, so logging it is safe and makes reports debuggable.
3882
4324
  releaseReservation()
3883
- const reason = maskAddresses(describeError(err))
4325
+ const reason = maskAddresses(describeErrorSafe(err))
3884
4326
  logInfo('feedback send failed: ' + reason)
3885
4327
  return { ok: false, message: '反馈发送失败:' + reason + '(详细原因已写入宿主日志)' }
3886
4328
  }
@@ -4032,6 +4474,18 @@ export function apply(ctx) {
4032
4474
  res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
4033
4475
  return
4034
4476
  }
4477
+ // Every subscriber holds a Map entry, a `req.on('close')` listener and a
4478
+ // live socket until it disconnects. A tab that is duplicated, a client that
4479
+ // never closes, or a script that opens streams in a loop would otherwise
4480
+ // grow that set without bound — the host process leaks memory and file
4481
+ // handles for as long as it runs. Refuse politely past the ceiling instead
4482
+ // of growing forever; the panel retries on its own schedule.
4483
+ if (sseStreams.size >= MAX_SSE_STREAMS) {
4484
+ logInfo('event stream refused: ' + sseStreams.size + ' streams already open')
4485
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
4486
+ res.end(JSON.stringify({ ok: false, message: '事件流连接数已达上限(' + MAX_SSE_STREAMS + '),请稍后重试' }))
4487
+ return
4488
+ }
4035
4489
  res.writeHead(200, {
4036
4490
  'content-type': 'text/event-stream; charset=utf-8',
4037
4491
  'cache-control': 'no-store',
@@ -4102,6 +4556,23 @@ export function apply(ctx) {
4102
4556
  res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
4103
4557
  return
4104
4558
  }
4559
+ // A request carrying TWO Host headers has no unambiguous origin: Node
4560
+ // exposes only the first in req.headers, while the proxy in front (or the
4561
+ // attacker's own client) may consider a different one authoritative. There
4562
+ // is no legitimate client of this route that sends two, so refuse rather
4563
+ // than guess which one the guard should believe.
4564
+ if (Array.isArray(req.rawHeaders)) {
4565
+ let hostCount = 0
4566
+ for (let index = 0; index < req.rawHeaders.length; index += 2) {
4567
+ if (String(req.rawHeaders[index]).toLowerCase() === 'host') hostCount += 1
4568
+ }
4569
+ if (hostCount > 1) {
4570
+ logInfo('route rejected a request with ' + hostCount + ' Host headers')
4571
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
4572
+ res.end(JSON.stringify({ ok: false, message: '请求带有多个 Host 头,已拒绝' }))
4573
+ return
4574
+ }
4575
+ }
4105
4576
  // Origin/Host guard, before anything else is read. See
4106
4577
  // requestOriginVerdict() for why the content-type rule below is not enough
4107
4578
  // on its own.
@@ -4116,10 +4587,15 @@ export function apply(ctx) {
4116
4587
  // the anti-CSRF coincidence (a plain HTML form cannot fabricate a JSON
4117
4588
  // body) into a hard rule: a cross-site or malformed post now gets a
4118
4589
  // readable 415 instead of a confusing parse failure.
4590
+ //
4591
+ // The match is on the MEDIA TYPE alone, exactly. A substring test let
4592
+ // `text/plain; note=application/json` through — and `text/plain` is a CORS
4593
+ // "simple" content type, so that one spelling bypasses the preflight that
4594
+ // the whole rule exists to rely on.
4119
4595
  const contentType = String(
4120
4596
  (req.headers !== null && typeof req.headers === 'object') ? (req.headers['content-type'] || '') : '',
4121
4597
  )
4122
- if (contentType.indexOf('application/json') === -1) {
4598
+ if (mediaTypeOf(contentType) !== 'application/json') {
4123
4599
  res.writeHead(415, { 'content-type': 'application/json; charset=utf-8' })
4124
4600
  res.end(JSON.stringify({ ok: false, message: 'content-type 必须是 application/json' }))
4125
4601
  return
@@ -4130,10 +4606,24 @@ export function apply(ctx) {
4130
4606
  // the whole host process down over one dead HTTP request.
4131
4607
  req.on('error', () => {})
4132
4608
  res.on('error', () => {})
4133
- let body = ''
4609
+ // A declared length far over the ceiling is refused before a single chunk
4610
+ // is buffered. The ceiling is counted in CHARACTERS, so a UTF-8 byte length
4611
+ // above 4× it cannot fit; buffering first and checking later is exactly the
4612
+ // memory cost this guard exists to avoid (~6 MB per concurrent request).
4613
+ const declaredLength = Number.parseInt(String(
4614
+ (req.headers !== null && typeof req.headers === 'object' ? req.headers['content-length'] : '') || '',
4615
+ ), 10)
4616
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_CHARS * 4) {
4617
+ res.writeHead(413, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', connection: 'close' })
4618
+ res.end(JSON.stringify({ ok: false, message: '请求体超过 ' + MAX_BODY_CHARS + ' 字符上限' }))
4619
+ // Drain the rest so the client sees the 413 rather than a reset socket.
4620
+ req.resume()
4621
+ return
4622
+ }
4134
4623
  // Over the ceiling we stop accumulating but keep draining, so the
4135
4624
  // response can go out as a clean 413 instead of a destroyed socket the
4136
4625
  // Client can only report as a generic network failure.
4626
+ let body = ''
4137
4627
  let overflow = false
4138
4628
  req.setEncoding('utf8')
4139
4629
  req.on('data', (chunk) => {
@@ -4156,7 +4646,7 @@ export function apply(ctx) {
4156
4646
  const payload = body === '' ? {} : JSON.parse(body)
4157
4647
  result = await dispatch(payload.method, payload.args === undefined ? null : payload.args)
4158
4648
  } catch (err) {
4159
- result = { ok: false, saved: false, message: describeError(err), snapshot: snapshot() }
4649
+ result = { ok: false, saved: false, message: describeErrorSafe(err), snapshot: snapshot() }
4160
4650
  }
4161
4651
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
4162
4652
  res.end(JSON.stringify(result))
@@ -4190,6 +4680,20 @@ export function apply(ctx) {
4190
4680
  // F12: the settings page can only warn about a non-loopback bind if the
4191
4681
  // snapshot carries what the route actually saw at registration time.
4192
4682
  state.boundHost = boundHost
4683
+ // This callback runs again whenever the service is published anew (host
4684
+ // restart, fiber remount). Registering on top of the previous registration
4685
+ // would LEAK it: the old handler stays mounted and keeps answering from the
4686
+ // state it closed over, while `routeDispose` only remembers the newest one.
4687
+ // Drop the previous registration first — newest wins.
4688
+ if (routeDispose !== null) {
4689
+ const previous = routeDispose
4690
+ routeDispose = null
4691
+ try {
4692
+ previous()
4693
+ } catch (err) {
4694
+ console.error('[instruction-memory] previous route dispose failed:', err)
4695
+ }
4696
+ }
4193
4697
  try {
4194
4698
  routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
4195
4699
  logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')