dsh-instruction-memory 1.2.0 → 1.3.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 +77 -0
- package/README.md +37 -0
- package/lib/client.js +416 -8
- package/lib/index.js +345 -72
- package/memory.schema.json +6 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -84,6 +84,12 @@ const MAX_SETS = 20
|
|
|
84
84
|
const MAX_SET_NAME = 30
|
|
85
85
|
/** Rolling history snapshots (F14): how many are kept PER writing device. */
|
|
86
86
|
const MAX_HISTORY = 20
|
|
87
|
+
/**
|
|
88
|
+
* F7: the longest single pause. Doubles as the setTimeout guard — 7 days is
|
|
89
|
+
* far below the 2^31-1 ms (≈24.8 days) timer ceiling, so the delay never
|
|
90
|
+
* overflows Node's timer range.
|
|
91
|
+
*/
|
|
92
|
+
const MAX_MUTE_MS = 7 * 24 * 60 * 60 * 1000
|
|
87
93
|
const HISTORY_DIR_NAME = 'history'
|
|
88
94
|
/** Name of the JSON Schema shipped with the package and copied next to the store. */
|
|
89
95
|
const SCHEMA_FILE_NAME = 'memory.schema.json'
|
|
@@ -552,6 +558,7 @@ function normalizeData(raw) {
|
|
|
552
558
|
version: FORMAT_VERSION,
|
|
553
559
|
rev: 0,
|
|
554
560
|
enabled: true,
|
|
561
|
+
disabledUntil: 0,
|
|
555
562
|
budgetChars: DEFAULT_BUDGET,
|
|
556
563
|
backupDir: '',
|
|
557
564
|
activeSetId: DEFAULT_SET_ID,
|
|
@@ -599,6 +606,18 @@ function normalizeData(raw) {
|
|
|
599
606
|
} else if ('backupDir' in raw && raw.backupDir !== null && raw.backupDir !== '') {
|
|
600
607
|
optionFixes.push('backupDir ' + describeValue(raw.backupDir) + ' → ""(需要字符串路径)')
|
|
601
608
|
}
|
|
609
|
+
// F7 temporary mute. The field is OPTIONAL (the format stays v2): missing
|
|
610
|
+
// means "not paused" and is adopted silently, exactly like a v1 file without
|
|
611
|
+
// sets. Present but not a usable instant is reset to 0 with a disclosure,
|
|
612
|
+
// like every other hand-edited option.
|
|
613
|
+
if ('disabledUntil' in raw) {
|
|
614
|
+
const muteValue = raw.disabledUntil
|
|
615
|
+
if (typeof muteValue === 'number' && Number.isFinite(muteValue) && muteValue >= 0) {
|
|
616
|
+
data.disabledUntil = Math.round(muteValue)
|
|
617
|
+
} else {
|
|
618
|
+
optionFixes.push('disabledUntil ' + describeValue(muteValue) + ' → 0(需要 ≥ 0 的 epoch 毫秒数,0 表示未暂停)')
|
|
619
|
+
}
|
|
620
|
+
}
|
|
602
621
|
|
|
603
622
|
// Instruction sets (F13). A missing/blank list means a v1 file (or a hand
|
|
604
623
|
// edit that removed it): synthesise the single 默认 set and record the fact,
|
|
@@ -705,6 +724,9 @@ function cloneData(data) {
|
|
|
705
724
|
version: FORMAT_VERSION,
|
|
706
725
|
rev: data.rev || 0,
|
|
707
726
|
enabled: data.enabled === true,
|
|
727
|
+
disabledUntil: typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil) && data.disabledUntil > 0
|
|
728
|
+
? data.disabledUntil
|
|
729
|
+
: 0,
|
|
708
730
|
budgetChars: data.budgetChars,
|
|
709
731
|
backupDir: typeof data.backupDir === 'string' ? data.backupDir : '',
|
|
710
732
|
activeSetId: data.activeSetId,
|
|
@@ -1145,13 +1167,44 @@ function planEntries(data, budget) {
|
|
|
1145
1167
|
* - a profile-only store (no instructions) still injects, and the master
|
|
1146
1168
|
* `enabled` switch suppresses both halves as before.
|
|
1147
1169
|
*/
|
|
1148
|
-
|
|
1170
|
+
/**
|
|
1171
|
+
* F7 temporary mute: a store-level pause with an expiry. `disabledUntil` is an
|
|
1172
|
+
* epoch-ms instant; while it lies in the future (and the master switch is on)
|
|
1173
|
+
* the store is muted. The wall clock is only the DEFAULT: both this and the
|
|
1174
|
+
* render entry points take `now`, so a fixed clock reproduces the same bytes
|
|
1175
|
+
* and tests pin the behaviour without waiting.
|
|
1176
|
+
*/
|
|
1177
|
+
export function isMuted(data, now = Date.now()) {
|
|
1178
|
+
return data !== null && typeof data === 'object' && data.enabled === true
|
|
1179
|
+
&& typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
|
|
1180
|
+
&& data.disabledUntil > now
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* The lapsed-mute decision the host timer acts on: a mute whose instant has
|
|
1185
|
+
* passed needs the section re-registered (and a snapshot pushed), but writes
|
|
1186
|
+
* NOTHING — expiry is a state change, not a save; the store file and its rev
|
|
1187
|
+
* stay untouched, and the stale value is simply ignored until the next real
|
|
1188
|
+
* write rewrites the file without it.
|
|
1189
|
+
*/
|
|
1190
|
+
export function muteExpired(data, now = Date.now()) {
|
|
1191
|
+
return data !== null && typeof data === 'object' && data.enabled === true
|
|
1192
|
+
&& typeof data.disabledUntil === 'number' && Number.isFinite(data.disabledUntil)
|
|
1193
|
+
&& data.disabledUntil > 0 && data.disabledUntil <= now
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export function planInjection(data, now = Date.now()) {
|
|
1149
1197
|
const budget = clampBudget(data !== null && typeof data === 'object' ? data.budgetChars : undefined)
|
|
1150
1198
|
const blank = () => ({
|
|
1151
1199
|
text: '', budget, activeCount: 0, included: [], omitted: [],
|
|
1152
1200
|
profiles: [], profileChars: 0, entryChars: 0,
|
|
1153
1201
|
})
|
|
1154
1202
|
if (data === null || typeof data !== 'object' || data.enabled !== true) return blank()
|
|
1203
|
+
// F7 temporary mute: while the pause is live the plan is EXACTLY the
|
|
1204
|
+
// enabled:false plan — same fields, same empty text — so the prompt section
|
|
1205
|
+
// disappears and the prompt is byte-identical to a deployment without this
|
|
1206
|
+
// plugin.
|
|
1207
|
+
if (isMuted(data, now)) return blank()
|
|
1155
1208
|
/**
|
|
1156
1209
|
* The set filter (F13) is applied HERE, at the one place both the rendered
|
|
1157
1210
|
* text and the budget decision are produced, so no caller can inject an
|
|
@@ -1186,8 +1239,8 @@ export function planInjection(data) {
|
|
|
1186
1239
|
* decision process lives in one place, and callers that only need the text
|
|
1187
1240
|
* (the prompt section) keep this byte-stable entry point.
|
|
1188
1241
|
*/
|
|
1189
|
-
export function buildBlock(data) {
|
|
1190
|
-
return planInjection(data).text
|
|
1242
|
+
export function buildBlock(data, now = Date.now()) {
|
|
1243
|
+
return planInjection(data, now).text
|
|
1191
1244
|
}
|
|
1192
1245
|
|
|
1193
1246
|
/* ------------------------------------------------------------------ *
|
|
@@ -1238,8 +1291,28 @@ export function apply(ctx) {
|
|
|
1238
1291
|
storeExists: false,
|
|
1239
1292
|
/** How many history snapshots the current directory holds (diagnostics). */
|
|
1240
1293
|
historyCount: 0,
|
|
1294
|
+
/**
|
|
1295
|
+
* The address the settings route is actually bound to (F12 security panel).
|
|
1296
|
+
* `''` until a web server publishes; the panel turns it into a warning when
|
|
1297
|
+
* the bind is not loopback.
|
|
1298
|
+
*/
|
|
1299
|
+
boundHost: '',
|
|
1241
1300
|
}
|
|
1242
1301
|
let sectionDispose = null
|
|
1302
|
+
/**
|
|
1303
|
+
* Which Host values this route accepts. Set when the route is registered,
|
|
1304
|
+
* from the address the web server is actually bound to (see the long note on
|
|
1305
|
+
* requestOriginVerdict). Until then the route does not exist at all, so the
|
|
1306
|
+
* default only matters if something registers it out of band. Declared next
|
|
1307
|
+
* to `state` so the snapshot can publish it at any time without a
|
|
1308
|
+
* temporal-dead-zone hazard (F12 publishes it as `security.hostGuarded`).
|
|
1309
|
+
*/
|
|
1310
|
+
let guardHost = true
|
|
1311
|
+
/**
|
|
1312
|
+
* F7: the pending mute-expiry timer, or null. Re-armed by scheduleMuteTimer
|
|
1313
|
+
* (from syncSection) and cleared in the effect cleanup.
|
|
1314
|
+
*/
|
|
1315
|
+
let muteTimer = null
|
|
1243
1316
|
|
|
1244
1317
|
/**
|
|
1245
1318
|
* A first run deliberately writes NOTHING (see readFromDisk step 3). This flag
|
|
@@ -1320,7 +1393,7 @@ export function apply(ctx) {
|
|
|
1320
1393
|
|
|
1321
1394
|
const sectionText = () => {
|
|
1322
1395
|
try {
|
|
1323
|
-
return buildBlock(state.data)
|
|
1396
|
+
return buildBlock(state.data, Date.now())
|
|
1324
1397
|
} catch (err) {
|
|
1325
1398
|
console.error('[instruction-memory] render failed:', err)
|
|
1326
1399
|
return ''
|
|
@@ -1336,7 +1409,7 @@ export function apply(ctx) {
|
|
|
1336
1409
|
*/
|
|
1337
1410
|
const sectionPlan = () => {
|
|
1338
1411
|
try {
|
|
1339
|
-
return planInjection(state.data)
|
|
1412
|
+
return planInjection(state.data, Date.now())
|
|
1340
1413
|
} catch (err) {
|
|
1341
1414
|
console.error('[instruction-memory] render failed:', err)
|
|
1342
1415
|
return {
|
|
@@ -1361,6 +1434,37 @@ export function apply(ctx) {
|
|
|
1361
1434
|
return sectionText()
|
|
1362
1435
|
}
|
|
1363
1436
|
|
|
1437
|
+
/**
|
|
1438
|
+
* F7: keep one host-side timer pointed at the mute's expiry. Expiry must be
|
|
1439
|
+
* automatic — no user action, no write: when the instant passes, the section
|
|
1440
|
+
* re-registers (or stays dropped) and open panels get a fresh snapshot,
|
|
1441
|
+
* while the store file and its rev stay untouched. syncSection re-arms it
|
|
1442
|
+
* on every state change (boot, commit, external reload), and re-arming
|
|
1443
|
+
* clears the previous timer first, so there is never more than one.
|
|
1444
|
+
*/
|
|
1445
|
+
const scheduleMuteTimer = () => {
|
|
1446
|
+
if (muteTimer !== null) {
|
|
1447
|
+
clearTimeout(muteTimer)
|
|
1448
|
+
muteTimer = null
|
|
1449
|
+
}
|
|
1450
|
+
if (!isMuted(state.data)) return
|
|
1451
|
+
const delay = Math.min(state.data.disabledUntil - Date.now(), MAX_MUTE_MS)
|
|
1452
|
+
muteTimer = setTimeout(() => {
|
|
1453
|
+
muteTimer = null
|
|
1454
|
+
// Clock skew or an early fire: if the mute is still live, re-arm instead
|
|
1455
|
+
// of acting; the sync below happens only on a real expiry.
|
|
1456
|
+
if (isMuted(state.data)) {
|
|
1457
|
+
scheduleMuteTimer()
|
|
1458
|
+
return
|
|
1459
|
+
}
|
|
1460
|
+
syncSection()
|
|
1461
|
+
pushSnapshot()
|
|
1462
|
+
logDebug('mute expired: section re-synced without a write')
|
|
1463
|
+
}, Math.max(1, delay))
|
|
1464
|
+
// A 7-day timer must not hold a shutting-down process open.
|
|
1465
|
+
if (typeof muteTimer.unref === 'function') muteTimer.unref()
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1364
1468
|
/**
|
|
1365
1469
|
* Keep the registered section exactly in step with the stored data: present
|
|
1366
1470
|
* when something must be injected, absent otherwise. An empty memory must
|
|
@@ -1380,9 +1484,7 @@ export function apply(ctx) {
|
|
|
1380
1484
|
state.sectionError = '注入注册失败:' + describeError(err)
|
|
1381
1485
|
console.error('[instruction-memory] section registration failed:', err)
|
|
1382
1486
|
}
|
|
1383
|
-
|
|
1384
|
-
}
|
|
1385
|
-
if (!wanted) {
|
|
1487
|
+
} else if (!wanted) {
|
|
1386
1488
|
if (sectionDispose !== null) {
|
|
1387
1489
|
const dispose = sectionDispose
|
|
1388
1490
|
sectionDispose = null
|
|
@@ -1396,6 +1498,9 @@ export function apply(ctx) {
|
|
|
1396
1498
|
state.sectionRegistered = false
|
|
1397
1499
|
state.sectionError = null
|
|
1398
1500
|
}
|
|
1501
|
+
// F7: whatever this sync just published, the mute timer must point at
|
|
1502
|
+
// exactly this state (and re-arming clears the previous timer first).
|
|
1503
|
+
scheduleMuteTimer()
|
|
1399
1504
|
}
|
|
1400
1505
|
|
|
1401
1506
|
ctx.effect(() => () => {
|
|
@@ -1408,6 +1513,10 @@ export function apply(ctx) {
|
|
|
1408
1513
|
console.error('[instruction-memory] cleanup failed:', err)
|
|
1409
1514
|
}
|
|
1410
1515
|
}
|
|
1516
|
+
if (muteTimer !== null) {
|
|
1517
|
+
clearTimeout(muteTimer)
|
|
1518
|
+
muteTimer = null
|
|
1519
|
+
}
|
|
1411
1520
|
stopWatching()
|
|
1412
1521
|
disposeStreams()
|
|
1413
1522
|
})
|
|
@@ -1540,6 +1649,9 @@ export function apply(ctx) {
|
|
|
1540
1649
|
version: FORMAT_VERSION,
|
|
1541
1650
|
rev: state.data.rev || 0,
|
|
1542
1651
|
enabled: state.data.enabled === true,
|
|
1652
|
+
disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
|
|
1653
|
+
? state.data.disabledUntil
|
|
1654
|
+
: 0,
|
|
1543
1655
|
budgetChars: clampBudget(state.data.budgetChars),
|
|
1544
1656
|
backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
|
|
1545
1657
|
activeSetId: state.data.activeSetId,
|
|
@@ -2070,14 +2182,29 @@ export function apply(ctx) {
|
|
|
2070
2182
|
if (!needsMaterialize) return false
|
|
2071
2183
|
/**
|
|
2072
2184
|
* A store may have APPEARED since boot — a hand-copied memory.json, a
|
|
2073
|
-
* backup restored by another tool, a second process.
|
|
2074
|
-
*
|
|
2075
|
-
*
|
|
2076
|
-
*
|
|
2077
|
-
* `state` call can come
|
|
2185
|
+
* backup restored by another tool, a second process. Three cases, and the
|
|
2186
|
+
* difference between the last two is the whole point:
|
|
2187
|
+
* - nothing there: materialise the empty store (the documented first run);
|
|
2188
|
+
* - READABLE: adopt it instead of overwriting whatever the user just put
|
|
2189
|
+
* there (the gap is wide — the panel's first `state` call can come
|
|
2190
|
+
* minutes after boot);
|
|
2191
|
+
* - present but UNREADABLE: write nothing at all and let the loader's own
|
|
2192
|
+
* disclosure say why. Overwriting would destroy the very file a user
|
|
2193
|
+
* could still repair by hand, and it would report `ok:true` while doing
|
|
2194
|
+
* it — the silent data loss this plugin must never commit.
|
|
2078
2195
|
*/
|
|
2079
|
-
|
|
2080
|
-
|
|
2196
|
+
let appeared = null
|
|
2197
|
+
let unreadable = false
|
|
2198
|
+
try {
|
|
2199
|
+
appeared = await readStoreFile(storePath)
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
// ENOENT is the FIRST RUN itself — nothing to adopt, so materialise.
|
|
2202
|
+
// Anything else (EACCES, EISDIR, a parse failure …) means a file IS
|
|
2203
|
+
// there and could not be adopted; readFromDisk() below reports exactly
|
|
2204
|
+
// what it was, and nothing is written.
|
|
2205
|
+
unreadable = !(err !== null && typeof err === 'object' && err.code === 'ENOENT')
|
|
2206
|
+
}
|
|
2207
|
+
if (unreadable || appeared !== null) {
|
|
2081
2208
|
const wrote = await readFromDisk()
|
|
2082
2209
|
syncSection()
|
|
2083
2210
|
return wrote
|
|
@@ -2170,6 +2297,9 @@ export function apply(ctx) {
|
|
|
2170
2297
|
version: FORMAT_VERSION,
|
|
2171
2298
|
rev: state.data.rev || 0,
|
|
2172
2299
|
enabled: state.data.enabled === true,
|
|
2300
|
+
disabledUntil: typeof state.data.disabledUntil === 'number' && Number.isFinite(state.data.disabledUntil) && state.data.disabledUntil > 0
|
|
2301
|
+
? state.data.disabledUntil
|
|
2302
|
+
: 0,
|
|
2173
2303
|
budgetChars: clampBudget(state.data.budgetChars),
|
|
2174
2304
|
backupDir: typeof state.data.backupDir === 'string' ? state.data.backupDir : '',
|
|
2175
2305
|
activeSetId: state.data.activeSetId,
|
|
@@ -2195,6 +2325,28 @@ export function apply(ctx) {
|
|
|
2195
2325
|
profiles: state.data.profiles.filter((profile) => belongsTo(profile, set.id)).length,
|
|
2196
2326
|
})),
|
|
2197
2327
|
},
|
|
2328
|
+
/**
|
|
2329
|
+
* F12: the one security signal the panel was missing. `hostGuarded` is
|
|
2330
|
+
* the SAME flag the route enforces on every request — false means the
|
|
2331
|
+
* Host allowlist is OFF because the server is bound beyond loopback,
|
|
2332
|
+
* i.e. the page is reachable without authentication from the local
|
|
2333
|
+
* network. `boundHost` says which address produced that decision so the
|
|
2334
|
+
* warning can name it instead of guessing.
|
|
2335
|
+
*/
|
|
2336
|
+
security: {
|
|
2337
|
+
hostGuarded: guardHost === true,
|
|
2338
|
+
boundHost: state.boundHost,
|
|
2339
|
+
},
|
|
2340
|
+
/**
|
|
2341
|
+
* F12: load-side diagnostics that used to be log-only. `loadedFrom` says
|
|
2342
|
+
* which branch of the boot load produced the current data (fresh store,
|
|
2343
|
+
* adopted file, recovered .bak, …); `rev` mirrors data.rev so a support
|
|
2344
|
+
* dump pairs it with the storage fields without another round trip.
|
|
2345
|
+
*/
|
|
2346
|
+
diagnostics: {
|
|
2347
|
+
loadedFrom: state.loadedFrom,
|
|
2348
|
+
rev: state.data.rev || 0,
|
|
2349
|
+
},
|
|
2198
2350
|
storage: {
|
|
2199
2351
|
path: state.storageDisplay !== null ? state.storageDisplay : state.storagePath,
|
|
2200
2352
|
error: state.storageError,
|
|
@@ -2218,6 +2370,12 @@ export function apply(ctx) {
|
|
|
2218
2370
|
registered: state.sectionRegistered,
|
|
2219
2371
|
available: true,
|
|
2220
2372
|
error: state.sectionError,
|
|
2373
|
+
/**
|
|
2374
|
+
* F7: the pause is decided by the HOST clock (isMuted on the live
|
|
2375
|
+
* store) and published as a derived flag; the client only renders a
|
|
2376
|
+
* countdown from data.disabledUntil and never decides by itself.
|
|
2377
|
+
*/
|
|
2378
|
+
muted: isMuted(state.data, Date.now()),
|
|
2221
2379
|
chars: preview.length,
|
|
2222
2380
|
/**
|
|
2223
2381
|
* The budget decision behind `preview`, for the settings page's
|
|
@@ -2505,25 +2663,72 @@ export function apply(ctx) {
|
|
|
2505
2663
|
}
|
|
2506
2664
|
}
|
|
2507
2665
|
}
|
|
2666
|
+
// F7 temporary mute: validated BEFORE the transaction so a bad value is
|
|
2667
|
+
// refused with a reason instead of being silently normalized. 0 means
|
|
2668
|
+
// "resume now"; a past instant means the same thing; anything beyond the
|
|
2669
|
+
// 7-day ceiling is clamped (and disclosed) rather than refused — a pause
|
|
2670
|
+
// is a de-escalation, refusing it would be user-hostile.
|
|
2671
|
+
let muteTarget = null
|
|
2672
|
+
let muteClamped = false
|
|
2673
|
+
if (patch !== null && typeof patch === 'object' && 'disabledUntil' in patch) {
|
|
2674
|
+
const rawMute = patch.disabledUntil
|
|
2675
|
+
if (typeof rawMute !== 'number' || !Number.isFinite(rawMute) || rawMute < 0) {
|
|
2676
|
+
return {
|
|
2677
|
+
ok: false,
|
|
2678
|
+
saved: false,
|
|
2679
|
+
message: 'disabledUntil 需要 0(立即恢复)或未来的 epoch 毫秒数',
|
|
2680
|
+
snapshot: snapshot(),
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
let wanted = Math.round(rawMute)
|
|
2684
|
+
if (wanted <= Date.now()) {
|
|
2685
|
+
wanted = 0
|
|
2686
|
+
} else if (wanted > Date.now() + MAX_MUTE_MS) {
|
|
2687
|
+
wanted = Date.now() + MAX_MUTE_MS
|
|
2688
|
+
muteClamped = true
|
|
2689
|
+
}
|
|
2690
|
+
muteTarget = wanted
|
|
2691
|
+
}
|
|
2692
|
+
const muteWasActive = isMuted(state.data)
|
|
2508
2693
|
const saved = await commit(() => {
|
|
2509
2694
|
if (patch !== null && typeof patch === 'object') {
|
|
2510
|
-
if (typeof patch.enabled === 'boolean')
|
|
2695
|
+
if (typeof patch.enabled === 'boolean') {
|
|
2696
|
+
state.data.enabled = patch.enabled
|
|
2697
|
+
// An explicit switch outranks the timer: flipping the master switch
|
|
2698
|
+
// ends any pause immediately.
|
|
2699
|
+
state.data.disabledUntil = 0
|
|
2700
|
+
}
|
|
2511
2701
|
if (typeof patch.budgetChars === 'number' && Number.isFinite(patch.budgetChars)) {
|
|
2512
2702
|
state.data.budgetChars = clampBudget(patch.budgetChars)
|
|
2513
2703
|
}
|
|
2514
2704
|
if (typeof patch.backupDir === 'string') state.data.backupDir = patch.backupDir.trim()
|
|
2705
|
+
if (muteTarget !== null && typeof patch.enabled !== 'boolean') {
|
|
2706
|
+
state.data.disabledUntil = muteTarget
|
|
2707
|
+
}
|
|
2515
2708
|
}
|
|
2516
2709
|
})
|
|
2517
2710
|
// A changed backup location moves where history snapshots and manual
|
|
2518
2711
|
// exports go, so it is reported rather than left to be discovered.
|
|
2519
|
-
|
|
2712
|
+
const notes = []
|
|
2520
2713
|
if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
|
|
2521
2714
|
const wanted = patch.backupDir.trim()
|
|
2522
|
-
|
|
2715
|
+
notes.push(wanted === ''
|
|
2523
2716
|
? '已恢复默认备份位置(版本历史与导出与存储文件同目录)'
|
|
2524
|
-
: '备份位置已更新:' + wanted + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)'
|
|
2717
|
+
: '备份位置已更新:' + wanted + '(版本历史与「导出到备份目录」随之改变;存储文件本身不移动)')
|
|
2718
|
+
}
|
|
2719
|
+
if (muteTarget !== null && !(patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean')) {
|
|
2720
|
+
if (muteTarget === 0) {
|
|
2721
|
+
if (muteWasActive) notes.push('已恢复注入。')
|
|
2722
|
+
} else {
|
|
2723
|
+
notes.push('已暂停注入至 ' + new Date(muteTarget).toLocaleString()
|
|
2724
|
+
+ '(到期自动恢复;暂停期间不注入任何指令或用户形象)'
|
|
2725
|
+
+ (muteClamped ? ';暂停时长上限为 7 天,已按上限记录' : ''))
|
|
2726
|
+
}
|
|
2525
2727
|
}
|
|
2526
|
-
|
|
2728
|
+
if (patch !== null && typeof patch === 'object' && typeof patch.enabled === 'boolean' && muteWasActive) {
|
|
2729
|
+
notes.push('总开关已切换,暂停注入状态已一并清除')
|
|
2730
|
+
}
|
|
2731
|
+
return settle(notes.length > 0 ? notes.join(';') : null, saved)
|
|
2527
2732
|
})
|
|
2528
2733
|
|
|
2529
2734
|
/**
|
|
@@ -3167,8 +3372,6 @@ export function apply(ctx) {
|
|
|
3167
3372
|
const seenIds = new Set(existing.map((entry) => entry.setId + '\u0000' + entry.id))
|
|
3168
3373
|
const seenBodies = new Set(existing.map((entry) => bodyKey(entry.setId, entry.title, entry.content)))
|
|
3169
3374
|
const accepted = []
|
|
3170
|
-
const cutFields = new Set()
|
|
3171
|
-
let truncatedCount = 0
|
|
3172
3375
|
let skipped = 0
|
|
3173
3376
|
for (const candidate of (rawEntries || [])) {
|
|
3174
3377
|
const entry = sanitizeEntry(candidate)
|
|
@@ -3185,17 +3388,12 @@ export function apply(ctx) {
|
|
|
3185
3388
|
skipped += 1
|
|
3186
3389
|
continue
|
|
3187
3390
|
}
|
|
3391
|
+
// Truncation travels per item and is only REPORTED for items that are
|
|
3392
|
+
// actually staged: an item later skipped as a fallback duplicate must
|
|
3393
|
+
// not inflate the truncation disclosure (or the inserted-count).
|
|
3188
3394
|
const cut = truncatedFields(candidate)
|
|
3189
|
-
if (cut.length > 0) {
|
|
3190
|
-
// sanitizeEntry enforces the limits silently, which is fine for the
|
|
3191
|
-
// editor (its inputs carry the same maxLength) but wrong here: an
|
|
3192
|
-
// imported 7,000-character instruction would come back as 6,000 with
|
|
3193
|
-
// nothing said about the loss.
|
|
3194
|
-
truncatedCount += 1
|
|
3195
|
-
for (const field of cut) cutFields.add(field)
|
|
3196
|
-
}
|
|
3197
3395
|
entry.updatedAt = Date.now()
|
|
3198
|
-
accepted.push({ entry, wanted })
|
|
3396
|
+
accepted.push({ entry, wanted, cut })
|
|
3199
3397
|
seenIds.add(wanted + '\u0000' + entry.id)
|
|
3200
3398
|
seenBodies.add(bodyKey(wanted, entry.title, entry.content))
|
|
3201
3399
|
}
|
|
@@ -3206,8 +3404,6 @@ export function apply(ctx) {
|
|
|
3206
3404
|
const seenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
|
|
3207
3405
|
const seenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
|
|
3208
3406
|
const acceptedProfiles = []
|
|
3209
|
-
const cutProfileFields = new Set()
|
|
3210
|
-
let truncatedProfileCount = 0
|
|
3211
3407
|
let skippedProfiles = 0
|
|
3212
3408
|
for (const candidate of (rawProfiles || [])) {
|
|
3213
3409
|
const profile = sanitizeProfile(candidate)
|
|
@@ -3226,25 +3422,11 @@ export function apply(ctx) {
|
|
|
3226
3422
|
continue
|
|
3227
3423
|
}
|
|
3228
3424
|
const cut = truncatedProfileFields(candidate)
|
|
3229
|
-
if (cut.length > 0) {
|
|
3230
|
-
truncatedProfileCount += 1
|
|
3231
|
-
for (const field of cut) cutProfileFields.add(field)
|
|
3232
|
-
}
|
|
3233
3425
|
profile.updatedAt = Date.now()
|
|
3234
|
-
acceptedProfiles.push({ profile, wanted })
|
|
3426
|
+
acceptedProfiles.push({ profile, wanted, cut })
|
|
3235
3427
|
seenProfileIds.add(wanted + '\u0000' + profile.id)
|
|
3236
3428
|
seenProfileBodies.add(wanted + '\u0000' + profile.name + '\u0000' + profile.content)
|
|
3237
3429
|
}
|
|
3238
|
-
if (accepted.length === 0 && acceptedProfiles.length === 0) {
|
|
3239
|
-
// Nothing was written to disk, so claiming saved:true would be a lie the
|
|
3240
|
-
// panel cannot distinguish from a real save.
|
|
3241
|
-
return {
|
|
3242
|
-
ok: true,
|
|
3243
|
-
saved: false,
|
|
3244
|
-
message: '没有新增内容:' + skipped + ' 条条目、' + skippedProfiles + ' 个用户形象已存在或无效',
|
|
3245
|
-
snapshot: snapshot(),
|
|
3246
|
-
}
|
|
3247
|
-
}
|
|
3248
3430
|
// Create only the declared sets that the accepted items actually reference,
|
|
3249
3431
|
// in declaration order, while there is room.
|
|
3250
3432
|
const wantedIds = []
|
|
@@ -3261,21 +3443,90 @@ export function apply(ctx) {
|
|
|
3261
3443
|
createdIds.add(declared.id)
|
|
3262
3444
|
}
|
|
3263
3445
|
let fellBack = 0
|
|
3264
|
-
const finalIdOf = (wanted) =>
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3446
|
+
const finalIdOf = (wanted) => (localSetIds.has(wanted) || createdIds.has(wanted) ? wanted : activeId)
|
|
3447
|
+
/**
|
|
3448
|
+
* Fallback de-duplication (review fix). The plan phase de-duplicated
|
|
3449
|
+
* against the set each item DECLARED, but finalIdOf() may retarget an item
|
|
3450
|
+
* into the active set (its set is unknown here, or the MAX_SETS cap blocked
|
|
3451
|
+
* creating it). Every retargeted item is therefore re-checked against the
|
|
3452
|
+
* set it REALLY lands in — pre-existing content, same-batch items already
|
|
3453
|
+
* staged there, and same-batch items that reached it through the normal
|
|
3454
|
+
* path — before anything is appended. The normal path (set exists or was
|
|
3455
|
+
* created) keeps the plan-phase decision untouched, which is what keeps
|
|
3456
|
+
* re-importing the same export file a no-op.
|
|
3457
|
+
*/
|
|
3458
|
+
const stagedEntries = []
|
|
3459
|
+
const stagedProfiles = []
|
|
3460
|
+
let dedupedOnFallback = 0
|
|
3461
|
+
{
|
|
3462
|
+
const finalSeenIds = new Set(existing.map((entry) => entry.setId + '\u0000' + entry.id))
|
|
3463
|
+
const finalSeenBodies = new Set(existing.map((entry) => bodyKey(entry.setId, entry.title, entry.content)))
|
|
3464
|
+
// Items the plan phase accepted under their declared set occupy that
|
|
3465
|
+
// set's id/body space even when the item itself later falls back (a
|
|
3466
|
+
// fallback item's declared set is neither local nor created, so the keys
|
|
3467
|
+
// never collide with these — they only guard the normal path).
|
|
3271
3468
|
for (const item of accepted) {
|
|
3272
|
-
item.
|
|
3273
|
-
|
|
3469
|
+
finalSeenIds.add(item.wanted + '\u0000' + item.entry.id)
|
|
3470
|
+
finalSeenBodies.add(bodyKey(item.wanted, item.entry.title, item.entry.content))
|
|
3471
|
+
}
|
|
3472
|
+
for (const item of accepted) {
|
|
3473
|
+
const finalId = finalIdOf(item.wanted)
|
|
3474
|
+
if (finalId !== item.wanted) {
|
|
3475
|
+
if (finalSeenBodies.has(bodyKey(finalId, item.entry.title, item.entry.content))) {
|
|
3476
|
+
dedupedOnFallback += 1
|
|
3477
|
+
continue
|
|
3478
|
+
}
|
|
3479
|
+
if (finalSeenIds.has(finalId + '\u0000' + item.entry.id)) {
|
|
3480
|
+
// The body is new to the fallback target but the id is taken
|
|
3481
|
+
// there: re-mint instead of writing a duplicate id into one set.
|
|
3482
|
+
item.entry.id = makeId()
|
|
3483
|
+
}
|
|
3484
|
+
fellBack += 1
|
|
3485
|
+
finalSeenIds.add(finalId + '\u0000' + item.entry.id)
|
|
3486
|
+
finalSeenBodies.add(bodyKey(finalId, item.entry.title, item.entry.content))
|
|
3487
|
+
}
|
|
3488
|
+
item.entry.setId = finalId
|
|
3489
|
+
stagedEntries.push(item)
|
|
3490
|
+
}
|
|
3491
|
+
const finalSeenProfileIds = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.id))
|
|
3492
|
+
const finalSeenProfileBodies = new Set(existingProfiles.map((profile) => profile.setId + '\u0000' + profile.name + '\u0000' + profile.content))
|
|
3493
|
+
for (const item of acceptedProfiles) {
|
|
3494
|
+
finalSeenProfileIds.add(item.wanted + '\u0000' + item.profile.id)
|
|
3495
|
+
finalSeenProfileBodies.add(item.wanted + '\u0000' + item.profile.name + '\u0000' + item.profile.content)
|
|
3274
3496
|
}
|
|
3275
3497
|
for (const item of acceptedProfiles) {
|
|
3276
|
-
|
|
3277
|
-
|
|
3498
|
+
const finalId = finalIdOf(item.wanted)
|
|
3499
|
+
if (finalId !== item.wanted) {
|
|
3500
|
+
if (finalSeenProfileBodies.has(finalId + '\u0000' + item.profile.name + '\u0000' + item.profile.content)) {
|
|
3501
|
+
dedupedOnFallback += 1
|
|
3502
|
+
continue
|
|
3503
|
+
}
|
|
3504
|
+
if (finalSeenProfileIds.has(finalId + '\u0000' + item.profile.id)) {
|
|
3505
|
+
item.profile.id = makeId()
|
|
3506
|
+
}
|
|
3507
|
+
fellBack += 1
|
|
3508
|
+
finalSeenProfileIds.add(finalId + '\u0000' + item.profile.id)
|
|
3509
|
+
finalSeenProfileBodies.add(finalId + '\u0000' + item.profile.name + '\u0000' + item.profile.content)
|
|
3510
|
+
}
|
|
3511
|
+
item.profile.setId = finalId
|
|
3512
|
+
stagedProfiles.push(item)
|
|
3278
3513
|
}
|
|
3514
|
+
}
|
|
3515
|
+
if (stagedEntries.length === 0 && stagedProfiles.length === 0) {
|
|
3516
|
+
// Nothing was written to disk, so claiming saved:true would be a lie the
|
|
3517
|
+
// panel cannot distinguish from a real save.
|
|
3518
|
+
return {
|
|
3519
|
+
ok: true,
|
|
3520
|
+
saved: false,
|
|
3521
|
+
message: '没有新增内容:' + skipped + ' 条条目、' + skippedProfiles + ' 个用户形象已存在或无效'
|
|
3522
|
+
+ (dedupedOnFallback > 0 ? ';另有 ' + dedupedOnFallback + ' 项因回退到当前配置集后与已有内容重复被跳过' : ''),
|
|
3523
|
+
snapshot: snapshot(),
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
const saved = await commit(() => {
|
|
3527
|
+
state.data.sets.push(...createdSets)
|
|
3528
|
+
for (const item of stagedEntries) state.data.entries.push(item.entry)
|
|
3529
|
+
for (const item of stagedProfiles) state.data.profiles.push(item.profile)
|
|
3279
3530
|
})
|
|
3280
3531
|
const parts = []
|
|
3281
3532
|
if (createdSets.length > 0) {
|
|
@@ -3284,19 +3535,41 @@ export function apply(ctx) {
|
|
|
3284
3535
|
if (fellBack > 0) {
|
|
3285
3536
|
parts.push('有 ' + fellBack + ' 项因原配置集不存在或已达 ' + MAX_SETS + ' 个上限,已归入当前配置集')
|
|
3286
3537
|
}
|
|
3287
|
-
if (
|
|
3288
|
-
|
|
3289
|
-
|
|
3538
|
+
if (dedupedOnFallback > 0) {
|
|
3539
|
+
parts.push('另有 ' + dedupedOnFallback + ' 项因回退到当前配置集后与已有内容重复被跳过')
|
|
3540
|
+
}
|
|
3541
|
+
if (stagedEntries.length > 0) {
|
|
3542
|
+
// Counts quote what actually LANDED: staged ⊆ accepted, and the gap is
|
|
3543
|
+
// the fallback duplicates already disclosed on their own line — an
|
|
3544
|
+
// accepted-based count would claim inserts that were skipped.
|
|
3545
|
+
const stagedCut = new Set()
|
|
3546
|
+
let stagedTruncated = 0
|
|
3547
|
+
for (const item of stagedEntries) {
|
|
3548
|
+
if (item.cut.length > 0) {
|
|
3549
|
+
stagedTruncated += 1
|
|
3550
|
+
for (const field of item.cut) stagedCut.add(field)
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
const truncated = stagedTruncated > 0
|
|
3554
|
+
? ';其中 ' + stagedTruncated + ' 条超过长度上限,' + [...stagedCut].join('、') + '已被截断'
|
|
3290
3555
|
: ''
|
|
3291
|
-
parts.push('新增指令 ' +
|
|
3556
|
+
parts.push('新增指令 ' + stagedEntries.length + ' 条,跳过 ' + skipped + ' 条' + truncated)
|
|
3292
3557
|
} else if (skipped > 0) {
|
|
3293
3558
|
parts.push('指令跳过 ' + skipped + ' 条(已存在或无效)')
|
|
3294
3559
|
}
|
|
3295
|
-
if (
|
|
3296
|
-
const
|
|
3297
|
-
|
|
3560
|
+
if (stagedProfiles.length > 0) {
|
|
3561
|
+
const stagedCut = new Set()
|
|
3562
|
+
let stagedTruncated = 0
|
|
3563
|
+
for (const item of stagedProfiles) {
|
|
3564
|
+
if (item.cut.length > 0) {
|
|
3565
|
+
stagedTruncated += 1
|
|
3566
|
+
for (const field of item.cut) stagedCut.add(field)
|
|
3567
|
+
}
|
|
3568
|
+
}
|
|
3569
|
+
const truncated = stagedTruncated > 0
|
|
3570
|
+
? ';其中 ' + stagedTruncated + ' 个超过长度上限,' + [...stagedCut].join('、') + '已被截断'
|
|
3298
3571
|
: ''
|
|
3299
|
-
parts.push('新增用户形象 ' +
|
|
3572
|
+
parts.push('新增用户形象 ' + stagedProfiles.length + ' 个,跳过 ' + skippedProfiles + ' 个' + truncated)
|
|
3300
3573
|
} else if (skippedProfiles > 0) {
|
|
3301
3574
|
parts.push('用户形象跳过 ' + skippedProfiles + ' 个(已存在或无效)')
|
|
3302
3575
|
}
|
|
@@ -3395,11 +3668,8 @@ export function apply(ctx) {
|
|
|
3395
3668
|
|
|
3396
3669
|
/* ---------------- same-origin JSON route for the Client half ---------------- */
|
|
3397
3670
|
|
|
3398
|
-
//
|
|
3399
|
-
//
|
|
3400
|
-
// requestOriginVerdict). Until then the route does not exist at all, so the
|
|
3401
|
-
// default only matters if something registers it out of band.
|
|
3402
|
-
let guardHost = true
|
|
3671
|
+
// `guardHost` is declared next to `state` (F12 publishes it as
|
|
3672
|
+
// `security.hostGuarded`); only `routeDispose` lives here.
|
|
3403
3673
|
|
|
3404
3674
|
let routeDispose = null
|
|
3405
3675
|
|
|
@@ -3574,6 +3844,9 @@ export function apply(ctx) {
|
|
|
3574
3844
|
// the README.
|
|
3575
3845
|
const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
|
|
3576
3846
|
guardHost = boundHost === '' || isLoopbackName(boundHost)
|
|
3847
|
+
// F12: the settings page can only warn about a non-loopback bind if the
|
|
3848
|
+
// snapshot carries what the route actually saw at registration time.
|
|
3849
|
+
state.boundHost = boundHost
|
|
3577
3850
|
try {
|
|
3578
3851
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
3579
3852
|
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|
package/memory.schema.json
CHANGED
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
"description": "总开关。为 false 时指令与用户形象都不注入。",
|
|
28
28
|
"default": true
|
|
29
29
|
},
|
|
30
|
+
"disabledUntil": {
|
|
31
|
+
"type": "integer",
|
|
32
|
+
"minimum": 0,
|
|
33
|
+
"description": "临时静音(F7):暂停注入的到期时刻(epoch 毫秒)。0 或缺失 = 未暂停;未来值 = 暂停中,到期后由插件自动恢复注入(无需用户操作,也不产生写盘,rev 不变)。它与总开关相互独立:手动切换 enabled 会把它清零(显式开关优先于定时)。字段缺失时按未暂停处理,不触发任何迁移。",
|
|
34
|
+
"default": 0
|
|
35
|
+
},
|
|
30
36
|
"budgetChars": {
|
|
31
37
|
"type": "integer",
|
|
32
38
|
"minimum": 800,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-instruction-memory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "User-only instruction memory for DSH: long-term instructions maintained in the settings UI and auto-injected into every conversation — the model has no write access. 指令记忆:只由用户在设置页维护的长期指令,自动注入此后每轮对话;模型没有任何写入口。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|