syndes 0.1.0 → 0.3.0

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/sync/index.mjs ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The pool: publish our shelf, read everyone else's.
3
+ *
4
+ * This module is the whole of "sharing", and it is deliberately small because
5
+ * the hard parts were designed away rather than solved:
6
+ *
7
+ * • Nothing is merged. Peer data is never appended to our chain — it is read
8
+ * from the pool and displayed beside ours. The ledger keeps exactly one
9
+ * writer, forever, which is the invariant everything else here rests on.
10
+ * • Nothing conflicts. A device writes only its own shelf (see transport.mjs).
11
+ * • Nothing sensitive travels. The unit of sharing is a sanitised rollup, not
12
+ * a record (see share.mjs).
13
+ *
14
+ * A pull failure is not an error state. The pool is a cache of other people's
15
+ * numbers; when it cannot be reached, the last copy on disk is still true as of
16
+ * its own timestamp, and the UI says how old it is rather than going blank.
17
+ */
18
+
19
+ import { mkdirSync, existsSync, readdirSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { readFileSync } from 'node:fs';
22
+ import { teamDir, teamDeviceDir, teamStateFile, rollupFile, packageRoot } from '../runtime/paths.mjs';
23
+ import { loadConfig, rawConfig, saveConfig, setPath, readJson } from '../runtime/config.mjs';
24
+ import { identity } from '../runtime/identity.mjs';
25
+ import { loadRollup, coveredDays } from '../analytics/rollup.mjs';
26
+ import { localDay, shiftDay } from '../analytics/ranges.mjs';
27
+ import { sanitise, manifestFor } from './share.mjs';
28
+ import { encode as encodeInvite } from './invite.mjs';
29
+ import {
30
+ transportFor, listDevices, readJsonFile, writeJsonFile, removeFile, git,
31
+ } from './transport.mjs';
32
+ import { rmSync } from 'node:fs';
33
+ import { writeFileSync, renameSync } from 'node:fs';
34
+ import { dirname } from 'node:path';
35
+ import { debug } from '../runtime/log.mjs';
36
+
37
+ /**
38
+ * Where our own shelf lives inside the pool.
39
+ *
40
+ * Takes the root rather than assuming the default working copy: a folder pool
41
+ * IS the shared folder, and publishing into teamDir instead would write a
42
+ * perfectly correct shelf somewhere nobody is reading.
43
+ */
44
+ function shelfOf(root, deviceId) {
45
+ return teamDeviceDir(root, deviceId);
46
+ }
47
+
48
+ export function teamConfig() {
49
+ return loadConfig().team ?? {};
50
+ }
51
+
52
+ export function isEnabled() {
53
+ const team = teamConfig();
54
+ return Boolean(team.enabled && team.transport);
55
+ }
56
+
57
+ // ── Setup ───────────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Join or create a pool.
61
+ *
62
+ * @param {{transport: 'git'|'folder', repo: string, scope?: string}} options
63
+ */
64
+ export function join_(options) {
65
+ const transport = transportFor(options.transport);
66
+ if (!transport) throw new Error(`unknown transport: ${options.transport}`);
67
+ if (!options.repo) throw new Error('a repository url or folder path is required');
68
+
69
+ const root = options.transport === 'folder' ? options.repo : teamDir;
70
+ mkdirSync(root, { recursive: true });
71
+
72
+ const started = transport.init(root, { repo: options.repo });
73
+ if (!started.ok) throw new Error(started.error ?? 'could not reach the pool');
74
+
75
+ const next = rawConfig();
76
+ setPath(next, 'team.enabled', true);
77
+ setPath(next, 'team.transport', options.transport);
78
+ setPath(next, 'team.repo', options.repo);
79
+ if (options.scope) setPath(next, 'team.scope', options.scope);
80
+ saveConfig(next);
81
+
82
+ return { root, transport: transport.id, describe: transport.describe(root) };
83
+ }
84
+
85
+ export function leave() {
86
+ const next = rawConfig();
87
+ setPath(next, 'team.enabled', false);
88
+ saveConfig(next);
89
+ return true;
90
+ }
91
+
92
+ /** The working copy for the configured transport. A folder pool IS the folder. */
93
+ export function poolRoot() {
94
+ const team = teamConfig();
95
+ return team.transport === 'folder' && team.repo ? team.repo : teamDir;
96
+ }
97
+
98
+ // ── Publishing ──────────────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * Write our sanitised rollups onto our own shelf.
102
+ *
103
+ * Rewrites every day in the window rather than only today: a day that was still
104
+ * open at the last publish has since been finalised, and a stale copy of it in
105
+ * the pool would quietly under-report the person who left their laptop early.
106
+ *
107
+ * @returns {{written: number, removed: number, days: string[]}}
108
+ */
109
+ export function publish({ root = poolRoot() } = {}) {
110
+ const team = teamConfig();
111
+ const me = identity();
112
+ const scope = team.scope ?? 'summary';
113
+ const keepDays = team.shareDays ?? 45;
114
+
115
+ const shelf = shelfOf(root, me.deviceId);
116
+ const rollups = join(shelf, 'rollup');
117
+ mkdirSync(rollups, { recursive: true });
118
+
119
+ const oldest = shiftDay(localDay(Date.now()), -(keepDays - 1));
120
+ const days = coveredDays().filter((day) => day >= oldest);
121
+
122
+ let written = 0;
123
+ const published = [];
124
+ for (const day of days) {
125
+ const rollup = loadRollup(day);
126
+ if (!rollup) continue;
127
+ const clean = sanitise(rollup, scope);
128
+ if (!clean) continue;
129
+ writeJsonFile(join(rollups, `${day}.json`), clean);
130
+ published.push(day);
131
+ written += 1;
132
+ }
133
+
134
+ // Retire days that have aged out of the window, so a shelf is bounded rather
135
+ // than growing for as long as somebody keeps the tool installed.
136
+ let removed = 0;
137
+ try {
138
+ for (const name of readdirSync(rollups)) {
139
+ const day = name.replace(/\.json$/, '');
140
+ if (name.endsWith('.json') && day < oldest) { removeFile(join(rollups, name)); removed += 1; }
141
+ }
142
+ } catch { /* nothing published yet */ }
143
+
144
+ writeJsonFile(join(shelf, 'meta.json'), manifestFor(me, {
145
+ scope, version: version(), days: published.length,
146
+ }));
147
+
148
+ return { written, removed, days: published };
149
+ }
150
+
151
+ // ── Reading the pool ────────────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Every shelf in the pool, ours included.
155
+ *
156
+ * @returns {{deviceId, name, host, os, scope, lastSeen, isMe, rollups: Map<string,object>}[]}
157
+ */
158
+ export function readPool({ root = poolRoot(), days = null } = {}) {
159
+ const me = identity();
160
+ const wanted = days ? new Set(days) : null;
161
+ const peers = [];
162
+
163
+ for (const deviceId of listDevices(root)) {
164
+ const shelf = teamDeviceDir(root, deviceId);
165
+ const meta = readJsonFile(join(shelf, 'meta.json')) ?? {};
166
+ const rollups = new Map();
167
+
168
+ let names = [];
169
+ try { names = readdirSync(join(shelf, 'rollup')); } catch { names = []; }
170
+
171
+ for (const name of names) {
172
+ if (!name.endsWith('.json')) continue;
173
+ const day = name.slice(0, -5);
174
+ if (wanted && !wanted.has(day)) continue;
175
+ const rollup = readJsonFile(join(shelf, 'rollup', name));
176
+ if (rollup) rollups.set(day, rollup);
177
+ }
178
+
179
+ peers.push({
180
+ deviceId,
181
+ name: meta.name ?? deviceId.slice(0, 6),
182
+ host: meta.host ?? null,
183
+ os: meta.os ?? null,
184
+ scope: meta.scope ?? 'summary',
185
+ lastSeen: meta.lastSeen ?? null,
186
+ isMe: deviceId === me.deviceId,
187
+ rollups,
188
+ });
189
+ }
190
+
191
+ // Ours first, then most recently seen. A list that reorders itself as people
192
+ // work is unreadable; anchoring self at the top keeps it stable.
193
+ peers.sort((a, b) => Number(b.isMe) - Number(a.isMe) || (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
194
+ return peers;
195
+ }
196
+
197
+ /**
198
+ * An invite for this pool, for the owner to send.
199
+ *
200
+ * Carries the repository address and a label — never a credential. Who may join
201
+ * is decided by the repository's collaborator list, which is the only place that
202
+ * decision can be revoked from.
203
+ */
204
+ export function inviteCode({ from = null } = {}) {
205
+ const team = teamConfig();
206
+ if (!team.enabled || !team.repo) throw new Error('sharing is off — join or create a pool first');
207
+ return encodeInvite({
208
+ repo: team.repo,
209
+ transport: team.transport ?? 'git',
210
+ pool: poolName(),
211
+ from: from ?? identity().name,
212
+ });
213
+ }
214
+
215
+ /** A readable label for the pool, taken from the repository name. */
216
+ export function poolName() {
217
+ const repo = teamConfig().repo ?? '';
218
+ const last = repo.replace(/\.git$/, '').split(/[/\\:]/).filter(Boolean).pop();
219
+ return last || 'pool';
220
+ }
221
+
222
+ /**
223
+ * Remove someone's shelf from the pool.
224
+ *
225
+ * This is the ONE operation that deliberately writes outside our own directory,
226
+ * and it is an explicit administrative act rather than part of any sync. Git
227
+ * permissions are per-repository, so anyone with write access could already do
228
+ * this by hand; having a command for it means it is done correctly — the folder
229
+ * removed, the change pushed — instead of half-done.
230
+ *
231
+ * It removes them from view, not from history. Earlier commits still hold the
232
+ * data and anyone who pulled before still has a copy. The caller is expected to
233
+ * have said so before calling this.
234
+ *
235
+ * @returns {{removed: boolean, name: string|null, pushed: boolean, error: string|null}}
236
+ */
237
+ export function removeDevice(target, { push = true } = {}) {
238
+ const root = poolRoot();
239
+ const me = identity();
240
+
241
+ const peers = readPool({ root });
242
+ const match = peers.find((peer) => peer.deviceId === target)
243
+ ?? peers.find((peer) => peer.name?.toLowerCase() === String(target).toLowerCase());
244
+
245
+ if (!match) return { removed: false, name: null, pushed: false, error: `no machine called "${target}" in the pool` };
246
+ if (match.deviceId === me.deviceId) {
247
+ // Deleting your own shelf while still publishing would simply re-create it
248
+ // on the next sync, which looks like the command silently failed.
249
+ return { removed: false, name: match.name, pushed: false, error: 'that is this machine — use `syndes team leave` instead' };
250
+ }
251
+
252
+ const shelf = teamDeviceDir(root, match.deviceId);
253
+ const relative = `devices/${match.deviceId}`;
254
+
255
+ if ((teamConfig().transport ?? 'git') !== 'git') {
256
+ try { rmSync(shelf, { recursive: true, force: true }); } catch (error) { return { removed: false, name: match.name, pushed: false, error: error.message }; }
257
+ return { removed: true, name: match.name, pushed: true, error: null };
258
+ }
259
+
260
+ const removed = git(root, ['rm', '-r', '--quiet', '--', relative]);
261
+ if (!removed.ok) return { removed: false, name: match.name, pushed: false, error: removed.error };
262
+
263
+ const committed = git(root, ['commit', '-m', `syndes: remove ${match.name} from the pool`, '--', relative]);
264
+ if (!committed.ok) return { removed: true, name: match.name, pushed: false, error: committed.error };
265
+
266
+ if (!push) return { removed: true, name: match.name, pushed: false, error: null };
267
+
268
+ const transport = transportFor('git');
269
+ transport.pull(root);
270
+ const pushed = git(root, ['push', 'origin', 'HEAD'], 25_000);
271
+ return { removed: true, name: match.name, pushed: pushed.ok, error: pushed.ok ? null : pushed.error };
272
+ }
273
+
274
+ // ── One cycle ───────────────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Pull, publish, push. The whole exchange, and the only thing the poller calls.
278
+ *
279
+ * Pull comes first so our push rebases onto whatever arrived while we were away;
280
+ * publish comes second so the thing we push is current rather than one cycle
281
+ * behind, which is what makes the dashboard feel live rather than lagged.
282
+ */
283
+ export function syncOnce({ push = true } = {}) {
284
+ if (!isEnabled()) return { ok: false, reason: 'sharing is off', at: Date.now() };
285
+
286
+ const team = teamConfig();
287
+ const transport = transportFor(team.transport);
288
+ const root = poolRoot();
289
+ if (!transport) return { ok: false, reason: 'unknown transport', at: Date.now() };
290
+
291
+ const pulled = transport.pull(root, { repo: team.repo });
292
+ const published = publish({ root });
293
+
294
+ let pushed = { ok: true, pushed: false, error: null };
295
+ if (push) {
296
+ pushed = transport.push(root, {
297
+ deviceId: identity().deviceId,
298
+ message: `syndes: ${identity().name} through ${localDay(Date.now())}`,
299
+ });
300
+ }
301
+
302
+ const state = {
303
+ ok: pulled.ok && pushed.ok,
304
+ at: Date.now(),
305
+ pulled: pulled.ok,
306
+ pushed: pushed.pushed,
307
+ published: published.written,
308
+ error: pulled.error ?? pushed.error ?? null,
309
+ };
310
+ saveState(state);
311
+ debug('team sync', state);
312
+ return state;
313
+ }
314
+
315
+ export function loadState() {
316
+ return readJson(teamStateFile).data ?? { ok: null, at: null, error: null };
317
+ }
318
+
319
+ function saveState(state) {
320
+ try {
321
+ mkdirSync(dirname(teamStateFile), { recursive: true });
322
+ const staging = `${teamStateFile}.tmp`;
323
+ writeFileSync(staging, `${JSON.stringify(state)}\n`);
324
+ renameSync(staging, teamStateFile);
325
+ } catch { /* the sync happened whether or not we could note it */ }
326
+ }
327
+
328
+ function version() {
329
+ try {
330
+ return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ export { join_ as joinPool, sanitise };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Invites: one string that carries everything a new machine needs to join.
3
+ *
4
+ * The alternative is a paragraph of instructions — "install this, then run that,
5
+ * paste this URL, use your own GitHub" — which is where onboarding goes to die.
6
+ * An invite collapses it into a line the inviter copies and the joiner pastes.
7
+ *
8
+ * ── An invite is NOT a credential ──────────────────────────────────────────
9
+ * It carries a repository address and a label, nothing else. Access is decided
10
+ * entirely by whether GitHub lets that person in, which the repository owner
11
+ * controls with collaborator permissions and can revoke. Encoding a token here
12
+ * would mean the inviter's own credentials travelling through a chat app, which
13
+ * is exactly the practice this tool should not be teaching. So the invite is
14
+ * base64 for tidiness, not for secrecy, and it is safe to say so out loud.
15
+ */
16
+
17
+ const PREFIX = 'syndes1_';
18
+ const VERSION = 1;
19
+
20
+ /**
21
+ * @param {{repo: string, transport: 'git'|'folder', pool?: string, from?: string}} details
22
+ * @returns {string}
23
+ */
24
+ export function encode({ repo, transport = 'git', pool = null, from = null }) {
25
+ if (!repo) throw new Error('an invite needs a repository');
26
+ const body = { v: VERSION, r: repo, t: transport };
27
+ if (pool) body.p = pool.slice(0, 60);
28
+ if (from) body.f = from.slice(0, 40);
29
+ return PREFIX + Buffer.from(JSON.stringify(body), 'utf8').toString('base64url');
30
+ }
31
+
32
+ /**
33
+ * Decode, tolerating what people actually paste — surrounding quotes, a stray
34
+ * backtick from a chat app, the whole command line rather than just the code.
35
+ *
36
+ * @returns {{repo, transport, pool, from}|null} null when it is not an invite
37
+ */
38
+ export function decode(input) {
39
+ if (typeof input !== 'string') return null;
40
+
41
+ let text = input.trim().replace(/^[`'"]+|[`'"]+$/g, '');
42
+ const found = /syndes1_[A-Za-z0-9_-]+/.exec(text);
43
+ if (found) text = found[0];
44
+ if (!text.startsWith(PREFIX)) return null;
45
+
46
+ try {
47
+ const body = JSON.parse(Buffer.from(text.slice(PREFIX.length), 'base64url').toString('utf8'));
48
+ if (body.v !== VERSION || typeof body.r !== 'string' || !body.r) return null;
49
+ return {
50
+ repo: body.r,
51
+ transport: body.t === 'folder' ? 'folder' : 'git',
52
+ pool: typeof body.p === 'string' ? body.p : null,
53
+ from: typeof body.f === 'string' ? body.f : null,
54
+ };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /** The line an inviter sends. Two steps, because the install must be global. */
61
+ export function commandFor(code) {
62
+ return `npm install -g syndes && syndes join ${code}`;
63
+ }
64
+
65
+ /** True for anything that looks like an invite, so `join` can accept a raw URL too. */
66
+ export function looksLikeInvite(input) {
67
+ return typeof input === 'string' && input.includes(PREFIX);
68
+ }
69
+
70
+ export { PREFIX };
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One sync cycle, in its own process.
4
+ *
5
+ * git talks to a network and the transport calls it synchronously, which is
6
+ * correct for a CLI and unacceptable inside the dashboard's event loop — a
7
+ * twenty-five second timeout on a slow remote would freeze every other request
8
+ * on the server, including the one drawing the page that asked for the sync.
9
+ *
10
+ * So the poller spawns this instead of calling syncOnce directly. The child
11
+ * blocks; the server does not.
12
+ */
13
+
14
+ import { syncOnce } from './index.mjs';
15
+
16
+ try {
17
+ const result = syncOnce({ push: !process.argv.includes('--no-push') });
18
+ process.stdout.write(JSON.stringify(result));
19
+ } catch (error) {
20
+ process.stdout.write(JSON.stringify({ ok: false, at: Date.now(), error: String(error?.message ?? error) }));
21
+ }
package/sync/share.mjs ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * What leaves the machine, and what never does.
3
+ *
4
+ * The local ledger holds prompts, file paths and shell commands. A shared pool
5
+ * is, by definition, somewhere other people can read — so the unit of sharing is
6
+ * NOT the ledger. It is the daily rollup, with the identifying fields removed
7
+ * before it is written.
8
+ *
9
+ * That choice is deliberate on three counts:
10
+ * • A rollup is already a count of things, not a copy of them. There is no
11
+ * prompt text in it to leak, because none was ever folded in.
12
+ * • It is a few kilobytes a day, so a poll every thirty seconds is free.
13
+ * • It carries every number the team view needs, so nothing is lost by
14
+ * refusing to ship the records themselves.
15
+ *
16
+ * Scopes:
17
+ * summary (default) counts, tokens, time, hours. No paths, no project ids,
18
+ * no per-tool breakdown.
19
+ * detailed adds project ids and the tool mix, for teams who have agreed to
20
+ * it. Still never prompt text, a file path, a command, or a session
21
+ * id — those are refused at every scope, because a scope is a dial
22
+ * for detail and not a way to opt out of the promise.
23
+ *
24
+ * Sanitisation is SUBTRACTIVE ONLY. A key is dropped, never rewritten, so a
25
+ * reader can always tell "zero" from "not shared". Where a count has to survive
26
+ * an id being removed it is added under a new `…Count` name rather than
27
+ * overwriting the field it came from.
28
+ *
29
+ * Paths may name a nested key with `*` for "every entry", because the leak this
30
+ * module exists to prevent hid one level down: volume.bySource carries a
31
+ * per-agent breakdown, and each of its buckets carried the session ids.
32
+ */
33
+
34
+ /** Refused at every scope. Nothing on this list is ever shared. */
35
+ const NEVER = {
36
+ volume: ['files', 'sessions', 'bySource.*.sessions'],
37
+ tools: ['targets', 'commands'],
38
+ rework: ['churn', 'storms'],
39
+ friction: ['byTarget'],
40
+ context: ['sessionsWithCompact'],
41
+ };
42
+
43
+ /** Dropped as well at `summary` — useful detail, but it describes your work. */
44
+ const SUMMARY_ONLY = {
45
+ volume: ['projects', 'bySource'],
46
+ tools: ['byTool'],
47
+ friction: ['byTool'],
48
+ };
49
+
50
+ /**
51
+ * @param {object} rollup from analytics/rollup.mjs buildDay()
52
+ * @param {'summary'|'detailed'} scope
53
+ * @returns {object|null} a rollup of the same shape, with fields removed
54
+ */
55
+ export function sanitise(rollup, scope = 'summary') {
56
+ if (!rollup?.metrics) return null;
57
+
58
+ const metrics = {};
59
+ for (const [name, metric] of Object.entries(rollup.metrics)) {
60
+ const paths = scope === 'detailed'
61
+ ? (NEVER[name] ?? [])
62
+ : [...(NEVER[name] ?? []), ...(SUMMARY_ONLY[name] ?? [])];
63
+ metrics[name] = strip(metric, paths);
64
+ }
65
+
66
+ // Counts survive their ids. "Four sessions" is what the team view divides by
67
+ // and it says nothing about which four.
68
+ if (rollup.metrics.volume) {
69
+ metrics.volume.sessionCount = (rollup.metrics.volume.sessions ?? []).length;
70
+ metrics.volume.projectCount = (rollup.metrics.volume.projects ?? []).length;
71
+ metrics.volume.fileCount = (rollup.metrics.volume.files ?? []).length;
72
+ }
73
+ if (rollup.metrics.context) {
74
+ metrics.context.sessionsWithCompactCount = (rollup.metrics.context.sessionsWithCompact ?? []).length;
75
+ }
76
+ if (rollup.metrics.rework) {
77
+ // The storms themselves name a command and never travel; the count does,
78
+ // because the score reads it and a missing one reads as "no storms".
79
+ metrics.rework.stormCount = (rollup.metrics.rework.storms ?? []).length;
80
+ }
81
+
82
+ return {
83
+ day: rollup.day,
84
+ version: rollup.version,
85
+ records: rollup.records,
86
+ builtAt: rollup.builtAt,
87
+ final: rollup.final,
88
+ scope,
89
+ metrics,
90
+ };
91
+ }
92
+
93
+ /** Remove each path, copying only as deep as a removal actually reaches. */
94
+ function strip(object, paths) {
95
+ if (!object || typeof object !== 'object') return object;
96
+ let out = { ...object };
97
+ for (const path of paths) out = drop(out, path.split('.'));
98
+ return out;
99
+ }
100
+
101
+ function drop(node, segments) {
102
+ if (!node || typeof node !== 'object') return node;
103
+ const [head, ...rest] = segments;
104
+
105
+ if (!rest.length) {
106
+ if (head === '*') return node;
107
+ const out = { ...node };
108
+ delete out[head];
109
+ return out;
110
+ }
111
+
112
+ if (head === '*') {
113
+ const out = Array.isArray(node) ? [...node] : { ...node };
114
+ for (const key of Object.keys(out)) out[key] = drop(out[key], rest);
115
+ return out;
116
+ }
117
+
118
+ if (!(head in node)) return node;
119
+ return { ...node, [head]: drop(node[head], rest) };
120
+ }
121
+
122
+ /**
123
+ * The card a device leaves beside its rollups.
124
+ *
125
+ * Written on every publish rather than once at enrolment, so `lastSeen` is
126
+ * meaningful: a shelf that has stopped being updated is visibly stale rather
127
+ * than silently indistinguishable from a quiet day.
128
+ */
129
+ export function manifestFor(identity, { scope, version, days }) {
130
+ return {
131
+ deviceId: identity.deviceId,
132
+ name: identity.name,
133
+ host: identity.host,
134
+ os: identity.os,
135
+ scope,
136
+ version,
137
+ days,
138
+ firstSeen: identity.createdAt,
139
+ lastSeen: Date.now(),
140
+ };
141
+ }
142
+
143
+ export { NEVER, SUMMARY_ONLY };