@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.
@@ -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
+ }
@@ -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.9.7';
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;