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/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
- export function planInjection(data) {
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
- return
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. Materialising the
2074
- * empty in-memory store over it would destroy exactly what the user just
2075
- * put there, so the disk is re-checked first and an existing file is
2076
- * adopted instead. (The gap is wide enough to matter: the panel's first
2077
- * `state` call can come minutes after boot.)
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
- const appeared = await readStoreFile(storePath).catch(() => null)
2080
- if (appeared !== null) {
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') state.data.enabled = patch.enabled
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
- let note = null
2712
+ const notes = []
2520
2713
  if (patch !== null && typeof patch === 'object' && typeof patch.backupDir === 'string') {
2521
2714
  const wanted = patch.backupDir.trim()
2522
- note = wanted === ''
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
- return settle(note, saved)
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
- if (localSetIds.has(wanted) || createdIds.has(wanted)) return wanted
3266
- fellBack += 1
3267
- return activeId
3268
- }
3269
- const saved = await commit(() => {
3270
- state.data.sets.push(...createdSets)
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.entry.setId = finalIdOf(item.wanted)
3273
- state.data.entries.push(item.entry)
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
- item.profile.setId = finalIdOf(item.wanted)
3277
- state.data.profiles.push(item.profile)
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 (accepted.length > 0) {
3288
- const truncated = truncatedCount > 0
3289
- ? ';其中 ' + truncatedCount + ' 条超过长度上限,' + [...cutFields].join('、') + '已被截断'
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('新增指令 ' + accepted.length + ' 条,跳过 ' + skipped + ' 条' + truncated)
3556
+ parts.push('新增指令 ' + stagedEntries.length + ' 条,跳过 ' + skipped + ' 条' + truncated)
3292
3557
  } else if (skipped > 0) {
3293
3558
  parts.push('指令跳过 ' + skipped + ' 条(已存在或无效)')
3294
3559
  }
3295
- if (acceptedProfiles.length > 0) {
3296
- const truncated = truncatedProfileCount > 0
3297
- ? ';其中 ' + truncatedProfileCount + ' 个超过长度上限,' + [...cutProfileFields].join('、') + '已被截断'
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('新增用户形象 ' + acceptedProfiles.length + ' 个,跳过 ' + skippedProfiles + ' 个' + truncated)
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
- // Which Host values this route accepts. Set when the route is registered,
3399
- // from the address the web server is actually bound to (see the long note on
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 + ')')
@@ -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.2.0",
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",