salidium 0.4.0 → 0.5.0

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.
@@ -13,6 +13,7 @@ import {
13
13
  existsSync as existsSync21,
14
14
  mkdirSync as mkdirSync12,
15
15
  openSync as openSync7,
16
+ readFileSync as readFileSync13,
16
17
  statfsSync as statfsSync3,
17
18
  statSync as statSync15
18
19
  } from "node:fs";
@@ -21,6 +22,7 @@ import { arch, homedir as homedir5, platform as platform2, release } from "node:
21
22
  import { join as join23, resolve as resolve6, sep as sep3 } from "node:path";
22
23
  import { createInterface } from "node:readline/promises";
23
24
  import { setTimeout as sleep } from "node:timers/promises";
25
+ import { parentPort } from "node:worker_threads";
24
26
 
25
27
  // ../core/dist/claims/markdown.js
26
28
  var FENCE = /(^|\n)\s*(```|~~~)/;
@@ -277,6 +279,17 @@ function headlineOf(text, max = 160) {
277
279
  return "";
278
280
  }
279
281
 
282
+ // ../core/dist/format/bytes.js
283
+ function formatBytes(bytes) {
284
+ if (bytes < 1e3)
285
+ return `${bytes} B`;
286
+ if (bytes < 1e3 * 1e3)
287
+ return `${(bytes / 1e3).toFixed(1)} KB`;
288
+ if (bytes < 1e3 * 1e3 * 1e3)
289
+ return `${(bytes / (1e3 * 1e3)).toFixed(1)} MB`;
290
+ return `${(bytes / (1e3 * 1e3 * 1e3)).toFixed(2)} GB`;
291
+ }
292
+
280
293
  // ../core/dist/verification/classifyCommand.js
281
294
  var RULES = [
282
295
  { runner: "vitest", method: "test", pattern: /^vitest(\s|$)/ },
@@ -18978,6 +18991,30 @@ var OperationsStoreMeasurementSchema = external_exports.object({
18978
18991
  retention: RetentionDaysSchema.nullable(),
18979
18992
  lastIngestAt: CanonicalTimestampSchema.nullable()
18980
18993
  }).strict();
18994
+ var StorageCompositionPartSchema = external_exports.object({
18995
+ key: external_exports.enum(["sessions", "checkpoints", "provenance", "structure", "reusable"]),
18996
+ bytes: external_exports.number().int().nonnegative()
18997
+ }).strict();
18998
+ var StorageCompositionProjectSchema = external_exports.object({
18999
+ /** Repository root, else working directory, else empty when the session recorded neither. */
19000
+ path: external_exports.string(),
19001
+ sessions: external_exports.number().int().nonnegative(),
19002
+ bytes: external_exports.number().int().nonnegative()
19003
+ }).strict();
19004
+ var StorageCompositionSchema = external_exports.object({
19005
+ contractVersion: external_exports.literal(OPERATIONS_CONTRACT_VERSION),
19006
+ state: external_exports.enum(["absent", "running", "ready", "failed"]),
19007
+ computedAt: CanonicalTimestampSchema.nullable(),
19008
+ elapsedMs: external_exports.number().int().nonnegative().nullable(),
19009
+ /** The database file at the moment of measurement, from page count and page size. */
19010
+ fileBytes: external_exports.number().int().nonnegative().nullable(),
19011
+ sessions: external_exports.number().int().nonnegative().nullable(),
19012
+ parts: external_exports.array(StorageCompositionPartSchema),
19013
+ projects: external_exports.array(StorageCompositionProjectSchema),
19014
+ /** Projects measured but not listed, so a truncated list can say so rather than imply totality. */
19015
+ projectsOmitted: external_exports.number().int().nonnegative(),
19016
+ failure: external_exports.string().max(500).nullable()
19017
+ }).strict();
18981
19018
  var MaintenancePhaseSchema = external_exports.enum([
18982
19019
  "idle",
18983
19020
  "pause",
@@ -19089,6 +19126,17 @@ var LocalAlertSchema = external_exports.object({
19089
19126
  state: external_exports.enum(["active", "acknowledged", "recovered"]),
19090
19127
  title: external_exports.string().min(1).max(160),
19091
19128
  detail: external_exports.string().min(1).max(500),
19129
+ /*
19130
+ * What to say once the condition is over.
19131
+ *
19132
+ * Without these a recovered alert can only be shown in the words that announced it, and every
19133
+ * surface that renders one inherits the present tense: the macOS notification read "Recovered:
19134
+ * The durable queue is growing", which is the all-clear and the alarm in one line. Optional
19135
+ * because a ledger written before this field existed still has to parse; readers fall back to
19136
+ * `title` and `detail`.
19137
+ */
19138
+ recoveryTitle: external_exports.string().min(1).max(160).optional(),
19139
+ recoveryDetail: external_exports.string().min(1).max(500).optional(),
19092
19140
  firstSeenAt: CanonicalTimestampSchema,
19093
19141
  lastSeenAt: CanonicalTimestampSchema,
19094
19142
  lastTransitionAt: CanonicalTimestampSchema,
@@ -24640,7 +24688,9 @@ function conditions(snapshot, config2, priorHookTrust, priorGapFingerprint, now)
24640
24688
  kind: "queue-age",
24641
24689
  severity: "warning",
24642
24690
  title: "Queued work is aging",
24643
- detail: `The oldest durable queue item is at least ${values2.alerts.queueAgeMinutes.value} minutes old.`
24691
+ detail: `The oldest item waiting to be stored is at least ${values2.alerts.queueAgeMinutes.value} minutes old.`,
24692
+ recoveryTitle: "Queued work is moving again",
24693
+ recoveryDetail: `Nothing has been waiting longer than ${values2.alerts.queueAgeMinutes.value} minutes. No action is needed.`
24644
24694
  });
24645
24695
  const velocity = snapshot.estimates.queueVelocity;
24646
24696
  if (velocity && velocity.value > 0 && velocity.value * (velocity.sampleWindowSeconds / 60) >= values2.alerts.queueGrowthFiles.value)
@@ -24648,8 +24698,10 @@ function conditions(snapshot, config2, priorHookTrust, priorGapFingerprint, now)
24648
24698
  key: "queue-growth",
24649
24699
  kind: "queue-growth",
24650
24700
  severity: "warning",
24651
- title: "The durable queue is growing",
24652
- detail: `Net growth crossed ${values2.alerts.queueGrowthFiles.value} files in the sampled window.`
24701
+ title: "Salidium is falling behind",
24702
+ detail: `Your agents are producing work faster than Salidium is storing it: ${values2.alerts.queueGrowthFiles.value} more files are waiting than when this window started. Nothing is lost while it waits.`,
24703
+ recoveryTitle: "Salidium caught up",
24704
+ recoveryDetail: "The backlog stopped growing. No action is needed."
24653
24705
  });
24654
24706
  if (snapshot.store.totalBytes !== null && snapshot.store.totalBytes >= values2.alerts.databaseSizeBytes.value)
24655
24707
  out.push({
@@ -24657,31 +24709,45 @@ function conditions(snapshot, config2, priorHookTrust, priorGapFingerprint, now)
24657
24709
  kind: "database-size",
24658
24710
  severity: "notice",
24659
24711
  title: "Local storage crossed its warning size",
24660
- detail: `The SQLite store and recovery log use ${snapshot.store.totalBytes} bytes.`
24712
+ detail: `Salidium is using ${formatBytes(snapshot.store.totalBytes)} on this Mac, past the ${formatBytes(values2.alerts.databaseSizeBytes.value)} mark. Retention is set to ${snapshot.store.retention === "forever" ? "keep everything forever" : `${snapshot.store.retention} days`}.`,
24713
+ recoveryTitle: "Local storage is back under its warning size",
24714
+ /*
24715
+ * No measurement in here. Recovery wording is composed while the condition is still true and
24716
+ * refreshed only for as long as it stays true, so quoting `store.totalBytes` would put the
24717
+ * size that raised the alert into the sentence saying the alert is over: "Salidium is using
24718
+ * 5.01 GB, below the 5.00 GB mark". The threshold is config and does not have that problem.
24719
+ */
24720
+ recoveryDetail: `Salidium is back below ${formatBytes(values2.alerts.databaseSizeBytes.value)}. Open Salidium to see the current size.`
24661
24721
  });
24662
24722
  if (snapshot.gaps.latestFingerprint && (snapshot.gaps.active > 0 || snapshot.gaps.latestFingerprint !== priorGapFingerprint))
24663
24723
  out.push({
24664
24724
  key: `collection-gap:${snapshot.gaps.latestFingerprint}`,
24665
24725
  kind: "collection-gap",
24666
24726
  severity: snapshot.gaps.active > 0 ? "critical" : "warning",
24667
- title: snapshot.gaps.active > 0 ? "Collection loss is active" : "A new collection gap was recorded",
24668
- detail: "The gap ledger changed. Exact dropped-event counts remain unavailable."
24727
+ title: snapshot.gaps.active > 0 ? "Salidium is missing some activity" : "Salidium missed some activity",
24728
+ detail: snapshot.gaps.active > 0 ? "Agent activity is happening that Salidium is not recording. Reports covering this period will be incomplete. How much was missed cannot be counted." : "A period of agent activity went unrecorded. Reports covering it will be incomplete. How much was missed cannot be counted.",
24729
+ recoveryTitle: "Salidium is recording everything again",
24730
+ recoveryDetail: "Collection is complete from here on. Reports covering the earlier gap stay incomplete."
24669
24731
  });
24670
24732
  if (snapshot.daemon.state !== "running")
24671
24733
  out.push({
24672
24734
  key: "daemon-health",
24673
24735
  kind: "daemon-health",
24674
24736
  severity: snapshot.daemon.state === "unresponsive" ? "critical" : "warning",
24675
- title: snapshot.daemon.state === "unresponsive" ? "The daemon is not answering" : "The daemon is stopped",
24676
- detail: `Daemon state changed to ${snapshot.daemon.state}.`
24737
+ title: snapshot.daemon.state === "unresponsive" ? "Salidium is not responding" : "Salidium has stopped",
24738
+ detail: snapshot.daemon.state === "unresponsive" ? "Salidium is running but not answering. Agent activity is not being recorded while this lasts." : "Salidium is not running. Agent activity is not being recorded until it starts again.",
24739
+ recoveryTitle: "Salidium is running again",
24740
+ recoveryDetail: "Recording has resumed. No action is needed."
24677
24741
  });
24678
24742
  if (snapshot.maintenance?.phase === "failure" || snapshot.maintenance?.phase === "recovery")
24679
24743
  out.push({
24680
24744
  key: `maintenance-failure:${snapshot.maintenance.operationId}`,
24681
24745
  kind: "maintenance-failure",
24682
24746
  severity: "critical",
24683
- title: "Maintenance needs recovery",
24684
- detail: (snapshot.maintenance.failure ?? snapshot.maintenance.message).slice(0, 500)
24747
+ title: "Maintenance did not finish",
24748
+ detail: (snapshot.maintenance.failure ?? snapshot.maintenance.message).slice(0, 500),
24749
+ recoveryTitle: "Maintenance finished",
24750
+ recoveryDetail: "The operation that needed attention completed. No action is needed."
24685
24751
  });
24686
24752
  for (const hook of snapshot.hooks) {
24687
24753
  const prior = priorHookTrust[hook.id];
@@ -24692,8 +24758,10 @@ function conditions(snapshot, config2, priorHookTrust, priorGapFingerprint, now)
24692
24758
  key: `hook-trust-change:${hook.id}`,
24693
24759
  kind: "hook-trust-change",
24694
24760
  severity: hook.trust === "modified" || hook.trust === "untrusted" ? "critical" : "notice",
24695
- title: `${hook.name} hook trust changed`,
24696
- detail: `Trust changed from ${prior} to ${hook.trust}.`
24761
+ title: unsafe ? `The ${hook.name} hook is no longer approved` : `The ${hook.name} hook was approved`,
24762
+ detail: unsafe ? `The hook file changed since you approved it (${prior} to ${hook.trust}). Salidium will not trust it until you approve the new version.` : `Approval state went from ${prior} to ${hook.trust}.`,
24763
+ recoveryTitle: `The ${hook.name} hook is approved again`,
24764
+ recoveryDetail: "The hook file matches an approved version. No action is needed."
24697
24765
  });
24698
24766
  }
24699
24767
  return out;
@@ -24724,6 +24792,8 @@ async function evaluateLocalAlerts(home, snapshot, config2, options = {}) {
24724
24792
  existing.severity = condition.severity;
24725
24793
  existing.title = condition.title;
24726
24794
  existing.detail = condition.detail;
24795
+ existing.recoveryTitle = condition.recoveryTitle;
24796
+ existing.recoveryDetail = condition.recoveryDetail;
24727
24797
  existing.notificationEligible = false;
24728
24798
  continue;
24729
24799
  }
@@ -24737,6 +24807,8 @@ async function evaluateLocalAlerts(home, snapshot, config2, options = {}) {
24737
24807
  state: "active",
24738
24808
  title: condition.title,
24739
24809
  detail: condition.detail,
24810
+ recoveryTitle: condition.recoveryTitle,
24811
+ recoveryDetail: condition.recoveryDetail,
24740
24812
  firstSeenAt: at,
24741
24813
  lastSeenAt: at,
24742
24814
  lastTransitionAt: at,
@@ -24814,7 +24886,13 @@ var DEFAULT_OPERATIONAL_CONFIG = {
24814
24886
  alerts: {
24815
24887
  queueAgeMinutes: 10,
24816
24888
  queueGrowthFiles: 100,
24817
- databaseSizeBytes: 5 * 1024 * 1024 * 1024,
24889
+ /*
24890
+ * Decimal, to pair with how the size is shown. Left at 5 GiB it read "warns at 5.37 GB", which
24891
+ * is not a number anyone chose, and the rail's own picker offered GiB steps that no longer
24892
+ * matched any label. This lowers the warning by about seven percent; it is a notice about a
24893
+ * local file, and `alerts.databaseSizeBytes` still overrides it.
24894
+ */
24895
+ databaseSizeBytes: 5 * 1e3 * 1e3 * 1e3,
24818
24896
  cooldownMinutes: 30,
24819
24897
  nativeNotifications: false
24820
24898
  },
@@ -26860,6 +26938,87 @@ var SqliteStore = class {
26860
26938
  });
26861
26939
  return true;
26862
26940
  }
26941
+ /*
26942
+ * What the store is made of, measured rather than estimated.
26943
+ *
26944
+ * Every part here is one pass over a table. The events pass is the expensive one: `length(json)`
26945
+ * does not read a blob's payload, but it does read the header of every row, so on a store with
26946
+ * 1.3 million events that is a ten second scan and the reason this never runs on the health path
26947
+ * or on a timer. The daemon runs it on a worker with its own read-only connection for the same
26948
+ * reason it runs usage backfill there: a ten second synchronous scan on the main loop is ten
26949
+ * seconds of unanswered hooks.
26950
+ *
26951
+ * `structure` is deliberately a subtraction. Naming what each index costs needs `dbstat`, which
26952
+ * is a further nine to fifteen seconds because it walks every page in the file, and the answer
26953
+ * it adds is "your indexes are large" rather than anything a reader can act on. The difference
26954
+ * is honest, arrives free, and is labelled as a remainder wherever it is shown.
26955
+ */
26956
+ storageComposition(projectLimit = 12) {
26957
+ const started = Date.now();
26958
+ const scalar = (sql) => {
26959
+ const row = this.db.prepare(sql).get();
26960
+ const value2 = row ? Object.values(row)[0] : 0;
26961
+ return typeof value2 === "number" ? value2 : Number(value2 ?? 0);
26962
+ };
26963
+ const pageSize = scalar("PRAGMA page_size");
26964
+ const fileBytes = scalar("PRAGMA page_count") * pageSize;
26965
+ const reusable = scalar("PRAGMA freelist_count") * pageSize;
26966
+ const sessions = this.db.prepare(`SELECT id, COALESCE(NULLIF(repo_root, ''), NULLIF(cwd, ''), '') AS project FROM sessions`).all();
26967
+ const projectOf = new Map(sessions.map((row) => [row.id, row.project]));
26968
+ const perProject = /* @__PURE__ */ new Map();
26969
+ for (const row of sessions) {
26970
+ const entry2 = perProject.get(row.project) ?? { sessions: 0, bytes: 0 };
26971
+ entry2.sessions += 1;
26972
+ perProject.set(row.project, entry2);
26973
+ }
26974
+ let sessionBytes = 0;
26975
+ let checkpointBytes = 0;
26976
+ const accumulate = (sql, into) => {
26977
+ for (const row of this.db.prepare(sql).all()) {
26978
+ const bytes = Number(row.b ?? 0);
26979
+ into(bytes);
26980
+ const project = projectOf.get(row.id);
26981
+ if (project === void 0)
26982
+ continue;
26983
+ const entry2 = perProject.get(project);
26984
+ if (entry2)
26985
+ entry2.bytes += bytes;
26986
+ }
26987
+ };
26988
+ accumulate("SELECT session_id AS id, SUM(length(json)) AS b FROM events GROUP BY session_id", (b) => {
26989
+ sessionBytes += b;
26990
+ });
26991
+ accumulate("SELECT session_id AS id, SUM(length(json)) AS b FROM changes GROUP BY session_id", (b) => {
26992
+ sessionBytes += b;
26993
+ });
26994
+ accumulate("SELECT session_id AS id, SUM(length(state_json)) AS b FROM checkpoints GROUP BY session_id", (b) => {
26995
+ checkpointBytes += b;
26996
+ });
26997
+ const provenanceBytes = scalar(`SELECT COALESCE(SUM(length(session_id) + length(event_id) + length(path)
26998
+ + length(record_hash) + length(captured_at) + length(COALESCE(origin, ''))), 0) AS b
26999
+ FROM raw_record_fingerprints`);
27000
+ const named = sessionBytes + checkpointBytes + provenanceBytes + reusable;
27001
+ const ranked = [...perProject.entries()].map(([path, value2]) => ({ path, sessions: value2.sessions, bytes: value2.bytes })).sort((a, b) => b.bytes - a.bytes || b.sessions - a.sessions);
27002
+ return {
27003
+ contractVersion: OPERATIONS_CONTRACT_VERSION,
27004
+ state: "ready",
27005
+ computedAt: new Date(started).toISOString(),
27006
+ elapsedMs: Date.now() - started,
27007
+ fileBytes,
27008
+ sessions: sessions.length,
27009
+ parts: [
27010
+ { key: "sessions", bytes: sessionBytes },
27011
+ { key: "checkpoints", bytes: checkpointBytes },
27012
+ { key: "provenance", bytes: provenanceBytes },
27013
+ // A store measured while it is being written to can name more than the file holds.
27014
+ { key: "structure", bytes: Math.max(fileBytes - named, 0) },
27015
+ { key: "reusable", bytes: reusable }
27016
+ ],
27017
+ projects: ranked.slice(0, projectLimit),
27018
+ projectsOmitted: Math.max(ranked.length - projectLimit, 0),
27019
+ failure: null
27020
+ };
27021
+ }
26863
27022
  /** Offline space reclamation after bounded cleanup batches. */
26864
27023
  compact() {
26865
27024
  if (!this.usageBackfillProgress().complete)
@@ -27715,9 +27874,14 @@ function runStorageOptimizationMaintenance(home, options = {}) {
27715
27874
  import { spawn as spawn3 } from "node:child_process";
27716
27875
  import { delimiter as delimiter3 } from "node:path";
27717
27876
  function notificationText(alert) {
27877
+ if (alert.state === "recovered")
27878
+ return {
27879
+ title: alert.recoveryTitle ?? `Recovered: ${alert.title}`,
27880
+ detail: alert.recoveryDetail ?? alert.detail
27881
+ };
27718
27882
  const detail = alert.kind === "maintenance-failure" ? "Maintenance needs local review. Open Salidium or run salidium maintenance status for details." : alert.detail;
27719
27883
  return {
27720
- title: alert.state === "recovered" ? `Recovered: ${alert.title}` : alert.title,
27884
+ title: alert.title,
27721
27885
  detail: `${detail} Open Salidium or run salidium status for details.`
27722
27886
  };
27723
27887
  }
@@ -28699,6 +28863,13 @@ function createHttpServer(deps) {
28699
28863
  }
28700
28864
  if (req.method === "POST" && url2.pathname === "/api/operations/maintenance/drain" && deps.operations)
28701
28865
  return json2(res, 200, deps.operations.drainQueue());
28866
+ if (url2.pathname === "/api/operations/storage" && deps.operations) {
28867
+ if (req.method === "GET")
28868
+ return json2(res, 200, deps.operations.storageComposition());
28869
+ if (req.method === "POST")
28870
+ return json2(res, 200, deps.operations.analyzeStorage());
28871
+ return json2(res, 405, { error: "method not allowed" });
28872
+ }
28702
28873
  const acknowledge = /^\/api\/operations\/alerts\/([^/]+)\/acknowledge$/.exec(url2.pathname);
28703
28874
  if (req.method === "POST" && acknowledge?.[1] && deps.operations) {
28704
28875
  try {
@@ -29438,6 +29609,67 @@ async function startDaemon(overrides = {}) {
29438
29609
  refreshHealthSampling?.();
29439
29610
  return effective2;
29440
29611
  };
29612
+ let composition = {
29613
+ contractVersion: OPERATIONS_CONTRACT_VERSION,
29614
+ state: "absent",
29615
+ computedAt: null,
29616
+ elapsedMs: null,
29617
+ fileBytes: null,
29618
+ sessions: null,
29619
+ parts: [],
29620
+ projects: [],
29621
+ projectsOmitted: 0,
29622
+ failure: null
29623
+ };
29624
+ let compositionWorker;
29625
+ const analyzeStorage = () => {
29626
+ if (compositionWorker)
29627
+ return composition;
29628
+ const sibling = new URL("./storage/compositionWorker.js", import.meta.url);
29629
+ const worker = existsSync16(fileURLToPath(sibling)) ? new Worker(sibling, { workerData: paths.db }) : (() => {
29630
+ const runtime = process.argv[1];
29631
+ if (!runtime)
29632
+ return void 0;
29633
+ return new Worker(resolve4(runtime), {
29634
+ argv: ["__storage-composition", paths.db]
29635
+ });
29636
+ })();
29637
+ if (!worker) {
29638
+ composition = {
29639
+ ...composition,
29640
+ state: "failed",
29641
+ failure: "Salidium could not locate a runtime to measure the store with."
29642
+ };
29643
+ return composition;
29644
+ }
29645
+ compositionWorker = worker;
29646
+ worker.unref();
29647
+ composition = { ...composition, state: "running", failure: null };
29648
+ worker.once("message", (message) => {
29649
+ composition = message;
29650
+ });
29651
+ worker.once("error", (error51) => {
29652
+ log.warn("storage composition worker failed", { err: String(error51) });
29653
+ composition = { ...composition, state: "failed", failure: String(error51).slice(0, 500) };
29654
+ });
29655
+ worker.once("exit", (code) => {
29656
+ if (compositionWorker === worker)
29657
+ compositionWorker = void 0;
29658
+ if (code !== 0 && composition.state === "running")
29659
+ composition = {
29660
+ ...composition,
29661
+ state: "failed",
29662
+ failure: `The measurement stopped with status ${code}.`
29663
+ };
29664
+ else if (composition.state === "running")
29665
+ composition = {
29666
+ ...composition,
29667
+ state: "failed",
29668
+ failure: "The measurement produced no result."
29669
+ };
29670
+ });
29671
+ return composition;
29672
+ };
29441
29673
  let port = config2.port;
29442
29674
  const server = createHttpServer({
29443
29675
  registry: registry2,
@@ -29473,7 +29705,9 @@ async function startDaemon(overrides = {}) {
29473
29705
  return inspectQueue(config2.home, { entryLimit: limit });
29474
29706
  },
29475
29707
  drainQueue: () => runQueueDrainMaintenance(config2.home, () => hooks.drainSpool()),
29476
- acknowledgeAlert: (id) => acknowledgeLocalAlert(config2.home, id)
29708
+ acknowledgeAlert: (id) => acknowledgeLocalAlert(config2.home, id),
29709
+ storageComposition: () => composition,
29710
+ analyzeStorage
29477
29711
  },
29478
29712
  settings: {
29479
29713
  explainer: explainerSettings,
@@ -29564,6 +29798,8 @@ async function startDaemon(overrides = {}) {
29564
29798
  clearTimeout(usageBackfillRetryTimer);
29565
29799
  if (usageBackfillWorker)
29566
29800
  void usageBackfillWorker.terminate();
29801
+ if (compositionWorker)
29802
+ void compositionWorker.terminate();
29567
29803
  if (healthTimer)
29568
29804
  clearTimeout(healthTimer);
29569
29805
  if (hookTrustTimer)
@@ -31510,6 +31746,15 @@ async function runFirstRunOnboarding(context, io, options = {}) {
31510
31746
  };
31511
31747
  }
31512
31748
 
31749
+ // src/pauseOnRun.ts
31750
+ function clearsPauseOnRun(command, argument, argv) {
31751
+ if (argv.includes("--no-resume")) return false;
31752
+ if (["pause", "resume", "stop", "service"].includes(command)) return false;
31753
+ if (command.startsWith("__")) return false;
31754
+ if (command === "storage" && argument === "optimize") return false;
31755
+ return true;
31756
+ }
31757
+
31513
31758
  // src/render.ts
31514
31759
  var H = "\u2500";
31515
31760
  var CONTROL = /[\u0000-\u001F\u007F-\u009F]/g;
@@ -31740,6 +31985,7 @@ Usage:
31740
31985
  salidium retention apply Apply one cleanup batch now (daemon must be stopped)
31741
31986
  salidium retention compact Return reusable SQLite pages to the OS (daemon must be stopped)
31742
31987
  salidium storage Show the lossless storage layout and page size
31988
+ salidium storage composition Measure what is using the space, by part and by project
31743
31989
  salidium storage optimize Coordinate, copy, verify, and install the compact layout
31744
31990
  salidium pin [session] Exempt a session from automatic retention
31745
31991
  salidium unpin [session] Remove the retention exemption
@@ -31761,7 +32007,9 @@ Environment:
31761
32007
 
31762
32008
  Native Windows imports transcript history but does not install the POSIX live-hook relay.
31763
32009
  Ordinary commands resume an expired or manual pause. pause, stop, service commands, and coordinated
31764
- storage optimize do not; resume changes collection state explicitly.
32010
+ storage optimize do not; resume changes collection state explicitly. Add --no-resume to any command
32011
+ to leave a pause in place, for a caller that has already seen the daemon answer and so cannot be
32012
+ recovering a marker its dead owner left behind.
31765
32013
  `;
31766
32014
  var require3 = createRequire2(import.meta.url);
31767
32015
  var VERSION2 = (() => {
@@ -31781,11 +32029,10 @@ async function main(argv) {
31781
32029
  const jsonOutput = argv.includes("--json");
31782
32030
  const quiet = argv.includes("--quiet");
31783
32031
  const positional = argv.filter(
31784
- (value2) => !["--yes", "-y", "--no-open", "--json", "--quiet"].includes(value2)
32032
+ (value2) => !["--yes", "-y", "--no-open", "--json", "--quiet", "--no-resume"].includes(value2)
31785
32033
  );
31786
32034
  const [cmd = "up", arg, ...args] = positional;
31787
- if (!["pause", "resume", "stop", "service", "__usage-backfill"].includes(cmd) && !(cmd === "storage" && arg === "optimize"))
31788
- await implicitlyResumeCollection();
32035
+ if (clearsPauseOnRun(cmd, arg, argv)) await implicitlyResumeCollection();
31789
32036
  switch (cmd) {
31790
32037
  case "help":
31791
32038
  case "--help":
@@ -31828,6 +32075,24 @@ async function main(argv) {
31828
32075
  store.close();
31829
32076
  }
31830
32077
  }
32078
+ /*
32079
+ * The other private worker entrypoint. Measuring what the store is made of reads the header of
32080
+ * every stored event, which is ten seconds on a three gigabyte store, and `node:sqlite` is
32081
+ * synchronous: run on the daemon's loop that is ten seconds of hooks going unanswered and
32082
+ * spooling to disk. It runs here instead, on its own event loop and its own connection, and
32083
+ * posts one message back.
32084
+ */
32085
+ case "__storage-composition": {
32086
+ if (!arg || !resolve6(arg).startsWith(`${resolve6(salidiumHome)}${sep3}`))
32087
+ throw new Error("storage composition store must be inside the Salidium state directory");
32088
+ const store = new SqliteStore(resolve6(arg), { concurrentWriter: true });
32089
+ try {
32090
+ parentPort?.postMessage(store.storageComposition());
32091
+ return 0;
32092
+ } finally {
32093
+ store.close();
32094
+ }
32095
+ }
31831
32096
  case "start": {
31832
32097
  const running = await ensureDaemon();
31833
32098
  const explanations = await currentExplanationState(running, "reachable");
@@ -32442,9 +32707,9 @@ Change with: salidium explanations off|when-done|each-reply
32442
32707
  `);
32443
32708
  process.stdout.write(`Store: ${formatBytes(storeBytes)}
32444
32709
  `);
32445
- if (storeBytes >= 1024 * 1024 * 1024)
32710
+ if (storeBytes >= 1e3 * 1e3 * 1e3)
32446
32711
  process.stdout.write(
32447
- inspectStoreLayout(db).optimized ? "Storage warning: history is over 1 GiB. Preview exactly what retention would delete before opting in.\n" : "Lossless storage optimization is available before deleting history. Run `salidium storage`, then `salidium storage optimize`; it coordinates queue drain and daemon stop.\n"
32712
+ inspectStoreLayout(db).optimized ? "Storage warning: history is over 1 GB. Preview exactly what retention would delete before opting in.\n" : "Lossless storage optimization is available before deleting history. Run `salidium storage`, then `salidium storage optimize`; it coordinates queue drain and daemon stop.\n"
32448
32713
  );
32449
32714
  process.stdout.write(`Pinned: ${store.pinnedSessionIds().length}
32450
32715
  `);
@@ -32464,10 +32729,54 @@ Change with: salidium explanations off|when-done|each-reply
32464
32729
  `);
32465
32730
  return 1;
32466
32731
  }
32467
- if (arg !== void 0 && arg !== "optimize") {
32468
- process.stderr.write("storage accepts only `optimize`\n");
32732
+ if (arg !== void 0 && arg !== "optimize" && arg !== "composition") {
32733
+ process.stderr.write("storage accepts only `composition` or `optimize`\n");
32469
32734
  return 2;
32470
32735
  }
32736
+ if (arg === "composition") {
32737
+ const store = new SqliteStore(db, { concurrentWriter: true });
32738
+ try {
32739
+ const measured = store.storageComposition();
32740
+ if (jsonOutput) {
32741
+ process.stdout.write(`${JSON.stringify(measured)}
32742
+ `);
32743
+ return 0;
32744
+ }
32745
+ const label = {
32746
+ sessions: "Recorded sessions",
32747
+ checkpoints: "Replay checkpoints",
32748
+ provenance: "Provenance records",
32749
+ structure: "Indexes and internal structure",
32750
+ reusable: "Reusable space"
32751
+ };
32752
+ process.stdout.write(`On this Mac: ${formatBytes(measured.fileBytes ?? 0)}
32753
+ `);
32754
+ for (const part of measured.parts)
32755
+ process.stdout.write(
32756
+ ` ${(label[part.key] ?? part.key).padEnd(30)} ${formatBytes(part.bytes)}
32757
+ `
32758
+ );
32759
+ process.stdout.write(
32760
+ `
32761
+ ${measured.sessions ?? 0} sessions across ${measured.projects.length + measured.projectsOmitted} projects.
32762
+ `
32763
+ );
32764
+ for (const project of measured.projects)
32765
+ process.stdout.write(
32766
+ ` ${formatBytes(project.bytes).padStart(9)} ${String(project.sessions).padStart(4)} ${project.sessions === 1 ? "session " : "sessions"} ${project.path || "No project recorded"}
32767
+ `
32768
+ );
32769
+ if (measured.projectsOmitted > 0)
32770
+ process.stdout.write(` ${measured.projectsOmitted} more not listed.
32771
+ `);
32772
+ process.stdout.write(
32773
+ "\nIndexes and internal structure is the remainder after the named parts, not a separate measurement.\n"
32774
+ );
32775
+ return 0;
32776
+ } finally {
32777
+ store.close();
32778
+ }
32779
+ }
32471
32780
  if (arg === void 0) {
32472
32781
  const layout = inspectStoreLayout(db);
32473
32782
  if (quiet) return layout.optimized ? 0 : 3;
@@ -32767,10 +33076,11 @@ async function readOperationsOverview(daemon, presence, providers) {
32767
33076
  alerts: await evaluateLocalAlerts(salidiumHome, health, config2)
32768
33077
  };
32769
33078
  }
32770
- function estimateLabel(estimate2) {
32771
- if (!estimate2) return "Unavailable (needs at least two exact samples)";
33079
+ function estimateLabel(estimate2, absent = "Unavailable (needs two measurements)") {
33080
+ if (!estimate2) return absent;
32772
33081
  const value2 = estimate2.unit === "bytes/minute" ? `${formatBytes(Math.abs(estimate2.value))}/minute` : Math.abs(estimate2.value).toFixed(1);
32773
- const sign = estimate2.value < 0 ? "\u2212" : estimate2.value > 0 ? "+" : "";
33082
+ const zero = /^[0.]+(?: B\/minute)?$/.test(value2);
33083
+ const sign = zero ? "" : estimate2.value < 0 ? "\u2212" : "+";
32774
33084
  const unit = estimate2.unit === "bytes/minute" ? "" : ` ${estimate2.unit}`;
32775
33085
  return `${sign}${value2}${unit} \xB7 derived from ${estimate2.samples} samples over ${Math.round(estimate2.sampleWindowSeconds)}s`;
32776
33086
  }
@@ -32934,10 +33244,9 @@ async function statusCommand(options) {
32934
33244
  );
32935
33245
  } else {
32936
33246
  const health = operations.health;
32937
- if (health.daemon.state === "stopped") process.stdout.write("not running\n");
32938
33247
  process.stdout.write(
32939
33248
  health.daemon.state === "running" ? `Daemon: Running \xB7 pid ${health.daemon.pid} \xB7 since ${health.daemon.startedAt}
32940
- ` : health.daemon.state === "unresponsive" ? `Daemon running: pid ${health.daemon.pid} \xB7 not answering
33249
+ ` : health.daemon.state === "unresponsive" ? `Daemon: Not answering \xB7 pid ${health.daemon.pid}
32941
33250
  ` : "Daemon: Stopped\n"
32942
33251
  );
32943
33252
  process.stdout.write(
@@ -32963,12 +33272,24 @@ async function statusCommand(options) {
32963
33272
  `Store (exact): ${health.store.totalBytes === null ? "Unavailable" : formatBytes(health.store.totalBytes)}, retention ${retentionLabel(health.store.retention)}, last ingest ${health.store.lastIngestAt ?? "Unavailable"}
32964
33273
  `
32965
33274
  );
33275
+ const projection = storageProjection(
33276
+ health.store.totalBytes,
33277
+ health.estimates.storageGrowth,
33278
+ operations.config.values.alerts.databaseSizeBytes.value
33279
+ );
33280
+ if (projection) process.stdout.write(`Store outlook: ${projection}
33281
+ `);
32966
33282
  process.stdout.write(
32967
33283
  `Queue velocity (estimate): ${estimateLabel(health.estimates.queueVelocity)}
32968
33284
  `
32969
33285
  );
33286
+ const sampled = health.estimates.queueVelocity !== null;
33287
+ const notShrinking = "Unavailable (the queue is not shrinking)";
32970
33288
  process.stdout.write(
32971
- `Drain rate (estimate): ${estimateLabel(health.estimates.drainRate)}
33289
+ `Drain rate (estimate): ${estimateLabel(
33290
+ health.estimates.drainRate,
33291
+ sampled ? notShrinking : void 0
33292
+ )}
32972
33293
  `
32973
33294
  );
32974
33295
  process.stdout.write(
@@ -32976,7 +33297,7 @@ async function statusCommand(options) {
32976
33297
  `
32977
33298
  );
32978
33299
  process.stdout.write(
32979
- `Time to empty (estimate): ${health.estimates.timeToEmpty ? `${Math.round(health.estimates.timeToEmpty.value)} seconds \xB7 derived` : "Unavailable"}
33300
+ `Time to empty (estimate): ${health.estimates.timeToEmpty ? `${Math.round(health.estimates.timeToEmpty.value)} seconds \xB7 derived` : health.queue.files === 0 ? "Already empty" : sampled ? notShrinking : "Unavailable (needs two measurements)"}
32980
33301
  `
32981
33302
  );
32982
33303
  process.stdout.write(
@@ -32986,7 +33307,7 @@ async function statusCommand(options) {
32986
33307
  process.stdout.write(`Explanations: ${explanationStateLabel(explanations)}
32987
33308
  `);
32988
33309
  process.stdout.write(
32989
- `Maintenance: ${health.maintenance ? `${health.maintenance.phase} \xB7 ${health.maintenance.message}` : "Idle"}
33310
+ `Maintenance: ${health.maintenance ? `${maintenancePhaseLabel(health.maintenance.phase)} \xB7 ${health.maintenance.message}` : "Idle"}
32990
33311
  `
32991
33312
  );
32992
33313
  process.stdout.write(
@@ -33220,11 +33541,25 @@ async function maintenanceCommand(action, args, options) {
33220
33541
  process.stderr.write("maintenance accepts status, queue, drain, optimize, or acknowledge\n");
33221
33542
  return 2;
33222
33543
  }
33223
- function formatBytes(bytes) {
33224
- if (bytes < 1024) return `${bytes} B`;
33225
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
33226
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
33227
- return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
33544
+ function maintenancePhaseLabel(phase) {
33545
+ if (phase === "failure") return "Did not finish";
33546
+ if (phase === "recovery") return "Recovering";
33547
+ if (phase === "completed") return "Finished";
33548
+ if (phase === "running") return "Running";
33549
+ return phase;
33550
+ }
33551
+ function storageProjection(total, growth, warnAt) {
33552
+ const parts = [];
33553
+ if (growth && growth.value > 0)
33554
+ parts.push(`about ${formatBytes(growth.value * 60 * 24)} a day at this rate`);
33555
+ if (total !== null) {
33556
+ const mark2 = formatBytes(warnAt);
33557
+ const room = formatBytes(warnAt - total);
33558
+ parts.push(
33559
+ total >= warnAt ? `past its ${mark2} warning mark` : room === mark2 ? `warns at ${mark2}` : `${room} below its ${mark2} warning mark`
33560
+ );
33561
+ }
33562
+ return parts.join(" \xB7 ");
33228
33563
  }
33229
33564
  function queueLabel(status) {
33230
33565
  return `${status.queue.files.toLocaleString()} file${status.queue.files === 1 ? "" : "s"}, ${formatBytes(status.queue.bytes)}`;
@@ -33496,11 +33831,22 @@ async function ensureDaemon() {
33496
33831
  for (let i = 0; i < 100; i++) {
33497
33832
  await sleep(100);
33498
33833
  if (childFailure)
33499
- throw new Error(`daemon ${childFailure} before it became ready; see ${paths.startupLogFile}`);
33834
+ throw new Error(
33835
+ `daemon ${childFailure} before it became ready: ${startupFailureReason(paths.startupLogFile)}`
33836
+ );
33500
33837
  const d = readDaemonJson(salidiumHome);
33501
33838
  if (d && d.pid === child.pid && await alive(d)) return d;
33502
33839
  }
33503
- throw new Error(`daemon did not start; see ${paths.startupLogFile}`);
33840
+ throw new Error(`daemon did not start: ${startupFailureReason(paths.startupLogFile)}`);
33841
+ }
33842
+ function startupFailureReason(logFile) {
33843
+ try {
33844
+ const lines = readFileSync13(logFile, "utf8").split("\n").map((line) => line.trim()).filter(Boolean);
33845
+ const last = lines.at(-1);
33846
+ if (last) return `${last.slice(0, 200)} (see ${logFile})`;
33847
+ } catch {
33848
+ }
33849
+ return `see ${logFile}`;
33504
33850
  }
33505
33851
  function compareSemver(left, right) {
33506
33852
  const parse3 = (value2) => {