pi-session-mcp 0.8.0 → 0.8.1

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.
@@ -1,18 +1,24 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { lstat, mkdir, open, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { lstat, mkdir, open, readFile, rename, rm, unlink } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
+ import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
5
6
  import { result } from "./setup-result.js";
6
- import { readSkillTree, shippedSkillsDirectory, SKILL_NAME_PATTERN, SkillTreeError, skillOverrideDirectory } from "./skill-library.js";
7
+ import { LEGACY_SKILL_TREE_FINGERPRINT_PATTERN, readSkillTree, releasedSkillFingerprint, shippedSkillsDirectory, SKILL_NAME_PATTERN, SKILL_TREE_FINGERPRINT_PATTERN, SkillTreeError, skillOverrideDirectory, } from "./skill-library.js";
7
8
  /**
8
9
  * `--customize-skill <name>`: copies a shipped skill into the operator's override directory as a
9
10
  * starting point, and records the shipped fingerprint it was based on. The operator's files are
10
11
  * never overwritten or edited; a changed original is only reported and acknowledged by moving
11
12
  * the recorded base. Reports carry the skill name and a code, never a path or file content.
13
+ *
14
+ * Applying is a cross-process transaction: everything from reading the bases file to replacing it
15
+ * runs under the kernel lock of `override-lock.ts`, so two runs for different skills cannot lose
16
+ * each other's base. A dry run takes no lock and writes nothing.
12
17
  */
13
18
  export const SKILL_BASES_FILE = ".pi-session-mcp-bases.json";
14
19
  export const MAX_SKILL_BASES_BYTES = 64 * 1024;
15
- const FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/;
20
+ /** Written by this version; version 1 (0.8.0) holds only legacy fingerprints and is still read. */
21
+ const BASES_SCHEMA_VERSION = 2;
16
22
  export class SkillBasesError extends Error {
17
23
  constructor() {
18
24
  super("Skill bases file is not usable");
@@ -46,13 +52,17 @@ export async function readSkillBases(overrideDirectory) {
46
52
  catch {
47
53
  throw new SkillBasesError();
48
54
  }
49
- if (!isPlainObject(parsed) || Object.keys(parsed).sort().join() !== "bases,schemaVersion" || parsed.schemaVersion !== 1)
55
+ if (!isPlainObject(parsed) || Object.keys(parsed).sort().join() !== "bases,schemaVersion")
56
+ throw new SkillBasesError();
57
+ const version = parsed.schemaVersion;
58
+ if (version !== 1 && version !== BASES_SCHEMA_VERSION)
50
59
  throw new SkillBasesError();
51
60
  const bases = parsed.bases;
52
61
  if (!isPlainObject(bases))
53
62
  throw new SkillBasesError();
54
63
  const entries = Object.entries(bases);
55
- if (!entries.every(([name, value]) => SKILL_NAME_PATTERN.test(name) && typeof value === "string" && FINGERPRINT_PATTERN.test(value)))
64
+ const validValue = (value) => typeof value === "string" && (LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) || (version === BASES_SCHEMA_VERSION && SKILL_TREE_FINGERPRINT_PATTERN.test(value)));
65
+ if (!entries.every(([name, value]) => SKILL_NAME_PATTERN.test(name) && validValue(value)))
56
66
  throw new SkillBasesError();
57
67
  return Object.freeze(Object.fromEntries(entries));
58
68
  }
@@ -62,10 +72,25 @@ function isPlainObject(value) {
62
72
  const prototype = Object.getPrototypeOf(value);
63
73
  return prototype === Object.prototype || prototype === null;
64
74
  }
65
- /** Atomic replacement: a private temporary file in the same directory, then a rename. */
66
- async function writeSkillBases(overrideDirectory, bases) {
67
- const sorted = Object.fromEntries(Object.entries(bases).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
68
- const content = Buffer.from(`${JSON.stringify({ schemaVersion: 1, bases: sorted })}\n`, "utf8");
75
+ async function syncDirectory(path) {
76
+ const handle = await open(path, "r");
77
+ try {
78
+ await handle.sync();
79
+ }
80
+ finally {
81
+ await handle.close();
82
+ }
83
+ }
84
+ /**
85
+ * Atomic replacement: a private temporary file in the same directory, fsynced, then a rename and a
86
+ * directory fsync. Every provable legacy entry is migrated to its version 2 value on the way; an
87
+ * entry that no released tree names stays verbatim. A failure before the rename leaves the
88
+ * previous file untouched.
89
+ */
90
+ async function writeSkillBases(overrideDirectory, bases, released, beforeRename) {
91
+ const migrated = Object.entries(bases).map(([name, value]) => [name, LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(value) ? releasedSkillFingerprint(name, value, released) ?? value : value]);
92
+ const sorted = Object.fromEntries(migrated.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)));
93
+ const content = Buffer.from(`${JSON.stringify({ schemaVersion: BASES_SCHEMA_VERSION, bases: sorted })}\n`, "utf8");
69
94
  if (content.length > MAX_SKILL_BASES_BYTES)
70
95
  throw new SkillBasesError();
71
96
  const temporary = join(overrideDirectory, `.${SKILL_BASES_FILE.slice(1)}.${randomBytes(6).toString("hex")}.tmp`);
@@ -78,12 +103,14 @@ async function writeSkillBases(overrideDirectory, bases) {
78
103
  finally {
79
104
  await handle.close();
80
105
  }
106
+ await beforeRename?.();
81
107
  await rename(temporary, join(overrideDirectory, SKILL_BASES_FILE));
82
108
  }
83
109
  catch (error) {
84
110
  await unlink(temporary).catch(() => undefined);
85
111
  throw error;
86
112
  }
113
+ await syncDirectory(overrideDirectory);
87
114
  }
88
115
  function isMissing(error) {
89
116
  return typeof error === "object" && error !== null && error.code === "ENOENT";
@@ -96,7 +123,40 @@ function report(request, status, code) {
96
123
  // A planned change reports `planned` overall, like a registration dry-run.
97
124
  return result(request.apply ? "apply" : "dry-run", [finding], status === "planned" ? "planned" : undefined);
98
125
  }
99
- export async function customizeSkill(request) {
126
+ const OUTDATED = Object.freeze({ kind: "record", planned: "skill_override_outdated", applied: "skill_base_updated" });
127
+ /** Pure with respect to the library: reads the override tree, never changes anything. */
128
+ async function decide(name, overridePath, shipped, bases, released) {
129
+ try {
130
+ if (!(await lstat(overridePath)).isDirectory())
131
+ return { kind: "failed", code: "skill_override_invalid" };
132
+ }
133
+ catch (error) {
134
+ if (!isMissing(error))
135
+ throw error;
136
+ return { kind: "copy", planned: "skill_customize_planned", applied: "skill_customized" };
137
+ }
138
+ let override;
139
+ try {
140
+ override = await readSkillTree(overridePath);
141
+ }
142
+ catch (error) {
143
+ if (error instanceof SkillTreeError)
144
+ return { kind: "failed", code: "skill_override_invalid" };
145
+ throw error;
146
+ }
147
+ const base = Object.hasOwn(bases, name) ? bases[name] : undefined;
148
+ // Without a base an identical override is a customization whose bases commit failed earlier.
149
+ if (base === undefined)
150
+ return override.fingerprint === shipped.fingerprint ? { kind: "record", planned: "skill_customize_planned", applied: "skill_customized" } : OUTDATED;
151
+ if (SKILL_TREE_FINGERPRINT_PATTERN.test(base))
152
+ return base === shipped.fingerprint ? { kind: "current" } : OUTDATED;
153
+ // A legacy base only names a released original when a trusted reference tree has that value.
154
+ const original = releasedSkillFingerprint(name, base, released);
155
+ if (original === undefined)
156
+ return { kind: "record", planned: "skill_base_unverified", applied: "skill_base_updated" };
157
+ return original === shipped.fingerprint ? { kind: "record", planned: "skill_base_migration_planned", applied: "skill_base_migrated" } : OUTDATED;
158
+ }
159
+ export async function customizeSkill(request, dependencies = {}) {
100
160
  const done = (status, code) => report(request, status, code);
101
161
  if (!SKILL_NAME_PATTERN.test(request.name))
102
162
  return done("failed", "skill_not_shipped");
@@ -112,69 +172,94 @@ export async function customizeSkill(request) {
112
172
  return done("failed", "skill_customize_failed");
113
173
  }
114
174
  const overrideDirectory = skillOverrideDirectory(request.configPath);
115
- let bases;
116
- try {
117
- bases = await readSkillBases(overrideDirectory);
118
- }
119
- catch (error) {
120
- if (error instanceof SkillBasesError)
175
+ const overridePath = join(overrideDirectory, request.name);
176
+ const read = async () => {
177
+ try {
178
+ return await readSkillBases(overrideDirectory);
179
+ }
180
+ catch (error) {
181
+ if (error instanceof SkillBasesError)
182
+ return undefined;
183
+ throw error;
184
+ }
185
+ };
186
+ if (!request.apply) {
187
+ // No lock and no write: the report is a snapshot of what an apply would decide.
188
+ const bases = await read();
189
+ if (bases === undefined)
121
190
  return done("failed", "skill_bases_corrupt");
122
- throw error;
191
+ const decision = await decide(request.name, overridePath, shipped, bases, dependencies.released);
192
+ if (decision.kind === "failed")
193
+ return done("failed", decision.code);
194
+ return decision.kind === "current" ? done("unchanged", "skill_override_current") : done("planned", decision.planned);
123
195
  }
124
- const overridePath = join(overrideDirectory, request.name);
125
- let exists = true;
196
+ await mkdir(overrideDirectory, { recursive: true, mode: 0o700 });
197
+ let lock;
126
198
  try {
127
- const info = await lstat(overridePath);
128
- if (!info.isDirectory())
129
- return done("failed", "skill_override_invalid");
199
+ lock = await acquireOverrideLock(overrideDirectory, dependencies.lock);
130
200
  }
131
201
  catch (error) {
132
- if (!isMissing(error))
133
- throw error;
134
- exists = false;
135
- }
136
- if (!exists) {
137
- if (!request.apply)
138
- return done("planned", "skill_customize_planned");
139
- await copyShippedSkill(shipped.files, overrideDirectory, overridePath, request.name);
140
- await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint });
141
- return done("ok", "skill_customized");
202
+ if (error instanceof OverrideLockError)
203
+ return done("failed", error.code === "override_lock_busy" ? "skill_customize_lock_busy" : "skill_customize_lock_unavailable");
204
+ throw error;
142
205
  }
143
206
  try {
144
- await readSkillTree(overridePath);
207
+ await dependencies.afterLock?.();
208
+ // Read only now: a snapshot from before the lock may miss a base another run just wrote.
209
+ const bases = await read();
210
+ if (bases === undefined)
211
+ return done("failed", "skill_bases_corrupt");
212
+ const decision = await decide(request.name, overridePath, shipped, bases, dependencies.released);
213
+ if (decision.kind === "failed")
214
+ return done("failed", decision.code);
215
+ if (decision.kind === "current")
216
+ return done("unchanged", "skill_override_current");
217
+ if (decision.kind === "copy")
218
+ await copyShippedSkill(shipped.files, overrideDirectory, overridePath, request.name);
219
+ await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint }, dependencies.released, dependencies.beforeBasesRename);
220
+ return done("ok", decision.applied);
145
221
  }
146
- catch (error) {
147
- if (error instanceof SkillTreeError)
148
- return done("failed", "skill_override_invalid");
149
- throw error;
222
+ finally {
223
+ await lock.release();
150
224
  }
151
- if (bases[request.name] === shipped.fingerprint)
152
- return done("unchanged", "skill_override_current");
153
- if (!request.apply)
154
- return done("planned", "skill_override_outdated");
155
- // Only the recorded base moves: it acknowledges that the operator reviewed the changed original.
156
- await writeSkillBases(overrideDirectory, { ...bases, [request.name]: shipped.fingerprint });
157
- return done("ok", "skill_base_updated");
158
225
  }
159
226
  catch {
160
227
  return done("failed", "skill_customize_failed");
161
228
  }
162
229
  }
163
230
  async function copyShippedSkill(files, overrideDirectory, overridePath, name) {
164
- await mkdir(overrideDirectory, { recursive: true, mode: 0o700 });
165
231
  const temporary = join(overrideDirectory, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
166
232
  try {
167
233
  await mkdir(temporary, { mode: 0o755 });
168
234
  for (const file of files) {
169
235
  const destination = join(temporary, ...file.path.split("/"));
170
236
  await mkdir(dirname(destination), { recursive: true });
171
- await writeFile(destination, file.bytes, { flag: "wx", mode: 0o644 });
237
+ const handle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
238
+ try {
239
+ await handle.writeFile(file.bytes);
240
+ await handle.sync();
241
+ }
242
+ finally {
243
+ await handle.close();
244
+ }
245
+ }
246
+ // Every directory entry of the temporary tree is durable before the rename and the bases commit.
247
+ const directories = new Set([temporary]);
248
+ for (const file of files) {
249
+ let cursor = dirname(join(temporary, ...file.path.split("/")));
250
+ while (cursor !== temporary) {
251
+ directories.add(cursor);
252
+ cursor = dirname(cursor);
253
+ }
172
254
  }
255
+ for (const directory of [...directories].sort((left, right) => right.length - left.length))
256
+ await syncDirectory(directory);
173
257
  // The override must not appear while the copy runs, and an operator's directory is never replaced.
174
258
  if (await lstat(overridePath).then(() => true, (error) => { if (isMissing(error))
175
259
  return false; throw error; }))
176
260
  throw new Error("override appeared");
177
261
  await rename(temporary, overridePath);
262
+ await syncDirectory(overrideDirectory);
178
263
  }
179
264
  catch (error) {
180
265
  await rm(temporary, { recursive: true, force: true }).catch(() => undefined);
@@ -37,13 +37,58 @@ export interface SkillTreeFile {
37
37
  }
38
38
  export interface SkillTree {
39
39
  readonly files: readonly SkillTreeFile[];
40
- /** SHA-256 hex over the sorted relative paths and contents; path-free and stable. */
40
+ /** Version 2 fingerprint (`v2:` and SHA-256 hex) of the paths and contents; path-free and stable. */
41
41
  readonly fingerprint: string;
42
+ /**
43
+ * The version 1 fingerprint that 0.8.0 recorded, only to recognize such records. It is
44
+ * ambiguous and never proves identity on its own; see `provesLegacyFingerprint`.
45
+ */
46
+ readonly legacyFingerprint: string;
42
47
  }
43
48
  export declare class SkillTreeError extends Error {
44
49
  readonly code: "skill_tree_missing" | "skill_tree_invalid" | "skill_tree_too_large";
45
50
  constructor(code: "skill_tree_missing" | "skill_tree_invalid" | "skill_tree_too_large");
46
51
  }
52
+ /** A version 2 skill tree fingerprint as it is stored. */
53
+ export declare const SKILL_TREE_FINGERPRINT_PATTERN: RegExp;
54
+ /** A version 1 fingerprint as 0.8.0 stored it: bare SHA-256 hex. */
55
+ export declare const LEGACY_SKILL_TREE_FINGERPRINT_PATTERN: RegExp;
56
+ /**
57
+ * Version 2: a domain and version tag, the file count, then per file in byte order of the path
58
+ * the length-prefixed UTF-8 path and the length-prefixed content. Every field has an explicit
59
+ * length, so two different trees never share an encoding.
60
+ */
61
+ export declare function skillTreeFingerprint(files: readonly SkillTreeFile[]): string;
62
+ /**
63
+ * Version 1, as 0.8.0 computed it: `path NUL content NUL` per file in reading order. File
64
+ * content may contain NUL bytes, so two different trees can share this value.
65
+ */
66
+ export declare function legacySkillTreeFingerprint(files: readonly SkillTreeFile[]): string;
67
+ export interface ReleasedSkillTree {
68
+ readonly name: string;
69
+ readonly release: string;
70
+ readonly legacyFingerprint: string;
71
+ readonly fingerprint: string;
72
+ }
73
+ /**
74
+ * The shipped skills of every release that recorded version 1 fingerprints (0.8.0 only), as
75
+ * trusted references for recognizing such records. Computed from the `v0.8.0` tag and checked
76
+ * against the published npm package.
77
+ */
78
+ export declare const RELEASED_SKILL_TREES: readonly ReleasedSkillTree[];
79
+ /**
80
+ * Whether a version 1 fingerprint from a record proves that `candidate` is the tree it names:
81
+ * a trusted reference, a released shipped tree of this name or one of `references` (such as
82
+ * the current library tree), has exactly this version 1 fingerprint and is byte-identical to
83
+ * `candidate` by its version 2 fingerprint. The version 1 fingerprint alone never suffices.
84
+ */
85
+ export declare function provesLegacyFingerprint(name: string, legacyFingerprint: string, candidate: Pick<SkillTree, "fingerprint">, references?: readonly Pick<SkillTree, "fingerprint" | "legacyFingerprint">[], released?: readonly ReleasedSkillTree[]): boolean;
86
+ /**
87
+ * The version 2 fingerprint of the released shipped skill that a version 1 fingerprint names,
88
+ * or undefined. Only released trees qualify: the current shipped tree could share a version 1
89
+ * fingerprint with an older original without being that original.
90
+ */
91
+ export declare function releasedSkillFingerprint(name: string, legacyFingerprint: string, released?: readonly ReleasedSkillTree[]): string | undefined;
47
92
  export interface ReadSkillTreeOptions {
48
93
  /**
49
94
  * `skip` (default) ignores entries whose name starts with `.`, as for a source tree. `reject`
@@ -59,6 +59,77 @@ export class SkillTreeError extends Error {
59
59
  this.name = "SkillTreeError";
60
60
  }
61
61
  }
62
+ /** A version 2 skill tree fingerprint as it is stored. */
63
+ export const SKILL_TREE_FINGERPRINT_PATTERN = /^v2:[0-9a-f]{64}$/;
64
+ /** A version 1 fingerprint as 0.8.0 stored it: bare SHA-256 hex. */
65
+ export const LEGACY_SKILL_TREE_FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/;
66
+ const FINGERPRINT_DOMAIN = "pi-session-mcp skill tree v2\n";
67
+ function uint32(value) {
68
+ const bytes = Buffer.alloc(4);
69
+ bytes.writeUInt32BE(value);
70
+ return bytes;
71
+ }
72
+ function uint64(value) {
73
+ const bytes = Buffer.alloc(8);
74
+ bytes.writeBigUInt64BE(BigInt(value));
75
+ return bytes;
76
+ }
77
+ /**
78
+ * Version 2: a domain and version tag, the file count, then per file in byte order of the path
79
+ * the length-prefixed UTF-8 path and the length-prefixed content. Every field has an explicit
80
+ * length, so two different trees never share an encoding.
81
+ */
82
+ export function skillTreeFingerprint(files) {
83
+ const entries = files.map((file) => ({ path: Buffer.from(file.path, "utf8"), bytes: file.bytes }))
84
+ .sort((left, right) => Buffer.compare(left.path, right.path));
85
+ const hash = createHash("sha256").update(FINGERPRINT_DOMAIN, "utf8").update(uint32(entries.length));
86
+ entries.forEach((entry, index) => {
87
+ if (index > 0 && Buffer.compare(entries[index - 1].path, entry.path) === 0)
88
+ throw new SkillTreeError("skill_tree_invalid");
89
+ hash.update(uint32(entry.path.length)).update(entry.path).update(uint64(entry.bytes.length)).update(entry.bytes);
90
+ });
91
+ return `v2:${hash.digest("hex")}`;
92
+ }
93
+ /**
94
+ * Version 1, as 0.8.0 computed it: `path NUL content NUL` per file in reading order. File
95
+ * content may contain NUL bytes, so two different trees can share this value.
96
+ */
97
+ export function legacySkillTreeFingerprint(files) {
98
+ const hash = createHash("sha256");
99
+ for (const file of files)
100
+ hash.update(file.path).update("\0").update(file.bytes).update("\0");
101
+ return hash.digest("hex");
102
+ }
103
+ /**
104
+ * The shipped skills of every release that recorded version 1 fingerprints (0.8.0 only), as
105
+ * trusted references for recognizing such records. Computed from the `v0.8.0` tag and checked
106
+ * against the published npm package.
107
+ */
108
+ export const RELEASED_SKILL_TREES = Object.freeze([
109
+ Object.freeze({ name: "pi-session-orchestration", release: "0.8.0", legacyFingerprint: "e2a72b558ea14748a4badd0058ff65b8731cc125033a666706b103842784673a", fingerprint: "v2:39edc4c4783dec7f90345b22844a17e5e48b465722bb0f1b20059d6ee51e1523" }),
110
+ Object.freeze({ name: "pi-session-implementation", release: "0.8.0", legacyFingerprint: "5f719b44da266d3f66dd5bf3c18f3e3e04ca7df357e2fe2eedbf0409e6c1bf64", fingerprint: "v2:f8ce75eff83a5c952f6ca51c04e1e8025cb7c2c558c30d692f291a3f6594eb12" }),
111
+ Object.freeze({ name: "pi-session-review", release: "0.8.0", legacyFingerprint: "8cf58ccb8d473a3eedf12ce6b902b1e91dd9e2fef83ad110c4d8e2e6164cb257", fingerprint: "v2:610c0b4829eeb1ce6c9b2000ad73f8e88ca230707eef7f7b1708a80767952bf0" }),
112
+ ]);
113
+ /**
114
+ * Whether a version 1 fingerprint from a record proves that `candidate` is the tree it names:
115
+ * a trusted reference, a released shipped tree of this name or one of `references` (such as
116
+ * the current library tree), has exactly this version 1 fingerprint and is byte-identical to
117
+ * `candidate` by its version 2 fingerprint. The version 1 fingerprint alone never suffices.
118
+ */
119
+ export function provesLegacyFingerprint(name, legacyFingerprint, candidate, references = [], released = RELEASED_SKILL_TREES) {
120
+ if (!LEGACY_SKILL_TREE_FINGERPRINT_PATTERN.test(legacyFingerprint) || !SKILL_TREE_FINGERPRINT_PATTERN.test(candidate.fingerprint))
121
+ return false;
122
+ const trusted = [...released.filter((tree) => tree.name === name), ...references];
123
+ return trusted.some((tree) => tree.legacyFingerprint === legacyFingerprint && tree.fingerprint === candidate.fingerprint);
124
+ }
125
+ /**
126
+ * The version 2 fingerprint of the released shipped skill that a version 1 fingerprint names,
127
+ * or undefined. Only released trees qualify: the current shipped tree could share a version 1
128
+ * fingerprint with an older original without being that original.
129
+ */
130
+ export function releasedSkillFingerprint(name, legacyFingerprint, released = RELEASED_SKILL_TREES) {
131
+ return released.find((tree) => tree.name === name && tree.legacyFingerprint === legacyFingerprint)?.fingerprint;
132
+ }
62
133
  /**
63
134
  * Reads the regular files below a skill directory in a stable order. Entries whose name starts
64
135
  * with `.` are skipped or rejected (see options). A symbolic link or special file inside the
@@ -113,8 +184,5 @@ export async function readSkillTree(directory, options = {}) {
113
184
  await visit(root, "", 0);
114
185
  if (!files.some((file) => file.path === "SKILL.md"))
115
186
  throw new SkillTreeError("skill_tree_missing");
116
- const hash = createHash("sha256");
117
- for (const file of files)
118
- hash.update(file.path).update("\0").update(file.bytes).update("\0");
119
- return Object.freeze({ files: Object.freeze(files), fingerprint: hash.digest("hex") });
187
+ return Object.freeze({ files: Object.freeze(files), fingerprint: skillTreeFingerprint(files), legacyFingerprint: legacySkillTreeFingerprint(files) });
120
188
  }