dsh-mnemon 0.5.27 → 0.5.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -7,7 +7,7 @@ import z from "@deepseek-ai/schemastery";
7
7
  import { basename, delimiter, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
8
8
  import { resolveEmbedding, resolvePersistenceStrategy, resolveRecallQuality } from "dsh-mnemon-source-memory-spaces";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
- import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
10
+ import { accessSync, chmodSync, closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
11
11
  import { homedir, hostname } from "node:os";
12
12
  import { strToU8, unzipSync, zipSync } from "fflate";
13
13
  import { DOCUMENTS_ACTIVE_LIMIT_BYTES, DOCUMENTS_VERSION } from "dsh-mnemon-source-documents/contracts";
@@ -541,6 +541,7 @@ var MnemonAutoBackupScheduler = class {
541
541
  lastAt;
542
542
  lastAttemptAt;
543
543
  lastError;
544
+ lastFailure;
544
545
  lastCommit;
545
546
  lastPushed;
546
547
  constructor(runtime, now = () => /* @__PURE__ */ new Date()) {
@@ -574,6 +575,7 @@ var MnemonAutoBackupScheduler = class {
574
575
  ...this.lastAt === void 0 ? {} : { lastAt: this.lastAt },
575
576
  ...this.lastAttemptAt === void 0 || !available ? {} : { nextAt: new Date(this.lastAttemptAt + minutes * MINUTE_MS).toISOString() },
576
577
  ...this.lastError === void 0 ? {} : { lastError: this.lastError },
578
+ ...this.lastError === void 0 || this.lastFailure === void 0 ? {} : { lastFailure: this.lastFailure },
577
579
  ...this.lastCommit === void 0 ? {} : { lastCommit: this.lastCommit },
578
580
  ...this.lastPushed === void 0 ? {} : { lastPushed: this.lastPushed }
579
581
  };
@@ -619,19 +621,26 @@ var MnemonAutoBackupScheduler = class {
619
621
  const controller = new AbortController();
620
622
  this.controller = controller;
621
623
  try {
622
- if (sync.settings().read().repoUrl === void 0) this.lastError = void 0;
623
- else if (!this.runtime.config.writeEnabled) this.lastError = "dsh-mnemon is configured read-only";
624
- else {
624
+ if (sync.settings().read().repoUrl === void 0) {
625
+ this.lastError = void 0;
626
+ this.lastFailure = void 0;
627
+ } else if (!this.runtime.config.writeEnabled) {
628
+ this.lastError = "dsh-mnemon is configured read-only";
629
+ this.lastFailure = void 0;
630
+ } else {
625
631
  const result = await sync.push({ signal: controller.signal });
626
632
  this.lastAt = this.now().toISOString();
627
633
  this.lastCommit = result.commit;
628
634
  this.lastPushed = result.pushed;
629
635
  this.lastError = result.pushed ? void 0 : result.committed ? result.reason : void 0;
636
+ this.lastFailure = this.lastError === void 0 ? void 0 : result.failure;
630
637
  }
631
638
  } catch (error) {
632
639
  const message = error instanceof Error ? error.message : String(error);
633
- if (!this.stopped && !message.includes(BUSY_MESSAGE)) {
640
+ const reason = error instanceof Error ? error.reason : void 0;
641
+ if (!this.stopped && reason !== "busy" && !message.includes(BUSY_MESSAGE)) {
634
642
  this.lastError = message;
643
+ this.lastFailure = typeof reason === "string" ? reason : void 0;
635
644
  console.warn("dsh-mnemon: automatic Git backup failed", error);
636
645
  }
637
646
  } finally {
@@ -707,7 +716,7 @@ function createStorageRoot(config, workspaceRoot) {
707
716
  const MNEMON_MACHINE_DIRECTORY = "state";
708
717
  const MNEMON_MACHINE_FILE = "machine.json";
709
718
  const MNEMON_MACHINE_VERSION = 1;
710
- const MAX_LABEL_CHARACTERS = 120;
719
+ const CONTROL = /[\p{Cc}\u2028\u2029]/u;
711
720
  function record$8(value) {
712
721
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
713
722
  }
@@ -715,16 +724,26 @@ function record$8(value) {
715
724
  function defaultMachineLabel() {
716
725
  const name = hostname().trim();
717
726
  const label = name === "" ? "this machine" : name;
718
- return label.length > MAX_LABEL_CHARACTERS ? label.slice(0, MAX_LABEL_CHARACTERS) : label;
727
+ return label.length > 120 ? label.slice(0, 120) : label;
728
+ }
729
+ /** A name a person gives this machine, as it is stored and shown. */
730
+ function machineLabel(value) {
731
+ const label = typeof value === "string" ? value.trim() : "";
732
+ if (label === "" || label.length > 120 || CONTROL.test(label)) throw new Error("machine name must be 1 to " + String(120) + " characters on one line");
733
+ return label;
719
734
  }
720
735
  /**
721
736
  * The machine identity store. One file, one identity, created on first read and
722
737
  * then stable for the life of the data directory: an id that changed would
723
738
  * make every earlier entry look like it came from another machine.
739
+ *
740
+ * The name is not part of that promise. A host-name label follows the computer's
741
+ * host name, and a person can name the machine instead. Every read goes to the
742
+ * file, because the pack manager and the sync channel each hold a store and a
743
+ * rename through one has to reach what the other stamps.
724
744
  */
725
745
  var MnemonMachineStore = class {
726
746
  root;
727
- cached;
728
747
  constructor(runner) {
729
748
  this.root = resolve(runner.effectiveDataDir());
730
749
  }
@@ -736,10 +755,42 @@ var MnemonMachineStore = class {
736
755
  }
737
756
  /** This installation's identity, creating and persisting one when none exists yet. */
738
757
  read() {
739
- if (this.cached !== void 0) return this.cached;
758
+ return this.current().identity;
759
+ }
760
+ /** The identity with the source of its name, bringing the stored record up to date. */
761
+ current() {
740
762
  const stored = this.stored();
741
- this.cached = stored ?? this.create();
742
- return this.cached;
763
+ if (stored === void 0) return this.write({
764
+ id: randomUUID(),
765
+ label: defaultMachineLabel(),
766
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
767
+ }, "hostname");
768
+ if (stored.source === "hostname") {
769
+ const label = defaultMachineLabel();
770
+ if (stored.identity.label !== label || !stored.marked) return this.write({
771
+ ...stored.identity,
772
+ label
773
+ }, "hostname");
774
+ } else if (!stored.marked) return this.write(stored.identity, "custom");
775
+ return {
776
+ identity: stored.identity,
777
+ source: stored.source
778
+ };
779
+ }
780
+ /**
781
+ * Name this machine. A typed name stays as given; null or an empty name goes back
782
+ * to the host name. The id never changes, so earlier entries keep pointing here.
783
+ */
784
+ rename(value) {
785
+ const label = value === null || value === "" ? void 0 : machineLabel(value);
786
+ const identity = this.current().identity;
787
+ return label === void 0 ? this.write({
788
+ ...identity,
789
+ label: defaultMachineLabel()
790
+ }, "hostname") : this.write({
791
+ ...identity,
792
+ label
793
+ }, "custom");
743
794
  }
744
795
  stored() {
745
796
  let value;
@@ -753,29 +804,351 @@ var MnemonMachineStore = class {
753
804
  if (typeof parsed.id !== "string" || parsed.id.trim() === "") return void 0;
754
805
  if (typeof parsed.label !== "string" || parsed.label.trim() === "") return void 0;
755
806
  if (typeof parsed.createdAt !== "string") return void 0;
756
- return {
807
+ const identity = {
757
808
  id: parsed.id,
758
809
  label: parsed.label,
759
810
  createdAt: parsed.createdAt
760
811
  };
761
- }
762
- create() {
763
- const identity = {
764
- id: randomUUID(),
765
- label: defaultMachineLabel(),
766
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
812
+ if (parsed.labelSource === "hostname" || parsed.labelSource === "custom") return {
813
+ identity,
814
+ source: parsed.labelSource,
815
+ marked: true
767
816
  };
817
+ return {
818
+ identity,
819
+ source: parsed.label === defaultMachineLabel() ? "hostname" : "custom",
820
+ marked: false
821
+ };
822
+ }
823
+ write(identity, source) {
768
824
  mkdirSync(this.directory(), {
769
825
  recursive: true,
770
826
  mode: 448
771
827
  });
772
828
  const temporary = `${this.path()}.${randomUUID()}.tmp`;
773
- writeFileSync(temporary, `${JSON.stringify({
774
- version: MNEMON_MACHINE_VERSION,
775
- ...identity
776
- }, null, 2)}\n`, { mode: 384 });
777
- renameSync(temporary, this.path());
778
- return identity;
829
+ try {
830
+ writeFileSync(temporary, `${JSON.stringify({
831
+ version: MNEMON_MACHINE_VERSION,
832
+ ...identity,
833
+ labelSource: source
834
+ }, null, 2)}\n`, { mode: 384 });
835
+ renameSync(temporary, this.path());
836
+ } finally {
837
+ rmSync(temporary, { force: true });
838
+ }
839
+ return {
840
+ identity,
841
+ source
842
+ };
843
+ }
844
+ };
845
+ //#endregion
846
+ //#region src/host/space-identity.ts
847
+ /**
848
+ * What a Memory Space holds, as opposed to how it was used, and the merge of two versions of one.
849
+ *
850
+ * Mnemon writes to a space whenever it is read: a recall, search or show raises
851
+ * `access_count`, stamps `last_accessed_at` and logs the read in `oplog`. Two machines
852
+ * that only read the same space therefore never hold the same `mnemon.db` bytes, and a
853
+ * merge that compares bytes kept the other machine's space as a new one on every sync,
854
+ * doubling the spaces each round (#370). Spaces are compared by their content instead:
855
+ * every table, column and row, the edges included, except the usage columns, the read log
856
+ * and the derived embedding and effective importance.
857
+ *
858
+ * Rows are compared by their columns in name order, so the order a table declares them in
859
+ * does not matter. One version contains another when it holds every row of it. Two versions
860
+ * of one space merge by adding to this machine's database the rows only the other holds:
861
+ * this machine's rows, read counts, read log and schema stay as they are, and the result holds
862
+ * the memories of both. A row both sides hold differently, a memory edited or forgotten on
863
+ * one of them, refuses the merge, and the caller keeps a copy instead.
864
+ *
865
+ * It needs `node:sqlite` (Node 22.13, 23.4 and later). Without it, for bytes that are not a
866
+ * readable database, for a database with a virtual table, or past the row limits, the identity
867
+ * is the bytes' digest, which is what merges compared before, and such a space contains and
868
+ * merges with no other: a copy is kept, never a memory lost.
869
+ */
870
+ /** Written by reading a space: the read log. */
871
+ const USAGE_TABLES = /* @__PURE__ */ new Set(["oplog"]);
872
+ /** Written by reading a space, or derived from what it holds. */
873
+ const USAGE_COLUMNS = /* @__PURE__ */ new Set([
874
+ "access_count",
875
+ "last_accessed_at",
876
+ "effective_importance",
877
+ "embedding"
878
+ ]);
879
+ const SCRATCH_PREFIX = ".dsh-space-identity-";
880
+ /**
881
+ * Past this many rows a space is compared by its bytes, and past the second limit every space
882
+ * a merge reads after it. A merge runs under the Pack lock, so a crafted database must not hold
883
+ * the Host or the lock for long: 120,000 real rows read in about 0.4 s.
884
+ */
885
+ const MEMORY_SPACE_IDENTITY_MAX_ROWS = 5e5;
886
+ const MEMORY_SPACE_MERGE_MAX_ROWS = 2e6;
887
+ function sqlite() {
888
+ try {
889
+ return process.getBuiltinModule?.("node:sqlite");
890
+ } catch {
891
+ return;
892
+ }
893
+ }
894
+ function quote(name) {
895
+ return "\"" + name.replaceAll("\"", "\"\"") + "\"";
896
+ }
897
+ /** A cell as JSON that keeps SQLite's types apart: 1, 1.0, '1', x'01' and NULL all differ. */
898
+ function cell(value) {
899
+ if (value instanceof Uint8Array) return { blob: Buffer.from(value).toString("base64") };
900
+ if (typeof value === "bigint") return { integer: String(value) };
901
+ if (typeof value === "number") return { real: Object.is(value, -0) ? "-0" : String(value) };
902
+ return value;
903
+ }
904
+ /** 128 bits of a row's digest: enough to tell rows apart, half the memory of the whole digest. */
905
+ function rowDigest(columns, row) {
906
+ return createHash("sha256").update(JSON.stringify(columns.map((column) => cell(row[column])))).digest("base64").slice(0, 22);
907
+ }
908
+ function digest(bytes) {
909
+ return createHash("sha256").update(bytes).digest("hex");
910
+ }
911
+ function sameBytes(left, right) {
912
+ return left === right || Buffer.from(left.buffer, left.byteOffset, left.byteLength).equals(right);
913
+ }
914
+ /** Cleanup never fails a merge: a copy another process holds open is swept by the next one. */
915
+ function remove(path) {
916
+ try {
917
+ rmSync(path, {
918
+ recursive: true,
919
+ force: true,
920
+ maxRetries: 3
921
+ });
922
+ } catch {}
923
+ }
924
+ /**
925
+ * Reads what each database holds once, and merges two of them. A merge holds one under the
926
+ * Memory Spaces lock and disposes it at the end, so the copies a Host that stopped mid-merge
927
+ * left behind are swept first.
928
+ */
929
+ var MemorySpaceIdentity = class {
930
+ parent;
931
+ module;
932
+ maxRows;
933
+ scratch;
934
+ known = /* @__PURE__ */ new WeakMap();
935
+ projections = /* @__PURE__ */ new WeakMap();
936
+ remaining;
937
+ /** `parent` holds the short-lived copies a database is read from: the storage root. */
938
+ constructor(parent, module = sqlite, maxRows = MEMORY_SPACE_IDENTITY_MAX_ROWS, maxMergeRows = MEMORY_SPACE_MERGE_MAX_ROWS) {
939
+ this.parent = parent;
940
+ this.module = module;
941
+ this.maxRows = maxRows;
942
+ this.remaining = maxMergeRows;
943
+ }
944
+ of(bytes) {
945
+ return this.contents(bytes)?.identity ?? "bytes:" + digest(bytes);
946
+ }
947
+ /** Whether `outer` holds every row `inner` holds; `outer` may have columns `inner` lacks. */
948
+ contains(outer, inner) {
949
+ if (sameBytes(outer, inner)) return true;
950
+ const whole = this.contents(outer);
951
+ const part = this.contents(inner);
952
+ if (whole === void 0 || part === void 0) return false;
953
+ for (const [name, table] of part.tables) {
954
+ const present = this.rowsOn(outer, whole, name, table.columns);
955
+ if (present === void 0) return false;
956
+ for (const row of table.rows) if (!present.has(row)) return false;
957
+ }
958
+ return true;
959
+ }
960
+ /**
961
+ * `local` with the rows only `incoming` holds added to it, or undefined when the two cannot
962
+ * merge: a row both hold differently, a table or column `local` lacks, or a database that
963
+ * cannot be read. `local` itself comes back when it already holds everything.
964
+ */
965
+ merge(local, incoming) {
966
+ if (sameBytes(local, incoming)) return local;
967
+ const mine = this.contents(local);
968
+ const theirs = this.contents(incoming);
969
+ if (mine === void 0 || theirs === void 0) return void 0;
970
+ const missing = /* @__PURE__ */ new Map();
971
+ for (const [name, table] of theirs.tables) {
972
+ const present = this.rowsOn(local, mine, name, table.columns);
973
+ if (present === void 0) return void 0;
974
+ const rows = new Set([...table.rows].filter((row) => !present.has(row)));
975
+ if (rows.size > 0) missing.set(name, rows);
976
+ }
977
+ if (missing.size === 0) return local;
978
+ const module = this.module();
979
+ const directory = this.directory();
980
+ if (module === void 0 || directory === void 0) return void 0;
981
+ const target = join(directory, randomUUID() + ".db");
982
+ const source = join(directory, randomUUID() + ".db");
983
+ let writer;
984
+ let reader;
985
+ try {
986
+ writeFileSync(target, local, { mode: 384 });
987
+ writeFileSync(source, incoming, { mode: 384 });
988
+ writer = this.open(module, target, false);
989
+ reader = this.open(module, source, true);
990
+ if (writer === void 0 || reader === void 0) return void 0;
991
+ writer.exec("BEGIN IMMEDIATE");
992
+ writer.exec("PRAGMA defer_foreign_keys = ON");
993
+ for (const [name, rows] of missing) {
994
+ const table = theirs.tables.get(name);
995
+ const own = mine.tables.get(name);
996
+ const written = table.all.filter((column) => own.all.includes(column));
997
+ const insert = writer.prepare(`INSERT INTO ${quote(name)} (${written.map(quote).join(", ")}) VALUES (${written.map(() => "?").join(", ")})`);
998
+ const statement = reader.prepare(`SELECT ${written.map(quote).join(", ")} FROM ${quote(name)}`);
999
+ statement.setReadBigInts(true);
1000
+ for (const row of statement.iterate()) {
1001
+ if (!this.spend()) throw new Error("the merge read limit was reached");
1002
+ if (!rows.has(rowDigest(table.columns, row))) continue;
1003
+ insert.run(...written.map((column) => row[column]));
1004
+ }
1005
+ }
1006
+ writer.exec("COMMIT");
1007
+ writer.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1008
+ writer.close();
1009
+ writer = void 0;
1010
+ return readFileSync(target);
1011
+ } catch {
1012
+ return;
1013
+ } finally {
1014
+ for (const database of [writer, reader]) try {
1015
+ database?.close();
1016
+ } catch {}
1017
+ for (const path of [target, source]) for (const suffix of [
1018
+ "",
1019
+ "-wal",
1020
+ "-shm",
1021
+ "-journal"
1022
+ ]) remove(path + suffix);
1023
+ }
1024
+ }
1025
+ dispose() {
1026
+ if (this.scratch !== void 0) remove(this.scratch);
1027
+ this.scratch = void 0;
1028
+ }
1029
+ spend() {
1030
+ this.remaining -= 1;
1031
+ return this.remaining >= 0;
1032
+ }
1033
+ contents(bytes) {
1034
+ const known = this.known.get(bytes);
1035
+ if (known !== void 0) return known ?? void 0;
1036
+ const contents = this.read(bytes);
1037
+ this.known.set(bytes, contents ?? null);
1038
+ return contents;
1039
+ }
1040
+ /** The digests of a table's rows over `columns`, which may leave out columns the table has. */
1041
+ rowsOn(bytes, contents, name, columns) {
1042
+ const table = contents.tables.get(name);
1043
+ if (table === void 0 || !columns.every((column) => table.columns.includes(column))) return void 0;
1044
+ if (columns.length === table.columns.length) return table.rows;
1045
+ const key = JSON.stringify([name, columns]);
1046
+ const cached = this.projections.get(bytes) ?? /* @__PURE__ */ new Map();
1047
+ this.projections.set(bytes, cached);
1048
+ if (!cached.has(key)) cached.set(key, this.withCopy(bytes, true, (database) => this.digests(database, name, columns) ?? null) ?? null);
1049
+ return cached.get(key) ?? void 0;
1050
+ }
1051
+ digests(database, name, columns) {
1052
+ const rows = /* @__PURE__ */ new Set();
1053
+ if (columns.length === 0) return rows;
1054
+ const statement = database.prepare(`SELECT ${columns.map(quote).join(", ")} FROM ${quote(name)}`);
1055
+ statement.setReadBigInts(true);
1056
+ let budget = this.maxRows;
1057
+ for (const row of statement.iterate()) {
1058
+ budget -= 1;
1059
+ if (budget < 0 || !this.spend()) return void 0;
1060
+ rows.add(rowDigest(columns, row));
1061
+ }
1062
+ return rows;
1063
+ }
1064
+ directory() {
1065
+ try {
1066
+ if (this.scratch === void 0) {
1067
+ for (const name of readdirSync(this.parent)) if (name.startsWith(SCRATCH_PREFIX)) remove(join(this.parent, name));
1068
+ }
1069
+ if (this.scratch === void 0 || !existsSync(this.scratch)) this.scratch = mkdtempSync(join(this.parent, SCRATCH_PREFIX));
1070
+ } catch {
1071
+ return;
1072
+ }
1073
+ return this.scratch;
1074
+ }
1075
+ /** A connection to a private copy, refused when the copy is missing or empty rather than created. */
1076
+ open(module, path, readOnly) {
1077
+ if (!existsSync(path)) return void 0;
1078
+ const database = new module.DatabaseSync(path, { readOnly });
1079
+ database.exec("PRAGMA trusted_schema = OFF");
1080
+ const pages = database.prepare("PRAGMA page_count").get();
1081
+ if (Number(pages?.page_count ?? 0) <= 0) {
1082
+ database.close();
1083
+ return;
1084
+ }
1085
+ return database;
1086
+ }
1087
+ withCopy(bytes, readOnly, use) {
1088
+ const module = this.module();
1089
+ if (module === void 0) return void 0;
1090
+ const directory = this.directory();
1091
+ if (directory === void 0) return void 0;
1092
+ const path = join(directory, randomUUID() + ".db");
1093
+ let database;
1094
+ try {
1095
+ writeFileSync(path, bytes, { mode: 384 });
1096
+ database = this.open(module, path, readOnly);
1097
+ return database === void 0 ? void 0 : use(database);
1098
+ } catch {
1099
+ return;
1100
+ } finally {
1101
+ try {
1102
+ database?.close();
1103
+ } catch {}
1104
+ for (const suffix of [
1105
+ "",
1106
+ "-wal",
1107
+ "-shm",
1108
+ "-journal"
1109
+ ]) remove(path + suffix);
1110
+ }
1111
+ }
1112
+ read(bytes) {
1113
+ return this.withCopy(bytes, true, (database) => {
1114
+ const listed = database.prepare("SELECT name, type FROM pragma_table_list WHERE schema = 'main'").all();
1115
+ if (listed.some((entry) => entry.type !== "table" && entry.type !== "view")) return void 0;
1116
+ const names = listed.filter((entry) => entry.type === "table" && !entry.name.startsWith("sqlite_") && !USAGE_TABLES.has(entry.name)).map((entry) => entry.name).sort();
1117
+ const hash = createHash("sha256");
1118
+ const tables = /* @__PURE__ */ new Map();
1119
+ let budget = this.maxRows;
1120
+ for (const name of names) {
1121
+ const all = database.prepare(`PRAGMA table_info(${quote(name)})`).all().map((column) => column.name);
1122
+ const columns = all.filter((column) => !USAGE_COLUMNS.has(column)).sort();
1123
+ const rows = [];
1124
+ if (columns.length > 0) {
1125
+ const statement = database.prepare(`SELECT ${columns.map(quote).join(", ")} FROM ${quote(name)}`);
1126
+ statement.setReadBigInts(true);
1127
+ for (const row of statement.iterate()) {
1128
+ budget -= 1;
1129
+ if (budget < 0 || !this.spend()) return void 0;
1130
+ rows.push(rowDigest(columns, row));
1131
+ }
1132
+ }
1133
+ rows.sort();
1134
+ hash.update(JSON.stringify([
1135
+ "table",
1136
+ name,
1137
+ columns,
1138
+ rows.length
1139
+ ]));
1140
+ for (const row of rows) hash.update(row);
1141
+ tables.set(name, {
1142
+ columns,
1143
+ all,
1144
+ rows: new Set(rows)
1145
+ });
1146
+ }
1147
+ return {
1148
+ identity: "content:" + hash.digest("hex"),
1149
+ tables
1150
+ };
1151
+ });
779
1152
  }
780
1153
  };
781
1154
  //#endregion
@@ -906,9 +1279,19 @@ function parseEntryOrigin(value) {
906
1279
  at: origin.at
907
1280
  };
908
1281
  }
1282
+ /**
1283
+ * Whole groups of four from the base64 alphabet, padded with at most two `=` at the end. The
1284
+ * length check and one character class keep this a flat loop at any size. A pattern that repeats
1285
+ * a four-character group keeps a backtracking entry per group, and V8 threw "Maximum call stack
1286
+ * size exceeded" once a payload passed about 4.5 MB (#368). The `u` flag stays off: under it,
1287
+ * V8 13 (Node 24) does the same for a class over a string holding a character above U+00FF.
1288
+ */
1289
+ function isBase64(value) {
1290
+ return value.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(value);
1291
+ }
909
1292
  function decodeArchive(base64) {
910
1293
  if (typeof base64 !== "string" || base64 === "" || base64.length > Math.ceil(16777216) * 4 + 4) throw new Error("Mnemon Pack archive is empty or too large");
911
- if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(base64)) throw new Error("Mnemon Pack payload is not valid base64");
1294
+ if (!isBase64(base64)) throw new Error("Mnemon Pack payload is not valid base64");
912
1295
  const bytes = Buffer.from(base64, "base64");
913
1296
  if (bytes.length === 0 || bytes.length > 50331648) throw new Error("Mnemon Pack archive is empty or too large");
914
1297
  return bytes;
@@ -1566,23 +1949,86 @@ function mergeTombstoneFiles(current, incoming) {
1566
1949
  tombstones: [...byKey.values()].sort((left, right) => left.deletedAt < right.deletedAt ? -1 : left.deletedAt > right.deletedAt ? 1 : 0)
1567
1950
  };
1568
1951
  }
1569
- function mergeDocuments(root, pack) {
1952
+ /**
1953
+ * One document's version as a sync agrees on it: every field but the time it was last read,
1954
+ * which a read moves without changing the document. A title, an archive or a link is as much
1955
+ * a part of it as the text.
1956
+ */
1957
+ function documentVersion(document) {
1958
+ const { lastAccessedAt: _read, ...fields } = document;
1959
+ return sha256$1(canonicalJson(fields));
1960
+ }
1961
+ /** JSON with every object's keys in order and unset ones left out, so the same record always hashes the same. */
1962
+ function canonicalJson(value) {
1963
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
1964
+ if (value !== null && typeof value === "object") {
1965
+ const fields = value;
1966
+ return `{${Object.keys(fields).filter((key) => fields[key] !== void 0).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(fields[key])}`).join(",")}}`;
1967
+ }
1968
+ return JSON.stringify(value) ?? "null";
1969
+ }
1970
+ /** Whether two records say the same thing to a reader: the text, the title, the description and the archive. */
1971
+ function sameDocument(left, right) {
1972
+ return left.contentHash === right.contentHash && left.title === right.title && left.description === right.description && left.status === right.status && (left.archiveSummary ?? "") === (right.archiveSummary ?? "");
1973
+ }
1974
+ /** Each document's version in one payload, by id, or nothing when the payload holds no valid Documents index. */
1975
+ function documentVersions(files) {
1976
+ const bytes = files["payload/documents/index.json"];
1977
+ if (bytes === void 0) return void 0;
1978
+ try {
1979
+ return Object.fromEntries(parseDocumentIndex(json$1(bytes, "payload/documents/index.json")).documents.map((document) => [document.id, documentVersion(document)]));
1980
+ } catch {
1981
+ return;
1982
+ }
1983
+ }
1984
+ /**
1985
+ * `base` is each document's version as this machine and the branch last agreed on it, which
1986
+ * only a sync channel knows, and it decides only for a document both hold. One the branch did
1987
+ * not change since then keeps this machine's version, edited or not. One only the branch
1988
+ * changed is the newer version of the same document and replaces the one here, unless its
1989
+ * revision is lower than this machine's, as an older dsh-mnemon can publish; that one is kept
1990
+ * beside it, as a merge without `base` keeps every document whose text differs.
1991
+ */
1992
+ function mergeDocuments(root, pack, base) {
1570
1993
  const current = readCurrentDocuments(root);
1571
1994
  const incoming = archiveDocuments(pack);
1572
1995
  const ids = new Map(current.index.documents.map((document) => [document.id, document]));
1573
- for (const source of incoming.index.documents) {
1574
- const existing = ids.get(source.id);
1575
- if (existing !== void 0 && existing.contentHash === source.contentHash) continue;
1576
- let document = source;
1577
- let bytes = incoming.files.get(source.id);
1578
- if (existing !== void 0) {
1579
- const remapped = remapDocument(source, bytes, randomUUID());
1580
- document = remapped.document;
1581
- bytes = remapped.bytes;
1582
- }
1996
+ const add = (document, bytes) => {
1583
1997
  current.index.documents.push(document);
1584
1998
  current.files.set(document.id, bytes);
1585
1999
  ids.set(document.id, document);
2000
+ };
2001
+ const copy = (source) => {
2002
+ const remapped = remapDocument(source, incoming.files.get(source.id), randomUUID());
2003
+ add(remapped.document, remapped.bytes);
2004
+ };
2005
+ for (const source of incoming.index.documents) {
2006
+ const existing = ids.get(source.id);
2007
+ const agreed = existing !== void 0 && base !== void 0 && Object.hasOwn(base, source.id) ? base[source.id] : void 0;
2008
+ if (existing !== void 0 && agreed !== void 0) {
2009
+ if (documentVersion(source) === agreed) continue;
2010
+ const at = current.index.documents.indexOf(existing);
2011
+ if (documentVersion(existing) !== agreed) {
2012
+ const kept = {
2013
+ ...existing,
2014
+ revision: Math.max(existing.revision, source.revision) + 1
2015
+ };
2016
+ current.index.documents[at] = kept;
2017
+ ids.set(source.id, kept);
2018
+ if (!current.index.documents.some((document) => sameDocument(document, source))) copy(source);
2019
+ continue;
2020
+ }
2021
+ if (source.revision >= existing.revision) {
2022
+ current.index.documents[at] = source;
2023
+ current.files.set(source.id, incoming.files.get(source.id));
2024
+ ids.set(source.id, source);
2025
+ continue;
2026
+ }
2027
+ }
2028
+ if (existing !== void 0 && existing.contentHash === source.contentHash) continue;
2029
+ if (existing !== void 0 && current.index.documents.some((document) => document.contentHash === source.contentHash)) continue;
2030
+ if (existing !== void 0) copy(source);
2031
+ else add(source, incoming.files.get(source.id));
1586
2032
  }
1587
2033
  if (current.index.documents.filter((document) => document.status === "active").reduce((sum, document) => sum + document.sizeBytes, 0) > DOCUMENTS_ACTIVE_LIMIT_BYTES) throw new Error(`merged Documents exceed the ${DOCUMENTS_ACTIVE_LIMIT_BYTES} byte active limit`);
1588
2034
  return current;
@@ -1591,23 +2037,43 @@ function mergeRegistry(root, pack) {
1591
2037
  const current = readCurrentRegistry(root);
1592
2038
  const incoming = archiveRegistry(pack);
1593
2039
  const ids = new Set(current.registry.bodies.map((body) => body.id));
1594
- for (const source of incoming.registry.bodies) {
1595
- let body = source;
1596
- const sourceDb = incoming.databases.get(source.id);
1597
- if (ids.has(source.id)) {
1598
- if (sha256$1(current.databases.get(source.id)) === sha256$1(sourceDb)) continue;
1599
- const id = randomUUID();
1600
- body = {
1601
- ...source,
1602
- id,
1603
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1604
- };
1605
- }
1606
- ids.add(body.id);
1607
- current.registry.bodies.push(body);
1608
- current.databases.set(body.id, sourceDb);
2040
+ const identity = new MemorySpaceIdentity(root);
2041
+ try {
2042
+ const covers = (outer, inner) => identity.of(outer) === identity.of(inner) || identity.contains(outer, inner);
2043
+ const held = (database, accept) => current.registry.bodies.some((body) => accept(body) && covers(current.databases.get(body.id), database));
2044
+ const add = (body, database) => {
2045
+ ids.add(body.id);
2046
+ current.registry.bodies.push(body);
2047
+ current.databases.set(body.id, database);
2048
+ };
2049
+ for (const source of incoming.registry.bodies) {
2050
+ const sourceDb = incoming.databases.get(source.id);
2051
+ if (ids.has(source.id)) {
2052
+ const localDb = current.databases.get(source.id);
2053
+ if (covers(localDb, sourceDb)) continue;
2054
+ const merged = identity.merge(localDb, sourceDb);
2055
+ if (merged !== void 0) {
2056
+ current.databases.set(source.id, merged);
2057
+ continue;
2058
+ }
2059
+ if (held(sourceDb, (body) => body.id !== source.id)) continue;
2060
+ const copy = current.registry.bodies.find((body) => body.id !== source.id && body.name === source.name && identity.contains(sourceDb, current.databases.get(body.id)));
2061
+ const advanced = copy === void 0 ? void 0 : identity.merge(current.databases.get(copy.id), sourceDb);
2062
+ if (copy !== void 0 && advanced !== void 0) {
2063
+ current.databases.set(copy.id, advanced);
2064
+ continue;
2065
+ }
2066
+ add({
2067
+ ...source,
2068
+ id: randomUUID(),
2069
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2070
+ }, sourceDb);
2071
+ } else if (!held(sourceDb, (body) => body.name === source.name)) add(source, sourceDb);
2072
+ }
2073
+ return current;
2074
+ } finally {
2075
+ identity.dispose();
1609
2076
  }
1610
- return current;
1611
2077
  }
1612
2078
  function persistedStore(root) {
1613
2079
  try {
@@ -1662,7 +2128,7 @@ function stageImport(root, pack, components, mode, runtimeLimits, options = {})
1662
2128
  keepRuntimeArchive(root, join(staging, "runtime"));
1663
2129
  }
1664
2130
  if (components.includes("documents")) {
1665
- const documents = mode === "merge" ? mergeDocuments(root, pack) : archiveDocuments(pack);
2131
+ const documents = mode === "merge" ? mergeDocuments(root, pack, options.documentsBase) : archiveDocuments(pack);
1666
2132
  writeDocuments(join(staging, "documents"), documents.index, documents.files);
1667
2133
  }
1668
2134
  if (components.includes("memory-spaces")) {
@@ -1899,6 +2365,10 @@ var MnemonPackManager = class {
1899
2365
  occupied: Object.fromEntries(COMPONENT_ORDER.map((component) => [component, occupied(this.root, component)]))
1900
2366
  };
1901
2367
  }
2368
+ /**
2369
+ * `documentsBase` is each document's version, by id, as a sync channel last agreed on it with
2370
+ * the branch; a merge uses it to tell an older version from a divergent one. A ZIP import has none.
2371
+ */
1902
2372
  async importPack(base64, options) {
1903
2373
  const pack = parseArchive(base64, this.runtimeLimits);
1904
2374
  if (options.mode !== "merge" && options.mode !== "replace") throw new Error("Pack import mode must be merge or replace");
@@ -2494,6 +2964,17 @@ function headerRecord(headers) {
2494
2964
  if (typeof headers.entries === "function") return Object.fromEntries(headers.entries());
2495
2965
  return { ...headers };
2496
2966
  }
2967
+ /**
2968
+ * What a tunnelled request read back, as a `fetch` answer: the status, the text and its
2969
+ * content type, which is all this channel uses. The type says how to read a body that
2970
+ * is not JSON, such as the form GitHub's sign-in answers with when no JSON was asked for.
2971
+ */
2972
+ function tunnelAnswer(status, type, text) {
2973
+ return new Response(status === 204 || status === 205 || status === 304 ? null : text, {
2974
+ status,
2975
+ ...type === void 0 ? {} : { headers: { "content-type": type } }
2976
+ });
2977
+ }
2497
2978
  /** The request one tunnel performs, as a `fetch`-shaped answer. */
2498
2979
  function tunnelRequest(agent, url, init) {
2499
2980
  return new Promise((resolve, reject) => {
@@ -2511,9 +2992,8 @@ function tunnelRequest(agent, url, init) {
2511
2992
  });
2512
2993
  response.once("error", reject);
2513
2994
  response.once("end", () => {
2514
- const status = response.statusCode ?? 502;
2515
- const text = Buffer.concat(chunks).toString("utf8");
2516
- resolve(new Response(status === 204 || status === 205 || status === 304 ? null : text, { status }));
2995
+ const type = response.headers["content-type"];
2996
+ resolve(tunnelAnswer(response.statusCode ?? 502, type, Buffer.concat(chunks).toString("utf8")));
2517
2997
  });
2518
2998
  });
2519
2999
  if (typeof init.body === "string") request.write(init.body);
@@ -2591,6 +3071,20 @@ function createGitHubTransport(options = {}) {
2591
3071
  });
2592
3072
  }
2593
3073
  //#endregion
3074
+ //#region src/host/sync-error.ts
3075
+ /**
3076
+ * A sync failure the settings page can name in its own language. The message
3077
+ * stays the Host's own words for logs and for anything the page cannot name.
3078
+ */
3079
+ var MnemonSyncError = class extends Error {
3080
+ reason;
3081
+ constructor(reason, message) {
3082
+ super(message);
3083
+ this.reason = reason;
3084
+ this.name = "MnemonSyncError";
3085
+ }
3086
+ };
3087
+ //#endregion
2594
3088
  //#region src/host/github-auth.ts
2595
3089
  /**
2596
3090
  * GitHub sign-in for the repository sync channel: the OAuth device flow, the
@@ -2606,7 +3100,9 @@ function createGitHubTransport(options = {}) {
2606
3100
  * process-wide dispatcher, then one retry through a proxy discovered from the
2607
3101
  * environment or from this machine's own settings, so a network that reaches
2608
3102
  * GitHub only through a proxy still signs in. Failures report GitHub's error
2609
- * code, the HTTP status and the transport reason only, never a response body.
3103
+ * code, the HTTP status and the transport reason, and a device code answer that
3104
+ * cannot be used adds its content type and field names; never a value from a
3105
+ * response body.
2610
3106
  */
2611
3107
  const MNEMON_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
2612
3108
  const MNEMON_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
@@ -2621,7 +3117,6 @@ const MNEMON_GITHUB_SCOPE = "repo";
2621
3117
  */
2622
3118
  const MNEMON_SYNC_GITHUB_CREDENTIAL_KEY = "dsh-mnemon/github";
2623
3119
  const MNEMON_GITHUB_REQUEST_TIMEOUT_MS = 3e4;
2624
- const MNEMON_GITHUB_DEFAULT_INTERVAL_MS = 5e3;
2625
3120
  const MNEMON_GITHUB_SLOW_DOWN_MS = 5e3;
2626
3121
  const MNEMON_GITHUB_MAX_INTERVAL_MS = 6e4;
2627
3122
  const MNEMON_GITHUB_FLOW_LIMIT_MS = 36e5;
@@ -2639,9 +3134,27 @@ function codeOf(body) {
2639
3134
  const fields = record$5(body);
2640
3135
  return text$1(fields?.error) ?? text$1(fields?.message);
2641
3136
  }
3137
+ /** A positive count GitHub sends as a JSON number, or as a string in a form-encoded answer. */
3138
+ function count(value) {
3139
+ const number = typeof value === "string" && /^\d{1,9}$/u.test(value) ? Number(value) : value;
3140
+ return typeof number === "number" && Number.isFinite(number) && number > 0 ? number : void 0;
3141
+ }
3142
+ /** A key that reads as a field name; anything else in a body that only claimed to be a form could hold a value. */
3143
+ const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/u;
3144
+ /**
3145
+ * What an answer GitHub sent looked like, without any of its values: those can be a
3146
+ * device code or a token. Enough to tell a proxy's HTML page from a reply that lost a field.
3147
+ */
3148
+ function shapeOf(answer) {
3149
+ const keys = Object.keys(record$5(answer.body) ?? {});
3150
+ const names = keys.filter((key) => FIELD_NAME.test(key)).slice(0, 8);
3151
+ const listed = keys.length > names.length ? [...names, String(keys.length - names.length) + " other"] : names;
3152
+ return "HTTP " + String(answer.status) + ", content-type " + (answer.type ?? "none") + ", fields: " + (listed.length === 0 ? "none" : listed.join(", "));
3153
+ }
2642
3154
  function failure$2(action, status, body) {
2643
3155
  const code = codeOf(body);
2644
- return /* @__PURE__ */ new Error(code === void 0 ? action + " failed with HTTP " + String(status) : action + " failed: " + code);
3156
+ const message = code === void 0 ? action + " failed with HTTP " + String(status) : action + " failed: " + code;
3157
+ return status === 401 ? new MnemonSyncError("github-unauthorized", message) : new Error(message);
2645
3158
  }
2646
3159
  /** Read one grant out of a stored record, refusing anything else. */
2647
3160
  function githubGrantFromRecord(record_) {
@@ -2784,16 +3297,16 @@ var MnemonGitHubAuth = class {
2784
3297
  const deviceCode = text$1(fields?.device_code);
2785
3298
  const userCode = text$1(fields?.user_code);
2786
3299
  const verificationUri = text$1(fields?.verification_uri);
2787
- if (deviceCode === void 0 || userCode === void 0 || verificationUri === void 0) throw new Error("the GitHub device code answer was incomplete");
2788
- const expiresIn = typeof fields?.expires_in === "number" ? fields.expires_in : 900;
2789
- const interval = typeof fields?.interval === "number" ? fields.interval : MNEMON_GITHUB_DEFAULT_INTERVAL_MS / 1e3;
3300
+ if (deviceCode === void 0 || userCode === void 0 || verificationUri === void 0) throw new Error("the GitHub device code answer was incomplete (" + shapeOf(answer) + ")");
3301
+ const expiresIn = count(fields?.expires_in) ?? 900;
3302
+ const intervalMs = Math.min(Math.max((count(fields?.interval) ?? 5) * 1e3, 1e3), MNEMON_GITHUB_MAX_INTERVAL_MS);
2790
3303
  this.flow = {
2791
3304
  deviceCode,
2792
3305
  userCode,
2793
3306
  verificationUri,
2794
3307
  expiresAt: this.now() + Math.min(expiresIn * 1e3, MNEMON_GITHUB_FLOW_LIMIT_MS),
2795
- intervalMs: Math.max(interval * 1e3, 1e3),
2796
- nextPollAt: this.now() + Math.max(interval * 1e3, 1e3)
3308
+ intervalMs,
3309
+ nextPollAt: this.now() + intervalMs
2797
3310
  };
2798
3311
  return this.status();
2799
3312
  }
@@ -2920,17 +3433,20 @@ var MnemonGitHubAuth = class {
2920
3433
  /** The repositories the account may write to, most recently pushed first. */
2921
3434
  async repositories(signal) {
2922
3435
  const token = await this.requireToken();
2923
- const url = "https://api.github.com/user/repos?per_page=" + String(100) + "&sort=updated&affiliation=owner%2Ccollaborator%2Corganization_member";
2924
- const { status, body } = await this.ask(url, {
2925
- headers: this.authorization(token),
2926
- ...signal === void 0 ? {} : { signal }
2927
- }, "listing GitHub repositories");
2928
- if (status !== 200) throw failure$2("listing GitHub repositories", status, body);
2929
- if (!Array.isArray(body)) throw new Error("the GitHub repository answer was not a list");
2930
3436
  const repositories = [];
2931
- for (const entry of body) {
2932
- const repository = repositoryOf(entry);
2933
- if (repository !== void 0) repositories.push(repository);
3437
+ for (let page = 1; page <= 5; page += 1) {
3438
+ const url = "https://api.github.com/user/repos?per_page=" + String(100) + "&page=" + String(page) + "&sort=updated&affiliation=owner%2Ccollaborator%2Corganization_member";
3439
+ const { status, body } = await this.ask(url, {
3440
+ headers: this.authorization(token),
3441
+ ...signal === void 0 ? {} : { signal }
3442
+ }, "listing GitHub repositories");
3443
+ if (status !== 200) throw failure$2("listing GitHub repositories", status, body);
3444
+ if (!Array.isArray(body)) throw new Error("the GitHub repository answer was not a list");
3445
+ for (const entry of body) {
3446
+ const repository = repositoryOf(entry);
3447
+ if (repository !== void 0 && !repositories.some((known) => known.fullName === repository.fullName)) repositories.push(repository);
3448
+ }
3449
+ if (body.length < 100) break;
2934
3450
  }
2935
3451
  return {
2936
3452
  login: await this.account(token, signal) ?? "",
@@ -2990,14 +3506,16 @@ var MnemonGitHubAuth = class {
2990
3506
  signal
2991
3507
  });
2992
3508
  const raw = await response.text();
3509
+ const type = response.headers.get("content-type") ?? void 0;
2993
3510
  let body;
2994
3511
  try {
2995
3512
  body = raw === "" ? void 0 : JSON.parse(raw);
2996
3513
  } catch {
2997
- body = void 0;
3514
+ body = /^application\/x-www-form-urlencoded\b/iu.test(type ?? "") ? Object.fromEntries(new URLSearchParams(raw)) : void 0;
2998
3515
  }
2999
3516
  return {
3000
3517
  status: response.status,
3518
+ ...type === void 0 ? {} : { type },
3001
3519
  body
3002
3520
  };
3003
3521
  } finally {
@@ -3234,7 +3752,31 @@ var MnemonSyncSettingsStore = class {
3234
3752
  //#region src/host/git-sync.ts
3235
3753
  /** The oldest Git the channel drives: `archive`, `ls-remote`, `-C`, and `credential` all predate it. */
3236
3754
  const MNEMON_SYNC_MINIMUM_GIT = "2.20";
3237
- const MNEMON_SYNC_GIT_TIMEOUT_MS = 12e4;
3755
+ /**
3756
+ * How long the settings page waits to learn whether the remote answers. A page
3757
+ * that opens should not hang on a host that drops packets, and anything slower
3758
+ * than this is reported as a timeout rather than awaited.
3759
+ */
3760
+ const MNEMON_SYNC_STATUS_TIMEOUT_MS = 2e4;
3761
+ /** A push Git refused because the branch moved is folded in and tried once more. */
3762
+ const PUSH_ATTEMPTS = 2;
3763
+ /**
3764
+ * No sync runs this long, so a lock older than this is abandoned whoever holds it.
3765
+ * It also covers a process id that a restart handed to an unrelated process.
3766
+ */
3767
+ const STALE_LOCK_MS = 36e5;
3768
+ const LOCK_OWNER = "owner.json";
3769
+ /**
3770
+ * A lock without an owner record was taken by a version that kept none, and may
3771
+ * still be in use by it; it is taken over only once no such run could still last.
3772
+ */
3773
+ const OWNERLESS_LOCK_MS = 9e5;
3774
+ /** The mirror's own Git config names the repository it was filled from. */
3775
+ const ORIGIN_KEY = "mnemon.origin";
3776
+ const LAST_RUN_FILE = "last-run.json";
3777
+ /** What this machine and the branch last agreed on: each document's version, by id, and the commit it was agreed at. */
3778
+ const BASE_FILE = "base.json";
3779
+ const AGREED_COMMIT = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u;
3238
3780
  const GIT_LABEL = "git";
3239
3781
  const MANIFEST = "manifest.json";
3240
3782
  const CHECKSUMS = "checksums.json";
@@ -3295,6 +3837,16 @@ function gitEnvironment(environment = process.env) {
3295
3837
  sanitized.GCM_INTERACTIVE = "never";
3296
3838
  return sanitized;
3297
3839
  }
3840
+ /** The host an HTTPS address hands a token to; any other address takes none. */
3841
+ function tokenHost(repoUrl) {
3842
+ if (repoUrl === void 0) return void 0;
3843
+ try {
3844
+ const url = new URL(repoUrl);
3845
+ return url.protocol === "https:" ? url.host.toLowerCase() : void 0;
3846
+ } catch {
3847
+ return;
3848
+ }
3849
+ }
3298
3850
  /** Whether a repository address is HTTPS on github.com, the only host a GitHub grant is for. */
3299
3851
  function isGitHubHttps(repoUrl) {
3300
3852
  try {
@@ -3304,6 +3856,37 @@ function isGitHubHttps(repoUrl) {
3304
3856
  return false;
3305
3857
  }
3306
3858
  }
3859
+ /**
3860
+ * What kind of failure one Git error describes. Git words these differently per
3861
+ * transport and per host, so the patterns are the phrases each transport uses:
3862
+ * a refused or missing credential, an address that leads nowhere (GitHub answers
3863
+ * "not found" for a private repository it will not show without credentials), a
3864
+ * network that does not reach the host, and a host that never answered.
3865
+ */
3866
+ function remoteIssueOf(message) {
3867
+ const text = message.toLowerCase();
3868
+ if (/did not respond within|timed out|timeout was reached/u.test(text)) return "timeout";
3869
+ if (/authentication failed|could not read (username|password)|terminal prompts disabled|permission denied|invalid username or password|bad credentials|returned error: 40[13]|host key verification failed|access denied|denied to /u.test(text)) return "auth";
3870
+ if (/repository not found|not found|does not appear to be a git repository|no such file or directory|does not exist|returned error: 404/u.test(text)) return "not-found";
3871
+ if (/could not resolve|failed to connect|couldn't connect|connection (refused|reset|closed|timed out)|network is unreachable|no route to host|unable to access|ssl|tls|proxy|empty reply/u.test(text)) return "network";
3872
+ return "unknown";
3873
+ }
3874
+ const REMOTE_FAILURE = {
3875
+ auth: "remote-auth",
3876
+ "not-found": "remote-not-found",
3877
+ network: "remote-network",
3878
+ timeout: "remote-timeout",
3879
+ unknown: void 0
3880
+ };
3881
+ /** One failed remote command as the error a caller reports: named when it can be. */
3882
+ function remoteFailure(message) {
3883
+ const reason = REMOTE_FAILURE[remoteIssueOf(message)];
3884
+ return reason === void 0 ? new Error(message) : new MnemonSyncError(reason, message);
3885
+ }
3886
+ /** Whether Git refused a push because the branch moved on since this run read it. */
3887
+ function rejectedPush(output) {
3888
+ return /\[rejected\]|fetch first|non-fast-forward/iu.test(output);
3889
+ }
3307
3890
  function record$3(value) {
3308
3891
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
3309
3892
  }
@@ -3450,9 +4033,9 @@ function objectCount(output) {
3450
4033
  if (loose === null || bytes === null || packed === null || packedBytes === null) return void 0;
3451
4034
  return {
3452
4035
  loose: Number(loose[1]),
3453
- bytes: Number(bytes[1]),
4036
+ bytes: Number(bytes[1]) * 1024,
3454
4037
  packed: Number(packed[1]),
3455
- packedBytes: Number(packedBytes[1])
4038
+ packedBytes: Number(packedBytes[1]) * 1024
3456
4039
  };
3457
4040
  }
3458
4041
  /** How many commits one page asks for: the caller's window, clamped to what one listing should carry. */
@@ -3718,6 +4301,8 @@ var MnemonGitSync = class {
3718
4301
  };
3719
4302
  const commit = existsSync(this.store.mirror()) && git.available ? await this.lastCommit(signal) : void 0;
3720
4303
  const autoBackup = this.autoBackupView?.();
4304
+ const lastRun = repoUrl === void 0 ? void 0 : this.lastRun();
4305
+ const machine = this.machine.current();
3721
4306
  return {
3722
4307
  enabled: true,
3723
4308
  configured: repoUrl !== void 0,
@@ -3726,14 +4311,27 @@ var MnemonGitSync = class {
3726
4311
  mirrorPath: this.store.mirror(),
3727
4312
  git,
3728
4313
  remote,
3729
- machine: this.machine.read(),
4314
+ machine: machine.identity,
4315
+ machineLabel: {
4316
+ source: machine.source,
4317
+ hostname: defaultMachineLabel()
4318
+ },
3730
4319
  ...commit === void 0 ? {} : { lastCommit: commit },
4320
+ ...lastRun === void 0 ? {} : { lastRun },
3731
4321
  ...autoBackup === void 0 ? {} : { autoBackup }
3732
4322
  };
3733
4323
  }
3734
4324
  async configure(patch) {
3735
- const next = this.store.patch(patch);
4325
+ const previous = this.store.read();
4326
+ const fields = record$3(patch);
4327
+ const { machineLabel: name, ...settings } = fields ?? {};
4328
+ const naming = fields !== void 0 && "machineLabel" in fields;
4329
+ if (naming && name !== null && name !== "") machineLabel(name);
4330
+ const next = this.store.patch(fields === void 0 ? patch : settings);
4331
+ if (next.token !== void 0 && previous.repoUrl !== void 0 && fields !== void 0 && !("token" in fields) && tokenHost(previous.repoUrl) !== tokenHost(next.repoUrl)) delete next.token;
3736
4332
  this.store.write(next);
4333
+ if (naming) this.machine.rename(name);
4334
+ if (previous.repoUrl !== next.repoUrl) rmSync(this.lastRunPath(), { force: true });
3737
4335
  return this.credentialView(next);
3738
4336
  }
3739
4337
  /**
@@ -3747,106 +4345,270 @@ var MnemonGitSync = class {
3747
4345
  await this.requireGit(input.signal);
3748
4346
  const message = commitMessage(input.message, this.now());
3749
4347
  return this.lock(async () => {
3750
- const prepared = await this.ensureMirror(settings, input.signal);
3751
- await this.packs.recordDeletions();
3752
- const remote = await this.readRemote(settings, input.signal);
3753
- let merged;
3754
- if (remote !== void 0) {
3755
- const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
3756
- mode: "merge",
3757
- tombstones: {
3758
- version: 1,
3759
- tombstones: remote.tombstones
3760
- }
3761
- });
3762
- merged = {
3763
- commit: remote.commit,
3764
- ...remote.machine === void 0 ? {} : { machine: {
3765
- id: remote.machine.id,
3766
- label: remote.machine.label
3767
- } },
3768
- components: imported.components,
3769
- summary: imported.summary,
3770
- tombstones: remote.tombstones.length
3771
- };
4348
+ let result = await this.pushOnce(settings, message, input.signal);
4349
+ let added = result.merged?.runtime?.added ?? 0;
4350
+ let held = result.merged?.runtime?.held ?? 0;
4351
+ for (let attempt = 1; attempt < PUSH_ATTEMPTS && result.failure === "push-rejected"; attempt += 1) {
4352
+ result = await this.pushOnce(settings, message, input.signal);
4353
+ added += result.merged?.runtime?.added ?? 0;
4354
+ held = result.merged?.runtime?.held ?? held;
3772
4355
  }
3773
- const exported = await this.packs.exportPack("full", { stamp: true });
3774
- const entries = unpack(Buffer.from(exported.base64, "base64"));
3775
- const extension = {
3776
- channel: "git",
3777
- branch: settings.branch,
3778
- subdir: settings.subdir,
3779
- pushedAt: exported.manifest.exportedAt,
3780
- machine: this.machine.read(),
3781
- tombstones: this.packs.tombstones().tombstones
4356
+ if (result.merged !== void 0 && (added > 0 || result.merged.runtime !== void 0)) result = {
4357
+ ...result,
4358
+ merged: {
4359
+ ...result.merged,
4360
+ runtime: {
4361
+ added,
4362
+ held
4363
+ }
4364
+ }
3782
4365
  };
3783
- entries[MANIFEST] = new TextEncoder().encode(JSON.stringify({
3784
- ...exported.manifest,
3785
- [SYNC_EXTENSION]: extension
3786
- }, null, 2) + "\n");
3787
- const before = readFiles(this.payloadRoot(settings.subdir));
3788
- if (prepared.tip === void 0 || !holdsPayload(before, entries)) this.writePayload(settings.subdir, entries);
3789
- const staged = await this.git([
3790
- "add",
3791
- "--all",
3792
- "--",
3793
- settings.subdir
3794
- ], {
3795
- cwd: this.store.mirror(),
3796
- signal: input.signal
4366
+ if (result.pushed || !result.committed) this.recordRun({
4367
+ kind: "sync",
4368
+ commit: result.commit,
4369
+ pushed: result.pushed,
4370
+ ...result.merged?.runtime === void 0 ? {} : { added: result.merged.runtime.added }
3797
4371
  });
3798
- if (staged.exitCode !== 0) throw new Error("git add failed: " + tail(staged.stderr || staged.stdout));
3799
- const committed = (await this.git([
3800
- "status",
3801
- "--porcelain",
4372
+ return result;
4373
+ });
4374
+ }
4375
+ async pushOnce(settings, message, signal) {
4376
+ const prepared = await this.ensureMirror(settings, signal);
4377
+ await this.packs.recordDeletions();
4378
+ const remote = await this.readPrepared(settings, prepared);
4379
+ let merged;
4380
+ if (remote !== void 0) {
4381
+ const base = await this.agreedDocuments(settings, remote.commit, signal);
4382
+ const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
4383
+ mode: "merge",
4384
+ tombstones: {
4385
+ version: 1,
4386
+ tombstones: remote.tombstones
4387
+ },
4388
+ ...base === void 0 ? {} : { documentsBase: base }
4389
+ });
4390
+ if (imported.components.includes("documents")) this.agree(settings, remote.files, remote.commit);
4391
+ merged = {
4392
+ commit: remote.commit,
4393
+ ...remote.machine === void 0 ? {} : { machine: {
4394
+ id: remote.machine.id,
4395
+ label: remote.machine.label
4396
+ } },
4397
+ components: imported.components,
4398
+ summary: imported.summary,
4399
+ tombstones: remote.tombstones.length,
4400
+ ...imported.runtime === void 0 ? {} : { runtime: imported.runtime }
4401
+ };
4402
+ }
4403
+ const exported = await this.packs.exportPack("full", { stamp: true });
4404
+ const entries = unpack(Buffer.from(exported.base64, "base64"));
4405
+ const extension = {
4406
+ channel: "git",
4407
+ branch: settings.branch,
4408
+ subdir: settings.subdir,
4409
+ pushedAt: exported.manifest.exportedAt,
4410
+ machine: this.machine.read(),
4411
+ tombstones: this.packs.tombstones().tombstones
4412
+ };
4413
+ entries[MANIFEST] = new TextEncoder().encode(JSON.stringify({
4414
+ ...exported.manifest,
4415
+ [SYNC_EXTENSION]: extension
4416
+ }, null, 2) + "\n");
4417
+ const before = readFiles(this.payloadRoot(settings.subdir));
4418
+ if (prepared.tip === void 0 || !holdsPayload(before, entries)) this.writePayload(settings.subdir, entries);
4419
+ const staged = await this.git([
4420
+ "add",
4421
+ "--all",
4422
+ "--",
4423
+ settings.subdir
4424
+ ], {
4425
+ cwd: this.store.mirror(),
4426
+ signal
4427
+ });
4428
+ if (staged.exitCode !== 0) throw new Error("git add failed: " + tail(staged.stderr || staged.stdout));
4429
+ const committed = (await this.git([
4430
+ "status",
4431
+ "--porcelain",
4432
+ "--",
4433
+ settings.subdir
4434
+ ], {
4435
+ cwd: this.store.mirror(),
4436
+ signal
4437
+ })).stdout.trim() !== "";
4438
+ if (committed) {
4439
+ const identity = await this.commitIdentity(settings, signal);
4440
+ const result = await this.git([
4441
+ ...identity,
4442
+ "commit",
4443
+ "--quiet",
4444
+ "-m",
4445
+ message,
3802
4446
  "--",
3803
4447
  settings.subdir
3804
4448
  ], {
3805
4449
  cwd: this.store.mirror(),
3806
- signal: input.signal
3807
- })).stdout.trim() !== "";
3808
- if (committed) {
3809
- const identity = [...settings.authorName === "" ? [] : ["-c", "user.name=" + settings.authorName], ...settings.authorEmail === "" ? [] : ["-c", "user.email=" + settings.authorEmail]];
3810
- const result = await this.git([
3811
- ...identity,
3812
- "commit",
3813
- "--quiet",
3814
- "-m",
3815
- message,
3816
- "--",
3817
- settings.subdir
3818
- ], {
3819
- cwd: this.store.mirror(),
3820
- signal: input.signal
3821
- });
3822
- if (result.exitCode !== 0) throw new Error("git commit failed: " + tail(result.stderr || result.stdout));
3823
- }
3824
- const head = await this.head(input.signal);
3825
- const published = committed ? await this.publish(settings, await this.credential(settings), input.signal) : {
3826
- pushed: false,
3827
- reason: prepared.tip === void 0 ? "nothing to publish" : "the branch already holds this payload"
3828
- };
3829
- const compaction = await this.compact(input.signal);
3830
- const summary = exported.manifest.summary.map((entry) => ({
3831
- ...entry,
3832
- changed: componentChanged(before, entries, entry.component)
3833
- }));
4450
+ signal
4451
+ });
4452
+ if (result.exitCode !== 0) throw new Error("git commit failed: " + tail(result.stderr || result.stdout));
4453
+ }
4454
+ const head = await this.head(signal);
4455
+ const published = committed ? await this.publish(settings, await this.credential(settings), signal) : {
4456
+ pushed: false,
4457
+ reason: prepared.tip === void 0 ? "nothing to publish" : "the branch already holds this payload"
4458
+ };
4459
+ if (published.pushed || !committed && prepared.tip !== void 0) this.agree(settings, entries, head);
4460
+ const compaction = await this.compact(signal);
4461
+ const summary = exported.manifest.summary.map((entry) => ({
4462
+ ...entry,
4463
+ changed: componentChanged(before, entries, entry.component)
4464
+ }));
4465
+ return {
4466
+ repoUrl: settings.repoUrl,
4467
+ branch: settings.branch,
4468
+ subdir: settings.subdir,
4469
+ commit: head,
4470
+ committed,
4471
+ message,
4472
+ files: Object.keys(entries).length,
4473
+ bytes: Object.values(entries).reduce((total, bytes) => total + bytes.byteLength, 0),
4474
+ summary,
4475
+ pushed: published.pushed,
4476
+ ...merged === void 0 ? {} : { merged },
4477
+ ...published.reason === void 0 ? {} : { reason: published.reason },
4478
+ ...published.failure === void 0 ? {} : { failure: published.failure },
4479
+ ...compaction === void 0 ? {} : { compaction }
4480
+ };
4481
+ }
4482
+ /**
4483
+ * The identity a sync commit is recorded under: the one configured here, else the
4484
+ * Git identity this machine has. A machine without one gets the channel's own
4485
+ * rather than a failed commit, and rather than the account and host names Git
4486
+ * would otherwise guess and publish.
4487
+ */
4488
+ async commitIdentity(settings, signal) {
4489
+ const name = settings.authorName !== "" ? settings.authorName : await this.configured("user.name", signal) ?? "dsh-mnemon sync";
4490
+ const email = settings.authorEmail !== "" ? settings.authorEmail : await this.configured("user.email", signal) ?? "mnemon@localhost";
4491
+ return [
4492
+ "-c",
4493
+ "user.name=" + name,
4494
+ "-c",
4495
+ "user.email=" + email
4496
+ ];
4497
+ }
4498
+ /** One Git setting as this machine resolves it, or nothing when no config sets it. */
4499
+ async configured(key, signal) {
4500
+ const result = await this.git([
4501
+ "config",
4502
+ "--get",
4503
+ key
4504
+ ], {
4505
+ cwd: this.store.mirror(),
4506
+ signal
4507
+ });
4508
+ const value = result.exitCode === 0 ? result.stdout.trim() : "";
4509
+ return value === "" ? void 0 : value;
4510
+ }
4511
+ /** When memory last travelled through this channel, or nothing when it never did. */
4512
+ lastRun() {
4513
+ try {
4514
+ const value = record$3(JSON.parse(readFileSync(this.lastRunPath(), "utf8")));
4515
+ if (value === void 0 || typeof value.at !== "string" || typeof value.commit !== "string") return void 0;
4516
+ if (value.kind !== "sync" && value.kind !== "merge") return void 0;
3834
4517
  return {
3835
- repoUrl: settings.repoUrl,
3836
- branch: settings.branch,
3837
- subdir: settings.subdir,
3838
- commit: head,
3839
- committed,
3840
- message,
3841
- files: Object.keys(entries).length,
3842
- bytes: Object.values(entries).reduce((total, bytes) => total + bytes.byteLength, 0),
3843
- summary,
3844
- pushed: published.pushed,
3845
- ...merged === void 0 ? {} : { merged },
3846
- ...published.reason === void 0 ? {} : { reason: published.reason },
3847
- ...compaction === void 0 ? {} : { compaction }
4518
+ at: value.at,
4519
+ kind: value.kind,
4520
+ commit: value.commit,
4521
+ pushed: value.pushed === true,
4522
+ ...typeof value.added === "number" && Number.isInteger(value.added) && value.added >= 0 ? { added: value.added } : {}
3848
4523
  };
3849
- });
4524
+ } catch {
4525
+ return;
4526
+ }
4527
+ }
4528
+ lastRunPath() {
4529
+ return join(this.store.directory(), "sync", LAST_RUN_FILE);
4530
+ }
4531
+ basePath() {
4532
+ return join(this.store.directory(), "sync", BASE_FILE);
4533
+ }
4534
+ /** The repository, branch and directory one agreement belongs to. */
4535
+ agreementScope(settings) {
4536
+ return sha256Hex(new TextEncoder().encode(JSON.stringify([
4537
+ settings.repoUrl,
4538
+ settings.branch,
4539
+ settings.subdir
4540
+ ])));
4541
+ }
4542
+ /**
4543
+ * Each document's version as this machine and the branch last agreed on it, after the last
4544
+ * merge or publish, when that still describes the branch at `tip`: the same repository,
4545
+ * branch and directory, agreed at a commit `tip` descends from. A branch deleted and published
4546
+ * again from another history has none, nor has the first sync, and a merge then keeps a copy
4547
+ * of every document that differs, as before.
4548
+ */
4549
+ async agreedDocuments(settings, tip, signal) {
4550
+ let value;
4551
+ try {
4552
+ value = record$3(JSON.parse(readFileSync(this.basePath(), "utf8")));
4553
+ } catch {
4554
+ return;
4555
+ }
4556
+ if (value?.version !== 1 || value.scope !== this.agreementScope(settings)) return void 0;
4557
+ const documents = record$3(value.documents);
4558
+ if (typeof value.commit !== "string" || !AGREED_COMMIT.test(value.commit) || documents === void 0) return void 0;
4559
+ if ((await this.git([
4560
+ "merge-base",
4561
+ "--is-ancestor",
4562
+ value.commit,
4563
+ tip
4564
+ ], {
4565
+ cwd: this.store.mirror(),
4566
+ signal
4567
+ })).exitCode !== 0) return void 0;
4568
+ return Object.fromEntries(Object.entries(documents).filter(([, version]) => typeof version === "string" && /^[a-f0-9]{64}$/u.test(version)));
4569
+ }
4570
+ /** Remember what this machine and the branch at `commit` now agree on. Bookkeeping never fails the run it follows. */
4571
+ agree(settings, files, commit) {
4572
+ const path = this.basePath();
4573
+ const temporary = path + "." + String(process.pid) + ".tmp";
4574
+ try {
4575
+ const documents = documentVersions(files);
4576
+ if (documents === void 0 || !AGREED_COMMIT.test(commit)) {
4577
+ rmSync(path, { force: true });
4578
+ return;
4579
+ }
4580
+ mkdirSync(join(path, ".."), {
4581
+ recursive: true,
4582
+ mode: 448
4583
+ });
4584
+ writeFileSync(temporary, JSON.stringify({
4585
+ version: 1,
4586
+ scope: this.agreementScope(settings),
4587
+ commit,
4588
+ documents
4589
+ }, null, 2) + "\n", { mode: 384 });
4590
+ renameSync(temporary, path);
4591
+ } catch {
4592
+ rmSync(temporary, { force: true });
4593
+ }
4594
+ }
4595
+ /** Remember one finished run. Bookkeeping never fails the run it describes. */
4596
+ recordRun(run) {
4597
+ const path = this.lastRunPath();
4598
+ const temporary = path + "." + String(process.pid) + ".tmp";
4599
+ try {
4600
+ mkdirSync(join(path, ".."), {
4601
+ recursive: true,
4602
+ mode: 448
4603
+ });
4604
+ writeFileSync(temporary, JSON.stringify({
4605
+ at: this.now().toISOString(),
4606
+ ...run
4607
+ }, null, 2) + "\n", { mode: 384 });
4608
+ renameSync(temporary, path);
4609
+ } catch {
4610
+ rmSync(temporary, { force: true });
4611
+ }
3850
4612
  }
3851
4613
  /** Read the remote payload and report what it would change, without importing anything. */
3852
4614
  async preview(signal) {
@@ -3855,7 +4617,7 @@ var MnemonGitSync = class {
3855
4617
  const exported = await this.packs.exportPack("full");
3856
4618
  return this.lock(async () => {
3857
4619
  const remote = await this.readRemote(settings, signal);
3858
- if (remote === void 0) throw new Error(this.absentPayload(settings));
4620
+ if (remote === void 0) throw this.absentPayload(settings);
3859
4621
  const local = unpack(Buffer.from(exported.base64, "base64"));
3860
4622
  const components = MNEMON_PACK_COMPONENTS.map((component) => {
3861
4623
  const published = remote.manifest.summary.find((entry) => entry.component === component);
@@ -3930,7 +4692,7 @@ var MnemonGitSync = class {
3930
4692
  const exported = await this.packs.exportPack("full");
3931
4693
  return this.lock(async () => {
3932
4694
  const remote = await this.readRemote(settings, signal);
3933
- if (remote === void 0) throw new Error(this.absentPayload(settings));
4695
+ if (remote === void 0) throw this.absentPayload(settings);
3934
4696
  const local = unpack(Buffer.from(exported.base64, "base64"));
3935
4697
  const limits = this.runtimeLimits();
3936
4698
  const mine = runtimeOf(local, limits, "the local");
@@ -4000,13 +4762,22 @@ var MnemonGitSync = class {
4000
4762
  };
4001
4763
  return this.lock(async () => {
4002
4764
  const remote = await this.readRemote(settings, input.signal);
4003
- if (remote === void 0) throw new Error(this.absentPayload(settings));
4765
+ if (remote === void 0) throw this.absentPayload(settings);
4766
+ const base = await this.agreedDocuments(settings, remote.commit, input.signal);
4004
4767
  const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
4005
4768
  ...options,
4006
4769
  tombstones: {
4007
4770
  version: 1,
4008
4771
  tombstones: remote.tombstones
4009
- }
4772
+ },
4773
+ ...base === void 0 ? {} : { documentsBase: base }
4774
+ });
4775
+ if (imported.components.includes("documents")) this.agree(settings, remote.files, remote.commit);
4776
+ this.recordRun({
4777
+ kind: "merge",
4778
+ commit: remote.commit,
4779
+ pushed: false,
4780
+ ...imported.runtime === void 0 ? {} : { added: imported.runtime.added }
4010
4781
  });
4011
4782
  return {
4012
4783
  imported: true,
@@ -4025,11 +4796,11 @@ var MnemonGitSync = class {
4025
4796
  });
4026
4797
  }
4027
4798
  absentPayload(settings) {
4028
- return "the branch " + settings.branch + " holds no Mnemon payload at " + settings.subdir + " yet; push one first";
4799
+ return new MnemonSyncError("remote-empty", "the branch " + settings.branch + " holds no Mnemon payload at " + settings.subdir + " yet; push one first");
4029
4800
  }
4030
4801
  requireRepository() {
4031
4802
  const settings = this.store.read();
4032
- if (settings.repoUrl === void 0) throw new Error("no sync repository is configured; set repoUrl in " + this.store.path() + " first");
4803
+ if (settings.repoUrl === void 0) throw new MnemonSyncError("no-repository", "no sync repository is configured; set repoUrl in " + this.store.path() + " first");
4033
4804
  return {
4034
4805
  ...settings,
4035
4806
  repoUrl: settings.repoUrl
@@ -4067,12 +4838,12 @@ var MnemonGitSync = class {
4067
4838
  }
4068
4839
  async requireGit(signal) {
4069
4840
  const status = await this.gitStatus(signal);
4070
- if (!status.available) throw new Error("Git is not available on this Host: " + (status.issue ?? "unknown reason"));
4841
+ if (!status.available) throw new MnemonSyncError("git-missing", "Git is not available on this Host: " + (status.issue ?? "unknown reason"));
4071
4842
  }
4072
4843
  async remoteStatus(settings, signal) {
4073
4844
  const credential = await this.credential(settings);
4074
4845
  try {
4075
- const tip = await this.lsRemote(settings, credential, signal);
4846
+ const tip = await this.lsRemote(settings, credential, signal, MNEMON_SYNC_STATUS_TIMEOUT_MS);
4076
4847
  return tip === void 0 ? {
4077
4848
  reachable: true,
4078
4849
  branchExists: false
@@ -4082,26 +4853,40 @@ var MnemonGitSync = class {
4082
4853
  commit: tip
4083
4854
  };
4084
4855
  } catch (error) {
4856
+ const message = this.mask(error instanceof Error ? error.message : String(error), credential);
4085
4857
  return {
4086
4858
  reachable: false,
4087
4859
  branchExists: false,
4088
- error: this.mask(error instanceof Error ? error.message : String(error), credential)
4860
+ error: message,
4861
+ issue: remoteIssueOf(message)
4089
4862
  };
4090
4863
  }
4091
4864
  }
4092
- async lsRemote(settings, credential, signal) {
4865
+ /**
4866
+ * The branch tip the remote holds, or nothing when it holds no such branch. A
4867
+ * remote that cannot be read fails with the kind of failure it was, the token
4868
+ * masked, so the page can say "sign in" rather than print Git's trace.
4869
+ */
4870
+ async lsRemote(settings, credential, signal, timeoutMs) {
4093
4871
  const reference = "refs/heads/" + settings.branch;
4094
- const result = await this.git([
4095
- "ls-remote",
4096
- "--heads",
4097
- settings.repoUrl,
4098
- reference
4099
- ], {
4100
- signal,
4101
- token: credential.token,
4102
- authenticated: true
4103
- });
4104
- if (result.exitCode !== 0) throw new Error(tail(result.stderr || result.stdout) || "git ls-remote failed");
4872
+ let result;
4873
+ try {
4874
+ result = await this.git([
4875
+ "ls-remote",
4876
+ "--heads",
4877
+ settings.repoUrl,
4878
+ reference
4879
+ ], {
4880
+ signal,
4881
+ token: credential.token,
4882
+ authenticated: true,
4883
+ ...timeoutMs === void 0 ? {} : { timeoutMs }
4884
+ });
4885
+ } catch (error) {
4886
+ if (signal?.aborted === true) throw error;
4887
+ throw remoteFailure(this.mask(error instanceof Error ? error.message : String(error), credential));
4888
+ }
4889
+ if (result.exitCode !== 0) throw remoteFailure(this.mask(tail(result.stderr || result.stdout) || "git ls-remote failed", credential));
4105
4890
  for (const line of result.stdout.split("\n")) {
4106
4891
  const [commit, name] = line.trim().split(/\s+/u);
4107
4892
  if (name === reference && commit !== void 0) return commit;
@@ -4218,6 +5003,20 @@ var MnemonGitSync = class {
4218
5003
  recursive: true,
4219
5004
  mode: 448
4220
5005
  });
5006
+ if (existsSync(join(mirror, ".git"))) {
5007
+ const origin = await this.git([
5008
+ "config",
5009
+ "--get",
5010
+ ORIGIN_KEY
5011
+ ], {
5012
+ cwd: mirror,
5013
+ signal
5014
+ });
5015
+ if (origin.exitCode !== 0 || origin.stdout.trim() !== settings.repoUrl) rmSync(mirror, {
5016
+ recursive: true,
5017
+ force: true
5018
+ });
5019
+ }
4221
5020
  if (!existsSync(join(mirror, ".git"))) {
4222
5021
  rmSync(mirror, {
4223
5022
  recursive: true,
@@ -4226,10 +5025,18 @@ var MnemonGitSync = class {
4226
5025
  const initialized = await this.git([
4227
5026
  "init",
4228
5027
  "--quiet",
4229
- "--initial-branch=" + settings.branch,
4230
5028
  mirror
4231
5029
  ], { signal });
4232
5030
  if (initialized.exitCode !== 0) throw new Error("git init failed: " + tail(initialized.stderr || initialized.stdout));
5031
+ const marked = await this.git([
5032
+ "config",
5033
+ ORIGIN_KEY,
5034
+ settings.repoUrl
5035
+ ], {
5036
+ cwd: mirror,
5037
+ signal
5038
+ });
5039
+ if (marked.exitCode !== 0) throw new Error("git config failed: " + tail(marked.stderr || marked.stdout));
4233
5040
  }
4234
5041
  const current = await this.git([
4235
5042
  "symbolic-ref",
@@ -4250,8 +5057,10 @@ var MnemonGitSync = class {
4250
5057
  });
4251
5058
  if (renamed.exitCode !== 0) throw new Error("git symbolic-ref failed: " + tail(renamed.stderr || renamed.stdout));
4252
5059
  }
4253
- const tip = await this.lsRemote(settings, credential, signal);
4254
- if (tip === void 0) return {};
5060
+ if (await this.lsRemote(settings, credential, signal) === void 0) {
5061
+ await this.startHistory(settings.branch, signal);
5062
+ return {};
5063
+ }
4255
5064
  const fetched = await this.git([
4256
5065
  "fetch",
4257
5066
  "--no-tags",
@@ -4264,7 +5073,7 @@ var MnemonGitSync = class {
4264
5073
  token: credential.token,
4265
5074
  authenticated: true
4266
5075
  });
4267
- if (fetched.exitCode !== 0) throw new Error("git fetch failed: " + this.mask(tail(fetched.stderr || fetched.stdout), credential));
5076
+ if (fetched.exitCode !== 0) throw remoteFailure("git fetch failed: " + this.mask(tail(fetched.stderr || fetched.stdout), credential));
4268
5077
  const reset = await this.git([
4269
5078
  "reset",
4270
5079
  "--hard",
@@ -4275,7 +5084,33 @@ var MnemonGitSync = class {
4275
5084
  signal
4276
5085
  });
4277
5086
  if (reset.exitCode !== 0) throw new Error("git reset failed: " + tail(reset.stderr || reset.stdout));
4278
- return { tip };
5087
+ return { tip: await this.head(signal) };
5088
+ }
5089
+ /**
5090
+ * Point the mirror at a branch the remote does not hold. The next commit starts a
5091
+ * history of its own: continuing the one this mirror kept, from a branch the remote
5092
+ * has since deleted, would publish every earlier payload all over again.
5093
+ */
5094
+ async startHistory(branch, signal) {
5095
+ const mirror = this.store.mirror();
5096
+ await this.git([
5097
+ "update-ref",
5098
+ "-d",
5099
+ "refs/heads/" + branch
5100
+ ], {
5101
+ cwd: mirror,
5102
+ signal
5103
+ });
5104
+ const emptied = await this.git(["read-tree", "--empty"], {
5105
+ cwd: mirror,
5106
+ signal
5107
+ });
5108
+ if (emptied.exitCode !== 0) throw new Error("git read-tree failed: " + tail(emptied.stderr || emptied.stdout));
5109
+ const cleaned = await this.git(["clean", "-fdxq"], {
5110
+ cwd: mirror,
5111
+ signal
5112
+ });
5113
+ if (cleaned.exitCode !== 0) throw new Error("git clean failed: " + tail(cleaned.stderr || cleaned.stdout));
4279
5114
  }
4280
5115
  /**
4281
5116
  * The payload directory inside the mirror. A link where that directory or one of
@@ -4318,26 +5153,37 @@ var MnemonGitSync = class {
4318
5153
  }
4319
5154
  async publish(settings, credential, signal) {
4320
5155
  const reference = "refs/heads/" + settings.branch + ":refs/heads/" + settings.branch;
4321
- const result = await this.git([
4322
- "push",
4323
- "--porcelain",
4324
- settings.repoUrl,
4325
- reference
4326
- ], {
4327
- cwd: this.store.mirror(),
4328
- signal,
4329
- token: credential.token,
4330
- authenticated: true
4331
- });
5156
+ let result;
5157
+ try {
5158
+ result = await this.git([
5159
+ "push",
5160
+ "--porcelain",
5161
+ settings.repoUrl,
5162
+ reference
5163
+ ], {
5164
+ cwd: this.store.mirror(),
5165
+ signal,
5166
+ token: credential.token,
5167
+ authenticated: true
5168
+ });
5169
+ } catch (error) {
5170
+ if (signal?.aborted === true) throw error;
5171
+ const reason = this.mask(error instanceof Error ? error.message : String(error), credential);
5172
+ const failure = REMOTE_FAILURE[remoteIssueOf(reason)];
5173
+ return {
5174
+ pushed: false,
5175
+ reason,
5176
+ ...failure === void 0 ? {} : { failure }
5177
+ };
5178
+ }
4332
5179
  if (result.exitCode === 0) return { pushed: true };
4333
- const reason = this.mask(tail(result.stderr || result.stdout), credential);
4334
- if (credential.token === void 0) return {
4335
- pushed: false,
4336
- reason: "the commit stays local because no token is available: " + (reason || "git push failed")
4337
- };
5180
+ const output = this.mask(result.stdout + "\n" + result.stderr, credential);
5181
+ const reason = this.mask(tail(result.stderr || result.stdout), credential) || "git push failed";
5182
+ const failure = rejectedPush(output) ? "push-rejected" : REMOTE_FAILURE[remoteIssueOf(output)];
4338
5183
  return {
4339
5184
  pushed: false,
4340
- reason: reason || "git push failed"
5185
+ reason: credential.token === void 0 && failure !== "push-rejected" ? "the commit stays local because no token is available: " + reason : reason,
5186
+ ...failure === void 0 ? {} : { failure }
4341
5187
  };
4342
5188
  }
4343
5189
  /**
@@ -4410,9 +5256,12 @@ var MnemonGitSync = class {
4410
5256
  };
4411
5257
  }
4412
5258
  }
4413
- /** Validate the remote payload and rebuild the exact pack archive the ZIP path would have produced. */
5259
+ /** Refresh the mirror, then read the payload the branch holds. */
4414
5260
  async readRemote(settings, signal) {
4415
- const prepared = await this.ensureMirror(settings, signal);
5261
+ return this.readPrepared(settings, await this.ensureMirror(settings, signal));
5262
+ }
5263
+ /** Validate the payload a refreshed mirror holds and rebuild the exact pack archive the ZIP path would have produced. */
5264
+ async readPrepared(settings, prepared) {
4416
5265
  if (prepared.tip === void 0) return void 0;
4417
5266
  const files = readFiles(this.payloadRoot(settings.subdir));
4418
5267
  const manifestBytes = files[MANIFEST];
@@ -4465,13 +5314,36 @@ var MnemonGitSync = class {
4465
5314
  recursive: true,
4466
5315
  mode: 448
4467
5316
  });
4468
- return withDirectoryLock(join(this.store.directory(), ".sync.lock"), operation);
5317
+ return withDirectoryLock(join(this.store.directory(), ".sync.lock"), async () => {
5318
+ this.sweepCredentials();
5319
+ return operation();
5320
+ });
5321
+ }
5322
+ /**
5323
+ * Remove the one-command credential files a Host that stopped mid-command left
5324
+ * behind. Each holds a token, so one whose process is gone is deleted rather
5325
+ * than kept until the next restart of that process id.
5326
+ */
5327
+ sweepCredentials() {
5328
+ let names;
5329
+ try {
5330
+ names = readdirSync(this.store.directory());
5331
+ } catch {
5332
+ return;
5333
+ }
5334
+ for (const name of names) {
5335
+ const match = /^\.credentials\.(\d+)\.\d+\.tmp$/u.exec(name);
5336
+ if (match === null) continue;
5337
+ const pid = Number(match[1]);
5338
+ if (pid === process.pid || processAlive(pid)) continue;
5339
+ rmSync(join(this.store.directory(), name), { force: true });
5340
+ }
4469
5341
  }
4470
5342
  async git(args, options) {
4471
5343
  const prepared = this.credentials(args, options);
4472
5344
  try {
4473
5345
  return await this.run(GIT_LABEL, prepared.args, {
4474
- timeoutMs: MNEMON_SYNC_GIT_TIMEOUT_MS,
5346
+ timeoutMs: options.timeoutMs ?? 12e4,
4475
5347
  env: gitEnvironment(),
4476
5348
  ...options.cwd === void 0 ? {} : { cwd: options.cwd },
4477
5349
  ...options.signal === void 0 ? {} : { signal: options.signal },
@@ -4520,11 +5392,17 @@ var MnemonGitSync = class {
4520
5392
  };
4521
5393
  }
4522
5394
  };
5395
+ /**
5396
+ * How the payload files differ. The manifest and the checksum inventory describe a
5397
+ * payload rather than hold memory, and the manifest's export time differs on every
5398
+ * read, so counting them would report a difference where every component agrees.
5399
+ */
4523
5400
  function compareFiles(published, local) {
4524
5401
  let changed = 0;
4525
5402
  let added = 0;
4526
5403
  let removed = 0;
4527
- for (const path of /* @__PURE__ */ new Set([...Object.keys(published), ...Object.keys(local)])) {
5404
+ const paths = new Set([...Object.keys(published), ...Object.keys(local)].filter((path) => path !== MANIFEST && path !== CHECKSUMS));
5405
+ for (const path of paths) {
4528
5406
  const remote = published[path];
4529
5407
  const mine = local[path];
4530
5408
  if (remote === void 0) added += 1;
@@ -4532,13 +5410,46 @@ function compareFiles(published, local) {
4532
5410
  else if (sha256Hex(remote) !== sha256Hex(mine)) changed += 1;
4533
5411
  }
4534
5412
  return {
4535
- total: (/* @__PURE__ */ new Set([...Object.keys(published), ...Object.keys(local)])).size,
5413
+ total: paths.size,
4536
5414
  changed,
4537
5415
  added,
4538
5416
  removed
4539
5417
  };
4540
5418
  }
4541
- /** A single-writer lock for one storage root's sync work; the pack locks cover the shared data. */
5419
+ /** Whether a process of this machine still runs; one the Host may not signal is assumed alive. */
5420
+ function processAlive(pid) {
5421
+ try {
5422
+ process.kill(pid, 0);
5423
+ return true;
5424
+ } catch (error) {
5425
+ return error.code === "EPERM";
5426
+ }
5427
+ }
5428
+ /**
5429
+ * Whether a lock was left by an operation that can no longer release it: its owner
5430
+ * process is gone, or it is older than any sync takes. The age also covers a
5431
+ * process id that a restart handed to an unrelated process.
5432
+ */
5433
+ function abandoned(path, now) {
5434
+ let age;
5435
+ try {
5436
+ age = now - statSync(path).mtimeMs;
5437
+ } catch {
5438
+ return false;
5439
+ }
5440
+ if (age > STALE_LOCK_MS) return true;
5441
+ try {
5442
+ const pid = record$3(JSON.parse(readFileSync(join(path, LOCK_OWNER), "utf8")))?.pid;
5443
+ return typeof pid === "number" && Number.isInteger(pid) && pid > 0 && pid !== process.pid && !processAlive(pid);
5444
+ } catch {
5445
+ return age > OWNERLESS_LOCK_MS;
5446
+ }
5447
+ }
5448
+ /**
5449
+ * A single-writer lock for one storage root's sync work; the pack locks cover the
5450
+ * shared data. The lock records its owner, so a Host that stopped mid-operation
5451
+ * does not leave every later sync refused until someone deletes the directory.
5452
+ */
4542
5453
  async function withDirectoryLock(path, operation) {
4543
5454
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
4544
5455
  let held = false;
@@ -4549,9 +5460,28 @@ async function withDirectoryLock(path, operation) {
4549
5460
  });
4550
5461
  held = true;
4551
5462
  } catch {
5463
+ if (abandoned(path, Date.now())) {
5464
+ const aside = path + ".abandoned." + String(process.pid) + "." + String(Date.now());
5465
+ try {
5466
+ if (abandoned(path, Date.now())) renameSync(path, aside);
5467
+ } catch {
5468
+ continue;
5469
+ }
5470
+ rmSync(aside, {
5471
+ recursive: true,
5472
+ force: true
5473
+ });
5474
+ continue;
5475
+ }
4552
5476
  await new Promise((resolveReady) => setTimeout(resolveReady, 25));
4553
5477
  }
4554
- if (!held) throw new Error("another Mnemon sync operation is still running");
5478
+ if (!held) throw new MnemonSyncError("busy", "another Mnemon sync operation is still running");
5479
+ try {
5480
+ writeFileSync(join(path, LOCK_OWNER), JSON.stringify({
5481
+ pid: process.pid,
5482
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
5483
+ }) + "\n", { mode: 384 });
5484
+ } catch {}
4555
5485
  try {
4556
5486
  return await operation();
4557
5487
  } finally {
@@ -5499,6 +6429,22 @@ function createRuntimeGraph(config, workspaceRoot, extensions) {
5499
6429
  }
5500
6430
  };
5501
6431
  }
6432
+ /**
6433
+ * When the Host asks a data directory's Providers again for namespaces they gained (#373): a
6434
+ * little after a runtime starts serving it, then, while a try cannot reach the Source, after
6435
+ * `retryDelayMs`, `attempts` tries in all. Tests shorten these.
6436
+ */
6437
+ const PROVIDER_REDISCOVERY_TIMING = {
6438
+ delayMs: 1e4,
6439
+ retryDelayMs: 3e4,
6440
+ attempts: 3
6441
+ };
6442
+ /** Data directories whose Provider namespaces this process has asked for again, or is asking now. */
6443
+ const rediscoveredDirectories = /* @__PURE__ */ new Set();
6444
+ /** An answer asking again cannot change: no Memory Spaces Source, or one older than this request. */
6445
+ function finalRediscoveryAnswer(error) {
6446
+ return /unsupported Memory Spaces management mutation operation|is not installed/u.test(error instanceof Error ? error.message : String(error));
6447
+ }
5502
6448
  /** Resolve every property access against one generation, binding methods to it. */
5503
6449
  function liveProxy(resolve) {
5504
6450
  return new Proxy({}, {
@@ -5537,6 +6483,8 @@ var LiveMnemonRuntime = class {
5537
6483
  workspaceGraphs = /* @__PURE__ */ new Map();
5538
6484
  agentGraphs = /* @__PURE__ */ new Map();
5539
6485
  retiredGraphs = /* @__PURE__ */ new Set();
6486
+ /** The start-up rediscovery each graph has waiting or running, to stop when the graph goes. */
6487
+ rediscoveries = /* @__PURE__ */ new Map();
5540
6488
  githubAuth;
5541
6489
  autoBackupView;
5542
6490
  closed = false;
@@ -5555,6 +6503,7 @@ var LiveMnemonRuntime = class {
5555
6503
  this.packs = liveProxy(() => this.current.packs);
5556
6504
  this.sync = liveProxy(() => this.current.sync);
5557
6505
  this.reviews = liveProxy(() => this.current.reviews);
6506
+ this.rediscoverProviders(initial);
5558
6507
  }
5559
6508
  /**
5560
6509
  * Share one GitHub sign-in with every generation this runtime builds. The
@@ -5589,6 +6538,7 @@ var LiveMnemonRuntime = class {
5589
6538
  this.current = next;
5590
6539
  this.applyGitHubAuth(next);
5591
6540
  this.applyAutoBackup(next);
6541
+ this.rediscoverProviders(next);
5592
6542
  this.retireGraph(previous);
5593
6543
  for (const graph of this.workspaceGraphs.values()) this.retireGraph(graph);
5594
6544
  this.workspaceGraphs.clear();
@@ -5626,6 +6576,8 @@ var LiveMnemonRuntime = class {
5626
6576
  this.agentGraphs.clear();
5627
6577
  this.workspaceGraphs.clear();
5628
6578
  this.retiredGraphs.clear();
6579
+ for (const stop of this.rediscoveries.values()) stop();
6580
+ this.rediscoveries.clear();
5629
6581
  for (const graph of graphs) graph.dispose();
5630
6582
  }
5631
6583
  /** Resolve the runtime that must serve one Agent execution. */
@@ -5674,11 +6626,64 @@ var LiveMnemonRuntime = class {
5674
6626
  graph = createRuntimeGraph(this.current.config, key, this.extensions);
5675
6627
  this.applyGitHubAuth(graph);
5676
6628
  this.applyAutoBackup(graph);
6629
+ this.rediscoverProviders(graph);
5677
6630
  this.workspaceGraphs.set(key, graph);
5678
6631
  }
5679
6632
  return graph;
5680
6633
  }
6634
+ /**
6635
+ * Ask the Providers of one data directory again for namespaces they gained, once per process,
6636
+ * a little after a runtime this Host serves starts (#373); a graph built only to validate
6637
+ * settings never asks. A read-only runtime asks nothing, and neither does one whose Memory
6638
+ * Spaces layer is off or does not maintain itself automatically. A try that cannot reach the
6639
+ * Source, before a generation serves for example, is made again a little later; one cut short
6640
+ * because its runtime was replaced is left to the runtime that replaced it.
6641
+ */
6642
+ rediscoverProviders(graph) {
6643
+ if (!graph.config.writeEnabled || !allowsParticipation(graph.config, "memory-spaces", "maintain", "automatic")) return;
6644
+ const directory = resolve(graph.directory);
6645
+ const controller = new AbortController();
6646
+ let timer;
6647
+ let running = false;
6648
+ const release = () => {
6649
+ if (this.rediscoveries.get(graph) === stop) this.rediscoveries.delete(graph);
6650
+ };
6651
+ const schedule = (attempt, delayMs) => {
6652
+ timer = setTimeout(() => void ask(attempt), delayMs);
6653
+ timer.unref?.();
6654
+ };
6655
+ const ask = async (attempt) => {
6656
+ timer = void 0;
6657
+ if (rediscoveredDirectories.has(directory)) return release();
6658
+ rediscoveredDirectories.add(directory);
6659
+ running = true;
6660
+ let again = false;
6661
+ try {
6662
+ await graph.source("memory-spaces").mutate("provider-rediscover", { trigger: "startup" }, controller.signal);
6663
+ } catch (error) {
6664
+ again = !finalRediscoveryAnswer(error);
6665
+ } finally {
6666
+ running = false;
6667
+ }
6668
+ if (controller.signal.aborted) return;
6669
+ if (again) {
6670
+ rediscoveredDirectories.delete(directory);
6671
+ if (attempt < PROVIDER_REDISCOVERY_TIMING.attempts) return schedule(attempt + 1, PROVIDER_REDISCOVERY_TIMING.retryDelayMs);
6672
+ }
6673
+ release();
6674
+ };
6675
+ const stop = () => {
6676
+ if (timer !== void 0) clearTimeout(timer);
6677
+ if (running) rediscoveredDirectories.delete(directory);
6678
+ controller.abort(/* @__PURE__ */ new Error("the Mnemon runtime was replaced"));
6679
+ };
6680
+ this.rediscoveries.get(graph)?.();
6681
+ this.rediscoveries.set(graph, stop);
6682
+ schedule(1, PROVIDER_REDISCOVERY_TIMING.delayMs);
6683
+ }
5681
6684
  retireGraph(graph) {
6685
+ this.rediscoveries.get(graph)?.();
6686
+ this.rediscoveries.delete(graph);
5682
6687
  graph.retire();
5683
6688
  if ([...this.agentGraphs.values()].some((binding) => binding.graph === graph)) this.retiredGraphs.add(graph);
5684
6689
  else graph.dispose();
@@ -11056,12 +12061,13 @@ function success$1(value) {
11056
12061
  };
11057
12062
  }
11058
12063
  function failure$1(error) {
12064
+ const reason = error instanceof Error ? error.reason : void 0;
11059
12065
  return {
11060
12066
  ok: false,
11061
12067
  error: {
11062
12068
  code: "internal",
11063
12069
  message: error instanceof Error ? error.message : String(error),
11064
- details: {}
12070
+ details: typeof reason === "string" ? { reason } : {}
11065
12071
  }
11066
12072
  };
11067
12073
  }