@tobq/boardclip 2.1.113 → 2.1.114

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/main.js CHANGED
@@ -676,6 +676,28 @@ function effectiveQuickPasteShortcut() {
676
676
  return settings.quick_paste_shortcut || defaultQuickPasteShortcut();
677
677
  }
678
678
 
679
+ // Windows: Win+N, taken by the keyboard hook like Win+V (it is Windows' own
680
+ // notification panel chord, which RegisterHotKey cannot win). Elsewhere none
681
+ // until one is recorded.
682
+ function defaultNewNoteShortcut() {
683
+ return process.platform === 'win32' ? 'Super+N' : '';
684
+ }
685
+
686
+ // A stored shortcut of SHORTCUT_OFF = none, even where the default is a chord
687
+ // (Windows: Win+N goes back to Windows' notification panel). '' = the default.
688
+ const SHORTCUT_OFF = 'none';
689
+
690
+ // The chord the user recorded ('' for the default or off).
691
+ function recordedNewNoteShortcut() {
692
+ const value = settings.new_note_shortcut || '';
693
+ return value === SHORTCUT_OFF ? '' : value;
694
+ }
695
+
696
+ function effectiveNewNoteShortcut() {
697
+ if (settings.new_note_shortcut === SHORTCUT_OFF) return '';
698
+ return settings.new_note_shortcut || defaultNewNoteShortcut();
699
+ }
700
+
679
701
  function globalShowShortcut() {
680
702
  if (process.platform === 'darwin' && shortcutUsesFn(settings.show_shortcut)) return '';
681
703
  return settings.show_shortcut || (process.platform === 'win32' ? '' : DEFAULT_SHOW_SHORTCUT);
@@ -700,6 +722,14 @@ function normalizeQuickPasteShortcut(shortcut) {
700
722
  return value;
701
723
  }
702
724
 
725
+ function normalizeNewNoteShortcut(shortcut) {
726
+ const value = String(shortcut || '').trim();
727
+ if (!value || value === defaultNewNoteShortcut()) return '';
728
+ // Off is stored only where the default is a chord (on macOS it IS the default).
729
+ if (value.toLowerCase() === SHORTCUT_OFF) return defaultNewNoteShortcut() ? SHORTCUT_OFF : '';
730
+ return value;
731
+ }
732
+
703
733
  function shortcutUsesFn(shortcut) {
704
734
  return String(shortcut || '').split('+').some(part => {
705
735
  const value = part.trim().toLowerCase();
@@ -840,6 +870,12 @@ let history = loadHistory();
840
870
  for (const h of history) migrateItemPin(h);
841
871
  for (const h of history) ensureItemId(h);
842
872
  let conflicts = loadConflicts();
873
+ // Flat tags (14 Oct 2026): ONE pure plan over both files (clipboardModel.planTagState),
874
+ // run first thing in whenReady (a second instance never gets there: no stray copy) and
875
+ // persisted at once, settings then history. No done flag: nothing in the plan is a one-off
876
+ // edit, so a start that dies between the two writes (or an MCP read between them) gets the
877
+ // very same clips from the new settings and the old history.
878
+ let tagMigration = null;
843
879
 
844
880
  // --- Pin model ---
845
881
  // Unified state: item.pin is null/undefined for unpinned items, or an object
@@ -935,7 +971,7 @@ function applyExternalTextEdit({ id, originalText, originalTitle, sourceGroups,
935
971
  originalTitle,
936
972
  newText,
937
973
  newTitle,
938
- sourceGroups,
974
+ sourceGroups: normalizeTagNames(Array.isArray(sourceGroups) ? sourceGroups : []),
939
975
  groupTombstones: settings.group_tombstones,
940
976
  supersedes: settings.supersedes,
941
977
  now: Date.now(),
@@ -1064,6 +1100,170 @@ function pruneHistory() {
1064
1100
  }
1065
1101
  }
1066
1102
 
1103
+ // --- Flat tags ---
1104
+ // The cutover (this device's first start of the flat-tags build) keeps a copy of the
1105
+ // three files plus an undo record (each changed clip's old tags) in clipboard-RECOVERED/
1106
+ // BEFORE anything changes in memory. No copy, no change: the app then runs on the old
1107
+ // names until the next start tries again.
1108
+ function migrateTagsAtLoad() {
1109
+ let plan;
1110
+ try {
1111
+ plan = clipboardModel.planTagState(history, settings, { aiGroupName: mcpCore.AI_GROUP_NAME });
1112
+ } catch (error) {
1113
+ diagnostics.record('groups.flatten.error', { stage: 'plan', error: error && error.message }, { forceFile: true });
1114
+ return null;
1115
+ }
1116
+ if (!plan.changed) return null;
1117
+ let evidence = '';
1118
+ if (plan.report.cutover && (plan.undo.length || plan.report.retired.length)) {
1119
+ try {
1120
+ evidence = saveFlattenEvidence(plan);
1121
+ } catch (error) {
1122
+ diagnostics.record('groups.flatten.error', { stage: 'evidence', error: error && error.message }, { forceFile: true });
1123
+ return null;
1124
+ }
1125
+ }
1126
+ history.splice(0, history.length, ...plan.history);
1127
+ Object.assign(settings, plan.settings);
1128
+ return { ...plan, evidence };
1129
+ }
1130
+
1131
+ function saveFlattenEvidence(plan) {
1132
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1133
+ // The local data dir, never a provider path.
1134
+ const evidenceDir = path.join(DATA_DIR, 'clipboard-RECOVERED', `flatten-groups-${stamp}`);
1135
+ fs.mkdirSync(evidenceDir, { recursive: true });
1136
+ for (const file of [DB_PATH, SETTINGS_PATH, CONFLICTS_PATH]) {
1137
+ if (fs.existsSync(file)) fs.copyFileSync(file, path.join(evidenceDir, path.basename(file)));
1138
+ }
1139
+ flattenLedgerCache = null;
1140
+ fs.writeFileSync(path.join(evidenceDir, 'undo.json'), JSON.stringify({
1141
+ note: 'Flat-tags cutover: each changed clip with the tags it had before, plus the old tag list and AI share list. Ids do not change, so the old tags can be put back by id.',
1142
+ at: Date.now(),
1143
+ report: plan.report,
1144
+ groups: settings.groups || [],
1145
+ groups_shared_with_ai: settings.groups_shared_with_ai || [],
1146
+ clips: plan.undo,
1147
+ }, null, 2));
1148
+ return evidenceDir;
1149
+ }
1150
+
1151
+ // whenReady, before any other write.
1152
+ function persistTagMigration() {
1153
+ if (!tagMigration) return;
1154
+ saveSettingsFile();
1155
+ writeHistoryStorageFile();
1156
+ const r = tagMigration.report;
1157
+ diagnostics.record('groups.flatten', {
1158
+ cutover: r.cutover, retired: r.retired.length, clips_changed: r.clipsChanged,
1159
+ groups_before: r.groupsBefore, groups_after: r.groupsAfter,
1160
+ shared_before: r.sharedBefore, shared_after: r.sharedAfter,
1161
+ visible_before: r.visibleBefore, visible_after: r.visibleAfter,
1162
+ unshared_to_protect: r.unsharedToProtect.length,
1163
+ copy_path: tagMigration.evidence || '',
1164
+ }, { forceFile: true });
1165
+ }
1166
+
1167
+ // Every cutover undo ledger this device wrote (normally one; a start that died half way
1168
+ // leaves a second), the earliest first: unsplitLiterals reads each clip's first entry.
1169
+ let flattenLedgerCache = null;
1170
+ function flattenLedger() {
1171
+ if (flattenLedgerCache) return flattenLedgerCache;
1172
+ const entries = [];
1173
+ const recoveredRoot = path.join(DATA_DIR, 'clipboard-RECOVERED');
1174
+ let names = [];
1175
+ try { names = fs.readdirSync(recoveredRoot).filter(n => n.startsWith('flatten-groups-')).sort(); } catch {}
1176
+ for (const name of names) {
1177
+ try {
1178
+ const undo = blobStore.parseJsonText(fs.readFileSync(path.join(recoveredRoot, name, 'undo.json'), 'utf-8'));
1179
+ if (undo && Array.isArray(undo.clips)) entries.push(...undo.clips);
1180
+ } catch {}
1181
+ }
1182
+ flattenLedgerCache = entries;
1183
+ return entries;
1184
+ }
1185
+
1186
+ // A name another device made with a plain '/' that this device's cutover split goes back
1187
+ // on this device's own clips (clipboardModel.unsplitLiterals). Returns the new history,
1188
+ // or null when nothing changed.
1189
+ function unsplitLiteralTags(list, source) {
1190
+ if (!(Number(settings.groups_flattened_at) > 0) || !(settings.group_literals || []).length) return null;
1191
+ const literalKeys = new Set(clipboardModel.normalizeGroupLiterals(settings.group_literals).map(r => clipSearch.tagKey(r.name)));
1192
+ if (!clipboardModel.normalizeGroupSplits(settings.group_splits).some(r => literalKeys.has(clipSearch.tagKey(r.name)))) return null;
1193
+ const result = clipboardModel.unsplitLiterals(list, flattenLedger(), settings);
1194
+ if (!result.ids.length) return null;
1195
+ diagnostics.record('groups.flatten.unsplit', { source, clips: result.ids.length }, { forceFile: true });
1196
+ return result;
1197
+ }
1198
+
1199
+ // The tag rule applied to a name list against the current settings (retired names,
1200
+ // stored spellings, deleted tags). For every write boundary: typed input, AI tools,
1201
+ // new-note tags, unify and fork unions, restored clips.
1202
+ function currentTagContext(extraLists = []) {
1203
+ return clipboardModel.tagContext(settings, extraLists);
1204
+ }
1205
+ function normalizeTagNames(names, ctx = currentTagContext([names])) {
1206
+ return clipboardModel.normalizeTagList((Array.isArray(names) ? names : []).map(String), ctx);
1207
+ }
1208
+ // Names typed or sent in one go ("todo, forge"), in stored form.
1209
+ function tagInputNames(input) {
1210
+ return clipboardModel.tagInputNames(input, settings);
1211
+ }
1212
+ // Make sure settings.groups lists each tag (one entry per tag); a tag deleted
1213
+ // earlier is re-created (its tombstone records the re-create, so sync keeps it).
1214
+ function registerTags(names) {
1215
+ if (!settings.groups) settings.groups = [];
1216
+ const listed = new Set(settings.groups.map(clipSearch.tagKey));
1217
+ let changed = false;
1218
+ const now = Date.now();
1219
+ const tombstones = normalizeGroupTombstones(settings.group_tombstones);
1220
+ let restored = false;
1221
+ for (const name of names) {
1222
+ const key = clipSearch.tagKey(name);
1223
+ if (!key) continue;
1224
+ for (const t of tombstones) {
1225
+ if (clipSearch.tagKey(t.name) === key && t.deletedAt > (t.restoredAt || 0)) { t.restoredAt = now; restored = true; }
1226
+ }
1227
+ if (listed.has(key)) continue;
1228
+ listed.add(key);
1229
+ settings.groups.push(name);
1230
+ changed = true;
1231
+ }
1232
+ if (restored) settings.group_tombstones = normalizeGroupTombstones(tombstones);
1233
+ return changed || restored;
1234
+ }
1235
+ // Tags the user or an AI tool named (typed, picked, sent). A brand-new name with a '/' is
1236
+ // a plain tag here: group_literals records it, so a device on its first flat start can
1237
+ // never retire it as an old nested name. Never for names that arrive with data (sync,
1238
+ // restored clips, unions): an old device's nested name there must stay retirable.
1239
+ function registerTypedTags(names) {
1240
+ const listed = new Set((settings.groups || []).map(clipSearch.tagKey));
1241
+ const fresh = names.filter(name => name.includes('/') && !listed.has(clipSearch.tagKey(name)));
1242
+ let literal = false;
1243
+ if (fresh.length) {
1244
+ const before = JSON.stringify(settings.group_literals || []);
1245
+ const at = Date.now();
1246
+ settings.group_literals = clipboardModel.normalizeGroupLiterals([...(settings.group_literals || []), ...fresh.map(name => ({ name, at }))]);
1247
+ literal = JSON.stringify(settings.group_literals) !== before;
1248
+ }
1249
+ return registerTags(names) || literal;
1250
+ }
1251
+ function itemHasTag(item, name) {
1252
+ const key = clipSearch.tagKey(name);
1253
+ return groupsOf(item).some(g => clipSearch.tagKey(g) === key);
1254
+ }
1255
+ function setItemTag(item, name, shouldHave) {
1256
+ if (!!shouldHave === itemHasTag(item, name)) return false;
1257
+ const pin = ensurePin(item);
1258
+ const key = clipSearch.tagKey(name);
1259
+ if (shouldHave) pin.groups = [...(pin.groups || []), name];
1260
+ else {
1261
+ pin.groups = (pin.groups || []).filter(g => clipSearch.tagKey(g) !== key);
1262
+ if (!pin.groups.length) delete pin.groups;
1263
+ }
1264
+ return true;
1265
+ }
1266
+
1067
1267
  // --- Migration: old settings.numpad_slots -> per-item pin ---
1068
1268
  // Runs once on first launch after upgrading from the Python-era config.
1069
1269
  function migrateNumpad() {
@@ -1098,10 +1298,6 @@ function normalizeSupersedes(list) {
1098
1298
  return clipboardModel.normalizeSupersedes(list);
1099
1299
  }
1100
1300
 
1101
- function groupTombstoneNames(list) {
1102
- return clipboardModel.groupTombstoneNames(list);
1103
- }
1104
-
1105
1301
  function tombstoneIds(list) {
1106
1302
  return clipboardModel.tombstoneIds(list);
1107
1303
  }
@@ -1129,11 +1325,11 @@ function addSupersede(record) {
1129
1325
  ]);
1130
1326
  }
1131
1327
 
1132
- function addGroupTombstone(name) {
1328
+ function addGroupTombstone(name, deletedAt = Date.now()) {
1133
1329
  if (!name) return;
1134
1330
  settings.group_tombstones = normalizeGroupTombstones([
1135
1331
  ...(settings.group_tombstones || []),
1136
- { name, deletedAt: Date.now() },
1332
+ { name, deletedAt, anyCase: true },
1137
1333
  ]);
1138
1334
  }
1139
1335
 
@@ -1168,11 +1364,20 @@ function mergeItems(localItem, remoteItem) {
1168
1364
  // each event+clip is logged at most once an hour.
1169
1365
  const forensicsLastLogged = new Map();
1170
1366
  const FORENSICS_REPEAT_MS = 60 * 60 * 1000;
1367
+ // Ids of LOCAL clips the last merge put in stored tag form (a tag deleted on another
1368
+ // device, a spelling twin arriving): applyRemoteEnvelope counts them as touched.
1369
+ let lastMergeNormalisedIds = new Set();
1171
1370
  function mergeHistories(local, remote) {
1172
1371
  const report = [];
1173
1372
  const merged = clipboardModel.mergeHistories(local, remote, settings, report);
1174
1373
  const now = Date.now();
1374
+ lastMergeNormalisedIds = new Set(report.filter(e => e.event === 'normalised').map(e => e.id));
1375
+ // A tag deleted or respelled elsewhere is routine; a retired nested name still on a local
1376
+ // clip is not (it should have gone at the cutover).
1377
+ const stragglers = report.filter(e => e.event === 'normalised' && e.split).length;
1378
+ if (stragglers) diagnostics.record('groups.flatten.straggler', { source: 'merge', local_clips: stragglers }, { forceFile: true });
1175
1379
  for (const entry of report) {
1380
+ if (entry.event === 'normalised') continue;
1176
1381
  const key = `${entry.event}:${entry.reason || ''}:${entry.id}`;
1177
1382
  if (now - (forensicsLastLogged.get(key) || 0) < FORENSICS_REPEAT_MS) continue;
1178
1383
  forensicsLastLogged.set(key, now);
@@ -1196,7 +1401,7 @@ function mergeHistories(local, remote) {
1196
1401
  }
1197
1402
 
1198
1403
  function mergeGroups(local, remote) {
1199
- return clipboardModel.mergeGroups(local, remote, settings.group_tombstones);
1404
+ return clipboardModel.mergeGroups(local, remote, settings.group_tombstones, currentTagContext([local || [], remote || []]));
1200
1405
  }
1201
1406
 
1202
1407
  // Settings the save-settings IPC may write without touching history or sync
@@ -1217,6 +1422,7 @@ function remoteSettingsPayload() {
1217
1422
  delete remoteSave.sync_disabled_paths;
1218
1423
  delete remoteSave.show_shortcut;
1219
1424
  delete remoteSave.quick_paste_shortcut;
1425
+ delete remoteSave.new_note_shortcut;
1220
1426
  delete remoteSave.quick_paste_restore;
1221
1427
  delete remoteSave.capture_rich;
1222
1428
  delete remoteSave.quick_paste_restore_delay_ms;
@@ -1241,8 +1447,12 @@ function remoteSettingsPayload() {
1241
1447
  delete remoteSave.facet_joins;
1242
1448
  delete remoteSave.find_regex;
1243
1449
  delete remoteSave.find_case;
1244
- // AI Access: per-machine, never synced. (groups_shared_with_ai DOES sync - it
1245
- // is user curation that should travel between machines.)
1450
+ // When this device cut over to flat tags: per device.
1451
+ delete remoteSave.groups_flattened_at;
1452
+ // The AI share list itself is per device (switch flips travel as ai_share_stamps).
1453
+ delete remoteSave.groups_shared_with_ai;
1454
+ // AI Access: per-machine, never synced. The AI share switches sync per tag through
1455
+ // ai_share_stamps (newest flip wins); the list itself is each device's own.
1246
1456
  delete remoteSave.mcp_secret;
1247
1457
  delete remoteSave.ai_access_enabled;
1248
1458
  delete remoteSave.ai_always_allow;
@@ -1266,6 +1476,40 @@ function mergeSyncedSettings(remoteSettings) {
1266
1476
  ...(settings.supersedes || []),
1267
1477
  ...(remoteSettings.supersedes || []),
1268
1478
  ]);
1479
+ // Retired nested tag names and plain '/' names (both permanent, every device agrees on
1480
+ // the earliest record).
1481
+ const splitsBefore = clipboardModel.splitMapOf(settings);
1482
+ const literalsBefore = (settings.group_literals || []).length;
1483
+ settings.group_splits = clipboardModel.normalizeGroupSplits([
1484
+ ...(settings.group_splits || []),
1485
+ ...(remoteSettings.group_splits || []),
1486
+ ]);
1487
+ settings.group_literals = clipboardModel.normalizeGroupLiterals([
1488
+ ...(settings.group_literals || []),
1489
+ ...(remoteSettings.group_literals || []),
1490
+ ]);
1491
+ if (settings.group_literals.length !== literalsBefore) unsplitPending = true;
1492
+ // A split new here (another device's late cutover) follows the cutover's share rule.
1493
+ const newSplits = [...clipboardModel.splitMapOf(settings)].filter(([key]) => !splitsBefore.has(key)).map(([name, parts]) => ({ name, parts }));
1494
+ if (newSplits.length) {
1495
+ const held = [...(settings.groups || []), ...history.flatMap(h => groupsOf(h))];
1496
+ const result = clipboardModel.shareListAfterNewSplits(settings.groups_shared_with_ai, newSplits, held);
1497
+ if (result.unshared.length) {
1498
+ settings.groups_shared_with_ai = result.shared;
1499
+ diagnostics.record('groups.flatten.late_split', { splits: newSplits.length, unshared: result.unshared.length }, { forceFile: true });
1500
+ notifyAiAccessChangedSoon();
1501
+ }
1502
+ }
1503
+ // AI share switches: a newer flip on another device applies here.
1504
+ const stamps = clipboardModel.mergeShareStamps(settings.ai_share_stamps, remoteSettings.ai_share_stamps);
1505
+ if (JSON.stringify(stamps) !== JSON.stringify(clipboardModel.normalizeShareStamps(settings.ai_share_stamps))) {
1506
+ settings.ai_share_stamps = stamps;
1507
+ const shared = clipboardModel.applyShareStamps(settings.groups_shared_with_ai, stamps, currentTagContext().spellings);
1508
+ if (shared !== settings.groups_shared_with_ai) {
1509
+ settings.groups_shared_with_ai = shared;
1510
+ notifyAiAccessChangedSoon();
1511
+ }
1512
+ }
1269
1513
  const secrets = [settings.p2p_secret, remoteSettings.p2p_secret].filter(Boolean).sort();
1270
1514
  if (secrets.length) settings.p2p_secret = secrets[0];
1271
1515
  if (remoteSettings.p2p_enabled !== undefined && settings.p2p_enabled === undefined) {
@@ -1345,13 +1589,32 @@ function captureTitleConflicts(localHistory, remoteHistory, source) {
1345
1589
  }
1346
1590
  }
1347
1591
 
1592
+ // Set when a plain '/' name arrives (mergeSyncedSettings): the next fold puts it back on
1593
+ // the clips this device's cutover split, before that fold's merge.
1594
+ let unsplitPending = false;
1348
1595
  function foldRemoteState(canonicalHistory, remoteHistory, remoteSettings, remoteConflicts) {
1349
1596
  mergeSyncedSettings(remoteSettings);
1350
1597
  mergeSyncedConflicts(remoteConflicts);
1598
+ let unsplitIds = [];
1599
+ if (unsplitPending) {
1600
+ unsplitPending = false;
1601
+ const result = unsplitLiteralTags(canonicalHistory, 'sync');
1602
+ if (result) { canonicalHistory = result.history; unsplitIds = result.ids; }
1603
+ }
1351
1604
  captureTitleConflicts(canonicalHistory, remoteHistory, 'sync');
1352
1605
  const historyGroups = canonicalHistory.flatMap(h => groupsOf(h));
1353
- settings.groups = mergeGroups(settings.groups, [...(remoteSettings && remoteSettings.groups || []), ...historyGroups]);
1354
- return mergeHistories(canonicalHistory, remoteHistory);
1606
+ const remoteGroups = Array.isArray(remoteHistory) ? remoteHistory.flatMap(h => groupsOf(h || {})) : [];
1607
+ const listed = [...(remoteSettings && remoteSettings.groups || []), ...historyGroups];
1608
+ const ctx = currentTagContext([settings.groups || [], listed, remoteGroups]);
1609
+ settings.groups = clipboardModel.mergeGroups(settings.groups, listed, settings.group_tombstones, ctx);
1610
+ if (Array.isArray(settings.groups_shared_with_ai)) {
1611
+ const shared = clipSearch.expandTagNames(settings.groups_shared_with_ai, null, ctx.spellings);
1612
+ if (shared !== settings.groups_shared_with_ai) settings.groups_shared_with_ai = shared;
1613
+ }
1614
+ const merged = mergeHistories(canonicalHistory, remoteHistory);
1615
+ for (const id of unsplitIds) lastMergeNormalisedIds.add(id);
1616
+ if ((settings.group_literals || []).length) settings.groups = clipboardModel.pruneLiteralParts(settings.groups, settings, merged.flatMap(h => groupsOf(h)));
1617
+ return merged;
1355
1618
  }
1356
1619
 
1357
1620
  // ---- Sync change feed (lib/sync-delta): one revision counter + per-entry
@@ -2718,7 +2981,9 @@ async function applyRemoteEnvelope(envelope, { peer = null, reason, source, tran
2718
2981
  const previousConflictsJson = JSON.stringify(conflictModel.normalizeConflictState(conflicts));
2719
2982
  const before = history.slice();
2720
2983
  const canonicalHistory = foldRemoteState(history.slice(), remoteHistory, remoteSettings, conflictModel.normalizeConflictState(remoteConflicts));
2721
- const localChanged = syncDelta.historyChangedBy(before, canonicalHistory, syncDelta.touchedIds(envelope));
2984
+ const touched = syncDelta.touchedIds(envelope);
2985
+ for (const id of lastMergeNormalisedIds) touched.add(id);
2986
+ const localChanged = syncDelta.historyChangedBy(before, canonicalHistory, touched);
2722
2987
  const { settingsChanged, conflictsChanged } = commitRemoteFold(canonicalHistory, { localChanged, previousSettingsJson, previousConflictsJson, notifyPeers });
2723
2988
  const peerName = peer ? (peer.deviceName || peer.deviceId) : (envelope.deviceName || envelope.deviceId);
2724
2989
  if (localChanged || settingsChanged || conflictsChanged) {
@@ -3663,6 +3928,12 @@ function historyEntryDiagnostics(entry) {
3663
3928
  function addToHistory(entry, matchFn) {
3664
3929
  const startedAt = Date.now();
3665
3930
  ensureItemId(entry);
3931
+ // The same clip is the same id (ids hash the content). A long clip kept as a text
3932
+ // blob holds only a preview in item.text, so a text compare alone missed it and
3933
+ // added a second entry with the SAME id.
3934
+ const entryKey = itemKey(entry);
3935
+ const callerMatch = matchFn;
3936
+ matchFn = (it) => !!it && (itemKey(it) === entryKey || callerMatch(it));
3666
3937
  const beforeTombstones = normalizeTombstones(settings.tombstones);
3667
3938
  settings.tombstones = beforeTombstones.filter(t => t.id !== itemKey(entry));
3668
3939
  const tombstoneRemoved = settings.tombstones.length !== beforeTombstones.length;
@@ -5520,9 +5791,10 @@ function createEditorWindow(session, presentOptions = {}) {
5520
5791
  }
5521
5792
 
5522
5793
  function sourceGroupsForNewNote(options) {
5523
- return [...new Set((Array.isArray(options && options.sourceGroups) ? options.sourceGroups : [])
5524
- .map(group => String(group || '').split('/').map(part => part.trim()).filter(Boolean).join('/'))
5525
- .filter(group => group && !group.startsWith('__')))];
5794
+ const names = (Array.isArray(options && options.sourceGroups) ? options.sourceGroups : [])
5795
+ .map(group => clipSearch.cleanTagName(group))
5796
+ .filter(group => group && !group.startsWith('__'));
5797
+ return normalizeTagNames(names);
5526
5798
  }
5527
5799
 
5528
5800
  // Open the editor for clip `id`, or a blank new-note editor when id is null.
@@ -5607,9 +5879,7 @@ function openEditor(id, options = {}) {
5607
5879
  originalId = itemKey(item);
5608
5880
  } else {
5609
5881
  sourceGroups = sourceGroupsForNewNote(options);
5610
- for (const group of sourceGroups) {
5611
- if (!settings.groups || !settings.groups.includes(group)) applyGroupCreate(group);
5612
- }
5882
+ if (registerTags(sourceGroups)) saveSettingsFile();
5613
5883
  }
5614
5884
  const baseHash = clipboardModel.textHashForText(baseText);
5615
5885
  editSessionSeq += 1;
@@ -5794,8 +6064,7 @@ function foldForkInto(fork, note) {
5794
6064
  const forkId = itemKey(fork);
5795
6065
  const noteId = itemKey(note);
5796
6066
  const forkSlot = numpadSlotOf(fork);
5797
- const noteGroups = groupsOf(note);
5798
- const newGroups = groupsOf(fork).filter(group => !noteGroups.includes(group));
6067
+ const newGroups = normalizeTagNames(groupsOf(fork)).filter(group => !itemHasTag(note, group));
5799
6068
  if (newGroups.length) {
5800
6069
  const pin = ensurePin(note);
5801
6070
  pin.groups = [...(pin.groups || []), ...newGroups];
@@ -6062,13 +6331,10 @@ function applyUnify(session) {
6062
6331
  const item = history.find(it => it.type !== 'image' && it.text === mergedText);
6063
6332
  if (item) {
6064
6333
  if (mergedTitle) clipboardModel.setTitleMetadata(item, mergedTitle);
6065
- for (const group of session.unionGroups) {
6066
- if (!settings.groups || !settings.groups.includes(group)) applyGroupCreate(group);
6067
- const pin = ensurePin(item);
6068
- if (!pin.groups) pin.groups = [];
6069
- if (!pin.groups.includes(group)) pin.groups.push(group);
6070
- }
6071
- if (session.unionGroups.length) touchPinGroups(item);
6334
+ const unionGroups = normalizeTagNames(session.unionGroups);
6335
+ if (registerTags(unionGroups)) saveSettingsFile();
6336
+ for (const group of unionGroups) setItemTag(item, group, true);
6337
+ if (unionGroups.length) touchPinGroups(item);
6072
6338
  if (session.pinned && !item.pin) item.pin = {};
6073
6339
  saveHistory();
6074
6340
  if (session.numberSlot != null) applyNumpadAssign(itemKey(item), session.numberSlot, clipboardModel.clipRevision(item));
@@ -6147,9 +6413,11 @@ const historyFeedSenders = new Set();
6147
6413
  function historyForRenderer() {
6148
6414
  return history.map(item => ({ ...item, rev: clipboardModel.clipRevision(item) }));
6149
6415
  }
6416
+ // What an MCP change says about its clip: the id and rev an AI holds for it (the
6417
+ // lib/mcp-core boundary: a real id only for a clip it can read, a keyed rev).
6150
6418
  function mcpMutationResult(id, ok) {
6151
6419
  const item = findHistoryItem(id);
6152
- return { ok: !!ok, id, rev: item ? clipboardModel.clipRevision(item) : null };
6420
+ return { ok: !!ok, id: item ? mcpCore.publicId(item, settings) : null, rev: item ? mcpCore.publicRev(item, settings) : null };
6153
6421
  }
6154
6422
 
6155
6423
  // ---- Reusable mutation primitives (shared by IPC + MCP) ----
@@ -6187,56 +6455,127 @@ function applyNumpadAssign(id, slot, rev) {
6187
6455
  return true;
6188
6456
  }
6189
6457
 
6190
- function applyGroupCreate(name) {
6191
- if (!name) return false;
6192
- if (!settings.groups) settings.groups = [];
6193
- settings.group_tombstones = normalizeGroupTombstones(settings.group_tombstones)
6194
- .filter(t => t.name !== name);
6195
- if (!settings.groups.includes(name)) settings.groups.push(name);
6458
+ // Create tags from typed input ("todo, forge" = two). A name matching an existing tag
6459
+ // in any case reuses it. Returns the tag names, or false for nothing to create.
6460
+ function applyGroupCreate(input) {
6461
+ const names = tagInputNames(input);
6462
+ if (!names.length) return false;
6463
+ registerTypedTags(names);
6196
6464
  saveSettingsFile();
6465
+ return names;
6466
+ }
6467
+
6468
+ // A deleted tag is shared with no one, so re-creating it later starts private on every
6469
+ // device: the unshare travels as a share stamp, but only once the Undo window has passed
6470
+ // (an Undo must not share or unshare anything on another device).
6471
+ const GROUP_DELETE_UNDO_MS = 15000;
6472
+ const pendingDeleteStamps = new Map();
6473
+ function commitDeleteStamp(key) {
6474
+ const pending = pendingDeleteStamps.get(key);
6475
+ if (!pending) return false;
6476
+ clearTimeout(pending.timer);
6477
+ pendingDeleteStamps.delete(key);
6478
+ if ((settings.groups || []).some(g => clipSearch.tagKey(g) === key)) return false;
6479
+ stampShareSwitch(pending.name, false, pending.at);
6197
6480
  return true;
6198
6481
  }
6482
+ function stampDeletedTagUnshared(name, at) {
6483
+ const key = clipSearch.tagKey(name);
6484
+ cancelDeleteStamp(key);
6485
+ const timer = setTimeout(() => { if (commitDeleteStamp(key)) saveSettingsFile(); }, GROUP_DELETE_UNDO_MS);
6486
+ if (timer.unref) timer.unref();
6487
+ pendingDeleteStamps.set(key, { timer, name, at });
6488
+ }
6489
+ function cancelDeleteStamp(key) {
6490
+ const pending = pendingDeleteStamps.get(key);
6491
+ if (pending) clearTimeout(pending.timer);
6492
+ pendingDeleteStamps.delete(key);
6493
+ }
6494
+ // Quitting inside an Undo window: the delete stands, so its unshare goes out now.
6495
+ function flushDeleteStamps() {
6496
+ let changed = false;
6497
+ for (const key of [...pendingDeleteStamps.keys()]) if (commitDeleteStamp(key)) changed = true;
6498
+ if (changed) saveSettingsFile();
6499
+ }
6199
6500
 
6501
+ // Delete ONE tag by its name (any case; never split: this is destructive). Only the
6502
+ // clips that had it are touched. The AI tag cannot be deleted. Returns what an Undo needs
6503
+ // (applyGroupRestore).
6200
6504
  function applyGroupDelete(name) {
6505
+ const key = clipSearch.tagKey(name);
6201
6506
  const groups = settings.groups || [];
6202
- const idx = groups.indexOf(name);
6203
- if (idx < 0) return false;
6204
- addGroupTombstone(name);
6205
- groups.splice(idx, 1);
6507
+ const idx = groups.findIndex(g => clipSearch.tagKey(g) === key);
6508
+ if (!key || idx < 0 || clipSearch.sameTag(name, mcpCore.AI_GROUP_NAME)) return false;
6509
+ const stored = groups[idx];
6206
6510
  const now = Date.now();
6511
+ addGroupTombstone(stored, now);
6512
+ groups.splice(idx, 1);
6513
+ const ids = [];
6207
6514
  for (const h of history) {
6208
- if (h.pin && h.pin.groups) {
6209
- h.pin.groups = h.pin.groups.filter(g => g !== name);
6210
- if (h.pin.groups.length === 0) delete h.pin.groups;
6211
- touchPinGroups(h, now);
6212
- }
6515
+ if (!setItemTag(h, stored, false)) continue;
6516
+ touchPinGroups(h, now);
6517
+ ids.push(itemKey(h));
6213
6518
  }
6214
- // Keep the AI-share allowlist consistent when a group is removed.
6215
- if (Array.isArray(settings.groups_shared_with_ai)) {
6216
- settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => g !== name);
6519
+ const shared = Array.isArray(settings.groups_shared_with_ai) && settings.groups_shared_with_ai.some(g => clipSearch.tagKey(g) === key);
6520
+ if (shared) {
6521
+ settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => clipSearch.tagKey(g) !== key);
6522
+ stampDeletedTagUnshared(stored, now);
6217
6523
  }
6218
6524
  saveSettingsFile();
6219
6525
  saveHistory();
6526
+ if (shared) notifyAiAccessChanged();
6527
+ return { ok: true, name: stored, ids, shared, index: idx };
6528
+ }
6529
+
6530
+ // Undo of a tag delete: the tag back where it was in the list (a re-create, which beats
6531
+ // the delete in sync), on the clips that had it, and shared with AI here again if it was.
6532
+ function applyGroupRestore(payload) {
6533
+ const typed = clipSearch.cleanTagName(payload && payload.name);
6534
+ if (!typed) return false;
6535
+ const key = clipSearch.tagKey(typed);
6536
+ const name = currentTagContext([[typed]]).spellings.get(key) || typed;
6537
+ cancelDeleteStamp(key);
6538
+ if (registerTags([name]) && Number.isInteger(payload.index) && payload.index >= 0) {
6539
+ const groups = settings.groups;
6540
+ const at = groups.findIndex(g => clipSearch.tagKey(g) === key);
6541
+ const index = Math.min(payload.index, groups.length - 1);
6542
+ if (at > index) groups.splice(index, 0, ...groups.splice(at, 1));
6543
+ }
6544
+ const ids = new Set(Array.isArray(payload.ids) ? payload.ids.map(String) : []);
6545
+ const now = Date.now();
6546
+ let changed = false;
6547
+ for (const h of history) {
6548
+ if (!ids.has(itemKey(h)) || !setItemTag(h, name, true)) continue;
6549
+ touchPinGroups(h, now);
6550
+ changed = true;
6551
+ }
6552
+ if (payload.shared) {
6553
+ if (!Array.isArray(settings.groups_shared_with_ai)) settings.groups_shared_with_ai = [];
6554
+ if (!settings.groups_shared_with_ai.some(g => clipSearch.tagKey(g) === key)) settings.groups_shared_with_ai.push(name);
6555
+ }
6556
+ saveSettingsFile();
6557
+ if (changed) saveHistory();
6558
+ if (payload.shared) notifyAiAccessChanged();
6220
6559
  return true;
6221
6560
  }
6222
6561
 
6223
- // Toggle membership in a group. Multi-group: an item can belong to many.
6562
+ // The tag toggle (a row's picker, an AI tool's assign_group). Several tags at once
6563
+ // ("todo, forge", or a retired name like todo/forge) are ONE decision: the missing ones
6564
+ // are added; if the clip already has them all, they are all removed.
6565
+ function planTagToggle(item, input) {
6566
+ return clipSearch.tagTogglePlan(groupsOf(item), tagInputNames(input));
6567
+ }
6568
+
6224
6569
  function applyGroupAssign(id, group, rev) {
6225
- if (!group) return false;
6226
6570
  const item = assertClipRevision(id, rev);
6227
- if (!settings.groups || !settings.groups.includes(group)) applyGroupCreate(group);
6228
- const pin = ensurePin(item);
6229
- if (!pin.groups) pin.groups = [];
6230
- const gIdx = pin.groups.indexOf(group);
6231
- if (gIdx >= 0) {
6232
- pin.groups.splice(gIdx, 1);
6233
- if (pin.groups.length === 0) delete pin.groups;
6234
- } else {
6235
- pin.groups.push(group);
6236
- }
6571
+ const plan = planTagToggle(item, group);
6572
+ if (!plan.add.length && !plan.remove.length) return false;
6573
+ if (registerTypedTags(plan.add)) saveSettingsFile();
6574
+ for (const name of plan.add) setItemTag(item, name, true);
6575
+ for (const name of plan.remove) setItemTag(item, name, false);
6237
6576
  touchPinGroups(item);
6238
6577
  saveHistory();
6239
- return true;
6578
+ return { ok: true, added: plan.add, removed: plan.remove };
6240
6579
  }
6241
6580
 
6242
6581
  function applyDeleteItem(id, rev) {
@@ -6279,13 +6618,19 @@ function applyDeleteItems(targets) {
6279
6618
  // tombstone so a later sync can't resurrect the deletion.
6280
6619
  function applyRestoreItems(snapshots) {
6281
6620
  let changed = false;
6621
+ const ctx = currentTagContext((Array.isArray(snapshots) ? snapshots : []).map(snap => groupsOf(snap || {})));
6282
6622
  for (const snap of (Array.isArray(snapshots) ? snapshots : [])) {
6283
6623
  if (!snap) continue;
6284
- const restored = cloneHistoryItem(snap);
6624
+ const restored = clipboardModel.normalizeItemTags(cloneHistoryItem(snap), ctx);
6625
+ if (registerTags(groupsOf(restored))) changed = true;
6285
6626
  ensureItemId(restored);
6286
6627
  const id = itemKey(restored);
6287
6628
  if (findHistoryIndex(id) >= 0) continue;
6288
6629
  settings.tombstones = normalizeTombstones(settings.tombstones).filter(t => t.id !== id);
6630
+ // The delete may already be on a peer or a provider and comes back with their
6631
+ // tombstones: a clock newer than the delete (its time-of-capture clock, so the clip
6632
+ // keeps its place) makes this copy beat it there too, as a re-copy does.
6633
+ restored.tsUpdatedAt = Date.now();
6289
6634
  history.push(restored);
6290
6635
  changed = true;
6291
6636
  }
@@ -6303,27 +6648,22 @@ function applyRestoreItems(snapshots) {
6303
6648
  // Explicit (non-toggle) group membership, mirroring applyGroupAssign's pin
6304
6649
  // bookkeeping. Returns true if it changed the item.
6305
6650
  function setItemGroupMembership(item, group, shouldHave) {
6306
- const pin = ensurePin(item);
6307
- if (!pin.groups) pin.groups = [];
6308
- const idx = pin.groups.indexOf(group);
6309
- const has = idx >= 0;
6310
- if (!!shouldHave === has) return false;
6311
- if (shouldHave) pin.groups.push(group);
6312
- else { pin.groups.splice(idx, 1); if (pin.groups.length === 0) delete pin.groups; }
6651
+ if (!setItemTag(item, group, shouldHave)) return false;
6313
6652
  touchPinGroups(item);
6314
6653
  return true;
6315
6654
  }
6316
6655
 
6317
- // Bulk add/remove a group across many clips in one save (smart tri-state toggle
6318
- // decided in the shared controller). Reuses the single-item group bookkeeping.
6656
+ // Bulk add/remove tags across many clips in one save (smart tri-state toggle
6657
+ // decided in the shared controller). "todo, forge" adds or removes both.
6319
6658
  function applyGroupAssignMany(targets, group, shouldHave) {
6320
- if (!group) return false;
6321
- // targets: [{ id, rev }]. All-or-nothing: checked before the group is even created.
6659
+ const names = tagInputNames(group);
6660
+ if (!names.length) return false;
6661
+ // targets: [{ id, rev }]. All-or-nothing: checked before any tag is even created.
6322
6662
  const resolved = assertClipRevisions(targets);
6323
- if (!settings.groups || !settings.groups.includes(group)) applyGroupCreate(group);
6663
+ if (shouldHave && registerTypedTags(names)) saveSettingsFile();
6324
6664
  let changed = false;
6325
6665
  for (const { item } of resolved) {
6326
- if (setItemGroupMembership(item, group, !!shouldHave)) changed = true;
6666
+ for (const name of names) if (setItemGroupMembership(item, name, !!shouldHave)) changed = true;
6327
6667
  }
6328
6668
  if (changed) saveHistory();
6329
6669
  return changed;
@@ -6342,17 +6682,15 @@ function applyAddText(text, group) {
6342
6682
  const value = String(text || '');
6343
6683
  if (!value) return false;
6344
6684
  addToHistory({ type: 'text', text: value, ts: Date.now() / 1000 }, it => it.type !== 'image' && it.text === value);
6345
- if (group) {
6346
- const item = history.find(it => it.type !== 'image' && it.text === value);
6685
+ const names = group ? tagInputNames(group) : [];
6686
+ if (names.length) {
6687
+ const key = itemKey({ type: 'text', text: value });
6688
+ const item = history.find(it => it.type !== 'image' && itemKey(it) === key);
6347
6689
  if (item) {
6348
- if (!settings.groups || !settings.groups.includes(group)) applyGroupCreate(group);
6349
- const pin = ensurePin(item);
6350
- if (!pin.groups) pin.groups = [];
6351
- if (!pin.groups.includes(group)) {
6352
- pin.groups.push(group);
6353
- touchPinGroups(item);
6354
- saveHistory();
6355
- }
6690
+ if (registerTypedTags(names)) saveSettingsFile();
6691
+ let changed = false;
6692
+ for (const name of names) if (setItemTag(item, name, true)) changed = true;
6693
+ if (changed) { touchPinGroups(item); saveHistory(); }
6356
6694
  }
6357
6695
  }
6358
6696
  return true;
@@ -6446,30 +6784,46 @@ function mcpUnregisterAll() {
6446
6784
 
6447
6785
  // ---- Allowlist / AI group ----
6448
6786
  function ensureAiGroupShared() {
6449
- let changed = false;
6450
- if (!settings.groups) settings.groups = [];
6451
- if (!settings.groups.includes(mcpCore.AI_GROUP_NAME)) {
6452
- applyGroupCreate(mcpCore.AI_GROUP_NAME);
6453
- }
6787
+ let changed = registerTags([mcpCore.AI_GROUP_NAME]);
6454
6788
  if (!Array.isArray(settings.groups_shared_with_ai)) settings.groups_shared_with_ai = [];
6455
- if (!settings.groups_shared_with_ai.includes(mcpCore.AI_GROUP_NAME)) {
6789
+ if (!settings.groups_shared_with_ai.some(g => clipSearch.sameTag(g, mcpCore.AI_GROUP_NAME))) {
6456
6790
  settings.groups_shared_with_ai.push(mcpCore.AI_GROUP_NAME);
6457
6791
  changed = true;
6458
6792
  }
6459
6793
  if (changed) saveSettingsFile();
6460
6794
  }
6461
6795
 
6462
- function setGroupSharedWithAi(name, shared) {
6463
- if (!name) return;
6796
+ // One flip of a tag's AI share switch: the list here, plus a stamp that carries the
6797
+ // flip to the other devices (newest flip per tag wins there).
6798
+ function stampShareSwitch(name, shared, at = Date.now()) {
6799
+ const key = clipSearch.tagKey(name);
6800
+ if (!key) return false;
6464
6801
  if (!Array.isArray(settings.groups_shared_with_ai)) settings.groups_shared_with_ai = [];
6465
- const has = settings.groups_shared_with_ai.includes(name);
6466
- if (shared && !has) settings.groups_shared_with_ai.push(name);
6467
- else if (!shared && has) settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => g !== name);
6468
- else return;
6802
+ const has = settings.groups_shared_with_ai.some(g => clipSearch.tagKey(g) === key);
6803
+ if (shared && !has) settings.groups_shared_with_ai.push(clipSearch.cleanTagName(name));
6804
+ else if (!shared && has) settings.groups_shared_with_ai = settings.groups_shared_with_ai.filter(g => clipSearch.tagKey(g) !== key);
6805
+ settings.ai_share_stamps = clipboardModel.mergeShareStamps(settings.ai_share_stamps, { [key]: { name: clipSearch.cleanTagName(name), shared: !!shared, at } });
6806
+ return shared !== has;
6807
+ }
6808
+
6809
+ // A share switch takes one existing tag, never typed input: splitting "a, b" here
6810
+ // would share two tags at once.
6811
+ function setGroupSharedWithAi(name, shared) {
6812
+ const key = clipSearch.tagKey(name);
6813
+ const stored = (settings.groups || []).find(g => clipSearch.tagKey(g) === key) || (clipSearch.sameTag(name, mcpCore.AI_GROUP_NAME) ? mcpCore.AI_GROUP_NAME : '');
6814
+ if (!stored) return;
6815
+ stampShareSwitch(stored, shared);
6469
6816
  saveSettingsFile();
6470
6817
  notifyAiAccessChanged();
6471
6818
  }
6472
6819
 
6820
+ let aiAccessNotifyTimer = null;
6821
+ function notifyAiAccessChangedSoon() {
6822
+ if (aiAccessNotifyTimer) return;
6823
+ aiAccessNotifyTimer = setTimeout(() => { aiAccessNotifyTimer = null; notifyAiAccessChanged(); }, 50);
6824
+ if (aiAccessNotifyTimer.unref) aiAccessNotifyTimer.unref();
6825
+ }
6826
+
6473
6827
  function isItemSharedWithAi(item) {
6474
6828
  return item ? mcpCore.isShared(item, mcpCore.sharedGroupSet(settings)) : false;
6475
6829
  }
@@ -6601,6 +6955,36 @@ function requestApproval(request, { signal } = {}) {
6601
6955
  });
6602
6956
  }
6603
6957
 
6958
+ // ---- AI tag tools: the planned change, read by both the gate and the prompt ----
6959
+ // The clip an add_clip lands on when its text is already in history: the add moves
6960
+ // that clip to the top and tags it (applyAddText), it makes no second one.
6961
+ function mcpAddTarget(text) {
6962
+ const value = String(text || '');
6963
+ if (!value) return null;
6964
+ return history.find(it => it.type !== 'image' && it.text === value)
6965
+ || findHistoryItem(clipboardModel.itemKey({ type: 'text', text: value }));
6966
+ }
6967
+ // What an AI tag change does to a clip: assign_group's toggle (planTagToggle) or
6968
+ // add_clip's tags (added only). `after` = the clip's tags once it ran.
6969
+ function plannedTagChange(item, tool, input) {
6970
+ const before = groupsOf(item);
6971
+ if (tool === 'add_clip') {
6972
+ const add = input ? tagInputNames(input).filter(name => !itemHasTag(item, name)) : [];
6973
+ return { add, remove: [], after: [...before, ...add] };
6974
+ }
6975
+ const plan = planTagToggle(item, input);
6976
+ const after = plan.add.length ? [...before, ...plan.add] : before.filter(g => !plan.remove.some(name => clipSearch.sameTag(name, g)));
6977
+ return { ...plan, after };
6978
+ }
6979
+ // Would a clip with these tags be readable by AI?
6980
+ function tagsVisibleToAi(names) {
6981
+ return mcpCore.isShared({ pin: { groups: names } }, mcpCore.sharedGroupSet(settings));
6982
+ }
6983
+ // The tag a name refers to (any case), as delete_group and the prompt see it.
6984
+ function existingTag(name) {
6985
+ return (settings.groups || []).find(g => clipSearch.sameTag(g, name)) || null;
6986
+ }
6987
+
6604
6988
  function buildApprovalRequest(tool, args, targetItem, client) {
6605
6989
  const dangerTools = new Set(['delete_clip', 'edit_clip', 'copy_to_clipboard', 'paste_clip']);
6606
6990
  // The prompt must say, in plain words, WHAT will happen, to WHICH clip, what
@@ -6614,7 +6998,41 @@ function buildApprovalRequest(tool, args, targetItem, client) {
6614
6998
  const shared = item ? isItemSharedWithAi(item) : false;
6615
6999
  const quote = s => `\u201c${String(s || '').replace(/\s+/g, ' ').trim().slice(0, 60)}\u201d`;
6616
7000
  const clipLabel = item ? (titleOf(item) ? quote(titleOf(item)) : (item.type === 'image' ? 'an image clip' : quote(String(item.text || '').split(/\r?\n/).find(l => l.trim()) || ''))) : '';
7001
+ // Group names in a sentence: “a”, “b” and “c”.
7002
+ const nameList = names => {
7003
+ const q = names.map(quote);
7004
+ return q.length < 2 ? (q[0] || '') : `${q.slice(0, -1).join(', ')} and ${q[q.length - 1]}`;
7005
+ };
7006
+ const tagWord = names => (names.length === 1 ? 'group' : 'groups');
7007
+ const tagsText = names => (names.length ? names.join(', ') : 'none');
7008
+ // A retired nested name in the input says what it now means.
7009
+ const splits = clipboardModel.splitMapOf(settings);
7010
+ const retiredNote = input => clipSearch.splitTagInput(input)
7011
+ .filter(name => !existingTag(name) && splits.has(clipSearch.tagKey(name)))
7012
+ .map(name => {
7013
+ const parts = splits.get(clipSearch.tagKey(name));
7014
+ return `${quote(name)} is an old nested name for the ${tagWord(parts)} ${nameList(parts)}.`;
7015
+ })
7016
+ .join(' ');
7017
+ // What the change does to what AI assistants can read.
7018
+ const sharedSet = mcpCore.sharedGroupSet(settings);
7019
+ const sharingNote = (before, after) => {
7020
+ const was = tagsVisibleToAi(before);
7021
+ const will = tagsVisibleToAi(after);
7022
+ if (!was && will) {
7023
+ const via = after.filter(g => sharedSet.has(g));
7024
+ return `AI assistants will then be able to read this clip, because ${nameList(via)} ${via.length === 1 ? 'is' : 'are'} shared with AI.`;
7025
+ }
7026
+ if (was && !will) return 'AI assistants will then no longer be able to read this clip: none of its groups will be shared with AI.';
7027
+ return '';
7028
+ };
7029
+ const sentences = (...parts) => parts.filter(Boolean).join(' ');
6617
7030
  const facts = [];
7031
+ // The Groups fact shows the change: before -> after.
7032
+ const tagFact = (before, after) => {
7033
+ const row = facts.find(f => f[0] === 'Groups');
7034
+ if (row) row[1] = `${tagsText(before)} \u2192 ${tagsText(after)}`;
7035
+ };
6618
7036
  let preview = null;
6619
7037
  if (item) {
6620
7038
  textBlobStore.hydrateTextItem(item, TEXT_DIR);
@@ -6622,25 +7040,37 @@ function buildApprovalRequest(tool, args, targetItem, client) {
6622
7040
  const lines = text ? text.split(/\r?\n/).length : 0;
6623
7041
  facts.push(['Clip', item.type === 'image' ? `image ${item.width || '?'}x${item.height || '?'}` : `${text.length} chars, ${lines} line${lines === 1 ? '' : 's'}`]);
6624
7042
  if (item.ts) facts.push(['Captured', new Date(item.ts * 1000).toLocaleString()]);
6625
- facts.push(['Groups', groups.length ? groups.join(', ') : 'none']);
7043
+ facts.push(['Groups', tagsText(groups)]);
6626
7044
  if (item.pin && typeof item.pin.number === 'number') facts.push(['Numpad', `slot ${item.pin.number}`]);
6627
7045
  if (item.type !== 'image') preview = { label: 'Clip text', text };
6628
7046
  }
6629
7047
  let title = '';
6630
7048
  let explain = '';
6631
7049
  let why = '';
6632
- const gatedWhy = 'This action always asks, whatever group the clip is in.';
7050
+ const gatedWhy = 'This action always asks, whatever groups the clip is in.';
7051
+ const privateWhy = 'Asking because this clip is in no group shared with AI.';
6633
7052
  const sharedWhy = shared
6634
7053
  ? 'The clip is in a group shared with AI; asking because you have not allowed this action for the session.'
6635
- : 'Asking because this clip is not in a group shared with AI.';
7054
+ : privateWhy;
6636
7055
  switch (tool) {
6637
7056
  case 'assign_group': {
6638
- const name = String(a.group || '');
6639
- const has = groups.includes(name);
6640
- title = has ? `Remove clip from group ${quote(name)}` : `Add clip to group ${quote(name)}`;
6641
- explain = has
6642
- ? `The clip ${clipLabel} will be taken out of the group ${quote(name)}. Its text is not changed and nothing is deleted.`
6643
- : `The clip ${clipLabel} will be tagged with the group ${quote(name)}${groups.length ? ` (it is already in ${groups.join(', ')})` : ' (it currently has no group)'}. Its text is not changed and nothing is deleted.`;
7057
+ // main's own toggle rule (planTagToggle): the named tags it lacks are
7058
+ // added; if it has them all, they are all removed.
7059
+ const plan = item ? plannedTagChange(item, tool, a.group) : { add: [], remove: [], after: groups };
7060
+ const already = plan.add.length ? tagInputNames(a.group).filter(name => itemHasTag(item, name)) : [];
7061
+ const unchanged = 'Its text is not changed and nothing is deleted.';
7062
+ if (plan.add.length) {
7063
+ title = `Add clip to ${tagWord(plan.add)} ${nameList(plan.add)}${already.length ? ` (it is already in ${nameList(already)})` : ''}`;
7064
+ explain = sentences(`The clip ${clipLabel} will be added to ${tagWord(plan.add)} ${nameList(plan.add)}.`, unchanged);
7065
+ } else if (plan.remove.length) {
7066
+ title = `Remove clip from ${tagWord(plan.remove)} ${nameList(plan.remove)}`;
7067
+ explain = sentences(`The clip ${clipLabel} will leave the ${tagWord(plan.remove)} ${nameList(plan.remove)}.`, unchanged);
7068
+ } else {
7069
+ title = "Change a clip's groups";
7070
+ explain = 'No group was named, so nothing will change.';
7071
+ }
7072
+ explain = sentences(explain, retiredNote(a.group), sharingNote(groups, plan.after));
7073
+ tagFact(groups, plan.after);
6644
7074
  why = sharedWhy;
6645
7075
  break;
6646
7076
  }
@@ -6679,22 +7109,62 @@ function buildApprovalRequest(tool, args, targetItem, client) {
6679
7109
  why = gatedWhy;
6680
7110
  break;
6681
7111
  }
6682
- case 'add_clip':
6683
- title = 'Add a new clip';
6684
- explain = `A new clip of ${String(a.text || '').length} characters will be added to your history${a.group ? ` in the group ${quote(a.group)}` : ''}. Nothing existing is changed.`;
6685
- why = 'Asking because you have not allowed clip creation for the session.';
6686
- preview = { label: 'New clip text', text: String(a.text || '') };
7112
+ case 'add_clip': {
7113
+ const sessionWhy = 'Asking because you have not allowed clip creation for the session.';
7114
+ if (item) {
7115
+ // The text is already a clip: the add moves and tags that clip.
7116
+ const plan = plannedTagChange(item, tool, a.group);
7117
+ title = plan.add.length ? `Add an existing clip to ${tagWord(plan.add)} ${nameList(plan.add)}` : 'Move an existing clip to the top';
7118
+ explain = sentences(
7119
+ `This text is already in your history as the clip ${clipLabel}, so no new clip is made: that clip moves to the top${plan.add.length ? ` and is added to ${tagWord(plan.add)} ${nameList(plan.add)}` : ''}.`,
7120
+ retiredNote(a.group),
7121
+ sharingNote(groups, plan.after),
7122
+ );
7123
+ tagFact(groups, plan.after);
7124
+ if (!tagsVisibleToAi(groups) && tagsVisibleToAi(plan.after)) why = 'Asking because this would let the AI read a clip it cannot read now.';
7125
+ else if (!shared) why = 'Asking because this text is already a clip the AI cannot read.';
7126
+ else why = sessionWhy;
7127
+ } else {
7128
+ const names = a.group ? tagInputNames(a.group) : [];
7129
+ title = 'Add a new clip';
7130
+ explain = sentences(`A new clip of ${String(a.text || '').length} characters will be added to your history${names.length ? `, in the ${tagWord(names)} ${nameList(names)}` : ''}. Nothing existing is changed.`, retiredNote(a.group));
7131
+ why = sessionWhy;
7132
+ preview = { label: 'New clip text', text: String(a.text || '') };
7133
+ }
6687
7134
  break;
6688
- case 'create_group':
6689
- title = `Create group ${quote(a.name)}`;
6690
- explain = `A new, empty group named ${quote(a.name)} will appear in your filters.`;
6691
- why = 'Asking because you have not allowed group changes for the session.';
7135
+ }
7136
+ case 'create_group': {
7137
+ const names = tagInputNames(a.name);
7138
+ const fresh = names.filter(name => !existingTag(name));
7139
+ const have = names.filter(name => existingTag(name));
7140
+ title = names.length ? `Create ${tagWord(names)} ${nameList(names)}` : 'Create a group';
7141
+ explain = sentences(
7142
+ fresh.length ? `${fresh.length === 1 ? 'A new, empty group' : 'New, empty groups'} ${nameList(fresh)} will appear in your filters.` : '',
7143
+ have.length ? `${nameList(have)} already ${have.length === 1 ? 'exists' : 'exist'}, so nothing changes for ${have.length === 1 ? 'it' : 'them'}.` : '',
7144
+ names.length ? retiredNote(a.name) : 'No group was named, so nothing will change.',
7145
+ );
7146
+ why = 'Asking because you have not allowed creating groups for the session.';
6692
7147
  break;
6693
- case 'delete_group':
6694
- title = `Delete group ${quote(a.name)}`;
6695
- explain = `The group ${quote(a.name)} will be removed. Clips stay in your history; they just lose this tag.`;
6696
- why = 'Asking because you have not allowed group changes for the session.';
7148
+ }
7149
+ case 'delete_group': {
7150
+ // One group by its full name, any case; never split (this is destructive).
7151
+ const tag = existingTag(a.name);
7152
+ const tagged = tag ? history.filter(h => itemHasTag(h, tag)) : [];
7153
+ const hidden = tag && sharedSet.has(tag)
7154
+ ? tagged.filter(h => !tagsVisibleToAi(groupsOf(h).filter(g => !clipSearch.sameTag(g, tag)))).length
7155
+ : 0;
7156
+ title = `Delete group ${quote(tag || a.name)}`;
7157
+ explain = tag
7158
+ ? sentences(
7159
+ tagged.length
7160
+ ? `The group ${quote(tag)} will be removed from ${tagged.length === 1 ? '1 clip' : `${tagged.length} clips`} and from your filters. ${tagged.length === 1 ? 'The clip stays' : 'The clips stay'} in your history.`
7161
+ : `The group ${quote(tag)} will be removed from your filters. No clip is in it.`,
7162
+ hidden ? `AI assistants will no longer be able to read ${hidden === tagged.length ? (hidden === 1 ? 'it' : 'them') : `${hidden} of them`}.` : '',
7163
+ )
7164
+ : `There is no group named ${quote(a.name)}, so nothing will change.`;
7165
+ why = tag && !sharedSet.has(tag) ? 'Asking because this group is not shared with AI.' : 'Asking because you have not allowed deleting groups for the session.';
6697
7166
  break;
7167
+ }
6698
7168
  case 'copy_to_clipboard':
6699
7169
  title = 'Put text on your clipboard';
6700
7170
  explain = `${a.text != null ? `${String(a.text).length} characters of AI-provided text` : `The clip ${clipLabel}`} will replace whatever is on your system clipboard right now.`;
@@ -6709,11 +7179,11 @@ function buildApprovalRequest(tool, args, targetItem, client) {
6709
7179
  case 'read_clip':
6710
7180
  title = 'Let the AI read a private clip';
6711
7181
  explain = `The full text of the clip ${clipLabel} will be sent to the AI. Nothing in your history changes.`;
6712
- why = 'Asking because this clip is not in a group shared with AI.';
7182
+ why = privateWhy;
6713
7183
  break;
6714
7184
  case 'search_all':
6715
7185
  title = 'Search all clips, private ones included';
6716
- explain = `Matching clips (title and a short preview) from your ENTIRE history, including groups not shared with AI, will be sent to the AI. Nothing changes.`;
7186
+ explain = `Matching clips (title and a short preview) from your ENTIRE history, including clips in no group shared with AI, will be sent to the AI. Nothing changes.`;
6717
7187
  why = gatedWhy;
6718
7188
  preview = { label: 'Query', text: String(a.query || '') };
6719
7189
  break;
@@ -6756,32 +7226,97 @@ function previewForItem(item) {
6756
7226
  // like delete_clip rather than being free-on-shared. Users can still grant a
6757
7227
  // per-tool "always allow" from the modal to make repeated edits frictionless.
6758
7228
  const MCP_ALWAYS_GATED = new Set(['delete_clip', 'edit_clip', 'copy_to_clipboard', 'paste_clip', 'read_clip', 'search_all', 'image_path']);
7229
+ // Free on what the AI can already read, asked on anything else.
6759
7230
  const MCP_MANAGE_FREE_ON_SHARED = new Set(['pin_clip', 'set_numpad', 'assign_group', 'create_group', 'delete_group', 'add_clip']);
6760
7231
 
6761
- function mcpNeedsApproval(tool, targetItem) {
7232
+ function mcpNeedsApproval(tool, targetItem, args = {}) {
6762
7233
  if (mcpSessionAllow.has(tool)) return false;
6763
7234
  if (Array.isArray(settings.ai_always_allow) && settings.ai_always_allow.includes(tool)) return false;
6764
7235
  if (MCP_ALWAYS_GATED.has(tool)) return true;
6765
7236
  if (MCP_MANAGE_FREE_ON_SHARED.has(tool)) {
6766
- if (!targetItem) return false; // create/delete group, add_clip with no clip target
7237
+ // Naming a group not shared with AI stays free (agents file into private groups,
7238
+ // e.g. "lawyer notes"): the answer echoes such a name as typed, the same for an
7239
+ // existing private group as for a brand-new one (mcpCore.groupToolView), so a free
7240
+ // answer tells nothing, while a prompt only for existing ones would.
7241
+ // add_clip's target is the clip that already holds its text (mcpAddTarget): the
7242
+ // add moves (and may tag) that clip, so it is free only on a clip the AI can
7243
+ // read, as every other change is. A free add onto a private clip would also
7244
+ // tell the AI whether a guessed text is in the history (no new clip is made).
7245
+ if (tool === 'add_clip') return !!targetItem && !isItemSharedWithAi(targetItem);
7246
+ // A group not shared with AI holds clips the AI cannot read: deleting it asks.
7247
+ if (tool === 'delete_group') {
7248
+ const tag = existingTag(args.name);
7249
+ return !!tag && !mcpCore.sharedGroupSet(settings).has(tag);
7250
+ }
7251
+ if (!targetItem) return false; // create_group
6767
7252
  return !isItemSharedWithAi(targetItem); // free on shared clips, gated on non-shared
6768
7253
  }
6769
7254
  return true;
6770
7255
  }
6771
7256
 
7257
+ // ---- The AI's ids and revs (the lib/mcp-core boundary) ----
7258
+ // An AI names a clip it can read by its real id and any other by the opaque handle it
7259
+ // was given, and every rev it holds is keyed. mcpResolveArgs turns them into the
7260
+ // app's own before anything runs (or any prompt opens), refusing in the AI's own
7261
+ // terms an id it may not address or a rev that moved; the apply* primitives then
7262
+ // check the real rev again after a (possibly long) approval.
7263
+ const MCP_ID_TOOLS = new Set([...MCP_REV_GUARDED_TOOLS, 'read_clip', 'image_path', 'copy_to_clipboard', 'paste_clip']);
7264
+ function mcpConflict(code, details) {
7265
+ return new clipRevisionCheck.RevisionConflict(code, details);
7266
+ }
7267
+ function mcpResolveArgs(tool, args) {
7268
+ if (!MCP_ID_TOOLS.has(tool) || (tool === 'copy_to_clipboard' && args.text != null)) return { args, item: null };
7269
+ const id = String(args.id == null ? '' : args.id);
7270
+ const found = mcpCore.resolvePublicId(history, settings, id);
7271
+ if (MCP_REV_GUARDED_TOOLS.has(tool)) {
7272
+ const rev = typeof args.expected_rev === 'string' ? args.expected_rev.trim() : '';
7273
+ if (!rev) throw mcpConflict('rev_required', { id });
7274
+ if (!found.item) throw found.movedTo ? mcpConflict('superseded', { id, currentId: found.movedTo }) : mcpConflict('not_found', { id });
7275
+ const currentRev = mcpCore.publicRev(found.item, settings);
7276
+ if (rev !== currentRev) throw mcpConflict('stale_revision', { id, expectedRev: rev, currentRev });
7277
+ return { args: { ...args, id: found.realId, expected_rev: clipboardModel.clipRevision(found.item) }, item: found.item };
7278
+ }
7279
+ if (!found.item) throw new Error(found.movedTo ? `Clip ${id} was edited and is now clip ${found.movedTo}.` : `No clip with id "${id}".`);
7280
+ return { args: { ...args, id: found.realId }, item: found.item };
7281
+ }
7282
+ // A conflict the apply* primitives raised after an approval, in the AI's terms.
7283
+ function mcpPublicError(err, publicArgs) {
7284
+ if (!clipRevisionCheck.isRevisionConflict(err)) return err;
7285
+ const d = err.details || {};
7286
+ const id = String(publicArgs.id == null ? '' : publicArgs.id);
7287
+ if (err.code === 'stale_revision') {
7288
+ const item = findHistoryItem(d.id);
7289
+ return mcpConflict('stale_revision', { id, expectedRev: publicArgs.expected_rev, currentRev: item ? mcpCore.publicRev(item, settings) : null });
7290
+ }
7291
+ if (err.code === 'superseded') {
7292
+ const current = findHistoryItem(d.currentId);
7293
+ return current ? mcpConflict('superseded', { id, currentId: mcpCore.publicId(current, settings) }) : mcpConflict('not_found', { id });
7294
+ }
7295
+ return mcpConflict(err.code, { id });
7296
+ }
7297
+
6772
7298
  async function mcpHandleRequest(reqPath, payload, ctx = {}) {
6773
- const { tool, args = {}, client } = payload || {};
7299
+ const { tool, args: publicArgs = {}, client } = payload || {};
6774
7300
  if (!tool) throw new Error('missing_tool');
6775
7301
  if (!settings.ai_access_enabled) throw new Error('ai_access_disabled');
6776
- const targetId = args.id || null;
6777
- const targetItem = targetId ? findHistoryItem(targetId) : null;
6778
- // Refuse a stale/missing rev BEFORE asking the human to approve a change that
6779
- // would be refused anyway. mcpExecute's primitives check it again after the
6780
- // (possibly long) approval, since the clip can change while the prompt is open.
6781
- if (MCP_REV_GUARDED_TOOLS.has(tool)) assertClipRevision(args.id, args.expected_rev);
7302
+ // The AI group is the one every assistant can always read: never deleted (the
7303
+ // app's own UI cannot delete it either), and no prompt asks about it.
7304
+ if (tool === 'delete_group' && clipSearch.sameTag(publicArgs.name, mcpCore.AI_GROUP_NAME)) {
7305
+ diagnostics.record('mcp.tool', { tool, client, decision: 'refused' }, { forceFile: true });
7306
+ return { ok: false, reason: `The "${mcpCore.AI_GROUP_NAME}" group cannot be deleted: it is the group every AI assistant can always read. To take a clip out of it, use assign_group.` };
7307
+ }
7308
+ // Refuse a stale/missing rev (or an id the AI may not name) BEFORE asking the
7309
+ // human to approve a change that would be refused anyway.
7310
+ const { args, item: resolvedItem } = mcpResolveArgs(tool, publicArgs);
7311
+ // The app's own check on the real id and rev, as every caller's (mcpExecute's
7312
+ // primitives repeat it after the approval, since the clip can change meanwhile).
7313
+ if (MCP_REV_GUARDED_TOOLS.has(tool)) {
7314
+ try { assertClipRevision(args.id, args.expected_rev); } catch (err) { throw mcpPublicError(err, publicArgs); }
7315
+ }
7316
+ const targetItem = tool === 'add_clip' ? mcpAddTarget(args.text) : resolvedItem;
6782
7317
 
6783
7318
  let decision = 'auto';
6784
- if (mcpNeedsApproval(tool, targetItem)) {
7319
+ if (mcpNeedsApproval(tool, targetItem, args)) {
6785
7320
  decision = await requestApproval(buildApprovalRequest(tool, args, targetItem, client), { signal: ctx.signal });
6786
7321
  if (decision === 'deny') throw new Error('denied');
6787
7322
  if (decision === 'timeout') throw new Error('timed_out');
@@ -6797,18 +7332,23 @@ async function mcpHandleRequest(reqPath, payload, ctx = {}) {
6797
7332
  // Re-check after the (possibly long) approval await: if AI access was toggled
6798
7333
  // off while the modal was open, refuse before performing the action.
6799
7334
  if (!settings.ai_access_enabled) throw new Error('ai_access_disabled');
6800
- return mcpExecute(tool, args, ctx);
7335
+ try {
7336
+ return await mcpExecute(tool, args, ctx, publicArgs);
7337
+ } catch (err) {
7338
+ throw mcpPublicError(err, publicArgs);
7339
+ }
6801
7340
  }
6802
7341
 
6803
- function mcpExecute(tool, args, ctx = {}) {
7342
+ // `args` are the app's own (real ids, real revs, mcpResolveArgs); `publicArgs` what
7343
+ // the AI sent. Every result goes back in the AI's terms (lib/mcp-core boundary).
7344
+ function mcpExecute(tool, args, ctx = {}, publicArgs = args) {
6804
7345
  switch (tool) {
6805
7346
  case 'read_clip': {
6806
7347
  const item = findHistoryItem(args.id);
6807
7348
  if (!item) throw new Error('not_found');
6808
- const sharedSet = mcpCore.sharedGroupSet(settings);
6809
- if (item.type === 'image') return mcpCore.clipView(item, { sharedSet });
7349
+ if (item.type === 'image') return mcpCore.clipView(item, { settings });
6810
7350
  textBlobStore.hydrateTextItem(item, TEXT_DIR);
6811
- return { ...mcpCore.fullTextResult(item, sharedSet), viaApproval: true };
7351
+ return { ...mcpCore.fullTextResult(item, { settings }), viaApproval: true };
6812
7352
  }
6813
7353
  case 'search_all':
6814
7354
  // A /regex/ runs on a worker thread: it must never hang this process.
@@ -6832,20 +7372,46 @@ function mcpExecute(tool, args, ctx = {}) {
6832
7372
  newTitle: args.title != null ? String(args.title) : undefined,
6833
7373
  });
6834
7374
  const saved = result && result.item ? result.item : item;
6835
- return { ok: !!(result && result.changed), reason: result && result.reason, id: itemKey(saved), rev: clipboardModel.clipRevision(saved) };
7375
+ return { ok: !!(result && result.changed), reason: result && result.reason, id: mcpCore.publicId(saved, settings), rev: mcpCore.publicRev(saved, settings) };
6836
7376
  }
6837
7377
  case 'pin_clip':
6838
7378
  return mcpMutationResult(args.id, applyPinToggle(args.id, args.expected_rev));
6839
7379
  case 'set_numpad':
6840
7380
  return mcpMutationResult(args.id, applyNumpadAssign(args.id, args.slot, args.expected_rev));
6841
- case 'assign_group':
6842
- return mcpMutationResult(args.id, applyGroupAssign(args.id, args.group, args.expected_rev));
6843
- case 'create_group':
6844
- return { ok: applyGroupCreate(args.name) };
6845
- case 'delete_group':
6846
- return { ok: applyGroupDelete(args.name) };
7381
+ case 'assign_group': {
7382
+ // What the AI typed, read before the change (the names it means may be created).
7383
+ // The answer names only groups shared with AI (mcpCore.groupToolView): whether a
7384
+ // private group existed, or was on the clip, is never in it.
7385
+ const tokens = mcpCore.namedGroupTokens(args.group, settings);
7386
+ const result = applyGroupAssign(args.id, args.group, args.expected_rev);
7387
+ const item = findHistoryItem(args.id);
7388
+ const view = mcpCore.groupToolView(tokens, settings, { added: result ? result.added : [], removed: result ? result.removed : [] });
7389
+ const out = {
7390
+ ...mcpMutationResult(args.id, result),
7391
+ added: view.added,
7392
+ removed: view.removed,
7393
+ groups: item ? mcpCore.visibleGroups(item, mcpCore.sharedGroupSet(settings)) : [],
7394
+ ...(view.notShared ? { notShared: view.notShared } : {}),
7395
+ };
7396
+ if (!result) out.reason = 'No group was named, so nothing changed.';
7397
+ return out;
7398
+ }
7399
+ case 'create_group': {
7400
+ const tokens = mcpCore.namedGroupTokens(args.name, settings);
7401
+ const created = applyGroupCreate(args.name);
7402
+ if (!created) return { ok: false, reason: 'No group was named, so nothing changed.' };
7403
+ return { ok: true, ...mcpCore.groupToolView(tokens, settings, { groups: created }) };
7404
+ }
7405
+ case 'delete_group': {
7406
+ // One group by its full name (any case), never split: a comma or a '/' is part of it.
7407
+ const shared = mcpCore.sharedGroupSet(settings).has(args.name);
7408
+ const deleted = applyGroupDelete(args.name);
7409
+ if (!deleted) return { ok: false, reason: `No group is named "${String(args.name || '')}", so nothing changed. delete_group takes one group by its full name.` };
7410
+ // A group not shared with AI: only the name as the AI typed it, nothing about its clips.
7411
+ return shared ? { ok: true, name: deleted.name, clips: deleted.ids.length } : { ok: true, name: clipSearch.cleanTagName(args.name) };
7412
+ }
6847
7413
  case 'delete_clip':
6848
- return { ok: applyDeleteItem(args.id, args.expected_rev), id: args.id };
7414
+ return { ok: applyDeleteItem(args.id, args.expected_rev), id: String(publicArgs.id) };
6849
7415
  case 'copy_to_clipboard': {
6850
7416
  if (args.text != null) { clipboard.writeText(String(args.text)); return { ok: true }; }
6851
7417
  const item = findHistoryItem(args.id);
@@ -6913,6 +7479,9 @@ function setupIPC() {
6913
7479
  quickPaste: effectiveQuickPasteShortcut(),
6914
7480
  quickPasteCustom: !!settings.quick_paste_shortcut,
6915
7481
  quickPasteDefault: defaultQuickPasteShortcut(),
7482
+ newNote: effectiveNewNoteShortcut(),
7483
+ newNoteCustom: !!settings.new_note_shortcut,
7484
+ newNoteDefault: defaultNewNoteShortcut(),
6916
7485
  windows_hook: process.platform === 'win32',
6917
7486
  },
6918
7487
  }));
@@ -7039,6 +7608,7 @@ function setupIPC() {
7039
7608
 
7040
7609
  ipcMain.handle('set-show-shortcut', (_, shortcut) => setShowShortcut(shortcut));
7041
7610
  ipcMain.handle('set-quick-paste-shortcut', (_, shortcut) => setQuickPasteShortcut(shortcut));
7611
+ ipcMain.handle('set-new-note-shortcut', (_, shortcut) => setNewNoteShortcut(shortcut));
7042
7612
  ipcMain.handle('suspend-shortcuts', () => suspendShortcutsForRecording());
7043
7613
  ipcMain.handle('resume-shortcuts', () => resumeShortcutsAfterRecording());
7044
7614
  ipcMain.handle('resolve-show-shortcut', (_, shortcut) => {
@@ -7049,6 +7619,7 @@ function setupIPC() {
7049
7619
  ipcMain.handle('group-create', (_, name) => applyGroupCreate(name));
7050
7620
 
7051
7621
  ipcMain.handle('group-delete', (_, name) => applyGroupDelete(name));
7622
+ ipcMain.handle('group-restore', (_, payload) => applyGroupRestore(payload));
7052
7623
 
7053
7624
  ipcMain.handle('group-assign', (_, id, group, rev) => applyGroupAssign(id, group, rev));
7054
7625
 
@@ -7372,56 +7943,49 @@ async function handleQuickPaste(slot) {
7372
7943
  await runNumpadSlotAction(slot, { source: 'shortcut' });
7373
7944
  }
7374
7945
 
7375
- function setShowShortcut(shortcut) {
7376
- const previous = settings.show_shortcut || '';
7377
- const next = normalizeShowShortcut(shortcut);
7378
- if (next && shortcutUsesFn(next) && process.platform !== 'darwin') {
7379
- return { ok: false, error: 'Globe shortcuts are only supported on macOS.' };
7946
+ // The global new-note shortcut (Win+N through the hook, or a recorded chord):
7947
+ // the popup's New note action, as a hand-off.
7948
+ function handleNewNoteShortcut() {
7949
+ diagnostics.record('shortcut.new_note', { popup_open: isPopupOpen() });
7950
+ if (process.platform === 'darwin') app.focus({ steal: true }); // an accessory app is not active
7951
+ openEditor(null, {});
7952
+ }
7953
+
7954
+ // Why a recorded chord cannot be saved ('' = it can). Only the popup
7955
+ // shortcut takes Globe (macOS's own hotkey path); `key` names what follows
7956
+ // the modifiers in the hint.
7957
+ function recordedShortcutError(shortcut, { globe = false, key = 'a key' } = {}) {
7958
+ if (shortcutUsesFn(shortcut)) {
7959
+ if (!globe) return 'Globe shortcuts are only supported for the popup shortcut.';
7960
+ if (process.platform !== 'darwin') return 'Globe shortcuts are only supported on macOS.';
7380
7961
  }
7381
- if (next && !shortcutHasKeyAndModifier(next)) {
7962
+ if (!shortcutHasKeyAndModifier(shortcut)) {
7382
7963
  const modifiers = process.platform === 'darwin'
7383
- ? 'Command, Control, Option, or Globe'
7964
+ ? (globe ? 'Command, Control, Option, or Globe' : 'Command, Control, or Option')
7384
7965
  : 'Command, Control, Alt, or Win';
7385
- return { ok: false, error: `Use ${modifiers} with a key.` };
7386
- }
7387
-
7388
- settings.show_shortcut = next;
7389
- const result = registerShortcuts();
7390
- if (!result.showShortcutRegistered) {
7391
- settings.show_shortcut = previous;
7392
- registerShortcuts();
7393
- return { ok: false, error: 'Shortcut is already in use or not supported.' };
7966
+ return `Use ${modifiers} with ${key}.`;
7394
7967
  }
7968
+ return '';
7969
+ }
7395
7970
 
7396
- saveSettingsFile();
7971
+ function setShowShortcut(shortcut) {
7972
+ const next = normalizeShowShortcut(shortcut);
7973
+ const invalid = next && recordedShortcutError(next, { globe: true });
7974
+ if (invalid) return { ok: false, error: invalid };
7975
+ const saved = commitShortcutSetting('show_shortcut', next, 'showShortcutRegistered', 'Shortcut is already in use or not supported.');
7976
+ if (!saved.ok) return saved;
7397
7977
  return { ok: true, shortcut: effectiveShowShortcut(), custom: !!settings.show_shortcut };
7398
7978
  }
7399
7979
 
7400
7980
  function setQuickPasteShortcut(shortcut) {
7401
- const previous = settings.quick_paste_shortcut || '';
7402
7981
  const next = normalizeQuickPasteShortcut(shortcut);
7403
- if (next && shortcutUsesFn(next)) {
7404
- return { ok: false, error: 'Globe shortcuts are only supported for the popup shortcut.' };
7405
- }
7406
- if (next && !shortcutHasKeyAndModifier(next)) {
7407
- const modifiers = process.platform === 'darwin'
7408
- ? 'Command, Control, or Option'
7409
- : 'Command, Control, Alt, or Win';
7410
- return { ok: false, error: `Use ${modifiers} with a number.` };
7411
- }
7982
+ const invalid = next && recordedShortcutError(next, { key: 'a number' });
7983
+ if (invalid) return { ok: false, error: invalid };
7412
7984
  if (next && quickPasteSlotFromShortcut(next) == null) {
7413
7985
  return { ok: false, error: 'Press a shortcut ending in 1-9.' };
7414
7986
  }
7415
-
7416
- settings.quick_paste_shortcut = next;
7417
- const result = registerShortcuts();
7418
- if (!result.quickPasteRegistered) {
7419
- settings.quick_paste_shortcut = previous;
7420
- registerShortcuts();
7421
- return { ok: false, error: 'Quick paste shortcut is already in use or not supported.' };
7422
- }
7423
-
7424
- saveSettingsFile();
7987
+ const saved = commitShortcutSetting('quick_paste_shortcut', next, 'quickPasteRegistered', 'Quick paste shortcut is already in use or not supported.');
7988
+ if (!saved.ok) return saved;
7425
7989
  return {
7426
7990
  ok: true,
7427
7991
  shortcut: effectiveQuickPasteShortcut(),
@@ -7429,20 +7993,62 @@ function setQuickPasteShortcut(shortcut) {
7429
7993
  };
7430
7994
  }
7431
7995
 
7996
+ // '' = the default (Windows: Win+N through the hook), SHORTCUT_OFF = none
7997
+ // (the row's Turn off), else a recorded chord.
7998
+ function setNewNoteShortcut(shortcut) {
7999
+ const next = normalizeNewNoteShortcut(shortcut);
8000
+ const invalid = next && next !== SHORTCUT_OFF && recordedShortcutError(next);
8001
+ if (invalid) return { ok: false, error: invalid };
8002
+ const saved = commitShortcutSetting('new_note_shortcut', next, 'newNoteRegistered', 'New note shortcut is already in use or not supported.');
8003
+ if (!saved.ok) return saved;
8004
+ return { ok: true, shortcut: effectiveNewNoteShortcut(), custom: !!settings.new_note_shortcut };
8005
+ }
8006
+
8007
+ // Save one recorded shortcut: register it, or put the previous one back.
8008
+ // Saving ends the recording, so the registration really happens (while
8009
+ // suspended, registerShortcuts registers nothing and reports success: a
8010
+ // recorded shortcut stayed dead, every other one with it, until a restart).
8011
+ function commitShortcutSetting(key, next, registeredFlag, error) {
8012
+ const previous = settings[key] || '';
8013
+ shortcutsSuspended = false;
8014
+ settings[key] = next;
8015
+ if (!registerShortcuts()[registeredFlag]) {
8016
+ settings[key] = previous;
8017
+ registerShortcuts();
8018
+ return { ok: false, error };
8019
+ }
8020
+ saveSettingsFile();
8021
+ return { ok: true };
8022
+ }
8023
+
7432
8024
  function registerShortcuts() {
8025
+ // What each shortcut needs: an OS registration (globalShortcut / Carbon), the
8026
+ // Windows hook, or nothing. A recorded new-note chord is an ordinary global
8027
+ // shortcut; Windows' default (Win+N) is the hook's; off is neither.
8028
+ const macosShowKey = process.platform === 'darwin' ? effectiveShowShortcut() : '';
8029
+ const showKey = process.platform === 'darwin' ? '' : globalShowShortcut();
8030
+ const quickPasteKey = globalQuickPasteShortcut();
8031
+ const recordedNewNote = recordedNewNoteShortcut();
8032
+ const newNoteKey = shortcutUsesFn(recordedNewNote) ? '' : recordedNewNote;
8033
+ const newNoteHookKey = process.platform === 'win32' && !settings.new_note_shortcut;
8034
+
7433
8035
  // A throwaway QA instance (BOARDCLIP_ISOLATED=1) never takes the keyboard: its
7434
8036
  // low-level hook would sit in front of the real app's, so Win+V would open the
7435
8037
  // sandbox and an assigned numpad key would paste sandbox text into the user's
7436
8038
  // app through the real clipboard. Windows hook, macOS hotkey and globalShortcut
7437
- // alike.
8039
+ // alike. A shortcut that needs no OS registration (the hook's, or none) reports
8040
+ // live, as it does for real (it cannot fail), so its row can be saved in QA.
7438
8041
  if (process.env.BOARDCLIP_ISOLATED === '1') {
7439
- diagnostics.record('shortcut.register', { platform: process.platform, isolated: true, show_registered: false, quick_paste_registered: false });
7440
- return {
7441
- showShortcutRegistered: false,
8042
+ const isolated = {
8043
+ showShortcutRegistered: !(macosShowKey || showKey),
7442
8044
  showShortcut: effectiveShowShortcut(),
7443
- quickPasteRegistered: false,
8045
+ quickPasteRegistered: !quickPasteKey,
7444
8046
  quickPasteShortcut: effectiveQuickPasteShortcut(),
8047
+ newNoteRegistered: !newNoteKey,
8048
+ newNoteShortcut: effectiveNewNoteShortcut(),
7445
8049
  };
8050
+ diagnostics.record('shortcut.register', { platform: process.platform, isolated: true, show_registered: isolated.showShortcutRegistered, quick_paste_registered: isolated.quickPasteRegistered, new_note_registered: isolated.newNoteRegistered });
8051
+ return isolated;
7446
8052
  }
7447
8053
  if (shortcutsSuspended) {
7448
8054
  return {
@@ -7450,15 +8056,15 @@ function registerShortcuts() {
7450
8056
  showShortcut: effectiveShowShortcut(),
7451
8057
  quickPasteRegistered: true,
7452
8058
  quickPasteShortcut: effectiveQuickPasteShortcut(),
8059
+ newNoteRegistered: true,
8060
+ newNoteShortcut: effectiveNewNoteShortcut(),
7453
8061
  };
7454
8062
  }
7455
8063
  globalShortcut.unregisterAll();
7456
8064
 
7457
8065
  let showShortcutRegistered = true;
7458
8066
  let quickPasteRegistered = true;
7459
- const macosShowKey = process.platform === 'darwin' ? effectiveShowShortcut() : '';
7460
- const showKey = process.platform === 'darwin' ? '' : globalShowShortcut();
7461
- const quickPasteKey = globalQuickPasteShortcut();
8067
+ let newNoteRegistered = true;
7462
8068
 
7463
8069
  if (process.platform === 'win32') {
7464
8070
  // Windows Clipboard History owns Win+V and Win+Numpad1-9, so we can't use
@@ -7469,7 +8075,9 @@ function registerShortcuts() {
7469
8075
  windowsHook = install({
7470
8076
  onShowPopup: showPopup,
7471
8077
  onNumpadPaste: handleNumpad,
8078
+ onNewNote: handleNewNoteShortcut,
7472
8079
  });
8080
+ windowsHook.setNewNoteHotkey(newNoteHookKey);
7473
8081
  // Seed the shared state with current history so plain numpad keys
7474
8082
  // immediately intercept for already-assigned slots.
7475
8083
  syncHookState();
@@ -7534,6 +8142,10 @@ function registerShortcuts() {
7534
8142
  }
7535
8143
  }
7536
8144
  }
8145
+ if (newNoteKey) {
8146
+ try { newNoteRegistered = globalShortcut.register(newNoteKey, handleNewNoteShortcut); } catch { newNoteRegistered = false; }
8147
+ if (!newNoteRegistered) logSafe(`Warning: Could not register new note shortcut ${newNoteKey}`);
8148
+ }
7537
8149
  diagnostics.record('shortcut.register', {
7538
8150
  platform: process.platform,
7539
8151
  show_key: macosShowKey || showKey,
@@ -7542,6 +8154,9 @@ function registerShortcuts() {
7542
8154
  quick_paste_key: quickPasteKey,
7543
8155
  quick_paste_registered: quickPasteRegistered,
7544
8156
  quick_paste_registrations: quickPasteRegistrations,
8157
+ new_note_key: newNoteKey || (newNoteHookKey ? defaultNewNoteShortcut() : ''),
8158
+ new_note_registered: newNoteRegistered,
8159
+ new_note_backend: newNoteKey ? 'electron' : (newNoteHookKey ? 'hook' : ''),
7545
8160
  }, { forceFile: true });
7546
8161
 
7547
8162
  return {
@@ -7549,12 +8164,16 @@ function registerShortcuts() {
7549
8164
  showShortcut: effectiveShowShortcut(),
7550
8165
  quickPasteRegistered,
7551
8166
  quickPasteShortcut: effectiveQuickPasteShortcut(),
8167
+ newNoteRegistered,
8168
+ newNoteShortcut: effectiveNewNoteShortcut(),
7552
8169
  };
7553
8170
  }
7554
8171
 
7555
8172
  function suspendShortcutsForRecording() {
7556
8173
  shortcutsSuspended = true;
7557
8174
  globalShortcut.unregisterAll();
8175
+ // The hook's Win+N too: a recording never opens a note (resume re-arms it).
8176
+ if (windowsHook) windowsHook.setNewNoteHotkey(false);
7558
8177
  if (macosHotkey) {
7559
8178
  macosHotkey.clearRuntimeShortcut();
7560
8179
  macosHotkey.clearQuickPasteShortcuts();
@@ -7646,6 +8265,10 @@ app.whenReady().then(() => {
7646
8265
 
7647
8266
  if (process.platform === 'darwin') app.dock.hide();
7648
8267
 
8268
+ tagMigration = migrateTagsAtLoad();
8269
+ persistTagMigration();
8270
+ const unsplit = unsplitLiteralTags(history, 'load');
8271
+ if (unsplit) history.splice(0, history.length, ...unsplit.history);
7649
8272
  migrateNumpad();
7650
8273
  recoverOrphanedEdits(); // restore any external edit orphaned by a prior crash/restart
7651
8274
  writeHistoryStorageFile();
@@ -7687,6 +8310,7 @@ app.whenReady().then(() => {
7687
8310
  // heartbeat is therefore an external kill or a native crash, never a quit.
7688
8311
  app.on('before-quit', () => {
7689
8312
  app.isQuitting = true;
8313
+ try { flushDeleteStamps(); } catch {}
7690
8314
  if (!quitReason) quitReason = 'quit';
7691
8315
  diagnostics.record('app.quit', { reason: quitReason, build: BUILD_INFO.label, uptime_s: Math.round(process.uptime()) }, { forceFile: true });
7692
8316
  });