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/CHANGELOG.md +149 -0
- package/README.md +140 -7
- package/docs/screenshot.png +0 -0
- package/lib/client.js +668 -79
- package/lib/index.js +398 -71
- package/lib/mailer.js +932 -0
- package/package.json +4 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
//
|
|
1259
|
-
//
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
-
|
|
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
|
|
1542
|
-
*
|
|
1543
|
-
* (the socket went away) can dispose THAT stream instead of just
|
|
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
|
-
|
|
1553
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1811
|
-
*
|
|
1812
|
-
*
|
|
1813
|
-
*
|
|
1814
|
-
* our naming pattern are never
|
|
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
|
|
1831
|
-
for (
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|