dsh-instruction-memory 1.3.3 → 1.4.1

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
@@ -26,12 +26,21 @@
26
26
  */
27
27
 
28
28
  import { copyFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
29
- import { watch } from 'node:fs'
29
+ 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 } from 'node:path'
33
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
+ import {
36
+ FEEDBACK_ENV,
37
+ FEEDBACK_LIMITS,
38
+ buildFeedbackMessage,
39
+ feedbackCooldownMs,
40
+ feedbackConfigFrom,
41
+ normalizeFeedback,
42
+ sendSmtpMail,
43
+ } from './mailer.js'
35
44
 
36
45
  export const name = 'instruction-memory'
37
46
 
@@ -84,6 +93,15 @@ const MAX_SETS = 20
84
93
  const MAX_SET_NAME = 30
85
94
  /** Rolling history snapshots (F14): how many are kept PER writing device. */
86
95
  const MAX_HISTORY = 20
96
+ /**
97
+ * Byte ceiling for one device's snapshots (F14). The count alone bounded
98
+ * nothing in practice: at the 200 × 6,000-character cap a single snapshot is
99
+ * ~3.6 MB, so twenty of them were ~72 MB per device — and a custom backup
100
+ * directory inside a cloud-synced folder meant that much upload traffic for
101
+ * one checkbox toggle. Newest snapshots win; the newest is always kept even
102
+ * when it alone exceeds the budget.
103
+ */
104
+ const MAX_HISTORY_BYTES = 32 * 1024 * 1024
87
105
  /**
88
106
  * F7: the longest single pause. Doubles as the setTimeout guard — 7 days is
89
107
  * far below the 2^31-1 ms (≈24.8 days) timer ceiling, so the delay never
@@ -121,6 +139,31 @@ const BACKUP_FILE_SUFFIX = '.bak'
121
139
  */
122
140
  const SECTION_ORDER = 950
123
141
  const API_PREFIX = '/instruction-memory/api'
142
+ /**
143
+ * Feedback mail: the optional config file next to the store. Kept OUT of
144
+ * the repository on purpose — the operator's SMTP credentials must never be
145
+ * committed, and a published package must work with the environment variables
146
+ * alone.
147
+ */
148
+ const FEEDBACK_CONFIG_FILE_NAME = 'feedback.json'
149
+
150
+ /**
151
+ * The plugin version reported in a feedback mail. Read from the package rather
152
+ * than duplicated here: a hard-coded copy would drift from package.json on the
153
+ * first release and quietly mislabel every report.
154
+ *
155
+ * `../package.json` resolves inside an installed package because npm always
156
+ * ships package.json next to lib/. A broken install must not take the plugin
157
+ * down, so an unreadable manifest degrades to '未知'.
158
+ */
159
+ const PLUGIN_VERSION = (() => {
160
+ try {
161
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
162
+ return typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : '未知'
163
+ } catch {
164
+ return '未知'
165
+ }
166
+ })()
124
167
 
125
168
  /**
126
169
  * Resolve this plugin's data file under the harness home.
@@ -131,7 +174,14 @@ const API_PREFIX = '/instruction-memory/api'
131
174
  */
132
175
  export function resolveStorePath(env = process.env, home = homedir()) {
133
176
  const configured = typeof env.DSH_HOME === 'string' ? env.DSH_HOME.trim() : ''
134
- const root = configured !== '' ? configured : join(home, '.dsh')
177
+ // A relative override (`DSH_HOME=./data`) is resolved ONCE against the
178
+ // process cwd. Without this the store path stayed relative for the whole
179
+ // process: the settings page promised an absolute path (README), the
180
+ // watcher's dirname() became '.', and the "same file" identity depended on
181
+ // how the path was spelled. Resolving does not make a relative override
182
+ // cwd-independent — it cannot be — but it pins one absolute location for
183
+ // this process and keeps every displayed path absolute.
184
+ const root = configured !== '' ? resolve(configured) : join(home, '.dsh')
135
185
  return join(root, STORE_DIR_NAME, STORE_FILE_NAME)
136
186
  }
137
187
 
@@ -204,6 +254,10 @@ export const LIMITS = Object.freeze({
204
254
  minBudget: MIN_BUDGET,
205
255
  maxBudget: MAX_BUDGET,
206
256
  maxHistory: MAX_HISTORY,
257
+ /** Feedback form ceilings, enforced by lib/mailer.js and mirrored in the panel. */
258
+ maxFeedbackTitle: FEEDBACK_LIMITS.maxTitle,
259
+ maxFeedbackBody: FEEDBACK_LIMITS.maxBody,
260
+ maxFeedbackContact: FEEDBACK_LIMITS.maxContact,
207
261
  })
208
262
 
209
263
  /** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
@@ -474,6 +528,41 @@ export function exportFileName(epoch) {
474
528
  return 'dsh-instruction-memory-' + stamp.slice(0, 8) + '-' + stamp.slice(9, 15) + 'Z.json'
475
529
  }
476
530
 
531
+ /**
532
+ * Which snapshots survive retention, for ONE writing device.
533
+ *
534
+ * Two ceilings apply together: the count (MAX_HISTORY) and a byte budget
535
+ * (MAX_HISTORY_BYTES). Counting alone bounded nothing at the top end — see the
536
+ * constant's note — while a byte budget alone would let a flood of tiny saves
537
+ * grow the directory without limit. The newest snapshot is always kept, even
538
+ * when it alone exceeds the byte budget: a history that prunes its own latest
539
+ * version is worse than a large one.
540
+ *
541
+ * Pure and exported so the policy is unit-testable without writing 32 MB.
542
+ *
543
+ * @param {{ name: string, size: number }[]} items ascending by name (chronological)
544
+ * @returns {{ keep: string[], remove: string[] }} `remove` is oldest-first
545
+ */
546
+ export function planHistoryPrune(items, maxCount = MAX_HISTORY, maxBytes = MAX_HISTORY_BYTES) {
547
+ const list = (Array.isArray(items) ? items : [])
548
+ .filter((item) => item !== null && typeof item === 'object' && typeof item.name === 'string' && item.name !== '')
549
+ const keep = []
550
+ let bytes = 0
551
+ for (let index = list.length - 1; index >= 0; index -= 1) {
552
+ const size = Number.isFinite(list[index].size) ? Math.max(0, list[index].size) : 0
553
+ // keep.length === 0 keeps the newest unconditionally (see above).
554
+ if (keep.length === 0 || (keep.length < maxCount && bytes + size <= maxBytes)) {
555
+ keep.push(list[index].name)
556
+ bytes += size
557
+ }
558
+ }
559
+ const kept = new Set(keep)
560
+ return {
561
+ keep,
562
+ remove: list.filter((item) => !kept.has(item.name)).map((item) => item.name),
563
+ }
564
+ }
565
+
477
566
  /**
478
567
  * Which fields of a raw entry had to be cut to fit the stored limits.
479
568
  *
@@ -710,6 +799,28 @@ function normalizeData(raw) {
710
799
  return loaded
711
800
  }
712
801
 
802
+ /**
803
+ * The scalar half of the store shape, in ONE place.
804
+ *
805
+ * The boot state, normalizeData, persistableData, cloneData and snapshot() all
806
+ * spell these fields out. A new field added to four of them and forgotten in
807
+ * the fifth is dropped on save, lost on rollback, or invisible to the panel —
808
+ * the boot state's own comment already warns about exactly that drift. The
809
+ * array halves stay explicit because each caller transforms them differently
810
+ * (copy, filter to the active set, or map to {id,name}).
811
+ */
812
+ const storeScalars = (data) => ({
813
+ version: FORMAT_VERSION,
814
+ rev: typeof data.rev === 'number' && Number.isFinite(data.rev) && data.rev > 0 ? data.rev : 0,
815
+ enabled: data.enabled === true,
816
+ disabledUntil: typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil) && data.disabledUntil > 0
817
+ ? data.disabledUntil
818
+ : 0,
819
+ budgetChars: clampBudget(data.budgetChars),
820
+ backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
821
+ activeSetId: data.activeSetId,
822
+ })
823
+
713
824
  /**
714
825
  * A structural copy of the store, shallow per entry.
715
826
  *
@@ -721,15 +832,7 @@ function normalizeData(raw) {
721
832
  */
722
833
  function cloneData(data) {
723
834
  return {
724
- version: FORMAT_VERSION,
725
- rev: data.rev || 0,
726
- enabled: data.enabled === true,
727
- disabledUntil: typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil) && data.disabledUntil > 0
728
- ? data.disabledUntil
729
- : 0,
730
- budgetChars: data.budgetChars,
731
- backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
732
- activeSetId: data.activeSetId,
835
+ ...storeScalars(data),
733
836
  sets: (Array.isArray(data.sets) ? data.sets : []).map((set) => ({ ...set })),
734
837
  entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
735
838
  profiles: (Array.isArray(data.profiles) ? data.profiles : []).map((profile) => ({ ...profile })),
@@ -1252,17 +1355,19 @@ export function apply(ctx) {
1252
1355
 
1253
1356
  const state = {
1254
1357
  data: {
1255
- version: FORMAT_VERSION,
1256
- rev: 0,
1257
- enabled: true,
1258
- // Must match normalizeData's output shape KEY FOR KEY: the watcher's
1259
- // self-write detection compares JSON strings, and a missing key here
1260
- // made every fresh process's first save look like an external change
1261
- // (one redundant reload + frame).
1262
- disabledUntil: 0,
1263
- budgetChars: DEFAULT_BUDGET,
1264
- backupDir: '',
1265
- activeSetId: DEFAULT_SET_ID,
1358
+ // Scalars come from the shared shape (see storeScalars) so this literal
1359
+ // cannot drift from normalizeData / persistableData / snapshot. The key
1360
+ // ORDER still matters: the watcher's self-write detection compares JSON
1361
+ // strings, and a missing or reordered key made every fresh process's
1362
+ // first save look like an external change (one redundant reload + frame).
1363
+ ...storeScalars({
1364
+ rev: 0,
1365
+ enabled: true,
1366
+ disabledUntil: 0,
1367
+ budgetChars: DEFAULT_BUDGET,
1368
+ backupDir: '',
1369
+ activeSetId: DEFAULT_SET_ID,
1370
+ }),
1266
1371
  sets: [{ id: DEFAULT_SET_ID, name: DEFAULT_SET_NAME }],
1267
1372
  entries: [],
1268
1373
  profiles: [],
@@ -1487,7 +1592,20 @@ export function apply(ctx) {
1487
1592
  const wanted = sectionText() !== ''
1488
1593
  if (wanted && sectionDispose === null) {
1489
1594
  try {
1490
- sectionDispose = systemPrompt.section({ name: SECTION_NAME, order: SECTION_ORDER, text: assembledSectionText })
1595
+ // `interpolate: false` is load-bearing, not a hint. The host runs every
1596
+ // section through `{{variable}}` interpolation by DEFAULT and THROWS on
1597
+ // a reference it cannot resolve (`unknown prompt variable "{{x}}" in
1598
+ // section "instruction-memory"`). Instruction memory is user prose — an
1599
+ // nginx snippet (`proxy_pass {{upstream}};`), a Vue/Jinja/Handlebars
1600
+ // template or any `{{…}}` pair — so the default would abort the whole
1601
+ // agent turn at renderPrompt(). This section is literal text and never
1602
+ // references a host variable, so opt out explicitly.
1603
+ sectionDispose = systemPrompt.section({
1604
+ name: SECTION_NAME,
1605
+ order: SECTION_ORDER,
1606
+ text: assembledSectionText,
1607
+ interpolate: false,
1608
+ })
1491
1609
  state.sectionRegistered = true
1492
1610
  state.sectionError = null
1493
1611
  logDebug('prompt section registered: order=' + SECTION_ORDER + ' chars=' + sectionText().length)
@@ -1538,10 +1656,12 @@ export function apply(ctx) {
1538
1656
  /* ---------------- external-change watching (SSE) ---------------- */
1539
1657
 
1540
1658
  /**
1541
- * Live subscribers to the settings page's event stream. Each entry maps a
1542
- * stream's `send(payload)` writer to its own teardown, so a write that fails
1543
- * (the socket went away) can dispose THAT stream instead of just dropping
1544
- * it from the broadcast set while its response stays open.
1659
+ * Live subscribers to the settings page's event stream. Each entry is
1660
+ * `{ cleanup, lite }`: `cleanup` is that stream's own teardown, so a write
1661
+ * that fails (the socket went away) can dispose THAT stream instead of just
1662
+ * dropping it from the broadcast set while its response stays open. `lite`
1663
+ * records whether the subscriber asked for lightweight frames (see
1664
+ * pushSnapshot).
1545
1665
  */
1546
1666
  const sseStreams = new Map()
1547
1667
  let storeWatcher = null
@@ -1549,14 +1669,25 @@ export function apply(ctx) {
1549
1669
 
1550
1670
  const pushSnapshot = () => {
1551
1671
  if (disposed || sseStreams.size === 0) return
1552
- const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
1553
- for (const [send, cleanup] of Array.from(sseStreams)) {
1672
+ // A lite stream only needs to know that the store moved: it fetches the
1673
+ // snapshot through POST `state` when the rev it sees differs from the one
1674
+ // on screen. The full frame is built LAZILY, so a page whose subscribers
1675
+ // are all lite (the current client) never pays for serialising the whole
1676
+ // store — up to ~3.4 MB at the 200 × 6,000-character cap — on every save.
1677
+ const litePayload = 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
1678
+ let fullPayload = null
1679
+ for (const [send, entry] of Array.from(sseStreams)) {
1554
1680
  try {
1555
- send(payload)
1681
+ if (entry.lite === true) {
1682
+ send(litePayload)
1683
+ } else {
1684
+ if (fullPayload === null) fullPayload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
1685
+ send(fullPayload)
1686
+ }
1556
1687
  } catch {
1557
1688
  // The response is dead: run its full teardown (end + unsubscribe),
1558
1689
  // not just a set delete that leaves the stream half-registered.
1559
- cleanup()
1690
+ entry.cleanup()
1560
1691
  }
1561
1692
  }
1562
1693
  }
@@ -1636,9 +1767,9 @@ export function apply(ctx) {
1636
1767
  }
1637
1768
 
1638
1769
  const disposeStreams = () => {
1639
- for (const cleanup of Array.from(sseStreams.values())) {
1770
+ for (const entry of Array.from(sseStreams.values())) {
1640
1771
  try {
1641
- cleanup()
1772
+ entry.cleanup()
1642
1773
  } catch {
1643
1774
  // The response is already gone.
1644
1775
  }
@@ -1660,15 +1791,7 @@ export function apply(ctx) {
1660
1791
  */
1661
1792
  const persistableData = () => ({
1662
1793
  $schema: SCHEMA_REF,
1663
- version: FORMAT_VERSION,
1664
- rev: state.data.rev || 0,
1665
- enabled: state.data.enabled === true,
1666
- disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
1667
- ? state.data.disabledUntil
1668
- : 0,
1669
- budgetChars: clampBudget(state.data.budgetChars),
1670
- backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
1671
- activeSetId: state.data.activeSetId,
1794
+ ...storeScalars(state.data),
1672
1795
  sets: (Array.isArray(state.data.sets) ? state.data.sets : []).map((set) => ({ ...set })),
1673
1796
  entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
1674
1797
  profiles: (Array.isArray(state.data.profiles) ? state.data.profiles : []).map((profile) => ({ ...profile })),
@@ -1728,6 +1851,14 @@ export function apply(ctx) {
1728
1851
  const token = randomUUID()
1729
1852
  const deadline = Date.now() + LOCK_WAIT_MS
1730
1853
  for (;;) {
1854
+ // The deadline is checked at the TOP of every attempt, not only on the
1855
+ // "someone else holds it" branch. It used to sit below the stale-lock
1856
+ // branch, which `continue`d after a failed unlink — so a stale marker
1857
+ // that could not be deleted (a Windows handle without delete sharing, a
1858
+ // read-only volume) spun here forever, and every later disk operation
1859
+ // queued behind a save that would never return. Waiting is always
1860
+ // bounded; proceeding unlocked is the documented fallback.
1861
+ if (Date.now() >= deadline) return null
1731
1862
  try {
1732
1863
  await writeFile(lockPath, token, { flag: 'wx' })
1733
1864
  return { path: lockPath, token }
@@ -1738,10 +1869,10 @@ export function apply(ctx) {
1738
1869
  // Crashed-holder debris. It may be recreated by a live process
1739
1870
  // between this unlink and the next wx attempt — that case simply
1740
1871
  // loops and re-evaluates freshness instead of forcing its way in.
1872
+ // A failed unlink must not become a tight loop, so every attempt
1873
+ // still sleeps below.
1741
1874
  await unlink(lockPath).catch(() => {})
1742
- continue
1743
1875
  }
1744
- if (Date.now() >= deadline) return null
1745
1876
  await new Promise((resolve) => setTimeout(resolve, 50))
1746
1877
  }
1747
1878
  }
@@ -1807,11 +1938,14 @@ export function apply(ctx) {
1807
1938
  let lastHistoryText = null
1808
1939
 
1809
1940
  /**
1810
- * Enforce the per-device retention limit. Grouping by the device tag inside
1811
- * the filename is deliberate: two machines sharing one backup folder (the
1812
- * documented F18 setup) each keep MAX_HISTORY of their OWN snapshots, so one
1813
- * device can never prune the other's history away. Files that do not match
1814
- * our naming pattern are never touched.
1941
+ * Enforce the per-device retention policy: the count and byte ceilings from
1942
+ * planHistoryPrune(). Grouping by the device tag inside the filename is
1943
+ * deliberate: two machines sharing one backup folder (the documented F18
1944
+ * setup) each keep their OWN budget, so one device can never prune the
1945
+ * other's history away. Files that do not match our naming pattern are never
1946
+ * touched. Sizes come from stat() rather than a maintained index because the
1947
+ * directory is shared with other processes and a stale index would prune the
1948
+ * wrong files.
1815
1949
  */
1816
1950
  const pruneHistory = async (dir) => {
1817
1951
  const names = (await readdir(dir)).filter((name) => HISTORY_FILE_PATTERN.test(name))
@@ -1827,15 +1961,17 @@ export function apply(ctx) {
1827
1961
  for (const bucket of byDevice.values()) {
1828
1962
  // Fixed-width UTC stamp first => lexicographic order is chronological.
1829
1963
  bucket.sort()
1830
- const excess = bucket.length - MAX_HISTORY
1831
- for (let index = 0; index < bucket.length; index += 1) {
1832
- if (index < excess) {
1833
- await unlink(join(dir, bucket[index])).catch(() => {})
1834
- removed += 1
1835
- } else {
1836
- kept += 1
1837
- }
1964
+ const sized = []
1965
+ for (const name of bucket) {
1966
+ const info = await stat(join(dir, name)).catch(() => null)
1967
+ sized.push({ name, size: info === null ? 0 : info.size })
1968
+ }
1969
+ const plan = planHistoryPrune(sized)
1970
+ for (const name of plan.remove) {
1971
+ await unlink(join(dir, name)).catch(() => {})
1972
+ removed += 1
1838
1973
  }
1974
+ kept += plan.keep.length
1839
1975
  }
1840
1976
  return { removed, kept }
1841
1977
  }
@@ -2308,15 +2444,7 @@ export function apply(ctx) {
2308
2444
  const activeProfiles = state.data.profiles.filter((profile) => inScope(profile, scope))
2309
2445
  return {
2310
2446
  data: {
2311
- version: FORMAT_VERSION,
2312
- rev: state.data.rev || 0,
2313
- enabled: state.data.enabled === true,
2314
- disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
2315
- ? state.data.disabledUntil
2316
- : 0,
2317
- budgetChars: clampBudget(state.data.budgetChars),
2318
- backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
2319
- activeSetId: state.data.activeSetId,
2447
+ ...storeScalars(state.data),
2320
2448
  sets: state.data.sets.map((set) => ({ id: set.id, name: set.name })),
2321
2449
  entries: activeEntries.map((entry) => ({ ...entry })),
2322
2450
  profiles: activeProfiles.map((profile) => ({ ...profile })),
@@ -2428,6 +2556,16 @@ export function apply(ctx) {
2428
2556
  minBudget: MIN_BUDGET,
2429
2557
  maxBudget: MAX_BUDGET,
2430
2558
  maxEntries: MAX_ENTRIES,
2559
+ // Per-field ceilings. The editor used to hard-code 120/6000/200 and
2560
+ // 8/24 for tags, so changing a limit here left the panel silently
2561
+ // disagreeing with the Host (it would accept text the loader then
2562
+ // truncated, with a note the user could not have predicted). Every
2563
+ // limit the panel enforces is published here instead.
2564
+ maxTitle: MAX_TITLE,
2565
+ maxContent: MAX_CONTENT,
2566
+ maxWhen: MAX_WHEN,
2567
+ maxTagsPerEntry: MAX_TAGS_PER_ENTRY,
2568
+ maxTagLength: MAX_TAG_LENGTH,
2431
2569
  maxProfiles: MAX_PROFILES,
2432
2570
  maxProfileName: MAX_PROFILE_NAME,
2433
2571
  maxProfileContent: MAX_PROFILE_CONTENT,
@@ -2436,6 +2574,9 @@ export function apply(ctx) {
2436
2574
  maxHistory: MAX_HISTORY,
2437
2575
  maxBackupDir: MAX_BACKUP_DIR,
2438
2576
  maxBodyChars: MAX_BODY_CHARS,
2577
+ maxFeedbackTitle: FEEDBACK_LIMITS.maxTitle,
2578
+ maxFeedbackBody: FEEDBACK_LIMITS.maxBody,
2579
+ maxFeedbackContact: FEEDBACK_LIMITS.maxContact,
2439
2580
  },
2440
2581
  }
2441
2582
  }
@@ -3590,6 +3731,161 @@ export function apply(ctx) {
3590
3731
  return settle('导入完成:' + parts.join(';'), saved)
3591
3732
  })
3592
3733
 
3734
+ /* ---------------- feedback mail ---------------- */
3735
+
3736
+ /**
3737
+ * Wall-clock of the last accepted send. Anti-spam, not a security boundary:
3738
+ * the route is reachable from every same-origin page of the host, and on a
3739
+ * non-loopback bind from the whole LAN. One message per cooldown is enough
3740
+ * for a human reporting a problem and stops a double submit from sending two
3741
+ * mails. The recipient is fixed by configuration, so the route can never be
3742
+ * used to mail a third party.
3743
+ */
3744
+ let feedbackLastSentAt = 0
3745
+
3746
+ /**
3747
+ * Where the optional config file lives. `DSH_INSTRUCTION_MEMORY_FEEDBACK_CONFIG`
3748
+ * wins; otherwise the file sits next to the store (`<DSH_HOME>/instruction-memory/
3749
+ * feedback.json`), outside the repository and outside the published package.
3750
+ */
3751
+ const feedbackConfigPath = () => {
3752
+ const configured = typeof process.env[FEEDBACK_ENV.config] === 'string'
3753
+ ? process.env[FEEDBACK_ENV.config].trim()
3754
+ : ''
3755
+ return configured !== '' ? configured : join(dirname(resolveStorePath()), FEEDBACK_CONFIG_FILE_NAME)
3756
+ }
3757
+
3758
+ /**
3759
+ * Read the optional config file. A missing file is normal (environment-only
3760
+ * setups); an unreadable or malformed one is reported instead of being
3761
+ * silently ignored, because silently falling back to the environment is how a
3762
+ * typo sends mail from the wrong account.
3763
+ */
3764
+ const readFeedbackConfigFile = async (path) => {
3765
+ let text
3766
+ try {
3767
+ text = await readFile(path, 'utf8')
3768
+ } catch (err) {
3769
+ if (err !== null && typeof err === 'object' && err.code === 'ENOENT') return null
3770
+ throw new Error('无法读取反馈配置文件 ' + path + ':' + describeError(err))
3771
+ }
3772
+ try {
3773
+ return JSON.parse(text)
3774
+ } catch (err) {
3775
+ throw new Error('反馈配置文件不是有效的 JSON(' + path + '):' + describeError(err))
3776
+ }
3777
+ }
3778
+
3779
+ /**
3780
+ * The environment block of a report. Versions and platform come from the Host
3781
+ * (authoritative); locale / time zone / user agent are presentation facts the
3782
+ * panel reports, sanitised again inside the mailer before they reach a header
3783
+ * or the body. Instruction content is deliberately absent.
3784
+ */
3785
+ const feedbackEnvironment = (args) => {
3786
+ const field = (value) => (typeof value === 'string' ? value : '')
3787
+ return {
3788
+ pluginVersion: PLUGIN_VERSION,
3789
+ dshVersion: field(process.env.DSH_VERSION) || field(process.env.DSH_RUNTIME_VERSION),
3790
+ node: process.version,
3791
+ platform: process.platform,
3792
+ arch: process.arch,
3793
+ locale: field(args.locale),
3794
+ timezone: field(args.timezone),
3795
+ userAgent: field(args.userAgent),
3796
+ }
3797
+ }
3798
+
3799
+ /**
3800
+ * `send-feedback` handler. Deliberately returns a small `{ ok, message }`
3801
+ * envelope instead of the usual snapshot one: a send changes no persisted
3802
+ * state, and handing the panel back its own snapshot would make the form
3803
+ * absorb (and re-sync) the whole panel for nothing.
3804
+ */
3805
+ const sendFeedback = async (args) => {
3806
+ const normalized = normalizeFeedback(args)
3807
+ if (!normalized.ok) return { ok: false, message: normalized.message }
3808
+
3809
+ const cooldown = feedbackCooldownMs(process.env)
3810
+ const now = Date.now()
3811
+ if (cooldown > 0 && feedbackLastSentAt > 0 && now - feedbackLastSentAt < cooldown) {
3812
+ const waitSeconds = Math.max(1, Math.ceil((cooldown - (now - feedbackLastSentAt)) / 1000))
3813
+ return { ok: false, message: '反馈发送过于频繁,请 ' + waitSeconds + ' 秒后再试' }
3814
+ }
3815
+
3816
+ // Reserve the window BEFORE the awaits below. Checking the timestamp here and
3817
+ // writing it only after a successful send straddled them: two concurrent
3818
+ // requests both passed the check and both delivered. A send that never
3819
+ // happens releases the reservation, so a genuine failure can be retried at
3820
+ // once.
3821
+ const previousSentAt = feedbackLastSentAt
3822
+ feedbackLastSentAt = now
3823
+ const releaseReservation = () => {
3824
+ if (feedbackLastSentAt === now) feedbackLastSentAt = previousSentAt
3825
+ }
3826
+
3827
+ const configPath = feedbackConfigPath()
3828
+ let file = null
3829
+ try {
3830
+ file = await readFeedbackConfigFile(configPath)
3831
+ } catch (err) {
3832
+ releaseReservation()
3833
+ logInfo('feedback config unusable: ' + describeError(err))
3834
+ return { ok: false, message: describeError(err) }
3835
+ }
3836
+ const readiness = feedbackConfigFrom({ env: process.env, file, configPath })
3837
+ if (!readiness.ok) {
3838
+ releaseReservation()
3839
+ logDebug('feedback not configured: ' + readiness.message)
3840
+ return { ok: false, message: readiness.message }
3841
+ }
3842
+
3843
+ const feedback = normalized.value
3844
+ const message = buildFeedbackMessage({
3845
+ feedback,
3846
+ environment: feedbackEnvironment(args),
3847
+ from: readiness.config.from,
3848
+ to: readiness.config.to,
3849
+ now: new Date(),
3850
+ })
3851
+
3852
+ // SMTP servers sometimes echo the mailbox inside a rejection ("550 no such
3853
+ // user <addr>"). The panel never needs to show the maintainer's address
3854
+ // back, so both UI text and the log keep it masked.
3855
+ const maskAddresses = (text) => {
3856
+ let out = String(text)
3857
+ // The SMTP account is masked too: a relay that differs from From/To is
3858
+ // echoed by some servers inside their rejection text.
3859
+ for (const address of [readiness.config.to, readiness.config.from, readiness.config.smtp.user]) {
3860
+ if (typeof address === 'string' && address !== '') out = out.split(address).join('[邮箱已隐藏]')
3861
+ }
3862
+ return out
3863
+ }
3864
+
3865
+ try {
3866
+ const sent = await sendSmtpMail({
3867
+ smtp: readiness.config.smtp,
3868
+ from: readiness.config.from,
3869
+ to: readiness.config.to,
3870
+ subject: message.subject,
3871
+ text: message.text,
3872
+ messageId: message.messageId,
3873
+ replyTo: message.replyTo,
3874
+ })
3875
+ feedbackLastSentAt = Date.now()
3876
+ logInfo('feedback sent: type=' + feedback.type + ' id=' + sent.messageId)
3877
+ return { ok: true, message: '反馈邮件已发送,感谢你的反馈!', messageId: sent.messageId }
3878
+ } catch (err) {
3879
+ // The failure reason is the only actionable thing a maintainer can act
3880
+ // on; it never contains the password (see mailer.js), and the configured
3881
+ // mailboxes are masked, so logging it is safe and makes reports debuggable.
3882
+ releaseReservation()
3883
+ const reason = maskAddresses(describeError(err))
3884
+ logInfo('feedback send failed: ' + reason)
3885
+ return { ok: false, message: '反馈发送失败:' + reason + '(详细原因已写入宿主日志)' }
3886
+ }
3887
+ }
3888
+
3593
3889
  const dispatch = async (method, args) => {
3594
3890
  await boot
3595
3891
  switch (method) {
@@ -3663,6 +3959,8 @@ export function apply(ctx) {
3663
3959
  return importData(
3664
3960
  args !== null && typeof args === 'object' ? args.payload : null,
3665
3961
  args !== null && typeof args === 'object' ? args.baseRev : undefined)
3962
+ case 'send-feedback':
3963
+ return sendFeedback(args)
3666
3964
  case 'batch':
3667
3965
  return batchEntries(
3668
3966
  args !== null && typeof args === 'object' ? args.op : null,
@@ -3689,6 +3987,30 @@ export function apply(ctx) {
3689
3987
 
3690
3988
  let routeDispose = null
3691
3989
 
3990
+ /**
3991
+ * Does this stream subscriber want lightweight frames?
3992
+ *
3993
+ * `?lite=1` is additive on purpose: a client that does not ask (every build
3994
+ * before this one) keeps receiving the bare snapshot exactly as documented,
3995
+ * so an old client against a new Host is unchanged. A lite subscriber gets
3996
+ * `{ rev, lite: true }` and fetches the snapshot itself when the rev differs
3997
+ * — which is the difference between pushing ~3.4 MB per save (maximal store)
3998
+ * and pushing ~40 bytes.
3999
+ *
4000
+ * `req.url` is not part of the harness contract, so anything unparseable
4001
+ * degrades to the legacy full frame rather than throwing.
4002
+ */
4003
+ const isLiteStreamRequest = (req) => {
4004
+ const url = req !== null && typeof req === 'object' && typeof req.url === 'string' ? req.url : ''
4005
+ const mark = url.indexOf('?')
4006
+ if (mark === -1) return false
4007
+ try {
4008
+ return new URLSearchParams(url.slice(mark + 1)).get('lite') === '1'
4009
+ } catch {
4010
+ return false
4011
+ }
4012
+ }
4013
+
3692
4014
  /**
3693
4015
  * The live-update stream for the settings page (GET + `Accept:
3694
4016
  * text/event-stream`, i.e. exactly what an EventSource sends).
@@ -3699,7 +4021,8 @@ export function apply(ctx) {
3699
4021
  * (someone navigating to the URL) keeps the old 405.
3700
4022
  *
3701
4023
  * Frames carry the bare snapshot — the same shape `state` answers with —
3702
- * which the Client's normalizer already accepts.
4024
+ * which the Client's normalizer already accepts. A subscriber that appends
4025
+ * `?lite=1` gets `{ rev, lite: true }` frames instead (see pushSnapshot).
3703
4026
  */
3704
4027
  const handleEventStream = (req, res) => {
3705
4028
  const verdict = requestOriginVerdict(req.headers, guardHost)
@@ -3717,6 +4040,7 @@ export function apply(ctx) {
3717
4040
  // Ask the browser to retry politely if the stream drops (e.g. the host was
3718
4041
  // restarted) instead of hammering the route.
3719
4042
  res.write('retry: 5000\n\n')
4043
+ const lite = isLiteStreamRequest(req)
3720
4044
  const send = (payload) => { res.write(payload) }
3721
4045
  let closed = false
3722
4046
  const cleanup = () => {
@@ -3728,7 +4052,7 @@ export function apply(ctx) {
3728
4052
  // The response is already gone.
3729
4053
  }
3730
4054
  }
3731
- sseStreams.set(send, cleanup)
4055
+ sseStreams.set(send, { cleanup, lite })
3732
4056
  req.on('close', cleanup)
3733
4057
  req.on('error', cleanup)
3734
4058
  res.on('error', cleanup)
@@ -3736,11 +4060,14 @@ export function apply(ctx) {
3736
4060
  // instead of waiting for the next change. It must wait for boot: a stream
3737
4061
  // opened while the initial disk read is still in flight would otherwise be
3738
4062
  // handed the empty boot state as its first frame, flashing "no
3739
- // instructions" before the loaded store arrives.
4063
+ // instructions" before the loaded store arrives. A lite subscriber gets
4064
+ // only the current rev and pulls the snapshot itself when it is behind.
3740
4065
  const sendConnectFrame = () => {
3741
4066
  if (closed) return
3742
4067
  try {
3743
- send('data: ' + JSON.stringify(snapshot()) + '\n\n')
4068
+ send(lite
4069
+ ? 'data: ' + JSON.stringify({ rev: state.data.rev || 0, lite: true }) + '\n\n'
4070
+ : 'data: ' + JSON.stringify(snapshot()) + '\n\n')
3744
4071
  } catch {
3745
4072
  cleanup()
3746
4073
  }