tinydecide 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +255 -2
- package/bin/tinydecide.js +253 -0
- package/examples/support-api.js +118 -0
- package/package.json +56 -4
- package/src/compile.js +265 -0
- package/src/features.js +128 -0
- package/src/hash.js +87 -0
- package/src/index.js +5 -0
- package/src/log.js +55 -0
- package/src/model.js +154 -0
- package/src/node.js +210 -0
- package/src/shadow.js +415 -0
- package/src/stats.js +78 -0
- package/src/store.js +21 -0
- package/src/train.js +129 -0
- package/src/worker.js +12 -0
- package/types/index.d.ts +287 -0
package/src/model.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The compiled model: hashed feature rows (int8, one scale per row) → two mean pools → linear head.
|
|
2
|
+
// A .tdm file is: "TDM1", u32 header length, JSON header (padded to 4 bytes), then the arrays.
|
|
3
|
+
|
|
4
|
+
import { FEATURES_VERSION, Featurizer } from './features.js';
|
|
5
|
+
import { U32Map } from './hash.js';
|
|
6
|
+
|
|
7
|
+
const MAGIC = [0x54, 0x44, 0x4d, 0x31]; // TDM1
|
|
8
|
+
|
|
9
|
+
export class Model {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} p
|
|
12
|
+
* @param {string[]} p.labels
|
|
13
|
+
* @param {number} p.d row width
|
|
14
|
+
* @param {Uint32Array} p.keys feature hash per row
|
|
15
|
+
* @param {Float32Array} p.scales per-row dequantization scale
|
|
16
|
+
* @param {Int8Array} p.q rows, keys.length × d
|
|
17
|
+
* @param {Float32Array} p.W head, labels.length × d
|
|
18
|
+
* @param {Float32Array} p.b bias, labels.length
|
|
19
|
+
* @param {number} [p.T] temperature for reported confidence
|
|
20
|
+
* @param {number} [p.cutoff] answer locally when the top-two logit margin ≥ cutoff
|
|
21
|
+
* @param {object} [p.meta] certificate and compile report summary
|
|
22
|
+
*/
|
|
23
|
+
constructor({ labels, d, keys, scales, q, W, b, T = 1, cutoff = Infinity, meta = {} }) {
|
|
24
|
+
Object.assign(this, { labels, d, keys, scales, q, W, b, T, cutoff, meta });
|
|
25
|
+
this.K = labels.length;
|
|
26
|
+
this.index = new U32Map(keys.length);
|
|
27
|
+
for (let i = 0; i < keys.length; i++) this.index.set(keys[i], i);
|
|
28
|
+
this.fz = new Featurizer();
|
|
29
|
+
this.h = new Float32Array(d);
|
|
30
|
+
this.tmp = new Float32Array(d);
|
|
31
|
+
this.z = new Float32Array(this.K);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Raw logits for an input. The returned array is reused by the next call. */
|
|
35
|
+
logits(input) {
|
|
36
|
+
const { w, c } = this.fz.run(input);
|
|
37
|
+
const { d, K, h, z, W, b } = this;
|
|
38
|
+
h.fill(0);
|
|
39
|
+
this.#pool(w); this.#pool(c);
|
|
40
|
+
for (let k = 0; k < K; k++) {
|
|
41
|
+
let s = b[k];
|
|
42
|
+
const o = k * d;
|
|
43
|
+
for (let j = 0; j < d; j++) s += W[o + j] * h[j];
|
|
44
|
+
z[k] = s;
|
|
45
|
+
}
|
|
46
|
+
return z;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#pool(feats) {
|
|
50
|
+
const { d, index, q, scales, h, tmp } = this;
|
|
51
|
+
tmp.fill(0);
|
|
52
|
+
let n = 0;
|
|
53
|
+
for (let i = 0; i < feats.length; i++) {
|
|
54
|
+
const r = index.get(feats[i]);
|
|
55
|
+
if (r < 0) continue;
|
|
56
|
+
n++;
|
|
57
|
+
const s = scales[r], o = r * d;
|
|
58
|
+
for (let j = 0; j < d; j++) tmp[j] += q[o + j] * s;
|
|
59
|
+
}
|
|
60
|
+
if (n) for (let j = 0; j < d; j++) h[j] += tmp[j] / n;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @returns {{ choice: string, index: number, confidence: number, margin: number, local: boolean, probs?: Record<string, number> }}
|
|
65
|
+
*/
|
|
66
|
+
decide(input, { probs = false } = {}) {
|
|
67
|
+
const z = this.logits(input);
|
|
68
|
+
const { top, second } = topTwo(z);
|
|
69
|
+
const margin = second < 0 ? Infinity : z[top] - z[second];
|
|
70
|
+
const p = softmax(z, this.T);
|
|
71
|
+
const out = { choice: this.labels[top], index: top, confidence: p[top], margin, local: margin >= this.cutoff };
|
|
72
|
+
if (probs) out.probs = Object.fromEntries(this.labels.map((l, k) => [l, p[k]]));
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A copy that answers with a different cutoff (e.g. Infinity to send everything to your API). */
|
|
77
|
+
withCutoff(cutoff) {
|
|
78
|
+
return new Model({ ...this, cutoff });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get bytes() {
|
|
82
|
+
return this.keys.byteLength + this.scales.byteLength + this.q.byteLength + this.W.byteLength + this.b.byteLength;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
toBytes() {
|
|
86
|
+
const header = new TextEncoder().encode(JSON.stringify({
|
|
87
|
+
format: 1, features: FEATURES_VERSION, labels: this.labels, d: this.d, n: this.keys.length,
|
|
88
|
+
T: this.T, cutoff: Number.isFinite(this.cutoff) ? this.cutoff : null, meta: this.meta,
|
|
89
|
+
}));
|
|
90
|
+
const hlen = (header.length + 3) & ~3;
|
|
91
|
+
const parts = [this.keys, this.scales, this.W, this.b, this.q];
|
|
92
|
+
const body = parts.reduce((s, a) => s + a.byteLength, 0);
|
|
93
|
+
const out = new Uint8Array(8 + hlen + body);
|
|
94
|
+
out.set(MAGIC, 0);
|
|
95
|
+
new DataView(out.buffer).setUint32(4, header.length, true);
|
|
96
|
+
out.set(header, 8);
|
|
97
|
+
out.fill(0x20, 8 + header.length, 8 + hlen);
|
|
98
|
+
let o = 8 + hlen;
|
|
99
|
+
for (const a of parts) { out.set(new Uint8Array(a.buffer, a.byteOffset, a.byteLength), o); o += a.byteLength; }
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static fromBytes(bytes) {
|
|
104
|
+
const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
105
|
+
if (MAGIC.some((m, i) => u8[i] !== m)) throw new Error('not a tinydecide model (bad magic)');
|
|
106
|
+
const len = new DataView(u8.buffer, u8.byteOffset, u8.byteLength).getUint32(4, true);
|
|
107
|
+
const h = JSON.parse(new TextDecoder().decode(u8.subarray(8, 8 + len)));
|
|
108
|
+
if (h.format !== 1) throw new Error(`unsupported model format ${h.format}`);
|
|
109
|
+
if (h.features !== FEATURES_VERSION) throw new Error(`model needs featurizer v${h.features}, this build has v${FEATURES_VERSION}`);
|
|
110
|
+
const K = h.labels.length;
|
|
111
|
+
// Copy the body into a fresh buffer: typed-array views need alignment, and a Node Buffer's
|
|
112
|
+
// .slice() is a view into a shared pool, not a copy.
|
|
113
|
+
const body = new Uint8Array(u8.byteLength - (8 + ((len + 3) & ~3)));
|
|
114
|
+
body.set(u8.subarray(8 + ((len + 3) & ~3)));
|
|
115
|
+
const buf = body.buffer;
|
|
116
|
+
let o = 0;
|
|
117
|
+
const take = (Ctor, n) => { const a = new Ctor(buf, o, n); o += n * Ctor.BYTES_PER_ELEMENT; return a; };
|
|
118
|
+
const keys = take(Uint32Array, h.n), scales = take(Float32Array, h.n);
|
|
119
|
+
const W = take(Float32Array, K * h.d), b = take(Float32Array, K), q = take(Int8Array, h.n * h.d);
|
|
120
|
+
return new Model({ labels: h.labels, d: h.d, keys, scales, q, W, b, T: h.T, cutoff: h.cutoff ?? Infinity, meta: h.meta });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function topTwo(z) {
|
|
125
|
+
let top = 0, second = -1;
|
|
126
|
+
for (let k = 1; k < z.length; k++) {
|
|
127
|
+
if (z[k] > z[top]) { second = top; top = k; } else if (second < 0 || z[k] > z[second]) second = k;
|
|
128
|
+
}
|
|
129
|
+
return { top, second };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function softmax(z, T = 1) {
|
|
133
|
+
let m = -Infinity;
|
|
134
|
+
for (let k = 0; k < z.length; k++) if (z[k] > m) m = z[k];
|
|
135
|
+
const p = new Float64Array(z.length);
|
|
136
|
+
let s = 0;
|
|
137
|
+
for (let k = 0; k < z.length; k++) s += p[k] = Math.exp((z[k] - m) / T);
|
|
138
|
+
for (let k = 0; k < z.length; k++) p[k] /= s;
|
|
139
|
+
return p;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Per-row symmetric int8 quantization of float training parameters. */
|
|
143
|
+
export function quantize({ labels, d, keys, E, W, b }, extra = {}) {
|
|
144
|
+
const n = keys.length;
|
|
145
|
+
const q = new Int8Array(n * d), scales = new Float32Array(n);
|
|
146
|
+
for (let r = 0; r < n; r++) {
|
|
147
|
+
let m = 0;
|
|
148
|
+
for (let j = 0; j < d; j++) m = Math.max(m, Math.abs(E[r * d + j]));
|
|
149
|
+
const s = m / 127 || 1;
|
|
150
|
+
scales[r] = m ? s : 0;
|
|
151
|
+
for (let j = 0; j < d; j++) q[r * d + j] = Math.round(E[r * d + j] / s);
|
|
152
|
+
}
|
|
153
|
+
return new Model({ labels, d, keys, scales, q, W: Float32Array.from(W), b: Float32Array.from(b), ...extra });
|
|
154
|
+
}
|
package/src/node.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The Node entry. Same API as the browser build, plus:
|
|
2
|
+
// - shadow('name', api) keeps answers, the model and its state in .tinydecide/name/, so a restart
|
|
3
|
+
// resumes where it left off
|
|
4
|
+
// - compiles train in a worker thread, off your server's event loop
|
|
5
|
+
|
|
6
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { Worker } from 'node:worker_threads';
|
|
9
|
+
import { gunzipSync } from 'node:zlib';
|
|
10
|
+
import { compile } from './compile.js';
|
|
11
|
+
import { logger } from './log.js';
|
|
12
|
+
import { Model } from './model.js';
|
|
13
|
+
import { NAME, shadow as core, validate } from './shadow.js';
|
|
14
|
+
import { memoryStore } from './store.js';
|
|
15
|
+
import { abortError } from './train.js';
|
|
16
|
+
|
|
17
|
+
export * from './index.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* shadow('support', api, options): in Node, a named decision is saved under `dir` (default
|
|
21
|
+
* .tinydecide, or $TINYDECIDE_DIR) and resumes after a restart.
|
|
22
|
+
*/
|
|
23
|
+
export function shadow(name, api, options) {
|
|
24
|
+
if (typeof name === 'function') return core(name, { compiler: compileInWorker, ...api });
|
|
25
|
+
const { dir = process.env.TINYDECIDE_DIR || '.tinydecide', ...rest } = options ?? {};
|
|
26
|
+
if (typeof name !== 'string' || !NAME.test(name)) return core(name, api, rest); // core explains the error
|
|
27
|
+
validate(name, api, rest); // before anything touches the disk
|
|
28
|
+
let store = rest.store;
|
|
29
|
+
if (!store) {
|
|
30
|
+
try {
|
|
31
|
+
store = fileStore(join(dir, name));
|
|
32
|
+
} catch (err) {
|
|
33
|
+
// A folder that can't be written, or that another process holds, must not stop the app.
|
|
34
|
+
store = memoryStore();
|
|
35
|
+
logger(name, rest.log ?? true)({ type: 'error', where: `opening ${join(dir, name)}`, error: `${err.message}. Answers stay in memory.` });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return core(name, api, { compiler: compileInWorker, ...rest, store });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Keeps one decision in a folder:
|
|
43
|
+
* answers.jsonl your API's recent answers (trimmed to the newest `limit`)
|
|
44
|
+
* model.tdm the installed model, and report.json, its compile report
|
|
45
|
+
* state.json present only while guarded
|
|
46
|
+
* lock the pid of the process using the folder; a second store on it throws
|
|
47
|
+
* Writes of the model and state are atomic.
|
|
48
|
+
*/
|
|
49
|
+
export function fileStore(dir, { limit = 100_000 } = {}) {
|
|
50
|
+
const root = dirname(dir);
|
|
51
|
+
const fresh = !existsSync(root);
|
|
52
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
53
|
+
// The log holds raw inputs, so keep the whole folder out of git unless someone opts in.
|
|
54
|
+
if (fresh) safeWrite(join(root, '.gitignore'), '# created by tinydecide: answers.jsonl holds raw inputs\n*\n');
|
|
55
|
+
const path = { answers: join(dir, 'answers.jsonl'), model: join(dir, 'model.tdm'), report: join(dir, 'report.json'), state: join(dir, 'state.json'), lock: resolve(dir, 'lock') };
|
|
56
|
+
takeLock(path.lock);
|
|
57
|
+
let fd = -1, size = 0;
|
|
58
|
+
|
|
59
|
+
function open() {
|
|
60
|
+
size = 0;
|
|
61
|
+
if (existsSync(path.answers)) {
|
|
62
|
+
const buf = readFileSync(path.answers);
|
|
63
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 10) size++;
|
|
64
|
+
// A crash mid-write can leave a partial last line; end it so the next answer starts clean.
|
|
65
|
+
if (buf.length && buf[buf.length - 1] !== 10) writeFileSync(path.answers, '\n', { flag: 'a' });
|
|
66
|
+
}
|
|
67
|
+
fd = openSync(path.answers, 'a', 0o600);
|
|
68
|
+
}
|
|
69
|
+
open();
|
|
70
|
+
|
|
71
|
+
/** Keeps the newest `limit` lines. Scans bytes for newlines, so nothing is parsed. */
|
|
72
|
+
function trim() {
|
|
73
|
+
const buf = readFileSync(path.answers);
|
|
74
|
+
let at = buf.length - 1; // the newline that ends the last line
|
|
75
|
+
for (let k = 0; k < limit && at > 0; k++) at = buf.lastIndexOf(10, at - 1);
|
|
76
|
+
closeSync(fd); // first: Windows will not rename over a file that is still open
|
|
77
|
+
fd = -1;
|
|
78
|
+
try {
|
|
79
|
+
atomicWrite(path.answers, buf.subarray(at + 1));
|
|
80
|
+
} finally {
|
|
81
|
+
open();
|
|
82
|
+
size = Math.min(size, limit); // after a failure, wait another quarter of `limit` before retrying
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
dir,
|
|
88
|
+
append(record) {
|
|
89
|
+
writeSync(fd, JSON.stringify(record) + '\n');
|
|
90
|
+
if (++size > limit * 1.25) trim();
|
|
91
|
+
},
|
|
92
|
+
load: () => readJsonl(path.answers).slice(-limit),
|
|
93
|
+
get size() { return size; },
|
|
94
|
+
loadModel: () => (existsSync(path.model) ? Model.fromBytes(readFileSync(path.model)) : null),
|
|
95
|
+
saveModel(model, report) {
|
|
96
|
+
atomicWrite(path.model, model.toBytes());
|
|
97
|
+
atomicWrite(path.report, JSON.stringify(report, null, 2) + '\n');
|
|
98
|
+
},
|
|
99
|
+
loadState() {
|
|
100
|
+
if (!existsSync(path.state)) return null;
|
|
101
|
+
try { return JSON.parse(readFileSync(path.state, 'utf8')); } catch { return null; }
|
|
102
|
+
},
|
|
103
|
+
saveState(state) {
|
|
104
|
+
if (state) atomicWrite(path.state, JSON.stringify(state) + '\n');
|
|
105
|
+
else rmSync(path.state, { force: true });
|
|
106
|
+
},
|
|
107
|
+
close() {
|
|
108
|
+
if (fd < 0) return;
|
|
109
|
+
closeSync(fd);
|
|
110
|
+
fd = -1;
|
|
111
|
+
rmSync(path.lock, { force: true });
|
|
112
|
+
held.delete(path.lock);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const held = new Set(); // lock files this process holds
|
|
118
|
+
|
|
119
|
+
/** One process per folder: a second writer would lose answers when the log is trimmed. */
|
|
120
|
+
function takeLock(path) {
|
|
121
|
+
if (held.has(path)) throw new Error(`${dirname(path)} is already open in this process`);
|
|
122
|
+
try {
|
|
123
|
+
writeFileSync(path, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (err.code !== 'EEXIST') throw err;
|
|
126
|
+
const pid = Number(readFileSync(path, 'utf8'));
|
|
127
|
+
// Our own pid here means an earlier run left it, as when a container restarts with the same pid.
|
|
128
|
+
if (pid && pid !== process.pid && alive(pid)) {
|
|
129
|
+
throw new Error(`${dirname(path)} is in use by process ${pid}. Use one process per folder, or delete ${path} if that process is not using it`);
|
|
130
|
+
}
|
|
131
|
+
rmSync(path, { force: true }); // stale: the process that wrote it is gone
|
|
132
|
+
writeFileSync(path, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
133
|
+
}
|
|
134
|
+
held.add(path);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function alive(pid) {
|
|
138
|
+
try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let workerBroken = false;
|
|
142
|
+
|
|
143
|
+
/** compile() in a worker thread. Falls back to the calling thread if workers are unavailable. */
|
|
144
|
+
function compileInWorker(rows, options = {}) {
|
|
145
|
+
const { signal, onProgress, background, ...rest } = options;
|
|
146
|
+
if (workerBroken) return compile(rows, { ...rest, signal, onProgress });
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
if (signal?.aborted) return reject(abortError());
|
|
149
|
+
let settled = false;
|
|
150
|
+
const done = (fn, v) => { if (!settled) { settled = true; signal?.removeEventListener('abort', abort); fn(v); } };
|
|
151
|
+
let worker;
|
|
152
|
+
try {
|
|
153
|
+
worker = new Worker(new URL('./worker.js', import.meta.url), { workerData: { rows, options: rest } });
|
|
154
|
+
} catch {
|
|
155
|
+
workerBroken = true;
|
|
156
|
+
return compile(rows, { ...rest, signal, onProgress }).then(resolve, reject);
|
|
157
|
+
}
|
|
158
|
+
if (background) worker.unref(); // an automatic compile never keeps a finished process alive
|
|
159
|
+
function abort() { worker.terminate(); done(reject, abortError()); }
|
|
160
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
161
|
+
worker.once('message', (m) => {
|
|
162
|
+
if (m.error) return done(reject, Object.assign(new Error(m.error.message), { name: m.error.name }));
|
|
163
|
+
done(resolve, { model: Model.fromBytes(m.bytes), report: m.report });
|
|
164
|
+
});
|
|
165
|
+
let fallback = false;
|
|
166
|
+
worker.once('error', (err) => {
|
|
167
|
+
// The worker could not start (a bundler that drops worker.js, say): compile here instead.
|
|
168
|
+
if (['ERR_WORKER_PATH', 'ERR_MODULE_NOT_FOUND', 'ERR_WORKER_INIT_FAILED'].includes(err?.code)) {
|
|
169
|
+
workerBroken = fallback = true;
|
|
170
|
+
compile(rows, { ...rest, signal, onProgress }).then((v) => done(resolve, v), (e) => done(reject, e));
|
|
171
|
+
} else done(reject, err);
|
|
172
|
+
});
|
|
173
|
+
worker.once('exit', (code) => { if (!fallback) done(reject, new Error(`the compile worker exited with code ${code}`)); });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Reads a .jsonl or .jsonl.gz file. Lines that are not valid JSON (a crash mid-write) are skipped. */
|
|
178
|
+
export function readJsonl(path, { onSkip } = {}) {
|
|
179
|
+
if (!existsSync(path)) return [];
|
|
180
|
+
let buf = readFileSync(path);
|
|
181
|
+
if (path.endsWith('.gz')) buf = gunzipSync(buf);
|
|
182
|
+
const out = [];
|
|
183
|
+
let line = 0;
|
|
184
|
+
for (const raw of buf.toString('utf8').split('\n')) {
|
|
185
|
+
line++;
|
|
186
|
+
const s = raw.trim();
|
|
187
|
+
if (!s) continue;
|
|
188
|
+
try { out.push(JSON.parse(s)); } catch { onSkip?.(line); }
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function loadModel(path) {
|
|
194
|
+
return Model.fromBytes(readFileSync(path));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function saveModel(path, model) {
|
|
198
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
199
|
+
atomicWrite(path, model.toBytes());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function atomicWrite(path, data) {
|
|
203
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
204
|
+
writeFileSync(tmp, data, { mode: 0o600 });
|
|
205
|
+
renameSync(tmp, path);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function safeWrite(path, data) {
|
|
209
|
+
try { writeFileSync(path, data, { flag: 'wx' }); } catch {}
|
|
210
|
+
}
|