@twinsearth/w2m-dsh-plugin 0.9.7 → 0.10.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/CHANGELOG.md +273 -0
- package/ROADMAP.md +135 -0
- package/package.json +11 -4
- package/src/agent/agent.mjs +38 -32
- package/src/agent/approval-index.mjs +300 -0
- package/src/agent/authority.mjs +305 -0
- package/src/agent/blocks.mjs +426 -0
- package/src/agent/health.mjs +330 -0
- package/src/agent/storage.mjs +380 -0
- package/src/plugin/tools.mjs +1 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiered storage: temporary, compressed, permanent — and the two kinds of knowledge that persist forever.
|
|
3
|
+
*
|
|
4
|
+
* ## Why tiers rather than one directory
|
|
5
|
+
*
|
|
6
|
+
* The Linux host reached 100% filesystem today from a single uncapped writer at 14.7 GB/day, and the
|
|
7
|
+
* recovery cost real time. The lesson was not "delete more" — it was that **data with different lifetimes
|
|
8
|
+
* was being stored in one place with one policy**, so the only available policy was "never delete" until it
|
|
9
|
+
* was "delete under pressure", which is exactly the wrong moment to be deciding what matters.
|
|
10
|
+
*
|
|
11
|
+
* So a file's tier is written down when it is stored, and the tier alone decides what may happen to it:
|
|
12
|
+
*
|
|
13
|
+
* | Tier | Lifetime | May it be deleted? |
|
|
14
|
+
* |---|---|---|
|
|
15
|
+
* | `temp` | a stated TTL | yes, automatically, once expired |
|
|
16
|
+
* | `compressed` | until the archive is no longer wanted | only whole-archive, never partially |
|
|
17
|
+
* | `permanent` | forever | **no** — and a caller asking to is refused, not obeyed |
|
|
18
|
+
* | `skill` | forever | **no** — a capability, not data |
|
|
19
|
+
* | `law` | forever | **no** — a rule, and rules are amended, never deleted |
|
|
20
|
+
*
|
|
21
|
+
* ## Why `permanent` refuses deletion rather than allowing it with a warning
|
|
22
|
+
*
|
|
23
|
+
* A warning is a thing a caller can ignore, and this module exists because the caller under pressure is
|
|
24
|
+
* the one who will. `memory.jsonl` is the **only** durable copy of what a command printed — the relay keeps
|
|
25
|
+
* `stdout_sha256` and never the text — so deleting it deletes the fleet's ability to answer what it already
|
|
26
|
+
* tried, and nothing else in the system notices. The refusal is the feature.
|
|
27
|
+
*
|
|
28
|
+
* Amending is different from deleting, and is supported: `law` entries carry an amendment chain, because a
|
|
29
|
+
* rule that cannot change is one that gets bypassed instead. What is refused is *removal*, and the chain is
|
|
30
|
+
* how a reader sees what a rule used to say.
|
|
31
|
+
*
|
|
32
|
+
* ## What this deliberately is not
|
|
33
|
+
*
|
|
34
|
+
* It is not a database and not a filesystem abstraction. It stores opaque bytes plus a declaration of what
|
|
35
|
+
* they are and how long they matter, in a directory, with a manifest. Three functions and a manifest is the
|
|
36
|
+
* whole dependency budget, which matters because every machine in this fleet has node and a shell and
|
|
37
|
+
* nothing else.
|
|
38
|
+
*/
|
|
39
|
+
import { createHash } from 'node:crypto';
|
|
40
|
+
import {
|
|
41
|
+
existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync,
|
|
42
|
+
} from 'node:fs';
|
|
43
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
44
|
+
import { join } from 'node:path';
|
|
45
|
+
|
|
46
|
+
/** The tiers, with the rule that applies to each. Data rather than branches, so it can be enumerated. */
|
|
47
|
+
export const TIERS = Object.freeze({
|
|
48
|
+
TEMP: 'temp',
|
|
49
|
+
COMPRESSED: 'compressed',
|
|
50
|
+
PERMANENT: 'permanent',
|
|
51
|
+
SKILL: 'skill',
|
|
52
|
+
LAW: 'law',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** Tiers whose contents may never be removed by this module. */
|
|
56
|
+
export const UNDELETABLE_TIERS = Object.freeze([TIERS.PERMANENT, TIERS.SKILL, TIERS.LAW]);
|
|
57
|
+
|
|
58
|
+
/** Default lifetime for `temp`, in milliseconds. Two hours: longer than any task, shorter than a shift. */
|
|
59
|
+
export const DEFAULT_TEMP_TTL_MS = 2 * 60 * 60 * 1000;
|
|
60
|
+
|
|
61
|
+
/** Why an operation was refused. Codes, so a caller branches without matching prose. */
|
|
62
|
+
export const STORAGE_REFUSAL = Object.freeze({
|
|
63
|
+
UNKNOWN_TIER: 'STORAGE_UNKNOWN_TIER',
|
|
64
|
+
NO_KEY: 'STORAGE_NO_KEY',
|
|
65
|
+
UNDELETABLE: 'STORAGE_UNDELETABLE_TIER',
|
|
66
|
+
NOT_FOUND: 'STORAGE_NOT_FOUND',
|
|
67
|
+
HASH_MISMATCH: 'STORAGE_HASH_MISMATCH',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** A refused operation carries enough to explain itself. */
|
|
71
|
+
export class StorageRefusal extends Error {
|
|
72
|
+
constructor(code, detail) {
|
|
73
|
+
super(`${code}: ${detail}`);
|
|
74
|
+
this.name = 'StorageRefusal';
|
|
75
|
+
this.code = code;
|
|
76
|
+
this.detail = detail;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isTier = (t) => Object.values(TIERS).includes(t);
|
|
81
|
+
|
|
82
|
+
/** `sha256` of a buffer, the identity of every stored blob. */
|
|
83
|
+
export function contentHash(bytes) {
|
|
84
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A key is a path-like name, and the constraints are deliberate.
|
|
89
|
+
*
|
|
90
|
+
* `..` is refused because keys become filenames, and a key that can escape its tier directory is a way to
|
|
91
|
+
* write anywhere the process can. An empty key is refused because it names nothing, and "the file with no
|
|
92
|
+
* name" is how a store acquires an entry nobody can find or delete.
|
|
93
|
+
*/
|
|
94
|
+
function keyProblem(key) {
|
|
95
|
+
if (typeof key !== 'string' || key.trim() === '') return 'key must be a non-empty string';
|
|
96
|
+
if (key.includes('..')) return 'key must not contain ".."';
|
|
97
|
+
if (key.startsWith('/') || /^[A-Za-z]:/.test(key)) return 'key must be relative';
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A storage root.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} options
|
|
105
|
+
* @param {string} options.dir The directory that holds every tier.
|
|
106
|
+
* @param {() => number} [options.now] Injected clock, so TTL is testable without waiting.
|
|
107
|
+
*/
|
|
108
|
+
export function createStore({ dir, now = () => Date.now() } = {}) {
|
|
109
|
+
if (typeof dir !== 'string' || dir === '') throw new TypeError('createStore: dir is required');
|
|
110
|
+
const manifestPath = join(dir, 'manifest.json');
|
|
111
|
+
|
|
112
|
+
const ensure = () => {
|
|
113
|
+
for (const tier of Object.values(TIERS)) mkdirSync(join(dir, tier), { recursive: true });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The manifest, read and written whole.
|
|
118
|
+
*
|
|
119
|
+
* Whole-file rather than append-only because it is small, and because a store whose index disagrees with
|
|
120
|
+
* its contents is worse than one that cannot start. A corrupt manifest is reported rather than rebuilt by
|
|
121
|
+
* guessing: rebuilding would silently adopt files nobody declared.
|
|
122
|
+
*/
|
|
123
|
+
function readManifest() {
|
|
124
|
+
if (!existsSync(manifestPath)) return { version: 1, entries: {} };
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
127
|
+
if (parsed === null || typeof parsed !== 'object' || typeof parsed.entries !== 'object') {
|
|
128
|
+
return { version: 1, entries: {}, corrupt: true };
|
|
129
|
+
}
|
|
130
|
+
return { version: 1, ...parsed };
|
|
131
|
+
} catch {
|
|
132
|
+
return { version: 1, entries: {}, corrupt: true };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function writeManifest(manifest) {
|
|
137
|
+
ensure();
|
|
138
|
+
// Written beside and moved into place: a manifest half-written by a crash would lose the index for
|
|
139
|
+
// every blob, which is the one failure this file cannot recover from by itself.
|
|
140
|
+
const temp = `${manifestPath}.tmp`;
|
|
141
|
+
writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
|
142
|
+
renameSync(temp, manifestPath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const blobPath = (tier, key) => join(dir, tier, key);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Store bytes under a key in a tier.
|
|
149
|
+
*
|
|
150
|
+
* `compressed` gzips on the way in and records the uncompressed size, so a reader can tell a truncated
|
|
151
|
+
* archive from a small one. `permanent`, `skill` and `law` are stored verbatim: they are the small,
|
|
152
|
+
* important things, and compressing them would save nothing while adding a way for them to be unreadable.
|
|
153
|
+
*
|
|
154
|
+
* @returns {{ok: true, tier: string, key: string, bytes: number, stored_bytes: number, sha256: string}}
|
|
155
|
+
*/
|
|
156
|
+
function put(tier, key, bytes, options = {}) {
|
|
157
|
+
if (!isTier(tier)) throw new StorageRefusal(STORAGE_REFUSAL.UNKNOWN_TIER, `${JSON.stringify(tier)} is not one of ${Object.values(TIERS).join(', ')}`);
|
|
158
|
+
const problem = keyProblem(key);
|
|
159
|
+
if (problem !== null) throw new StorageRefusal(STORAGE_REFUSAL.NO_KEY, problem);
|
|
160
|
+
|
|
161
|
+
const input = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes), 'utf8');
|
|
162
|
+
const payload = tier === TIERS.COMPRESSED ? gzipSync(input) : input;
|
|
163
|
+
const target = blobPath(tier, key);
|
|
164
|
+
ensure();
|
|
165
|
+
// Parent directories for a nested key, created before the write rather than discovered by its failure.
|
|
166
|
+
const parent = target.slice(0, target.lastIndexOf('/'));
|
|
167
|
+
if (parent.length > 0) mkdirSync(parent, { recursive: true });
|
|
168
|
+
writeFileSync(target, payload);
|
|
169
|
+
|
|
170
|
+
const manifest = readManifest();
|
|
171
|
+
const entry = {
|
|
172
|
+
tier,
|
|
173
|
+
key,
|
|
174
|
+
sha256: contentHash(input),
|
|
175
|
+
bytes: input.length,
|
|
176
|
+
stored_bytes: payload.length,
|
|
177
|
+
stored_at: new Date(now()).toISOString(),
|
|
178
|
+
expires_at: tier === TIERS.TEMP
|
|
179
|
+
? new Date(now() + (Number.isFinite(options.ttlMs) ? options.ttlMs : DEFAULT_TEMP_TTL_MS)).toISOString()
|
|
180
|
+
: null,
|
|
181
|
+
// Free-form, so a caller can record why a thing is kept without this module having to know.
|
|
182
|
+
labels: options.labels ?? null,
|
|
183
|
+
...(tier === TIERS.COMPRESSED ? { encoding: 'gzip' } : {}),
|
|
184
|
+
};
|
|
185
|
+
manifest.entries[`${tier}/${key}`] = entry;
|
|
186
|
+
writeManifest(manifest);
|
|
187
|
+
return { ok: true, tier, key, bytes: input.length, stored_bytes: payload.length, sha256: entry.sha256 };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Read bytes back.
|
|
192
|
+
*
|
|
193
|
+
* A blob whose content does not match the hash recorded for it is reported as `HASH_MISMATCH` rather than
|
|
194
|
+
* returned. Returning it would be worse than failing: the caller asked for content by identity, and
|
|
195
|
+
* handing back something else under that identity is how a corrupted archive becomes a wrong answer that
|
|
196
|
+
* nobody traces back here.
|
|
197
|
+
*/
|
|
198
|
+
function get(tier, key) {
|
|
199
|
+
if (!isTier(tier)) throw new StorageRefusal(STORAGE_REFUSAL.UNKNOWN_TIER, tier);
|
|
200
|
+
const problem = keyProblem(key);
|
|
201
|
+
if (problem !== null) throw new StorageRefusal(STORAGE_REFUSAL.NO_KEY, problem);
|
|
202
|
+
|
|
203
|
+
const target = blobPath(tier, key);
|
|
204
|
+
if (!existsSync(target)) return { ok: false, reason_code: STORAGE_REFUSAL.NOT_FOUND, detail: `${tier}/${key} is not stored` };
|
|
205
|
+
|
|
206
|
+
const raw = readFileSync(target);
|
|
207
|
+
const entry = readManifest().entries[`${tier}/${key}`] ?? null;
|
|
208
|
+
const bytes = entry?.encoding === 'gzip' ? gunzipSync(raw) : raw;
|
|
209
|
+
|
|
210
|
+
if (entry !== null && contentHash(bytes) !== entry.sha256) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
reason_code: STORAGE_REFUSAL.HASH_MISMATCH,
|
|
214
|
+
detail: `${tier}/${key} hashes to something other than what was stored; refusing to return it as that content`,
|
|
215
|
+
expected: entry.sha256,
|
|
216
|
+
actual: contentHash(bytes),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { ok: true, tier, key, bytes, entry };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Remove a blob.
|
|
224
|
+
*
|
|
225
|
+
* Refused outright for `permanent`, `skill` and `law`. The refusal is the feature: a warning is a thing a
|
|
226
|
+
* caller under pressure ignores, and the caller under pressure is exactly who this module exists for.
|
|
227
|
+
* `temp` and `compressed` may be removed, and removing a `compressed` blob removes the whole archive
|
|
228
|
+
* rather than part of it -- a partially-deleted archive is not a smaller archive, it is a broken one.
|
|
229
|
+
*/
|
|
230
|
+
function remove(tier, key, options = {}) {
|
|
231
|
+
if (!isTier(tier)) throw new StorageRefusal(STORAGE_REFUSAL.UNKNOWN_TIER, tier);
|
|
232
|
+
if (UNDELETABLE_TIERS.includes(tier) && options.force !== true) {
|
|
233
|
+
throw new StorageRefusal(
|
|
234
|
+
STORAGE_REFUSAL.UNDELETABLE,
|
|
235
|
+
`"${tier}" may not be deleted. memory.jsonl is the only durable copy of what a command printed -- the relay keeps hashes and never the text -- and device.key is the identity peers know this machine by. Amend rather than delete, or pass force with a reason.`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
const target = blobPath(tier, key);
|
|
239
|
+
const manifest = readManifest();
|
|
240
|
+
delete manifest.entries[`${tier}/${key}`];
|
|
241
|
+
if (!existsSync(target)) {
|
|
242
|
+
writeManifest(manifest);
|
|
243
|
+
return { ok: false, reason_code: STORAGE_REFUSAL.NOT_FOUND, detail: `${tier}/${key} was not stored` };
|
|
244
|
+
}
|
|
245
|
+
rmSync(target, { force: true });
|
|
246
|
+
writeManifest(manifest);
|
|
247
|
+
return { ok: true, tier, key, forced: options.force === true };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Delete expired `temp` blobs and nothing else.
|
|
252
|
+
*
|
|
253
|
+
* This is the only automatic deletion in the module, and it is narrow on purpose: it removes only what
|
|
254
|
+
* was *stored with a TTL that has passed*. Nothing here decides that a permanent thing has become
|
|
255
|
+
* unimportant, because that decision is not one a cleanup pass is entitled to make.
|
|
256
|
+
*/
|
|
257
|
+
function sweep({ nowMs = now() } = {}) {
|
|
258
|
+
const manifest = readManifest();
|
|
259
|
+
const removed = [];
|
|
260
|
+
for (const [id, entry] of Object.entries(manifest.entries)) {
|
|
261
|
+
if (entry.tier !== TIERS.TEMP || entry.expires_at === null) continue;
|
|
262
|
+
if (Date.parse(entry.expires_at) > nowMs) continue;
|
|
263
|
+
const target = blobPath(entry.tier, entry.key);
|
|
264
|
+
try { rmSync(target, { force: true }); } catch { /* already gone */ }
|
|
265
|
+
delete manifest.entries[id];
|
|
266
|
+
removed.push(id);
|
|
267
|
+
}
|
|
268
|
+
if (removed.length > 0) writeManifest(manifest);
|
|
269
|
+
return { removed, remaining: Object.keys(manifest.entries).length };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Every entry, optionally for one tier. */
|
|
273
|
+
function list(tier = null) {
|
|
274
|
+
const manifest = readManifest();
|
|
275
|
+
return Object.values(manifest.entries).filter((e) => tier === null || e.tier === tier);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Verify every blob against the manifest.
|
|
280
|
+
*
|
|
281
|
+
* Exists because "it is stored" and "it can still be read" are different claims, and the difference is
|
|
282
|
+
* discovered at the worst possible moment -- when someone finally needs the data. A blob whose file is
|
|
283
|
+
* missing, or whose bytes no longer match, is named rather than counted.
|
|
284
|
+
*/
|
|
285
|
+
function verify() {
|
|
286
|
+
const manifest = readManifest();
|
|
287
|
+
const missing = [];
|
|
288
|
+
const mismatched = [];
|
|
289
|
+
for (const entry of Object.values(manifest.entries)) {
|
|
290
|
+
const target = blobPath(entry.tier, entry.key);
|
|
291
|
+
if (!existsSync(target)) { missing.push(`${entry.tier}/${entry.key}`); continue; }
|
|
292
|
+
try {
|
|
293
|
+
const raw = readFileSync(target);
|
|
294
|
+
const bytes = entry.encoding === 'gzip' ? gunzipSync(raw) : raw;
|
|
295
|
+
if (contentHash(bytes) !== entry.sha256) mismatched.push(`${entry.tier}/${entry.key}`);
|
|
296
|
+
} catch {
|
|
297
|
+
mismatched.push(`${entry.tier}/${entry.key}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return { checked: Object.keys(manifest.entries).length, missing, mismatched, ok: missing.length === 0 && mismatched.length === 0 };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Total stored bytes per tier, for a health report. */
|
|
304
|
+
function usage() {
|
|
305
|
+
const out = {};
|
|
306
|
+
for (const tier of Object.values(TIERS)) {
|
|
307
|
+
const p = join(dir, tier);
|
|
308
|
+
let bytes = 0;
|
|
309
|
+
let files = 0;
|
|
310
|
+
if (existsSync(p)) {
|
|
311
|
+
const stack = [p];
|
|
312
|
+
while (stack.length > 0) {
|
|
313
|
+
const d = stack.pop();
|
|
314
|
+
let entries;
|
|
315
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { continue; }
|
|
316
|
+
for (const e of entries) {
|
|
317
|
+
const full = join(d, e.name);
|
|
318
|
+
try {
|
|
319
|
+
if (e.isDirectory()) stack.push(full);
|
|
320
|
+
else if (e.isFile()) { bytes += statSync(full).size; files += 1; }
|
|
321
|
+
} catch { /* skip */ }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
out[tier] = { bytes, files };
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return { put, get, remove, sweep, list, verify, usage, manifestPath, _internals: { readManifest } };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Amend a `law` entry rather than replacing it.
|
|
335
|
+
*
|
|
336
|
+
* A rule that cannot change is one that gets bypassed instead, so amendment is supported -- and what is
|
|
337
|
+
* kept is the chain, because "what does the rule say now" and "what did it used to say" are both questions
|
|
338
|
+
* a reader has, and only the second one explains why something was done the old way.
|
|
339
|
+
*
|
|
340
|
+
* @param {object} store
|
|
341
|
+
* @param {string} key
|
|
342
|
+
* @param {string} text
|
|
343
|
+
* @param {{by?: string|null, why?: string|null, now?: () => number}} [options]
|
|
344
|
+
*/
|
|
345
|
+
export function amendLaw(store, key, text, options = {}) {
|
|
346
|
+
const now = options.now ?? (() => Date.now());
|
|
347
|
+
const existing = store.get(TIERS.LAW, key);
|
|
348
|
+
const prior = existing.ok ? JSON.parse(existing.bytes.toString('utf8')) : null;
|
|
349
|
+
const amendment = {
|
|
350
|
+
at: new Date(now()).toISOString(),
|
|
351
|
+
by: options.by ?? null,
|
|
352
|
+
why: options.why ?? null,
|
|
353
|
+
supersedes_sha256: prior === null ? null : existing.entry.sha256,
|
|
354
|
+
};
|
|
355
|
+
const body = {
|
|
356
|
+
text,
|
|
357
|
+
amendments: [...(prior?.amendments ?? []), amendment],
|
|
358
|
+
};
|
|
359
|
+
const written = store.put(TIERS.LAW, key, JSON.stringify(body, null, 2), { labels: { kind: 'law' } });
|
|
360
|
+
return { ...written, amendments: body.amendments.length, superseded: prior !== null };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Store a skill.
|
|
365
|
+
*
|
|
366
|
+
* A skill is a capability rather than data, which is why it has its own tier: it is not a record of what
|
|
367
|
+
* happened, so the rules that govern records do not fit it, and it is not disposable, so the rules that
|
|
368
|
+
* govern caches do not either. What it shares with `law` is that losing it costs something that cannot be
|
|
369
|
+
* recomputed from anything else in the system.
|
|
370
|
+
*/
|
|
371
|
+
export function putSkill(store, name, { description, body, entry, labels = null }) {
|
|
372
|
+
const record = {
|
|
373
|
+
name,
|
|
374
|
+
description: description ?? null,
|
|
375
|
+
body: body ?? null,
|
|
376
|
+
entry: entry ?? null,
|
|
377
|
+
stored_at: new Date().toISOString(),
|
|
378
|
+
};
|
|
379
|
+
return store.put(TIERS.SKILL, name, JSON.stringify(record, null, 2), { labels });
|
|
380
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -185,7 +185,7 @@ const MAX_STAGES = 16;
|
|
|
185
185
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
186
186
|
* overrides it at runtime for tests and packagers.
|
|
187
187
|
*/
|
|
188
|
-
const PLUGIN_VERSION = '0.
|
|
188
|
+
const PLUGIN_VERSION = '0.10.2';
|
|
189
189
|
|
|
190
190
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
191
191
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|