synabun 2026.7.30 → 2026.7.31

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
@@ -10,6 +10,7 @@ A point release on top of 2026.07.20, focused on correct Claude Code model selec
10
10
 
11
11
  ## 🛠 Fixed
12
12
 
13
+ - **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
14
  - **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
15
  - **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
16
  - **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.
@@ -44,6 +44,21 @@ 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
+
47
62
  export class BackupStorageError extends Error {
48
63
  constructor({ availableBytes, requiredBytes, folderPath }) {
49
64
  const availableMiB = Math.floor(availableBytes / 1024 / 1024);
@@ -112,6 +127,7 @@ function shouldSkipDataPath(relativePath) {
112
127
  const name = parts.at(-1);
113
128
  if (parts[0] === 'updater') return true;
114
129
  if (parts[0] === 'logs') return true;
130
+ if (parts.length > 1 && EXCLUDED_DATA_DIRS.has(parts[0])) return true;
115
131
  if (TRANSIENT_DATA_NAMES.has(name)) return true;
116
132
  if (name.endsWith('.tmp') || name.endsWith('.pid')) return true;
117
133
  if (/\.db-(?:wal|shm)$/i.test(name)) return true;
@@ -205,6 +221,33 @@ export function assertBackupWorkingSpace({
205
221
  };
206
222
  }
207
223
 
224
+ /**
225
+ * Remove archive temps, SQLite temps, and stage dirs abandoned by an earlier
226
+ * run. A backup interrupted with Ctrl-C never reaches its own catch/finally,
227
+ * so its partial archive is orphaned forever — a killed pre-update snapshot
228
+ * left hundreds of MB behind with nothing to reclaim it.
229
+ */
230
+ export function cleanupStaleBackupArtifacts(folderPath, { maxAgeMs = 60 * 60 * 1000, now = Date.now } = {}) {
231
+ if (!existsSync(folderPath)) return [];
232
+ const removed = [];
233
+ let entries;
234
+ try { entries = readdirSync(folderPath); }
235
+ catch { return removed; }
236
+ for (const name of entries) {
237
+ const isArchiveTemp = /\.zip\.\d+\.tmp$/.test(name);
238
+ const isSqliteTemp = /^\.synabun-(?:db|extra-db)-.*\.tmp$/.test(name);
239
+ const isStageDir = /^\.synabun-files-/.test(name);
240
+ if (!isArchiveTemp && !isSqliteTemp && !isStageDir) continue;
241
+ const path = resolve(folderPath, name);
242
+ try {
243
+ if (now() - statSync(path).mtimeMs < maxAgeMs) continue;
244
+ rmSync(path, { recursive: true, force: true });
245
+ removed.push(name);
246
+ } catch { /* another process may be mid-cleanup; leave it */ }
247
+ }
248
+ return removed;
249
+ }
250
+
208
251
  function snapshotMutableBackupFiles(files, folderPath) {
209
252
  if (!files.some(isMutableBackupInput)) return { files, stageDir: null };
210
253
  const stageDir = mkdtempSync(resolve(folderPath, `.synabun-files-${process.pid}-`));
@@ -260,7 +303,10 @@ async function writeArchive({ tempPath, files, manifest }) {
260
303
  archive.on('warning', error => error.code === 'ENOENT' ? null : reject(error));
261
304
  archive.on('error', reject);
262
305
  archive.pipe(output);
263
- for (const file of files) archive.file(file.diskPath, { name: `${BACKUP_PREFIX}/${file.archivePath}` });
306
+ for (const file of files) {
307
+ const store = INCOMPRESSIBLE_EXTENSIONS.has(extname(file.archivePath).toLowerCase());
308
+ archive.file(file.diskPath, { name: `${BACKUP_PREFIX}/${file.archivePath}`, store });
309
+ }
264
310
  archive.append(JSON.stringify(manifest, null, 2), { name: `${BACKUP_PREFIX}/manifest.json` });
265
311
  archive.finalize().catch(reject);
266
312
  });
@@ -294,11 +340,16 @@ export async function createVerifiedBackup({
294
340
  freeSpaceReserveBytes = BACKUP_FREE_SPACE_RESERVE_BYTES,
295
341
  getAvailableBytes = backupAvailableBytes,
296
342
  onBeforeArchive = null,
343
+ onProgress = null,
297
344
  now = () => new Date(),
298
345
  } = {}) {
346
+ // A large data home spends minutes in checksum/archive with no output. Phase
347
+ // reporting is what stops that from reading as a hang to the caller.
348
+ const report = typeof onProgress === 'function' ? onProgress : () => {};
299
349
  if (!dataHome || !folderPath) throw new Error('dataHome and folderPath are required');
300
350
  const createdAt = now();
301
351
  mkdirSync(folderPath, { recursive: true });
352
+ cleanupStaleBackupArtifacts(folderPath);
302
353
  const destination = resolve(folderPath, archiveNameFor(kind, createdAt));
303
354
  const tempPath = `${destination}.${process.pid}.tmp`;
304
355
  const sqliteTemp = resolve(folderPath, `.synabun-db-${process.pid}-${Date.now()}.tmp`);
@@ -325,7 +376,7 @@ export async function createVerifiedBackup({
325
376
  file.archivePath.replace(/\\/g, '/'),
326
377
  file,
327
378
  ])).values()];
328
- assertBackupWorkingSpace({
379
+ const workingSpace = assertBackupWorkingSpace({
329
380
  folderPath,
330
381
  files: sourceFiles,
331
382
  databasePath,
@@ -333,6 +384,7 @@ export async function createVerifiedBackup({
333
384
  freeSpaceReserveBytes,
334
385
  getAvailableBytes,
335
386
  });
387
+ report({ phase: 'collect', files: sourceFiles.length, bytes: workingSpace.archiveBytes });
336
388
 
337
389
  sqliteSnapshot = createConsistentSqliteSnapshot(databasePath, sqliteTemp);
338
390
  files.length = 0;
@@ -357,6 +409,7 @@ export async function createVerifiedBackup({
357
409
  const stableInputs = snapshotMutableBackupFiles([...unique.values()], folderPath);
358
410
  stageDir = stableInputs.stageDir;
359
411
  const finalFiles = stableInputs.files.sort((a, b) => a.archivePath.localeCompare(b.archivePath));
412
+ report({ phase: 'checksum', files: finalFiles.length });
360
413
  const checksums = {};
361
414
  for (const file of finalFiles) checksums[file.archivePath] = `sha256:${await hashFile(file.diskPath)}`;
362
415
 
@@ -380,7 +433,9 @@ export async function createVerifiedBackup({
380
433
  verification: { algorithm: 'sha256', publication: 'verify-before-atomic-rename' },
381
434
  };
382
435
  if (typeof onBeforeArchive === 'function') await onBeforeArchive({ files: finalFiles, manifest });
436
+ report({ phase: 'archive', files: finalFiles.length, bytes: workingSpace.archiveBytes });
383
437
  await writeArchive({ tempPath, files: finalFiles, manifest });
438
+ report({ phase: 'verify', files: finalFiles.length });
384
439
  const publishedManifest = verifyBackupArchive(tempPath);
385
440
  renameSync(tempPath, destination);
386
441
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synabun",
3
- "version": "2026.7.30",
3
+ "version": "2026.7.31",
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,15 @@ 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 }) => {
267
+ const size = bytes ? ` (${(bytes / 1024 / 1024).toFixed(0)} MB)` : '';
268
+ if (phase === 'collect') info(` ${files} files to protect${size}`);
269
+ else if (phase === 'checksum') info(` Checksumming ${files} files...`);
270
+ else if (phase === 'archive') info(` Compressing${size} — this can take several minutes...`);
271
+ else if (phase === 'verify') info(' Verifying archive integrity...');
272
+ },
264
273
  });
265
274
  applyBackupRetention({ folderPath });
266
275
  snapshotPath = snapshot.path;