dsh-mnemon 0.5.28 → 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";
@@ -843,6 +843,315 @@ var MnemonMachineStore = class {
843
843
  }
844
844
  };
845
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
+ });
1152
+ }
1153
+ };
1154
+ //#endregion
846
1155
  //#region src/host/pack.ts
847
1156
  const MNEMON_PACK_FORMAT = "mnemonpack";
848
1157
  const MNEMON_PACK_MIME = "application/zip";
@@ -970,9 +1279,19 @@ function parseEntryOrigin(value) {
970
1279
  at: origin.at
971
1280
  };
972
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
+ }
973
1292
  function decodeArchive(base64) {
974
1293
  if (typeof base64 !== "string" || base64 === "" || base64.length > Math.ceil(16777216) * 4 + 4) throw new Error("Mnemon Pack archive is empty or too large");
975
- 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");
976
1295
  const bytes = Buffer.from(base64, "base64");
977
1296
  if (bytes.length === 0 || bytes.length > 50331648) throw new Error("Mnemon Pack archive is empty or too large");
978
1297
  return bytes;
@@ -1630,24 +1949,86 @@ function mergeTombstoneFiles(current, incoming) {
1630
1949
  tombstones: [...byKey.values()].sort((left, right) => left.deletedAt < right.deletedAt ? -1 : left.deletedAt > right.deletedAt ? 1 : 0)
1631
1950
  };
1632
1951
  }
1633
- 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) {
1634
1993
  const current = readCurrentDocuments(root);
1635
1994
  const incoming = archiveDocuments(pack);
1636
1995
  const ids = new Map(current.index.documents.map((document) => [document.id, document]));
1996
+ const add = (document, bytes) => {
1997
+ current.index.documents.push(document);
1998
+ current.files.set(document.id, bytes);
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
+ };
1637
2005
  for (const source of incoming.index.documents) {
1638
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
+ }
1639
2028
  if (existing !== void 0 && existing.contentHash === source.contentHash) continue;
1640
2029
  if (existing !== void 0 && current.index.documents.some((document) => document.contentHash === source.contentHash)) continue;
1641
- let document = source;
1642
- let bytes = incoming.files.get(source.id);
1643
- if (existing !== void 0) {
1644
- const remapped = remapDocument(source, bytes, randomUUID());
1645
- document = remapped.document;
1646
- bytes = remapped.bytes;
1647
- }
1648
- current.index.documents.push(document);
1649
- current.files.set(document.id, bytes);
1650
- ids.set(document.id, document);
2030
+ if (existing !== void 0) copy(source);
2031
+ else add(source, incoming.files.get(source.id));
1651
2032
  }
1652
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`);
1653
2034
  return current;
@@ -1656,26 +2037,43 @@ function mergeRegistry(root, pack) {
1656
2037
  const current = readCurrentRegistry(root);
1657
2038
  const incoming = archiveRegistry(pack);
1658
2039
  const ids = new Set(current.registry.bodies.map((body) => body.id));
1659
- for (const source of incoming.registry.bodies) {
1660
- let body = source;
1661
- const sourceDb = incoming.databases.get(source.id);
1662
- if (ids.has(source.id)) {
1663
- const currentDb = current.databases.get(source.id);
1664
- const incomingHash = sha256$1(sourceDb);
1665
- if (sha256$1(currentDb) === incomingHash) continue;
1666
- if ([...current.databases.values()].some((database) => sha256$1(database) === incomingHash)) continue;
1667
- const id = randomUUID();
1668
- body = {
1669
- ...source,
1670
- id,
1671
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1672
- };
1673
- }
1674
- ids.add(body.id);
1675
- current.registry.bodies.push(body);
1676
- 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();
1677
2076
  }
1678
- return current;
1679
2077
  }
1680
2078
  function persistedStore(root) {
1681
2079
  try {
@@ -1730,7 +2128,7 @@ function stageImport(root, pack, components, mode, runtimeLimits, options = {})
1730
2128
  keepRuntimeArchive(root, join(staging, "runtime"));
1731
2129
  }
1732
2130
  if (components.includes("documents")) {
1733
- const documents = mode === "merge" ? mergeDocuments(root, pack) : archiveDocuments(pack);
2131
+ const documents = mode === "merge" ? mergeDocuments(root, pack, options.documentsBase) : archiveDocuments(pack);
1734
2132
  writeDocuments(join(staging, "documents"), documents.index, documents.files);
1735
2133
  }
1736
2134
  if (components.includes("memory-spaces")) {
@@ -1967,6 +2365,10 @@ var MnemonPackManager = class {
1967
2365
  occupied: Object.fromEntries(COMPONENT_ORDER.map((component) => [component, occupied(this.root, component)]))
1968
2366
  };
1969
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
+ */
1970
2372
  async importPack(base64, options) {
1971
2373
  const pack = parseArchive(base64, this.runtimeLimits);
1972
2374
  if (options.mode !== "merge" && options.mode !== "replace") throw new Error("Pack import mode must be merge or replace");
@@ -2562,6 +2964,17 @@ function headerRecord(headers) {
2562
2964
  if (typeof headers.entries === "function") return Object.fromEntries(headers.entries());
2563
2965
  return { ...headers };
2564
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
+ }
2565
2978
  /** The request one tunnel performs, as a `fetch`-shaped answer. */
2566
2979
  function tunnelRequest(agent, url, init) {
2567
2980
  return new Promise((resolve, reject) => {
@@ -2579,9 +2992,8 @@ function tunnelRequest(agent, url, init) {
2579
2992
  });
2580
2993
  response.once("error", reject);
2581
2994
  response.once("end", () => {
2582
- const status = response.statusCode ?? 502;
2583
- const text = Buffer.concat(chunks).toString("utf8");
2584
- 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")));
2585
2997
  });
2586
2998
  });
2587
2999
  if (typeof init.body === "string") request.write(init.body);
@@ -2688,7 +3100,9 @@ var MnemonSyncError = class extends Error {
2688
3100
  * process-wide dispatcher, then one retry through a proxy discovered from the
2689
3101
  * environment or from this machine's own settings, so a network that reaches
2690
3102
  * GitHub only through a proxy still signs in. Failures report GitHub's error
2691
- * 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.
2692
3106
  */
2693
3107
  const MNEMON_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
2694
3108
  const MNEMON_GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
@@ -2703,7 +3117,6 @@ const MNEMON_GITHUB_SCOPE = "repo";
2703
3117
  */
2704
3118
  const MNEMON_SYNC_GITHUB_CREDENTIAL_KEY = "dsh-mnemon/github";
2705
3119
  const MNEMON_GITHUB_REQUEST_TIMEOUT_MS = 3e4;
2706
- const MNEMON_GITHUB_DEFAULT_INTERVAL_MS = 5e3;
2707
3120
  const MNEMON_GITHUB_SLOW_DOWN_MS = 5e3;
2708
3121
  const MNEMON_GITHUB_MAX_INTERVAL_MS = 6e4;
2709
3122
  const MNEMON_GITHUB_FLOW_LIMIT_MS = 36e5;
@@ -2721,6 +3134,23 @@ function codeOf(body) {
2721
3134
  const fields = record$5(body);
2722
3135
  return text$1(fields?.error) ?? text$1(fields?.message);
2723
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
+ }
2724
3154
  function failure$2(action, status, body) {
2725
3155
  const code = codeOf(body);
2726
3156
  const message = code === void 0 ? action + " failed with HTTP " + String(status) : action + " failed: " + code;
@@ -2867,16 +3297,16 @@ var MnemonGitHubAuth = class {
2867
3297
  const deviceCode = text$1(fields?.device_code);
2868
3298
  const userCode = text$1(fields?.user_code);
2869
3299
  const verificationUri = text$1(fields?.verification_uri);
2870
- if (deviceCode === void 0 || userCode === void 0 || verificationUri === void 0) throw new Error("the GitHub device code answer was incomplete");
2871
- const expiresIn = typeof fields?.expires_in === "number" ? fields.expires_in : 900;
2872
- 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);
2873
3303
  this.flow = {
2874
3304
  deviceCode,
2875
3305
  userCode,
2876
3306
  verificationUri,
2877
3307
  expiresAt: this.now() + Math.min(expiresIn * 1e3, MNEMON_GITHUB_FLOW_LIMIT_MS),
2878
- intervalMs: Math.max(interval * 1e3, 1e3),
2879
- nextPollAt: this.now() + Math.max(interval * 1e3, 1e3)
3308
+ intervalMs,
3309
+ nextPollAt: this.now() + intervalMs
2880
3310
  };
2881
3311
  return this.status();
2882
3312
  }
@@ -3076,14 +3506,16 @@ var MnemonGitHubAuth = class {
3076
3506
  signal
3077
3507
  });
3078
3508
  const raw = await response.text();
3509
+ const type = response.headers.get("content-type") ?? void 0;
3079
3510
  let body;
3080
3511
  try {
3081
3512
  body = raw === "" ? void 0 : JSON.parse(raw);
3082
3513
  } catch {
3083
- body = void 0;
3514
+ body = /^application\/x-www-form-urlencoded\b/iu.test(type ?? "") ? Object.fromEntries(new URLSearchParams(raw)) : void 0;
3084
3515
  }
3085
3516
  return {
3086
3517
  status: response.status,
3518
+ ...type === void 0 ? {} : { type },
3087
3519
  body
3088
3520
  };
3089
3521
  } finally {
@@ -3342,6 +3774,9 @@ const OWNERLESS_LOCK_MS = 9e5;
3342
3774
  /** The mirror's own Git config names the repository it was filled from. */
3343
3775
  const ORIGIN_KEY = "mnemon.origin";
3344
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;
3345
3780
  const GIT_LABEL = "git";
3346
3781
  const MANIFEST = "manifest.json";
3347
3782
  const CHECKSUMS = "checksums.json";
@@ -3943,13 +4378,16 @@ var MnemonGitSync = class {
3943
4378
  const remote = await this.readPrepared(settings, prepared);
3944
4379
  let merged;
3945
4380
  if (remote !== void 0) {
4381
+ const base = await this.agreedDocuments(settings, remote.commit, signal);
3946
4382
  const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
3947
4383
  mode: "merge",
3948
4384
  tombstones: {
3949
4385
  version: 1,
3950
4386
  tombstones: remote.tombstones
3951
- }
4387
+ },
4388
+ ...base === void 0 ? {} : { documentsBase: base }
3952
4389
  });
4390
+ if (imported.components.includes("documents")) this.agree(settings, remote.files, remote.commit);
3953
4391
  merged = {
3954
4392
  commit: remote.commit,
3955
4393
  ...remote.machine === void 0 ? {} : { machine: {
@@ -4018,6 +4456,7 @@ var MnemonGitSync = class {
4018
4456
  pushed: false,
4019
4457
  reason: prepared.tip === void 0 ? "nothing to publish" : "the branch already holds this payload"
4020
4458
  };
4459
+ if (published.pushed || !committed && prepared.tip !== void 0) this.agree(settings, entries, head);
4021
4460
  const compaction = await this.compact(signal);
4022
4461
  const summary = exported.manifest.summary.map((entry) => ({
4023
4462
  ...entry,
@@ -4089,6 +4528,70 @@ var MnemonGitSync = class {
4089
4528
  lastRunPath() {
4090
4529
  return join(this.store.directory(), "sync", LAST_RUN_FILE);
4091
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
+ }
4092
4595
  /** Remember one finished run. Bookkeeping never fails the run it describes. */
4093
4596
  recordRun(run) {
4094
4597
  const path = this.lastRunPath();
@@ -4260,13 +4763,16 @@ var MnemonGitSync = class {
4260
4763
  return this.lock(async () => {
4261
4764
  const remote = await this.readRemote(settings, input.signal);
4262
4765
  if (remote === void 0) throw this.absentPayload(settings);
4766
+ const base = await this.agreedDocuments(settings, remote.commit, input.signal);
4263
4767
  const imported = await this.packs.importPack(Buffer.from(remote.archive).toString("base64"), {
4264
4768
  ...options,
4265
4769
  tombstones: {
4266
4770
  version: 1,
4267
4771
  tombstones: remote.tombstones
4268
- }
4772
+ },
4773
+ ...base === void 0 ? {} : { documentsBase: base }
4269
4774
  });
4775
+ if (imported.components.includes("documents")) this.agree(settings, remote.files, remote.commit);
4270
4776
  this.recordRun({
4271
4777
  kind: "merge",
4272
4778
  commit: remote.commit,
@@ -4551,8 +5057,7 @@ var MnemonGitSync = class {
4551
5057
  });
4552
5058
  if (renamed.exitCode !== 0) throw new Error("git symbolic-ref failed: " + tail(renamed.stderr || renamed.stdout));
4553
5059
  }
4554
- const tip = await this.lsRemote(settings, credential, signal);
4555
- if (tip === void 0) {
5060
+ if (await this.lsRemote(settings, credential, signal) === void 0) {
4556
5061
  await this.startHistory(settings.branch, signal);
4557
5062
  return {};
4558
5063
  }
@@ -4579,7 +5084,7 @@ var MnemonGitSync = class {
4579
5084
  signal
4580
5085
  });
4581
5086
  if (reset.exitCode !== 0) throw new Error("git reset failed: " + tail(reset.stderr || reset.stdout));
4582
- return { tip };
5087
+ return { tip: await this.head(signal) };
4583
5088
  }
4584
5089
  /**
4585
5090
  * Point the mirror at a branch the remote does not hold. The next commit starts a
@@ -5924,6 +6429,22 @@ function createRuntimeGraph(config, workspaceRoot, extensions) {
5924
6429
  }
5925
6430
  };
5926
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
+ }
5927
6448
  /** Resolve every property access against one generation, binding methods to it. */
5928
6449
  function liveProxy(resolve) {
5929
6450
  return new Proxy({}, {
@@ -5962,6 +6483,8 @@ var LiveMnemonRuntime = class {
5962
6483
  workspaceGraphs = /* @__PURE__ */ new Map();
5963
6484
  agentGraphs = /* @__PURE__ */ new Map();
5964
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();
5965
6488
  githubAuth;
5966
6489
  autoBackupView;
5967
6490
  closed = false;
@@ -5980,6 +6503,7 @@ var LiveMnemonRuntime = class {
5980
6503
  this.packs = liveProxy(() => this.current.packs);
5981
6504
  this.sync = liveProxy(() => this.current.sync);
5982
6505
  this.reviews = liveProxy(() => this.current.reviews);
6506
+ this.rediscoverProviders(initial);
5983
6507
  }
5984
6508
  /**
5985
6509
  * Share one GitHub sign-in with every generation this runtime builds. The
@@ -6014,6 +6538,7 @@ var LiveMnemonRuntime = class {
6014
6538
  this.current = next;
6015
6539
  this.applyGitHubAuth(next);
6016
6540
  this.applyAutoBackup(next);
6541
+ this.rediscoverProviders(next);
6017
6542
  this.retireGraph(previous);
6018
6543
  for (const graph of this.workspaceGraphs.values()) this.retireGraph(graph);
6019
6544
  this.workspaceGraphs.clear();
@@ -6051,6 +6576,8 @@ var LiveMnemonRuntime = class {
6051
6576
  this.agentGraphs.clear();
6052
6577
  this.workspaceGraphs.clear();
6053
6578
  this.retiredGraphs.clear();
6579
+ for (const stop of this.rediscoveries.values()) stop();
6580
+ this.rediscoveries.clear();
6054
6581
  for (const graph of graphs) graph.dispose();
6055
6582
  }
6056
6583
  /** Resolve the runtime that must serve one Agent execution. */
@@ -6099,11 +6626,64 @@ var LiveMnemonRuntime = class {
6099
6626
  graph = createRuntimeGraph(this.current.config, key, this.extensions);
6100
6627
  this.applyGitHubAuth(graph);
6101
6628
  this.applyAutoBackup(graph);
6629
+ this.rediscoverProviders(graph);
6102
6630
  this.workspaceGraphs.set(key, graph);
6103
6631
  }
6104
6632
  return graph;
6105
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
+ }
6106
6684
  retireGraph(graph) {
6685
+ this.rediscoveries.get(graph)?.();
6686
+ this.rediscoveries.delete(graph);
6107
6687
  graph.retire();
6108
6688
  if ([...this.agentGraphs.values()].some((binding) => binding.graph === graph)) this.retiredGraphs.add(graph);
6109
6689
  else graph.dispose();