@systemdox/mcp-server 0.1.57 → 0.1.59

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.
@@ -147,7 +147,7 @@ var PERSON_ID_RE = new RegExp(`^${PERSON_ID}$`);
147
147
 
148
148
  // src/sync-memory.ts
149
149
  import { existsSync as existsSync2 } from "fs";
150
- import { mkdir as mkdir3, readdir as readdir2, readFile as readFile3, rm as rm3, writeFile as writeFile3 } from "fs/promises";
150
+ import { appendFile, mkdir as mkdir3, open, readdir as readdir2, readFile as readFile3, rename as rename2, rm as rm3, stat, writeFile as writeFile3 } from "fs/promises";
151
151
  import { dirname as dirname3, join as join3, resolve as resolve2 } from "path";
152
152
 
153
153
  // src/lib/memory-sync.ts
@@ -171,15 +171,41 @@ function readFrontmatter(text2) {
171
171
  const m = BLOCK_RE.exec(text2);
172
172
  if (!m) return void 0;
173
173
  const front = { shareFalse: false };
174
- for (const line of m[1].split(/\r?\n/)) {
175
- const kv = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*?)[ \t]*$/.exec(line);
174
+ const lines = m[1].split(/\r?\n/);
175
+ if (lines.some((l) => /^[ \t]*\t/.test(l) && l.trim())) return front;
176
+ let topType;
177
+ let topShare;
178
+ let nestedType;
179
+ let nestedShare;
180
+ let inMetadata = false;
181
+ let nestedIndent;
182
+ for (const line of lines) {
183
+ if (!line.trim() || /^[ \t]*#/.test(line)) continue;
184
+ const indent = /^ */.exec(line)[0].length;
185
+ if (indent === 0) {
186
+ inMetadata = /^metadata[ \t]*:[ \t]*(#.*)?$/.test(line);
187
+ nestedIndent = void 0;
188
+ }
189
+ const kv = /^ *([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*?)[ \t]*$/.exec(line);
176
190
  if (!kv) continue;
177
191
  const raw = kv[2].replace(/[ \t]+#.*$/, "");
178
192
  const quoted = /^(["'])(.*)\1$/.exec(raw);
179
193
  const value = quoted ? quoted[2] : raw;
180
- if (kv[1] === "type") front.type = value.trim().toLowerCase();
181
- if (kv[1] === "share") front.shareFalse = quoted ? value.trim().toLowerCase() === "false" : YAML_FALSE.has(value);
194
+ const isFalse = quoted ? value.trim().toLowerCase() === "false" : YAML_FALSE.has(value);
195
+ if (indent === 0) {
196
+ if (kv[1] === "type") topType = value.trim().toLowerCase();
197
+ if (kv[1] === "share") topShare = isFalse;
198
+ continue;
199
+ }
200
+ if (!inMetadata) continue;
201
+ nestedIndent ??= indent;
202
+ if (indent !== nestedIndent) continue;
203
+ if (kv[1] === "type") nestedType = value.trim().toLowerCase();
204
+ if (kv[1] === "share") nestedShare = isFalse;
182
205
  }
206
+ const type = topType ?? nestedType;
207
+ if (type !== void 0) front.type = type;
208
+ front.shareFalse = (topShare ?? nestedShare) === true;
183
209
  return front;
184
210
  }
185
211
  function isShareable(path, text2) {
@@ -477,18 +503,50 @@ async function updateState(workspace, change) {
477
503
  }
478
504
 
479
505
  // src/sync-memory.ts
506
+ var HOOK_LOG_MAX_BYTES = 1024 * 1024;
480
507
  var SYNC_MEMORY_COMMAND = "npx -y @systemdox/mcp-server sync-memory";
481
508
  var SyncFailed = class extends Error {
482
509
  };
510
+ var SyncStopped = class extends Error {
511
+ constructor(reason, message) {
512
+ super(message);
513
+ this.reason = reason;
514
+ }
515
+ reason;
516
+ };
517
+ function failureReason(e) {
518
+ if (e instanceof SyncStopped) return e.reason;
519
+ const { status, code } = refusal(e);
520
+ if (status !== void 0) return code ? `SystemDox refused it, ${code}` : "SystemDox refused it";
521
+ const err = e;
522
+ const net = err?.cause?.code ?? err?.code ?? "";
523
+ if (e instanceof TypeError || /^(ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|UND_ERR)/.test(net)) {
524
+ return "SystemDox could not be reached";
525
+ }
526
+ return "it could not finish";
527
+ }
528
+ async function writeWhole(path, text2) {
529
+ await mkdir3(dirname3(path), { recursive: true });
530
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
531
+ try {
532
+ await writeFile3(tmp, text2, "utf8");
533
+ await rename2(tmp, path);
534
+ } catch (e) {
535
+ await rm3(tmp, { force: true });
536
+ throw e;
537
+ }
538
+ }
483
539
  function refusal(e) {
484
540
  if (!(e instanceof Error)) return {};
485
541
  const { status, code } = e;
486
542
  return { status: typeof status === "number" ? status : void 0, code: typeof code === "string" ? code : void 0 };
487
543
  }
488
544
  var toPath = (root, rel) => join3(root, ...rel.split("/"));
545
+ var LEFT_TEMP_RE = /\.\d+\.[a-z0-9]+\.tmp$/;
489
546
  async function readFolder(root) {
490
547
  const files = {};
491
548
  const names = /* @__PURE__ */ new Set();
549
+ const temps = [];
492
550
  async function walk2(dir, prefix) {
493
551
  for (const e of await readdir2(dir, { withFileTypes: true })) {
494
552
  if (e.name.startsWith(".")) continue;
@@ -496,12 +554,13 @@ async function readFolder(root) {
496
554
  if (e.isDirectory()) await walk2(join3(dir, e.name), rel);
497
555
  else if (e.isFile()) {
498
556
  names.add(rel.toLowerCase());
557
+ if (LEFT_TEMP_RE.test(e.name)) temps.push(rel);
499
558
  if (e.name.endsWith(".md")) files[rel] = await readFile3(join3(dir, e.name), "utf8");
500
559
  }
501
560
  }
502
561
  }
503
562
  if (existsSync2(root)) await walk2(root, "");
504
- return { files, names };
563
+ return { files, names, temps };
505
564
  }
506
565
  async function pullTeam(client, ws, project) {
507
566
  try {
@@ -550,7 +609,11 @@ var Report = class {
550
609
  deletedHere = /* @__PURE__ */ new Set();
551
610
  deletedFromTeam = /* @__PURE__ */ new Set();
552
611
  conflicts = [];
612
+ /** The conflict copies written this run, by name. */
613
+ copies = /* @__PURE__ */ new Set();
553
614
  skipped = /* @__PURE__ */ new Map();
615
+ /** Skipped files a sync in the member's terminal can send: held for a value, or for the vault. */
616
+ waiting = /* @__PURE__ */ new Set();
554
617
  people = [];
555
618
  toAnswer = /* @__PURE__ */ new Map();
556
619
  add(plan) {
@@ -558,12 +621,32 @@ var Report = class {
558
621
  plan.pushed.forEach((p) => this.pushed.add(p));
559
622
  plan.deletedHere.forEach((p) => this.deletedHere.add(p));
560
623
  plan.deletedFromTeam.forEach((p) => this.deletedFromTeam.add(p));
561
- for (const c of plan.conflicts) this.conflicts.push(`${c.path}: the team's text; yours is beside it as ${c.copy}`);
624
+ for (const c of plan.conflicts) {
625
+ this.conflicts.push(`${c.path}: the team's text; yours is beside it as ${c.copy}`);
626
+ this.copies.add(c.copy);
627
+ }
562
628
  for (const line of plan.indexTeamLines) this.conflicts.push(`${INDEX_FILE}: your line was kept; the team's was: ${line}`);
563
629
  }
564
630
  skip(path, reason) {
565
631
  if (!this.skipped.has(path)) this.skipped.set(path, reason);
566
632
  }
633
+ /** Skipped, and waiting for the member at a terminal. */
634
+ wait(path, reason) {
635
+ this.skip(path, reason);
636
+ this.waiting.add(path);
637
+ }
638
+ summary(outcome, version, reason) {
639
+ return {
640
+ outcome,
641
+ ...reason ? { reason } : {},
642
+ version,
643
+ pulled: this.pulled.size,
644
+ pushed: this.pushed.size,
645
+ deleted: this.deletedHere.size + this.deletedFromTeam.size,
646
+ conflictCopies: [...this.copies].sort(),
647
+ waiting: [...this.waiting].sort()
648
+ };
649
+ }
567
650
  lines(summary, dryRun) {
568
651
  const out = [summary];
569
652
  const section = (title, items) => {
@@ -666,7 +749,7 @@ async function people(opts, ws, files, report, hint) {
666
749
  for (const path of g.files) {
667
750
  if (leftOut.has(path)) continue;
668
751
  current[path] = addShareFalse(current[path]);
669
- await writeFile3(toPath(opts.dir, path), current[path], "utf8");
752
+ await writeWhole(toPath(opts.dir, path), current[path]);
670
753
  leftOut.add(path);
671
754
  }
672
755
  report.people.push({ kind: g.kind, answer: "left out (share: false)", files: g.files.length });
@@ -769,7 +852,7 @@ async function people(opts, ws, files, report, hint) {
769
852
  const yes = /^y(es)?$/i.test((await ask(`Rewrite ${paths.length} file${paths.length === 1 ? "" : "s"} as shown? [y/N] `)).trim());
770
853
  if (yes) {
771
854
  for (const path of paths) {
772
- await writeFile3(toPath(opts.dir, path), rewritten[path], "utf8");
855
+ await writeWhole(toPath(opts.dir, path), rewritten[path]);
773
856
  current[path] = rewritten[path];
774
857
  }
775
858
  }
@@ -784,11 +867,11 @@ async function people(opts, ws, files, report, hint) {
784
867
  held.add(path);
785
868
  const keys = pending.filter((f) => f.kind !== HOME_PATH).map((f) => groupKey(f.kind, f.normalised));
786
869
  if (keys.some((k) => vaultDown.has(k))) {
787
- report.skip(path, "the vault did not answer (SYD-K019) for a value in it; it syncs once the value is filed");
870
+ report.wait(path, "the vault did not answer (SYD-K019) for a value in it; it syncs once the value is filed");
788
871
  } else if (keys.some((k) => !answers.has(k))) {
789
- report.skip(path, `it holds a value nobody has answered for: run \`${hint}\` in a terminal`);
872
+ report.wait(path, `it holds a value nobody has answered for: run \`${hint}\` in a terminal`);
790
873
  } else {
791
- report.skip(path, `it holds a value to rewrite as its identifier or ~: run \`${hint}\` in a terminal`);
874
+ report.wait(path, `it holds a value to rewrite as its identifier or ~: run \`${hint}\` in a terminal`);
792
875
  }
793
876
  }
794
877
  for (const g of groups) {
@@ -799,12 +882,11 @@ async function people(opts, ws, files, report, hint) {
799
882
  }
800
883
  return { files: current, held, leftOut };
801
884
  }
885
+ async function removeLeftTemps(dir, temps) {
886
+ for (const rel of temps) await rm3(toPath(dir, rel), { force: true });
887
+ }
802
888
  async function applyPlan(dir, plan) {
803
- for (const [path, text2] of Object.entries(plan.writes)) {
804
- const full = toPath(dir, path);
805
- await mkdir3(dirname3(full), { recursive: true });
806
- await writeFile3(full, text2, "utf8");
807
- }
889
+ for (const [path, text2] of Object.entries(plan.writes)) await writeWhole(toPath(dir, path), text2);
808
890
  for (const path of plan.deletes) await rm3(toPath(dir, path), { force: true });
809
891
  }
810
892
  var sameManifest = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([p, h]) => b[p] === h);
@@ -849,8 +931,19 @@ async function syncMemory(opts) {
849
931
  const { client, out } = opts;
850
932
  const now = opts.now?.() ?? /* @__PURE__ */ new Date();
851
933
  try {
934
+ const checkDeadline = () => {
935
+ if (opts.deadline !== void 0 && Date.now() > opts.deadline) {
936
+ throw new SyncStopped("took too long", "Stopped: the sync's time ran out before anything more was written or sent.");
937
+ }
938
+ };
852
939
  const own = await client.getOwnTeamSetup();
853
940
  const ws = own.workspace;
941
+ if (opts.workspace && ws !== opts.workspace) {
942
+ throw new SyncStopped(
943
+ "the key is for another workspace",
944
+ `The key is for ${ws}, not ${opts.workspace}: nothing was synced. Set SYSTEMDOX_API_KEY to a key of ${opts.workspace}, or unset it.`
945
+ );
946
+ }
854
947
  const project = opts.project ?? (own.memory.length === 1 ? own.memory[0].project : void 0);
855
948
  if (!project) {
856
949
  throw new SyncFailed(
@@ -864,6 +957,12 @@ async function syncMemory(opts) {
864
957
  const date = localDate(now);
865
958
  const stored = (await readState(ws)).memory?.[project];
866
959
  let team = await pullTeam(client, ws, project);
960
+ if (opts.unattended && stored && !team.exists) {
961
+ throw new SyncStopped(
962
+ "the team memory could not be read",
963
+ `${ws}/${project} could not be read from SystemDox: nothing was changed. Run sync-memory in your terminal to see why.`
964
+ );
965
+ }
867
966
  let base = stored ? { version: stored.version, manifest: stored.manifest, exists: true } : { version: 0, manifest: {}, exists: false };
868
967
  const folder = await readFolder(dir);
869
968
  const shared = {};
@@ -876,14 +975,16 @@ async function syncMemory(opts) {
876
975
  } else shared[path] = text2;
877
976
  }
878
977
  const importWrites = {};
879
- if (opts.importDir) {
880
- const other = await readFolder(resolve2(opts.importDir));
881
- const taken = new Set(folder.names);
978
+ const current = { ...folder.files };
979
+ const taken = new Set(folder.names);
980
+ for (const importDir of opts.importDirs ?? []) {
981
+ const other = await readFolder(resolve2(importDir));
882
982
  for (const [path, text2] of Object.entries(other.files).sort(([a], [b]) => a.localeCompare(b))) {
883
983
  if (!isShareable(path, text2) || !isFilePath(path)) continue;
884
- const here = folder.files[path];
984
+ const here = current[path];
885
985
  if (here === void 0) {
886
986
  shared[path] = text2;
987
+ current[path] = text2;
887
988
  importWrites[path] = text2;
888
989
  taken.add(path.toLowerCase());
889
990
  } else if (here !== text2) {
@@ -891,16 +992,14 @@ async function syncMemory(opts) {
891
992
  taken.add(copy.toLowerCase());
892
993
  importWrites[copy] = text2;
893
994
  report.conflicts.push(`${path}: this folder's text; the imported one is beside it as ${copy}`);
995
+ report.copies.add(copy);
894
996
  }
895
997
  }
896
- folder.names = taken;
897
- if (!opts.dryRun) {
898
- for (const [path, text2] of Object.entries(importWrites)) {
899
- const full = toPath(dir, path);
900
- await mkdir3(dirname3(full), { recursive: true });
901
- await writeFile3(full, text2, "utf8");
902
- }
903
- }
998
+ }
999
+ folder.names = taken;
1000
+ if (!opts.dryRun) {
1001
+ checkDeadline();
1002
+ for (const [path, text2] of Object.entries(importWrites)) await writeWhole(toPath(dir, path), text2);
904
1003
  }
905
1004
  const found = await people(run2, ws, shared, report, hint);
906
1005
  for (const path of found.leftOut) {
@@ -925,12 +1024,16 @@ async function syncMemory(opts) {
925
1024
  );
926
1025
  return 0;
927
1026
  }
1027
+ checkDeadline();
1028
+ if (attempt === 1) await removeLeftTemps(dir, folder.temps);
928
1029
  await applyPlan(dir, plan);
929
1030
  let version = team.version;
930
1031
  if (!sameManifest(plan.manifest, team.manifest)) {
1032
+ checkDeadline();
931
1033
  await upload(client, ws, project, plan, team.manifest, report);
932
1034
  }
933
1035
  if (!sameManifest(plan.manifest, team.manifest)) {
1036
+ checkDeadline();
934
1037
  try {
935
1038
  version = (await client.pushMemoryVersion(ws, project, { baseVersion: team.version, manifest: plan.manifest })).version;
936
1039
  } catch (e) {
@@ -942,6 +1045,7 @@ async function syncMemory(opts) {
942
1045
  false
943
1046
  )
944
1047
  );
1048
+ opts.summary?.(report.summary("moved-on", team.version, "moved on twice"));
945
1049
  return 2;
946
1050
  }
947
1051
  base = team;
@@ -966,10 +1070,21 @@ async function syncMemory(opts) {
966
1070
  false
967
1071
  )
968
1072
  );
1073
+ opts.summary?.(report.summary("synced", version));
969
1074
  return 0;
970
1075
  }
971
1076
  } catch (e) {
972
1077
  out(e.message);
1078
+ opts.summary?.({
1079
+ outcome: "failed",
1080
+ reason: failureReason(e),
1081
+ version: 0,
1082
+ pulled: 0,
1083
+ pushed: 0,
1084
+ deleted: 0,
1085
+ conflictCopies: [],
1086
+ waiting: []
1087
+ });
973
1088
  return 1;
974
1089
  }
975
1090
  }
@@ -1141,7 +1256,9 @@ function registerTeamSetupTools(server, apiClient, { localFiles }) {
1141
1256
  project: args.project,
1142
1257
  dryRun: !!args.dryRun,
1143
1258
  client: apiClient,
1144
- out: (...l) => lines.push(...l)
1259
+ out: (...l) => lines.push(...l),
1260
+ // Nobody is at a terminal: a project the team no longer serves is left as it is.
1261
+ unattended: true
1145
1262
  });
1146
1263
  return { ...text(lines.join("\n")), ...code ? { isError: true } : {} };
1147
1264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@systemdox/mcp-server",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
4
4
  "mcpName": "io.github.puglieseweb/systemdox",
5
5
  "type": "module",
6
6
  "description": "Give AI coding agents your architecture context over MCP — checks, specs, ADRs, project rules, and docs from SystemDox, delivered at write time",