synabun 2026.7.30 → 2026.7.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ # SynaBun v.2026.07.32
2
+
3
+ A follow-up to 2026.07.31 that makes updating fast again on a large data home.
4
+
5
+ ## 🛠 Fixed
6
+
7
+ - **Updating took several minutes on a large data home** — Before a new version starts, SynaBun snapshots your data home so the update can be rolled back. That snapshot archived *everything*, so a multi-GB data home spent minutes compressing during `npm install` — one report was 9,470 files and 3,832 MB. An upgrade snapshot only needs to protect state: it never rewrites your generated images, audio, or video, and those survive a rollback on disk regardless. Upgrade snapshots now skip generated media, and if what remains is still over ~1 GB they fall back to irreplaceable state only — your `.env`, everything under `mcp-data/`, and any `.db`, `.sqlite`, or `.json`. Compression on the blocking path drops to the fastest level, since what is left is SQLite and JSON that still shrink well. The snapshot reports what it skipped and why, and its manifest records whether it is `state-only` or `complete` so a restore can never mistake one for the other. Scheduled and manual backups are unchanged and still capture everything.
8
+
9
+ ---
10
+
1
11
  # SynaBun v.2026.07.30
2
12
 
3
13
  A point release on top of 2026.07.20, focused on correct Claude Code model selection, a new project storage cleanup tool, and a stricter default browser policy.
@@ -10,6 +20,7 @@ A point release on top of 2026.07.20, focused on correct Claude Code model selec
10
20
 
11
21
  ## 🛠 Fixed
12
22
 
23
+ - **First launch after an update could look frozen** — The pre-update snapshot protects your data home before a new version starts, but it ran silently, so on a large data home there was nothing on screen for long enough that the upgrade looked hung and got killed — and an interrupted run left a partial archive behind that nothing reclaimed. The snapshot now reports its collect, checksum, archive, and verify phases with file counts and sizes; skips `data/youtube-downloads`, a re-downloadable cache rather than user state; stores already-compressed payloads instead of spending full CPU deflating video and images for no gain; and reclaims temporary files orphaned by an interrupted run. On a 1.8 GB data home this took the snapshot from 17.8s and 543 MB to 9.7s and 172 MB.
13
24
  - **Sidepanel launched Opus 4.8 when Opus 5 was picked** — The model picker read its list from the standalone Claude Code CLI, while sidepanel chats ran on the Agent SDK's own bundled CLI. Both accept the same alias selectors and each resolved them against its own model table, so the picker could show one model while the session launched another. `@anthropic-ai/claude-agent-sdk` moves `0.3.174` → `0.3.220` so both sides resolve identically, and a version skew between the two is now reported instead of passing silently. Takes effect after a server restart, not just an install.
14
25
  - **Claude model pricing** — Corrected against published list prices. Opus 5 was missing from the table entirely and fell through to a Sonnet fallback, while the Opus and Fable rows were inflated roughly 3x. Displayed cost for past Opus and Fable sessions drops accordingly and now tracks what was actually billed.
15
26
  - **Coexistence ruleset preview included neighbouring sections** — The extraction boundary matched an LF-only marker that was absent from the template's CRLF section, so the preview could return the condensed rulesets alongside the requested coexistence rules. It now stops at a stable heading.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "claude-memory-mcp",
3
- "version": "2026.7.30",
3
+ "version": "2026.7.32",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "claude-memory-mcp",
9
- "version": "2026.7.30",
9
+ "version": "2026.7.32",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@huggingface/transformers": "^3.0.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-mcp",
3
- "version": "2026.7.30",
3
+ "version": "2026.7.32",
4
4
  "private": true,
5
5
  "description": "Private MCP server runtime bundled with SynaBun for persistent memory and agent tooling",
6
6
  "license": "Apache-2.0",
@@ -44,6 +44,80 @@ const IMMUTABLE_BACKUP_EXTENSIONS = new Set([
44
44
  '.pdf', '.png', '.svg', '.webm', '.webp', '.woff', '.woff2', '.zip',
45
45
  ]);
46
46
 
47
+ // Re-downloadable bulk media under data/. These are pipeline caches, not user
48
+ // state: including them made a pre-update snapshot spend minutes deflating
49
+ // hundreds of MB of video with no progress output, which reads as a hang and
50
+ // gets killed. A restore re-fetches them.
51
+ const EXCLUDED_DATA_DIRS = new Set([
52
+ 'youtube-downloads',
53
+ ]);
54
+
55
+ // Payloads that are already compressed. DEFLATE spends full CPU on every byte
56
+ // of these for no meaningful size gain, so they are stored verbatim instead.
57
+ const INCOMPRESSIBLE_EXTENSIONS = new Set([
58
+ '.avif', '.gif', '.ico', '.jpeg', '.jpg', '.mov', '.mp3', '.mp4', '.ogg',
59
+ '.png', '.webm', '.webp', '.woff', '.woff2', '.zip',
60
+ ]);
61
+
62
+ // Generated media. An upgrade snapshot exists to roll back *state* if a new
63
+ // version misbehaves; it never rewrites these, and they survive a rollback on
64
+ // disk regardless. Archiving them put multi-GB data homes through several
65
+ // minutes of compression on the npm update path, so upgrade snapshots skip
66
+ // them. Scheduled and manual backups still capture everything.
67
+ const BULK_MEDIA_EXTENSIONS = new Set([
68
+ '.avif', '.bmp', '.gif', '.heic', '.jpeg', '.jpg', '.m4a', '.m4v', '.mkv',
69
+ '.mov', '.mp3', '.mp4', '.ogg', '.png', '.tiff', '.wav', '.webm', '.webp',
70
+ ]);
71
+
72
+ // Snapshot kinds taken while the user is blocked on a version change.
73
+ const UPGRADE_BACKUP_KINDS = new Set(['pre-update', 'pre-migration']);
74
+
75
+ // Irreplaceable state: the memory database, UI state databases, and the JSON
76
+ // that configures them. Everything a rollback actually needs lives here.
77
+ const ESSENTIAL_STATE_EXTENSIONS = new Set(['.db', '.sqlite', '.sqlite3', '.json']);
78
+
79
+ // Above this, an upgrade snapshot stops trying to be comprehensive and keeps
80
+ // only irreplaceable state. Dropping media is not enough on its own — a data
81
+ // home can reach multiple GB in ordinary files, and compressing that on the
82
+ // npm update path is what made upgrades look broken.
83
+ const UPGRADE_SNAPSHOT_SOFT_LIMIT_BYTES = 1024 * 1024 * 1024;
84
+
85
+ function fileBytes(path) {
86
+ try { return statSync(path).size; } catch { return 0; }
87
+ }
88
+
89
+ /**
90
+ * Pick what an upgrade snapshot archives. Generated media always goes; if the
91
+ * remainder is still oversized, fall back to irreplaceable state only. Returns
92
+ * the kept files plus what was dropped, so the caller can say so out loud.
93
+ */
94
+ function selectUpgradeSnapshotFiles(files, { softLimitBytes = UPGRADE_SNAPSHOT_SOFT_LIMIT_BYTES } = {}) {
95
+ const excluded = { reason: 'generated-media', files: 0, bytes: 0 };
96
+ const kept = [];
97
+ for (const file of files) {
98
+ if (BULK_MEDIA_EXTENSIONS.has(extname(file.archivePath).toLowerCase())) {
99
+ excluded.files += 1;
100
+ excluded.bytes += fileBytes(file.diskPath);
101
+ continue;
102
+ }
103
+ kept.push(file);
104
+ }
105
+
106
+ const keptBytes = kept.reduce((total, file) => total + fileBytes(file.diskPath), 0);
107
+ if (keptBytes <= softLimitBytes) return { files: kept, excluded };
108
+
109
+ const essential = [];
110
+ for (const file of kept) {
111
+ const isEnv = file.archivePath === 'env.bak';
112
+ const isMcpState = file.archivePath.startsWith('mcp-data/');
113
+ const isStateFile = ESSENTIAL_STATE_EXTENSIONS.has(extname(file.archivePath).toLowerCase());
114
+ if (isEnv || isMcpState || isStateFile) { essential.push(file); continue; }
115
+ excluded.files += 1;
116
+ excluded.bytes += fileBytes(file.diskPath);
117
+ }
118
+ return { files: essential, excluded: { ...excluded, reason: 'oversized-data-home' } };
119
+ }
120
+
47
121
  export class BackupStorageError extends Error {
48
122
  constructor({ availableBytes, requiredBytes, folderPath }) {
49
123
  const availableMiB = Math.floor(availableBytes / 1024 / 1024);
@@ -112,6 +186,7 @@ function shouldSkipDataPath(relativePath) {
112
186
  const name = parts.at(-1);
113
187
  if (parts[0] === 'updater') return true;
114
188
  if (parts[0] === 'logs') return true;
189
+ if (parts.length > 1 && EXCLUDED_DATA_DIRS.has(parts[0])) return true;
115
190
  if (TRANSIENT_DATA_NAMES.has(name)) return true;
116
191
  if (name.endsWith('.tmp') || name.endsWith('.pid')) return true;
117
192
  if (/\.db-(?:wal|shm)$/i.test(name)) return true;
@@ -205,6 +280,33 @@ export function assertBackupWorkingSpace({
205
280
  };
206
281
  }
207
282
 
283
+ /**
284
+ * Remove archive temps, SQLite temps, and stage dirs abandoned by an earlier
285
+ * run. A backup interrupted with Ctrl-C never reaches its own catch/finally,
286
+ * so its partial archive is orphaned forever — a killed pre-update snapshot
287
+ * left hundreds of MB behind with nothing to reclaim it.
288
+ */
289
+ export function cleanupStaleBackupArtifacts(folderPath, { maxAgeMs = 60 * 60 * 1000, now = Date.now } = {}) {
290
+ if (!existsSync(folderPath)) return [];
291
+ const removed = [];
292
+ let entries;
293
+ try { entries = readdirSync(folderPath); }
294
+ catch { return removed; }
295
+ for (const name of entries) {
296
+ const isArchiveTemp = /\.zip\.\d+\.tmp$/.test(name);
297
+ const isSqliteTemp = /^\.synabun-(?:db|extra-db)-.*\.tmp$/.test(name);
298
+ const isStageDir = /^\.synabun-files-/.test(name);
299
+ if (!isArchiveTemp && !isSqliteTemp && !isStageDir) continue;
300
+ const path = resolve(folderPath, name);
301
+ try {
302
+ if (now() - statSync(path).mtimeMs < maxAgeMs) continue;
303
+ rmSync(path, { recursive: true, force: true });
304
+ removed.push(name);
305
+ } catch { /* another process may be mid-cleanup; leave it */ }
306
+ }
307
+ return removed;
308
+ }
309
+
208
310
  function snapshotMutableBackupFiles(files, folderPath) {
209
311
  if (!files.some(isMutableBackupInput)) return { files, stageDir: null };
210
312
  const stageDir = mkdtempSync(resolve(folderPath, `.synabun-files-${process.pid}-`));
@@ -250,17 +352,20 @@ function normalizeAdditionalEntries(entries = []) {
250
352
  return result;
251
353
  }
252
354
 
253
- async function writeArchive({ tempPath, files, manifest }) {
355
+ async function writeArchive({ tempPath, files, manifest, level = 6 }) {
254
356
  const { createWriteStream } = await import('node:fs');
255
357
  await new Promise((resolveWrite, reject) => {
256
358
  const output = createWriteStream(tempPath, { flags: 'wx' });
257
- const archive = archiver('zip', { zlib: { level: 6 } });
359
+ const archive = archiver('zip', { zlib: { level } });
258
360
  output.on('close', resolveWrite);
259
361
  output.on('error', reject);
260
362
  archive.on('warning', error => error.code === 'ENOENT' ? null : reject(error));
261
363
  archive.on('error', reject);
262
364
  archive.pipe(output);
263
- for (const file of files) archive.file(file.diskPath, { name: `${BACKUP_PREFIX}/${file.archivePath}` });
365
+ for (const file of files) {
366
+ const store = INCOMPRESSIBLE_EXTENSIONS.has(extname(file.archivePath).toLowerCase());
367
+ archive.file(file.diskPath, { name: `${BACKUP_PREFIX}/${file.archivePath}`, store });
368
+ }
264
369
  archive.append(JSON.stringify(manifest, null, 2), { name: `${BACKUP_PREFIX}/manifest.json` });
265
370
  archive.finalize().catch(reject);
266
371
  });
@@ -294,11 +399,17 @@ export async function createVerifiedBackup({
294
399
  freeSpaceReserveBytes = BACKUP_FREE_SPACE_RESERVE_BYTES,
295
400
  getAvailableBytes = backupAvailableBytes,
296
401
  onBeforeArchive = null,
402
+ onProgress = null,
403
+ upgradeSoftLimitBytes = UPGRADE_SNAPSHOT_SOFT_LIMIT_BYTES,
297
404
  now = () => new Date(),
298
405
  } = {}) {
406
+ // A large data home spends minutes in checksum/archive with no output. Phase
407
+ // reporting is what stops that from reading as a hang to the caller.
408
+ const report = typeof onProgress === 'function' ? onProgress : () => {};
299
409
  if (!dataHome || !folderPath) throw new Error('dataHome and folderPath are required');
300
410
  const createdAt = now();
301
411
  mkdirSync(folderPath, { recursive: true });
412
+ cleanupStaleBackupArtifacts(folderPath);
302
413
  const destination = resolve(folderPath, archiveNameFor(kind, createdAt));
303
414
  const tempPath = `${destination}.${process.pid}.tmp`;
304
415
  const sqliteTemp = resolve(folderPath, `.synabun-db-${process.pid}-${Date.now()}.tmp`);
@@ -321,11 +432,20 @@ export async function createVerifiedBackup({
321
432
 
322
433
  // De-duplicate before estimating work space so explicit additional entries
323
434
  // cannot inflate the preflight or create ambiguous ZIP members.
324
- const sourceFiles = [...new Map(files.map(file => [
435
+ const deduped = [...new Map(files.map(file => [
325
436
  file.archivePath.replace(/\\/g, '/'),
326
437
  file,
327
438
  ])).values()];
328
- assertBackupWorkingSpace({
439
+
440
+ // Keep the upgrade path bounded: state is archived, generated media is not.
441
+ const leanSnapshot = UPGRADE_BACKUP_KINDS.has(kind);
442
+ const selection = leanSnapshot
443
+ ? selectUpgradeSnapshotFiles(deduped, { softLimitBytes: upgradeSoftLimitBytes })
444
+ : { files: deduped, excluded: null };
445
+ const sourceFiles = selection.files;
446
+ const excluded = selection.excluded;
447
+
448
+ const workingSpace = assertBackupWorkingSpace({
329
449
  folderPath,
330
450
  files: sourceFiles,
331
451
  databasePath,
@@ -333,6 +453,14 @@ export async function createVerifiedBackup({
333
453
  freeSpaceReserveBytes,
334
454
  getAvailableBytes,
335
455
  });
456
+ report({
457
+ phase: 'collect',
458
+ files: sourceFiles.length,
459
+ bytes: workingSpace.archiveBytes,
460
+ skippedFiles: excluded?.files ?? 0,
461
+ skippedBytes: excluded?.bytes ?? 0,
462
+ skippedReason: excluded?.reason ?? null,
463
+ });
336
464
 
337
465
  sqliteSnapshot = createConsistentSqliteSnapshot(databasePath, sqliteTemp);
338
466
  files.length = 0;
@@ -357,6 +485,7 @@ export async function createVerifiedBackup({
357
485
  const stableInputs = snapshotMutableBackupFiles([...unique.values()], folderPath);
358
486
  stageDir = stableInputs.stageDir;
359
487
  const finalFiles = stableInputs.files.sort((a, b) => a.archivePath.localeCompare(b.archivePath));
488
+ report({ phase: 'checksum', files: finalFiles.length });
360
489
  const checksums = {};
361
490
  for (const file of finalFiles) checksums[file.archivePath] = `sha256:${await hashFile(file.diskPath)}`;
362
491
 
@@ -376,11 +505,19 @@ export async function createVerifiedBackup({
376
505
  } : null,
377
506
  files: finalFiles.map(file => file.archivePath),
378
507
  checksums,
508
+ // A lean snapshot is not a whole-data-home backup. Recording that here
509
+ // keeps a restore from silently presenting it as one.
510
+ scope: leanSnapshot ? 'state-only' : 'complete',
511
+ excluded,
379
512
  verified: true,
380
513
  verification: { algorithm: 'sha256', publication: 'verify-before-atomic-rename' },
381
514
  };
382
515
  if (typeof onBeforeArchive === 'function') await onBeforeArchive({ files: finalFiles, manifest });
383
- await writeArchive({ tempPath, files: finalFiles, manifest });
516
+ report({ phase: 'archive', files: finalFiles.length, bytes: workingSpace.archiveBytes });
517
+ // Level 1 on the blocking upgrade path: the remaining payload is mostly
518
+ // SQLite and JSON, where fast deflate still shrinks well.
519
+ await writeArchive({ tempPath, files: finalFiles, manifest, level: leanSnapshot ? 1 : 6 });
520
+ report({ phase: 'verify', files: finalFiles.length });
384
521
  const publishedManifest = verifyBackupArchive(tempPath);
385
522
  renameSync(tempPath, destination);
386
523
  return {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "neural-memory-interface",
3
- "version": "2026.7.30",
3
+ "version": "2026.7.32",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "neural-memory-interface",
9
- "version": "2026.7.30",
9
+ "version": "2026.7.32",
10
10
  "hasInstallScript": true,
11
11
  "license": "Apache-2.0",
12
12
  "dependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neural-memory-interface",
3
- "version": "2026.7.30",
3
+ "version": "2026.7.32",
4
4
  "private": true,
5
5
  "description": "Private local SynaBun interface for memory visualization and multi-agent workflows",
6
6
  "license": "Apache-2.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synabun",
3
- "version": "2026.7.30",
3
+ "version": "2026.7.32",
4
4
  "description": "Local-first persistent vector memory, MCP tooling, and multi-agent workflows for AI assistants",
5
5
  "license": "Apache-2.0",
6
6
  "author": "The SynaBun Authors",
package/setup.js CHANGED
@@ -261,6 +261,19 @@ async function protectFirstLaunchAfterUpdate(version) {
261
261
  folderPath,
262
262
  kind: 'pre-update',
263
263
  appVersion: manifest.lastAppVersion || null,
264
+ // Without this the checksum and archive phases run silently for minutes
265
+ // on a large data home, which users read as a hang and kill.
266
+ onProgress: ({ phase, files, bytes, skippedFiles, skippedBytes }) => {
267
+ const size = bytes ? ` (${(bytes / 1024 / 1024).toFixed(0)} MB)` : '';
268
+ if (phase === 'collect') {
269
+ info(` ${files} files to protect${size}`);
270
+ if (skippedFiles) {
271
+ info(` Skipping ${skippedFiles} generated media files (${(skippedBytes / 1024 / 1024).toFixed(0)} MB) — an upgrade never touches them`);
272
+ }
273
+ } else if (phase === 'checksum') info(` Checksumming ${files} files...`);
274
+ else if (phase === 'archive') info(` Compressing${size}...`);
275
+ else if (phase === 'verify') info(' Verifying archive integrity...');
276
+ },
264
277
  });
265
278
  applyBackupRetention({ folderPath });
266
279
  snapshotPath = snapshot.path;