@systemdox/mcp-server 0.1.56 → 0.1.58
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/README.md +37 -9
- package/dist/cli.js +454 -69
- package/dist/http.js +155 -38
- package/dist/index.js +155 -38
- package/dist/lib/memory-sync.d.ts +10 -3
- package/dist/lib/memory-sync.js +30 -4
- package/dist/lib/team-setup-files.d.ts +8 -1
- package/dist/lib/team-setup-files.js +8 -0
- package/dist/lib/team-setup-types.d.ts +2 -0
- package/dist/server.js +154 -37
- package/dist/setup-mcp.js +10 -9
- package/dist/setup-team.d.ts +38 -5
- package/dist/setup-team.js +228 -44
- package/dist/sync-memory.d.ts +61 -9
- package/dist/sync-memory.js +343 -34
- package/dist/tools/team-setup.js +147 -30
- package/package.json +1 -1
package/dist/setup-team.js
CHANGED
|
@@ -352,15 +352,41 @@ function readFrontmatter(text) {
|
|
|
352
352
|
const m = BLOCK_RE.exec(text);
|
|
353
353
|
if (!m) return void 0;
|
|
354
354
|
const front = { shareFalse: false };
|
|
355
|
-
|
|
356
|
-
|
|
355
|
+
const lines = m[1].split(/\r?\n/);
|
|
356
|
+
if (lines.some((l) => /^[ \t]*\t/.test(l) && l.trim())) return front;
|
|
357
|
+
let topType;
|
|
358
|
+
let topShare;
|
|
359
|
+
let nestedType;
|
|
360
|
+
let nestedShare;
|
|
361
|
+
let inMetadata = false;
|
|
362
|
+
let nestedIndent;
|
|
363
|
+
for (const line of lines) {
|
|
364
|
+
if (!line.trim() || /^[ \t]*#/.test(line)) continue;
|
|
365
|
+
const indent = /^ */.exec(line)[0].length;
|
|
366
|
+
if (indent === 0) {
|
|
367
|
+
inMetadata = /^metadata[ \t]*:[ \t]*(#.*)?$/.test(line);
|
|
368
|
+
nestedIndent = void 0;
|
|
369
|
+
}
|
|
370
|
+
const kv = /^ *([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*?)[ \t]*$/.exec(line);
|
|
357
371
|
if (!kv) continue;
|
|
358
372
|
const raw = kv[2].replace(/[ \t]+#.*$/, "");
|
|
359
373
|
const quoted = /^(["'])(.*)\1$/.exec(raw);
|
|
360
374
|
const value = quoted ? quoted[2] : raw;
|
|
361
|
-
|
|
362
|
-
if (
|
|
363
|
-
|
|
375
|
+
const isFalse = quoted ? value.trim().toLowerCase() === "false" : YAML_FALSE.has(value);
|
|
376
|
+
if (indent === 0) {
|
|
377
|
+
if (kv[1] === "type") topType = value.trim().toLowerCase();
|
|
378
|
+
if (kv[1] === "share") topShare = isFalse;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (!inMetadata) continue;
|
|
382
|
+
nestedIndent ??= indent;
|
|
383
|
+
if (indent !== nestedIndent) continue;
|
|
384
|
+
if (kv[1] === "type") nestedType = value.trim().toLowerCase();
|
|
385
|
+
if (kv[1] === "share") nestedShare = isFalse;
|
|
386
|
+
}
|
|
387
|
+
const type = topType ?? nestedType;
|
|
388
|
+
if (type !== void 0) front.type = type;
|
|
389
|
+
front.shareFalse = (topShare ?? nestedShare) === true;
|
|
364
390
|
return front;
|
|
365
391
|
}
|
|
366
392
|
function isShareable(path, text) {
|
|
@@ -686,20 +712,52 @@ async function removeFolder(path) {
|
|
|
686
712
|
|
|
687
713
|
// src/sync-memory.ts
|
|
688
714
|
import { existsSync } from "fs";
|
|
689
|
-
import { mkdir as mkdir2, readdir, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
715
|
+
import { appendFile, mkdir as mkdir2, open, readdir, readFile as readFile2, rename as rename2, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
|
|
690
716
|
import { dirname as dirname2, join as join2, resolve } from "path";
|
|
717
|
+
var HOOK_LOG_MAX_BYTES = 1024 * 1024;
|
|
691
718
|
var SYNC_MEMORY_COMMAND = "npx -y @systemdox/mcp-server sync-memory";
|
|
692
719
|
var SyncFailed = class extends Error {
|
|
693
720
|
};
|
|
721
|
+
var SyncStopped = class extends Error {
|
|
722
|
+
constructor(reason, message) {
|
|
723
|
+
super(message);
|
|
724
|
+
this.reason = reason;
|
|
725
|
+
}
|
|
726
|
+
reason;
|
|
727
|
+
};
|
|
728
|
+
function failureReason(e) {
|
|
729
|
+
if (e instanceof SyncStopped) return e.reason;
|
|
730
|
+
const { status, code } = refusal(e);
|
|
731
|
+
if (status !== void 0) return code ? `SystemDox refused it, ${code}` : "SystemDox refused it";
|
|
732
|
+
const err = e;
|
|
733
|
+
const net = err?.cause?.code ?? err?.code ?? "";
|
|
734
|
+
if (e instanceof TypeError || /^(ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|UND_ERR)/.test(net)) {
|
|
735
|
+
return "SystemDox could not be reached";
|
|
736
|
+
}
|
|
737
|
+
return "it could not finish";
|
|
738
|
+
}
|
|
739
|
+
async function writeWhole(path, text) {
|
|
740
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
741
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
742
|
+
try {
|
|
743
|
+
await writeFile2(tmp, text, "utf8");
|
|
744
|
+
await rename2(tmp, path);
|
|
745
|
+
} catch (e) {
|
|
746
|
+
await rm2(tmp, { force: true });
|
|
747
|
+
throw e;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
694
750
|
function refusal(e) {
|
|
695
751
|
if (!(e instanceof Error)) return {};
|
|
696
752
|
const { status, code } = e;
|
|
697
753
|
return { status: typeof status === "number" ? status : void 0, code: typeof code === "string" ? code : void 0 };
|
|
698
754
|
}
|
|
699
755
|
var toPath = (root, rel) => join2(root, ...rel.split("/"));
|
|
756
|
+
var LEFT_TEMP_RE = /\.\d+\.[a-z0-9]+\.tmp$/;
|
|
700
757
|
async function readFolder(root) {
|
|
701
758
|
const files = {};
|
|
702
759
|
const names = /* @__PURE__ */ new Set();
|
|
760
|
+
const temps = [];
|
|
703
761
|
async function walk(dir, prefix) {
|
|
704
762
|
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
705
763
|
if (e.name.startsWith(".")) continue;
|
|
@@ -707,12 +765,13 @@ async function readFolder(root) {
|
|
|
707
765
|
if (e.isDirectory()) await walk(join2(dir, e.name), rel);
|
|
708
766
|
else if (e.isFile()) {
|
|
709
767
|
names.add(rel.toLowerCase());
|
|
768
|
+
if (LEFT_TEMP_RE.test(e.name)) temps.push(rel);
|
|
710
769
|
if (e.name.endsWith(".md")) files[rel] = await readFile2(join2(dir, e.name), "utf8");
|
|
711
770
|
}
|
|
712
771
|
}
|
|
713
772
|
}
|
|
714
773
|
if (existsSync(root)) await walk(root, "");
|
|
715
|
-
return { files, names };
|
|
774
|
+
return { files, names, temps };
|
|
716
775
|
}
|
|
717
776
|
async function pullTeam(client, ws, project) {
|
|
718
777
|
try {
|
|
@@ -761,7 +820,11 @@ var Report = class {
|
|
|
761
820
|
deletedHere = /* @__PURE__ */ new Set();
|
|
762
821
|
deletedFromTeam = /* @__PURE__ */ new Set();
|
|
763
822
|
conflicts = [];
|
|
823
|
+
/** The conflict copies written this run, by name. */
|
|
824
|
+
copies = /* @__PURE__ */ new Set();
|
|
764
825
|
skipped = /* @__PURE__ */ new Map();
|
|
826
|
+
/** Skipped files a sync in the member's terminal can send: held for a value, or for the vault. */
|
|
827
|
+
waiting = /* @__PURE__ */ new Set();
|
|
765
828
|
people = [];
|
|
766
829
|
toAnswer = /* @__PURE__ */ new Map();
|
|
767
830
|
add(plan) {
|
|
@@ -769,12 +832,32 @@ var Report = class {
|
|
|
769
832
|
plan.pushed.forEach((p) => this.pushed.add(p));
|
|
770
833
|
plan.deletedHere.forEach((p) => this.deletedHere.add(p));
|
|
771
834
|
plan.deletedFromTeam.forEach((p) => this.deletedFromTeam.add(p));
|
|
772
|
-
for (const c of plan.conflicts)
|
|
835
|
+
for (const c of plan.conflicts) {
|
|
836
|
+
this.conflicts.push(`${c.path}: the team's text; yours is beside it as ${c.copy}`);
|
|
837
|
+
this.copies.add(c.copy);
|
|
838
|
+
}
|
|
773
839
|
for (const line of plan.indexTeamLines) this.conflicts.push(`${INDEX_FILE}: your line was kept; the team's was: ${line}`);
|
|
774
840
|
}
|
|
775
841
|
skip(path, reason) {
|
|
776
842
|
if (!this.skipped.has(path)) this.skipped.set(path, reason);
|
|
777
843
|
}
|
|
844
|
+
/** Skipped, and waiting for the member at a terminal. */
|
|
845
|
+
wait(path, reason) {
|
|
846
|
+
this.skip(path, reason);
|
|
847
|
+
this.waiting.add(path);
|
|
848
|
+
}
|
|
849
|
+
summary(outcome, version, reason) {
|
|
850
|
+
return {
|
|
851
|
+
outcome,
|
|
852
|
+
...reason ? { reason } : {},
|
|
853
|
+
version,
|
|
854
|
+
pulled: this.pulled.size,
|
|
855
|
+
pushed: this.pushed.size,
|
|
856
|
+
deleted: this.deletedHere.size + this.deletedFromTeam.size,
|
|
857
|
+
conflictCopies: [...this.copies].sort(),
|
|
858
|
+
waiting: [...this.waiting].sort()
|
|
859
|
+
};
|
|
860
|
+
}
|
|
778
861
|
lines(summary, dryRun) {
|
|
779
862
|
const out = [summary];
|
|
780
863
|
const section = (title, items) => {
|
|
@@ -877,7 +960,7 @@ async function people(opts, ws, files, report, hint) {
|
|
|
877
960
|
for (const path of g.files) {
|
|
878
961
|
if (leftOut.has(path)) continue;
|
|
879
962
|
current[path] = addShareFalse(current[path]);
|
|
880
|
-
await
|
|
963
|
+
await writeWhole(toPath(opts.dir, path), current[path]);
|
|
881
964
|
leftOut.add(path);
|
|
882
965
|
}
|
|
883
966
|
report.people.push({ kind: g.kind, answer: "left out (share: false)", files: g.files.length });
|
|
@@ -980,7 +1063,7 @@ async function people(opts, ws, files, report, hint) {
|
|
|
980
1063
|
const yes = /^y(es)?$/i.test((await ask(`Rewrite ${paths.length} file${paths.length === 1 ? "" : "s"} as shown? [y/N] `)).trim());
|
|
981
1064
|
if (yes) {
|
|
982
1065
|
for (const path of paths) {
|
|
983
|
-
await
|
|
1066
|
+
await writeWhole(toPath(opts.dir, path), rewritten[path]);
|
|
984
1067
|
current[path] = rewritten[path];
|
|
985
1068
|
}
|
|
986
1069
|
}
|
|
@@ -995,11 +1078,11 @@ async function people(opts, ws, files, report, hint) {
|
|
|
995
1078
|
held.add(path);
|
|
996
1079
|
const keys = pending.filter((f) => f.kind !== HOME_PATH).map((f) => groupKey(f.kind, f.normalised));
|
|
997
1080
|
if (keys.some((k) => vaultDown.has(k))) {
|
|
998
|
-
report.
|
|
1081
|
+
report.wait(path, "the vault did not answer (SYD-K019) for a value in it; it syncs once the value is filed");
|
|
999
1082
|
} else if (keys.some((k) => !answers.has(k))) {
|
|
1000
|
-
report.
|
|
1083
|
+
report.wait(path, `it holds a value nobody has answered for: run \`${hint}\` in a terminal`);
|
|
1001
1084
|
} else {
|
|
1002
|
-
report.
|
|
1085
|
+
report.wait(path, `it holds a value to rewrite as its identifier or ~: run \`${hint}\` in a terminal`);
|
|
1003
1086
|
}
|
|
1004
1087
|
}
|
|
1005
1088
|
for (const g of groups) {
|
|
@@ -1010,12 +1093,11 @@ async function people(opts, ws, files, report, hint) {
|
|
|
1010
1093
|
}
|
|
1011
1094
|
return { files: current, held, leftOut };
|
|
1012
1095
|
}
|
|
1096
|
+
async function removeLeftTemps(dir, temps) {
|
|
1097
|
+
for (const rel of temps) await rm2(toPath(dir, rel), { force: true });
|
|
1098
|
+
}
|
|
1013
1099
|
async function applyPlan(dir, plan) {
|
|
1014
|
-
for (const [path, text] of Object.entries(plan.writes))
|
|
1015
|
-
const full = toPath(dir, path);
|
|
1016
|
-
await mkdir2(dirname2(full), { recursive: true });
|
|
1017
|
-
await writeFile2(full, text, "utf8");
|
|
1018
|
-
}
|
|
1100
|
+
for (const [path, text] of Object.entries(plan.writes)) await writeWhole(toPath(dir, path), text);
|
|
1019
1101
|
for (const path of plan.deletes) await rm2(toPath(dir, path), { force: true });
|
|
1020
1102
|
}
|
|
1021
1103
|
var sameManifest = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([p, h]) => b[p] === h);
|
|
@@ -1060,8 +1142,19 @@ async function syncMemory(opts) {
|
|
|
1060
1142
|
const { client, out } = opts;
|
|
1061
1143
|
const now = opts.now?.() ?? /* @__PURE__ */ new Date();
|
|
1062
1144
|
try {
|
|
1145
|
+
const checkDeadline = () => {
|
|
1146
|
+
if (opts.deadline !== void 0 && Date.now() > opts.deadline) {
|
|
1147
|
+
throw new SyncStopped("took too long", "Stopped: the sync's time ran out before anything more was written or sent.");
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1063
1150
|
const own = await client.getOwnTeamSetup();
|
|
1064
1151
|
const ws = own.workspace;
|
|
1152
|
+
if (opts.workspace && ws !== opts.workspace) {
|
|
1153
|
+
throw new SyncStopped(
|
|
1154
|
+
"the key is for another workspace",
|
|
1155
|
+
`The key is for ${ws}, not ${opts.workspace}: nothing was synced. Set SYSTEMDOX_API_KEY to a key of ${opts.workspace}, or unset it.`
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1065
1158
|
const project = opts.project ?? (own.memory.length === 1 ? own.memory[0].project : void 0);
|
|
1066
1159
|
if (!project) {
|
|
1067
1160
|
throw new SyncFailed(
|
|
@@ -1075,6 +1168,12 @@ async function syncMemory(opts) {
|
|
|
1075
1168
|
const date = localDate(now);
|
|
1076
1169
|
const stored = (await readState(ws)).memory?.[project];
|
|
1077
1170
|
let team = await pullTeam(client, ws, project);
|
|
1171
|
+
if (opts.unattended && stored && !team.exists) {
|
|
1172
|
+
throw new SyncStopped(
|
|
1173
|
+
"the team memory could not be read",
|
|
1174
|
+
`${ws}/${project} could not be read from SystemDox: nothing was changed. Run sync-memory in your terminal to see why.`
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
1078
1177
|
let base = stored ? { version: stored.version, manifest: stored.manifest, exists: true } : { version: 0, manifest: {}, exists: false };
|
|
1079
1178
|
const folder = await readFolder(dir);
|
|
1080
1179
|
const shared = {};
|
|
@@ -1087,14 +1186,16 @@ async function syncMemory(opts) {
|
|
|
1087
1186
|
} else shared[path] = text;
|
|
1088
1187
|
}
|
|
1089
1188
|
const importWrites = {};
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1189
|
+
const current = { ...folder.files };
|
|
1190
|
+
const taken = new Set(folder.names);
|
|
1191
|
+
for (const importDir of opts.importDirs ?? []) {
|
|
1192
|
+
const other = await readFolder(resolve(importDir));
|
|
1093
1193
|
for (const [path, text] of Object.entries(other.files).sort(([a], [b]) => a.localeCompare(b))) {
|
|
1094
1194
|
if (!isShareable(path, text) || !isFilePath(path)) continue;
|
|
1095
|
-
const here =
|
|
1195
|
+
const here = current[path];
|
|
1096
1196
|
if (here === void 0) {
|
|
1097
1197
|
shared[path] = text;
|
|
1198
|
+
current[path] = text;
|
|
1098
1199
|
importWrites[path] = text;
|
|
1099
1200
|
taken.add(path.toLowerCase());
|
|
1100
1201
|
} else if (here !== text) {
|
|
@@ -1102,16 +1203,14 @@ async function syncMemory(opts) {
|
|
|
1102
1203
|
taken.add(copy.toLowerCase());
|
|
1103
1204
|
importWrites[copy] = text;
|
|
1104
1205
|
report.conflicts.push(`${path}: this folder's text; the imported one is beside it as ${copy}`);
|
|
1206
|
+
report.copies.add(copy);
|
|
1105
1207
|
}
|
|
1106
1208
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
await writeFile2(full, text, "utf8");
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1209
|
+
}
|
|
1210
|
+
folder.names = taken;
|
|
1211
|
+
if (!opts.dryRun) {
|
|
1212
|
+
checkDeadline();
|
|
1213
|
+
for (const [path, text] of Object.entries(importWrites)) await writeWhole(toPath(dir, path), text);
|
|
1115
1214
|
}
|
|
1116
1215
|
const found = await people(run3, ws, shared, report, hint);
|
|
1117
1216
|
for (const path of found.leftOut) {
|
|
@@ -1136,12 +1235,16 @@ async function syncMemory(opts) {
|
|
|
1136
1235
|
);
|
|
1137
1236
|
return 0;
|
|
1138
1237
|
}
|
|
1238
|
+
checkDeadline();
|
|
1239
|
+
if (attempt === 1) await removeLeftTemps(dir, folder.temps);
|
|
1139
1240
|
await applyPlan(dir, plan);
|
|
1140
1241
|
let version = team.version;
|
|
1141
1242
|
if (!sameManifest(plan.manifest, team.manifest)) {
|
|
1243
|
+
checkDeadline();
|
|
1142
1244
|
await upload(client, ws, project, plan, team.manifest, report);
|
|
1143
1245
|
}
|
|
1144
1246
|
if (!sameManifest(plan.manifest, team.manifest)) {
|
|
1247
|
+
checkDeadline();
|
|
1145
1248
|
try {
|
|
1146
1249
|
version = (await client.pushMemoryVersion(ws, project, { baseVersion: team.version, manifest: plan.manifest })).version;
|
|
1147
1250
|
} catch (e) {
|
|
@@ -1153,6 +1256,7 @@ async function syncMemory(opts) {
|
|
|
1153
1256
|
false
|
|
1154
1257
|
)
|
|
1155
1258
|
);
|
|
1259
|
+
opts.summary?.(report.summary("moved-on", team.version, "moved on twice"));
|
|
1156
1260
|
return 2;
|
|
1157
1261
|
}
|
|
1158
1262
|
base = team;
|
|
@@ -1177,33 +1281,45 @@ async function syncMemory(opts) {
|
|
|
1177
1281
|
false
|
|
1178
1282
|
)
|
|
1179
1283
|
);
|
|
1284
|
+
opts.summary?.(report.summary("synced", version));
|
|
1180
1285
|
return 0;
|
|
1181
1286
|
}
|
|
1182
1287
|
} catch (e) {
|
|
1183
1288
|
out(e.message);
|
|
1289
|
+
opts.summary?.({
|
|
1290
|
+
outcome: "failed",
|
|
1291
|
+
reason: failureReason(e),
|
|
1292
|
+
version: 0,
|
|
1293
|
+
pulled: 0,
|
|
1294
|
+
pushed: 0,
|
|
1295
|
+
deleted: 0,
|
|
1296
|
+
conflictCopies: [],
|
|
1297
|
+
waiting: []
|
|
1298
|
+
});
|
|
1184
1299
|
return 1;
|
|
1185
1300
|
}
|
|
1186
1301
|
}
|
|
1187
1302
|
|
|
1188
1303
|
// src/setup-team.ts
|
|
1189
|
-
var SETUP_USAGE = "Usage: systemdox-mcp-server setup [--project <name>] [--dir <checkout>] [--yes] [--overwrite]";
|
|
1304
|
+
var SETUP_USAGE = "Usage: systemdox-mcp-server setup [--project <name>] [--dir <checkout>] [--import <folder>]... [--yes] [--overwrite]";
|
|
1190
1305
|
var HEADERS_HELPER = "npx -y @systemdox/mcp-server marketplace-auth";
|
|
1191
1306
|
var SetupFailed = class extends Error {
|
|
1192
1307
|
};
|
|
1193
1308
|
function parseSetupArgs(argv) {
|
|
1194
|
-
const args = { yes: false, overwrite: false };
|
|
1309
|
+
const args = { yes: false, overwrite: false, importDirs: [] };
|
|
1195
1310
|
for (let i = 0; i < argv.length; i++) {
|
|
1196
1311
|
const a = argv[i];
|
|
1197
1312
|
if (a === "--yes") args.yes = true;
|
|
1198
1313
|
else if (a === "--overwrite") args.overwrite = true;
|
|
1199
|
-
else if (a === "--project" || a === "--dir") {
|
|
1314
|
+
else if (a === "--project" || a === "--dir" || a === "--import") {
|
|
1200
1315
|
const value = argv[++i];
|
|
1201
1316
|
if (!value || value.startsWith("--")) throw new Error(`${a} needs a value
|
|
1202
1317
|
${SETUP_USAGE}`);
|
|
1203
1318
|
if (a === "--project") {
|
|
1204
1319
|
if (!isWorkspace(value)) throw new Error(`${value} is not a project name (lower case letters, digits and -)`);
|
|
1205
1320
|
args.project = value;
|
|
1206
|
-
} else args.dir = value;
|
|
1321
|
+
} else if (a === "--dir") args.dir = value;
|
|
1322
|
+
else args.importDirs.push(value);
|
|
1207
1323
|
} else throw new Error(`Unexpected argument ${a}
|
|
1208
1324
|
${SETUP_USAGE}`);
|
|
1209
1325
|
}
|
|
@@ -1223,6 +1339,60 @@ async function claudeCodeVersion() {
|
|
|
1223
1339
|
return void 0;
|
|
1224
1340
|
}
|
|
1225
1341
|
}
|
|
1342
|
+
async function gitTopFolder(dir) {
|
|
1343
|
+
try {
|
|
1344
|
+
const { stdout } = await run2("git", ["-C", dir, "rev-parse", "--show-toplevel"], { timeout: 1e4, windowsHide: true });
|
|
1345
|
+
const top = stdout.trim();
|
|
1346
|
+
return top ? resolve2(top) : void 0;
|
|
1347
|
+
} catch {
|
|
1348
|
+
return void 0;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
function claudeMemoryFolder(path) {
|
|
1352
|
+
return join3(homeDir(), ".claude", "projects", path.replace(/[^A-Za-z0-9]/g, "-"), "memory");
|
|
1353
|
+
}
|
|
1354
|
+
async function countMemoryFiles(folder) {
|
|
1355
|
+
let count = 0;
|
|
1356
|
+
async function walk(dir, prefix) {
|
|
1357
|
+
let entries2;
|
|
1358
|
+
try {
|
|
1359
|
+
entries2 = await readdir2(dir, { withFileTypes: true });
|
|
1360
|
+
} catch {
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
for (const e of entries2) {
|
|
1364
|
+
if (e.name.startsWith(".")) continue;
|
|
1365
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
1366
|
+
if (e.isDirectory()) await walk(join3(dir, e.name), rel);
|
|
1367
|
+
else if (e.isFile() && e.name.endsWith(".md")) {
|
|
1368
|
+
try {
|
|
1369
|
+
if (isShareable(rel, await readFile3(join3(dir, e.name), "utf8"))) count++;
|
|
1370
|
+
} catch {
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
await walk(folder, "");
|
|
1376
|
+
return count;
|
|
1377
|
+
}
|
|
1378
|
+
async function memoryImports(checkout, teamFolder, named, gitTop = gitTopFolder) {
|
|
1379
|
+
const candidates = [resolve2(checkout)];
|
|
1380
|
+
const top = await gitTop(checkout);
|
|
1381
|
+
if (top && !candidates.includes(top)) candidates.push(top);
|
|
1382
|
+
const out = [];
|
|
1383
|
+
for (const path of candidates) {
|
|
1384
|
+
const folder = claudeMemoryFolder(path);
|
|
1385
|
+
if (resolve2(folder) === resolve2(teamFolder)) continue;
|
|
1386
|
+
const files = await countMemoryFiles(folder);
|
|
1387
|
+
if (files) out.push({ folder, files, named: false });
|
|
1388
|
+
}
|
|
1389
|
+
for (const dir of named) {
|
|
1390
|
+
const folder = resolve2(dir);
|
|
1391
|
+
if (out.some((i) => i.folder === folder)) continue;
|
|
1392
|
+
out.push({ folder, files: await countMemoryFiles(folder), named: true });
|
|
1393
|
+
}
|
|
1394
|
+
return out;
|
|
1395
|
+
}
|
|
1226
1396
|
function parseVersion(text) {
|
|
1227
1397
|
const m = text?.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
1228
1398
|
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : void 0;
|
|
@@ -1264,9 +1434,9 @@ function skipValue(t, i) {
|
|
|
1264
1434
|
while (i < t.length && !/[\s,}\]]/.test(t[i])) i++;
|
|
1265
1435
|
return i;
|
|
1266
1436
|
}
|
|
1267
|
-
function objectMembers(t,
|
|
1437
|
+
function objectMembers(t, open2) {
|
|
1268
1438
|
const members = [];
|
|
1269
|
-
let i = skipSpace(t,
|
|
1439
|
+
let i = skipSpace(t, open2 + 1);
|
|
1270
1440
|
if (t[i] === "}") return { members, close: i };
|
|
1271
1441
|
for (; ; ) {
|
|
1272
1442
|
i = skipSpace(t, i);
|
|
@@ -1293,13 +1463,13 @@ function setJsonMember(text, keys, value) {
|
|
|
1293
1463
|
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
1294
1464
|
const render = (v, indent) => compact ? JSON.stringify(v) : JSON.stringify(v, null, unit).split("\n").join(eol + indent);
|
|
1295
1465
|
const nest = (from) => keys.slice(from).reduceRight((acc, k) => ({ [k]: acc }), value);
|
|
1296
|
-
let
|
|
1466
|
+
let open2 = skipSpace(text, 0);
|
|
1297
1467
|
for (let k = 0; k < keys.length; k++) {
|
|
1298
|
-
const { members, close } = objectMembers(text,
|
|
1468
|
+
const { members, close } = objectMembers(text, open2);
|
|
1299
1469
|
const found = members.filter((m) => m.key === keys[k]).at(-1);
|
|
1300
1470
|
if (found) {
|
|
1301
1471
|
if (k < keys.length - 1 && text[found.valueStart] === "{") {
|
|
1302
|
-
|
|
1472
|
+
open2 = found.valueStart;
|
|
1303
1473
|
continue;
|
|
1304
1474
|
}
|
|
1305
1475
|
return text.slice(0, found.valueStart) + render(nest(k + 1), lineIndent(text, found.keyStart)) + text.slice(found.valueEnd);
|
|
@@ -1308,16 +1478,16 @@ function setJsonMember(text, keys, value) {
|
|
|
1308
1478
|
if (compact) {
|
|
1309
1479
|
const member = `${name}:${render(nest(k + 1), "")}`;
|
|
1310
1480
|
if (members.length) return text.slice(0, members.at(-1).valueEnd) + "," + member + text.slice(members.at(-1).valueEnd);
|
|
1311
|
-
return text.slice(0,
|
|
1481
|
+
return text.slice(0, open2 + 1) + member + text.slice(close);
|
|
1312
1482
|
}
|
|
1313
1483
|
if (members.length) {
|
|
1314
1484
|
const last = members.at(-1);
|
|
1315
1485
|
const indent2 = lineIndent(text, last.keyStart);
|
|
1316
1486
|
return text.slice(0, last.valueEnd) + `,${eol}${indent2}${name}: ${render(nest(k + 1), indent2)}` + text.slice(last.valueEnd);
|
|
1317
1487
|
}
|
|
1318
|
-
const outer = lineIndent(text,
|
|
1488
|
+
const outer = lineIndent(text, open2);
|
|
1319
1489
|
const indent = outer + unit;
|
|
1320
|
-
return text.slice(0,
|
|
1490
|
+
return text.slice(0, open2 + 1) + `${eol}${indent}${name}: ${render(nest(k + 1), indent)}${eol}${outer}` + text.slice(close);
|
|
1321
1491
|
}
|
|
1322
1492
|
return text;
|
|
1323
1493
|
}
|
|
@@ -1363,9 +1533,9 @@ function importBlock(workspace, eol = "\n") {
|
|
|
1363
1533
|
function withImportBlock(text, workspace) {
|
|
1364
1534
|
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
1365
1535
|
const block = importBlock(workspace, eol);
|
|
1366
|
-
const
|
|
1536
|
+
const open2 = `<!-- systemdox:${workspace} -->`;
|
|
1367
1537
|
const close = `<!-- /systemdox:${workspace} -->`;
|
|
1368
|
-
const start = text.indexOf(
|
|
1538
|
+
const start = text.indexOf(open2);
|
|
1369
1539
|
const end = start >= 0 ? text.indexOf(close, start) : -1;
|
|
1370
1540
|
if (start >= 0 && end >= 0) return text.slice(0, start) + block + text.slice(end + close.length);
|
|
1371
1541
|
if (!text) return block + eol;
|
|
@@ -1489,6 +1659,15 @@ async function runTeamSetup(opts) {
|
|
|
1489
1659
|
}
|
|
1490
1660
|
items.push(item(`autoMemoryDirectory in ${localSettings}`, [memoryFolder]));
|
|
1491
1661
|
}
|
|
1662
|
+
const imports = project && memoryFolder && !state.memory?.[project] ? await memoryImports(checkout, memoryFolder, opts.importDirs ?? [], opts.gitTop) : [];
|
|
1663
|
+
for (const i of imports) {
|
|
1664
|
+
items.push(
|
|
1665
|
+
item(
|
|
1666
|
+
`${i.named ? "the folder --import names" : "the memories Claude Code kept for this checkout"}, imported into ${project} on its first sync`,
|
|
1667
|
+
[`${i.folder}: ${i.files} file${i.files === 1 ? "" : "s"} to share`]
|
|
1668
|
+
)
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1492
1671
|
const pending = items.filter((i) => !accepted.has(i.sha256));
|
|
1493
1672
|
if (pending.length) {
|
|
1494
1673
|
if (!canAsk) {
|
|
@@ -1558,6 +1737,7 @@ async function runTeamSetup(opts) {
|
|
|
1558
1737
|
const code = await syncMemory({
|
|
1559
1738
|
project,
|
|
1560
1739
|
dryRun: false,
|
|
1740
|
+
importDirs: imports.map((i) => i.folder),
|
|
1561
1741
|
client,
|
|
1562
1742
|
out: (...lines) => lines.forEach((l) => out(l)),
|
|
1563
1743
|
ask: opts.terminal ? opts.ask : void 0,
|
|
@@ -1602,7 +1782,11 @@ export {
|
|
|
1602
1782
|
HEADERS_HELPER,
|
|
1603
1783
|
SETUP_USAGE,
|
|
1604
1784
|
claudeCodeVersion,
|
|
1785
|
+
claudeMemoryFolder,
|
|
1786
|
+
countMemoryFiles,
|
|
1787
|
+
gitTopFolder,
|
|
1605
1788
|
importBlock,
|
|
1789
|
+
memoryImports,
|
|
1606
1790
|
parseSetupArgs,
|
|
1607
1791
|
runTeamSetup,
|
|
1608
1792
|
runTeamSetupCommand,
|
package/dist/sync-memory.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `systemdox-mcp-server sync-memory [--project <name>] [--dir <folder>]
|
|
3
|
-
* [--import <folder>] [--dry-run]`,
|
|
4
|
-
* `sync_memory`
|
|
5
|
-
*
|
|
3
|
+
* [--import <folder>]... [--dry-run]`, the core of the local MCP tool
|
|
4
|
+
* `sync_memory`, and `sync-memory --hook <workspace>`, the run the team plugin
|
|
5
|
+
* makes at the start and the end of a session (ADR-217 §2.3 with Amendments 2
|
|
6
|
+
* and 3, specs/changes/team-setup/contract.md "sync-memory and the MCP tool
|
|
6
7
|
* sync_memory").
|
|
7
8
|
*
|
|
8
9
|
* 1. Pull: the team's latest version (`getMemory`), the base (state.json; with
|
|
@@ -17,18 +18,43 @@
|
|
|
17
18
|
* state.json takes the new base only after the push succeeds.
|
|
18
19
|
*
|
|
19
20
|
* Exit codes: 0 synced; 1 usage, no key, or the API refused; 2 the push was
|
|
20
|
-
* refused twice on a stale base.
|
|
21
|
+
* refused twice on a stale base. `--hook` always exits 0: it syncs only the
|
|
22
|
+
* projects this computer synced before, asks nothing, prints one line per
|
|
23
|
+
* project (and a line for conflict copies and one for waiting files), appends
|
|
24
|
+
* the full report to the workspace's sync-memory.log, holds sync.lock, keeps
|
|
25
|
+
* to a 20-second budget and never holds a session up.
|
|
21
26
|
*/
|
|
22
27
|
import { SystemDoxApiClient } from "./lib/api-client.js";
|
|
23
28
|
import { type CommandIo } from "./lib/team-setup-files.js";
|
|
24
|
-
export declare const SYNC_MEMORY_USAGE
|
|
29
|
+
export declare const SYNC_MEMORY_USAGE: string;
|
|
30
|
+
/** The hook run's budget, lock staleness and log size (contract.md "--hook"). */
|
|
31
|
+
export declare const HOOK_BUDGET_MS = 20000;
|
|
32
|
+
export declare const HOOK_LOCK_STALE_MS = 60000;
|
|
33
|
+
export declare const HOOK_LOG_MAX_BYTES: number;
|
|
25
34
|
export declare const SYNC_MEMORY_COMMAND = "npx -y @systemdox/mcp-server sync-memory";
|
|
26
35
|
export type SyncClient = Pick<SystemDoxApiClient, "getOwnTeamSetup" | "getMemory" | "readBlobs" | "findMissingBlobs" | "uploadBlobs" | "pushMemoryVersion" | "lookupValues" | "answerValues" | "fileMemoryPeople">;
|
|
27
36
|
export interface SyncMemoryArgs {
|
|
28
37
|
project?: string;
|
|
29
38
|
dir?: string;
|
|
30
|
-
|
|
39
|
+
/** Folders to import on this sync, in order (`--import`, repeatable). */
|
|
40
|
+
importDirs?: string[];
|
|
31
41
|
dryRun: boolean;
|
|
42
|
+
/** `--hook <workspace>`: the run the team plugin makes. */
|
|
43
|
+
hook?: string;
|
|
44
|
+
}
|
|
45
|
+
/** What a sync came to, for the hook run's one line (never a value). */
|
|
46
|
+
export interface SyncSummary {
|
|
47
|
+
outcome: "synced" | "moved-on" | "failed";
|
|
48
|
+
/** For `failed`: why, in a few words. */
|
|
49
|
+
reason?: string;
|
|
50
|
+
version: number;
|
|
51
|
+
pulled: number;
|
|
52
|
+
pushed: number;
|
|
53
|
+
deleted: number;
|
|
54
|
+
/** The `.local-<date>.md` copies this run wrote. */
|
|
55
|
+
conflictCopies: string[];
|
|
56
|
+
/** The files skipped this run (waiting for the member, or refused). */
|
|
57
|
+
waiting: string[];
|
|
32
58
|
}
|
|
33
59
|
export interface SyncMemoryOptions extends SyncMemoryArgs {
|
|
34
60
|
client: SyncClient;
|
|
@@ -38,6 +64,14 @@ export interface SyncMemoryOptions extends SyncMemoryArgs {
|
|
|
38
64
|
* nothing is asked and nothing is rewritten. */
|
|
39
65
|
ask?: (question: string) => Promise<string>;
|
|
40
66
|
now?: () => Date;
|
|
67
|
+
/** Told what the sync came to, once, at its end. */
|
|
68
|
+
summary?: (summary: SyncSummary) => void;
|
|
69
|
+
/** The workspace the run is for (`--hook <workspace>`): a key for another one syncs nothing. */
|
|
70
|
+
workspace?: string;
|
|
71
|
+
/** Epoch ms: past it, nothing more is written or sent (the hook's budget). */
|
|
72
|
+
deadline?: number;
|
|
73
|
+
/** A hook run: a project the team no longer serves is left as it is. */
|
|
74
|
+
unattended?: boolean;
|
|
41
75
|
}
|
|
42
76
|
export declare function parseSyncMemoryArgs(argv: string[]): SyncMemoryArgs;
|
|
43
77
|
/**
|
|
@@ -45,9 +79,27 @@ export declare function parseSyncMemoryArgs(argv: string[]): SyncMemoryArgs;
|
|
|
45
79
|
* report goes to `opts.out`.
|
|
46
80
|
*/
|
|
47
81
|
export declare function syncMemory(opts: SyncMemoryOptions): Promise<number>;
|
|
48
|
-
/** The
|
|
49
|
-
export declare function
|
|
82
|
+
/** The lines the hook prints for one project (contract.md "--hook"): never a value. */
|
|
83
|
+
export declare function hookLines(ws: string, project: string, folder: string, s: SyncSummary): string[];
|
|
84
|
+
export interface HookIo extends CommandIo {
|
|
50
85
|
client?: SyncClient;
|
|
51
|
-
ask?: (question: string) => Promise<string>;
|
|
52
86
|
now?: () => Date;
|
|
87
|
+
/** The budget and the lock's staleness, for tests. */
|
|
88
|
+
budgetMs?: number;
|
|
89
|
+
lockStaleMs?: number;
|
|
90
|
+
/** Epoch ms the budget counts from: the process start for the CLI (npx and
|
|
91
|
+
* loading count against Claude Code's 30 seconds too), now otherwise. */
|
|
92
|
+
startedAt?: number;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* `sync-memory --hook <workspace>`: the run the team plugin makes at the start
|
|
96
|
+
* and the end of a session. Every project this computer has synced before, in
|
|
97
|
+
* its default folder; never a first sync; one line per project on stdout (plus
|
|
98
|
+
* a line for conflict copies and one for waiting files), the full report in
|
|
99
|
+
* the log; one run at a time per workspace; a budget; exit 0 whatever happens.
|
|
100
|
+
*/
|
|
101
|
+
export declare function runHookSync(workspace: string, io: HookIo, env?: NodeJS.ProcessEnv): Promise<number>;
|
|
102
|
+
/** The command: parse, find the key, sync. */
|
|
103
|
+
export declare function runSyncMemory(argv: string[], io: HookIo & {
|
|
104
|
+
ask?: (question: string) => Promise<string>;
|
|
53
105
|
}, env?: NodeJS.ProcessEnv): Promise<number>;
|