strom-research 1.13.0-beta.7 → 1.13.0-beta.8

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.
@@ -120,28 +120,35 @@ function grokBlock(lines) {
120
120
  }
121
121
  /**
122
122
  * Strom's block taken out, the person's text before and after it kept as it is — only the blank line an install puts
123
- * between the person's text and the block it appends goes with it.
123
+ * between the person's text and the block it appends goes with it. gap: whether the install that wrote the block put
124
+ * one there (recorded in agents-taught.json); false: no blank line around the block is strom's. Unknown (an
125
+ * installation that recorded nothing, a file strom created): the blank line before the block was strom's, or, the block
126
+ * first in the file, the one after it.
124
127
  */
125
- function grokWithout(text) {
128
+ function grokWithout(text, gap) {
126
129
  let lines = text.split("\n");
127
130
  const blank = (i) => i >= 0 && i < lines.length && lines[i].trim() === "";
128
131
  for (let at = grokBlock(lines); at; at = grokBlock(lines)) {
129
132
  const [b, e] = at;
130
133
  let from = b;
131
134
  let to = e + 1;
132
- // a blank line before it, and the end of the file or another blank line after it: the one before was strom's
133
- if (blank(b - 1) && (e + 1 >= lines.length || blank(e + 1)))
134
- from = b - 1;
135
- // first in the file, then a blank line and the person's text: that blank line was strom's
136
- else if (b === 0 && blank(e + 1) && e + 1 < lines.length - 1)
137
- to = e + 2;
135
+ if (gap !== false) {
136
+ // a blank line before it, whatever follows (the end of the file, a blank line, the person's text right after its
137
+ // end): that one was strom's
138
+ if (blank(b - 1))
139
+ from = b - 1;
140
+ // first in the file, then a blank line and the person's text: that blank line was strom's
141
+ else if (b === 0 && blank(e + 1) && e + 1 < lines.length - 1)
142
+ to = e + 2;
143
+ }
138
144
  // taken to the end of a file with no line break at its end: the CR of the break before it went with it
139
145
  const tail = to >= lines.length && from > 0 ? [lines[from - 1].replace(/\r$/, "")] : lines.slice(from - 1, from);
140
146
  lines = [...lines.slice(0, Math.max(0, from - 1)), ...tail, ...lines.slice(to)];
141
147
  }
142
148
  return lines.join("\n");
143
149
  }
144
- function grokWith(text) {
150
+ /** config.toml with strom's block; gap: whether a new block was put after a blank line (undefined: none put now). */
151
+ function grokWith(text, rec) {
145
152
  const allow = `allow = [${allowRules("grok").map((r) => JSON.stringify(r)).join(", ")}]`;
146
153
  const nl = text.includes("\r\n") ? "\r\n" : "\n";
147
154
  const cr = nl === "\r\n" ? "\r" : "";
@@ -166,19 +173,19 @@ function grokWith(text) {
166
173
  if (inner) {
167
174
  const last = e === lines.length - 1; // the file ends with strom's end line: no line break after it
168
175
  const block = [TOML_BEGIN, ...inner, TOML_END].map((l, i, all) => (last && i === all.length - 1 ? l : l + cr));
169
- return [...lines.slice(0, b), ...block, ...lines.slice(e + 1)].join("\n");
176
+ return { text: [...lines.slice(0, b), ...block, ...lines.slice(e + 1)].join("\n") };
170
177
  }
171
178
  // a table of strom's beside the person's own [permission] table (two would break the file): into theirs
172
179
  }
173
- const rest = at ? grokWithout(text) : text;
180
+ const rest = at ? grokWithout(text, rec) : text;
174
181
  const rl = rest.split("\n");
175
182
  const header = rl.findIndex(isPermissionHeader);
176
183
  if (header < 0) {
177
184
  // appended after a blank line, the person's last line ending as it did (grokWithout takes exactly this away)
178
185
  const block = [TOML_BEGIN, "[permission]", allow, TOML_END].join(nl);
179
186
  if (!rest)
180
- return block + nl;
181
- return rest.endsWith("\n") ? `${rest}${nl}${block}${nl}` : `${rest}${nl}${nl}${block}`;
187
+ return { text: block + nl, gap: false };
188
+ return { text: rest.endsWith("\n") ? `${rest}${nl}${block}${nl}` : `${rest}${nl}${nl}${block}`, gap: true };
182
189
  }
183
190
  // the user's table: up to the next table
184
191
  let end = rl.findIndex((l, i) => i > header && isTomlTable(l));
@@ -186,7 +193,7 @@ function grokWith(text) {
186
193
  end = rl.length;
187
194
  if (hasOwnAllow(rl.slice(header + 1, end)))
188
195
  return undefined;
189
- return [...rl.slice(0, header + 1), ...[TOML_BEGIN, allow, TOML_END].map((l) => l + cr), ...rl.slice(header + 1)].join("\n");
196
+ return { text: [...rl.slice(0, header + 1), ...[TOML_BEGIN, allow, TOML_END].map((l) => l + cr), ...rl.slice(header + 1)].join("\n"), gap: false };
190
197
  }
191
198
  /** A rule of strom's in Claude Code's or Antigravity's settings, whichever installation wrote it. */
192
199
  function isStromRule(agent, rule) {
@@ -410,18 +417,56 @@ function jsonSpan(text, i) {
410
417
  end++;
411
418
  return { start, end };
412
419
  }
420
+ /** How a JSON text on one line spaces it: what each place holds the first time it comes (outside strings), else nothing. */
421
+ function oneLineSpacing(body) {
422
+ const seen = new Map();
423
+ const note = (k, v) => {
424
+ if (!seen.has(k))
425
+ seen.set(k, v);
426
+ };
427
+ const wsAfter = (i) => /^[ \t]*/.exec(body.slice(i + 1))[0];
428
+ const wsBefore = (i) => /[ \t]*$/.exec(body.slice(0, i))[0];
429
+ for (let i = 0; i < body.length; i++) {
430
+ const c = body[i];
431
+ if (c === '"') {
432
+ for (i++; i < body.length && body[i] !== '"'; i++)
433
+ if (body[i] === "\\")
434
+ i++;
435
+ continue;
436
+ }
437
+ if (c === ":" || c === ",") {
438
+ note(`${c}<`, wsBefore(i));
439
+ note(`${c}>`, wsAfter(i));
440
+ }
441
+ else if (c === "{" || c === "[") {
442
+ const after = wsAfter(i);
443
+ if (body[i + 1 + after.length] !== (c === "{" ? "}" : "]"))
444
+ note(`${c}>`, after);
445
+ }
446
+ else if (c === "}" || c === "]") {
447
+ const before = wsBefore(i);
448
+ if (body[i - 1 - before.length] !== (c === "}" ? "{" : "["))
449
+ note(`${c}<`, before);
450
+ }
451
+ }
452
+ const at = (k, or = "") => seen.get(k) ?? or;
453
+ // no comma yet (one member): spaced after as its colon is
454
+ return { colon: `${at(":<")}:${at(":>")}`, comma: `${at(",<")},${at(",>", at(":>"))}`, obj: [at("{>"), at("}<")], arr: [at("[>"), at("]<")] };
455
+ }
413
456
  /**
414
457
  * The settings written as the person's file is: what did not change exactly as its text has it, what did in its
415
- * indentation (or compact, as the file is), its line breaks, the whitespace around it. A blank file or none: strom's
416
- * way, two spaces and a line break at the end.
458
+ * indentation (or on one line as the file is, with its spaces), its line breaks, the whitespace around it. A blank file
459
+ * or none: strom's way, two spaces and a line break at the end.
417
460
  */
418
461
  function formatJson(s, text) {
419
462
  if (text === undefined || !text.trim())
420
463
  return JSON.stringify(s, null, 2) + "\n";
421
464
  const body = text.trim();
422
465
  const nl = body.includes("\r\n") ? "\r\n" : "\n";
423
- // the first indented line is one level in; all on one line: compact (an empty {} says nothing: strom's way)
466
+ // the first indented line is one level in; all on one line: none, its own spaces below (an empty {} says nothing: strom's way)
424
467
  const indent = /\n([ \t]+)\S/.exec(body)?.[1] ?? (body.includes("\n") || /^\{\s*\}$/.test(body) ? " " : "");
468
+ // on one line: the person's spaces (none: compact)
469
+ const one = indent ? undefined : oneLineSpacing(body);
425
470
  const same = (v, at) => JSON.stringify(v) === JSON.stringify(JSON.parse(text.slice(at.start, at.end)));
426
471
  const write = (v, at, depth) => {
427
472
  if (at && same(v, at))
@@ -434,9 +479,11 @@ function formatJson(s, text) {
434
479
  const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
435
480
  if (!entries.length)
436
481
  return open + close;
437
- const parts = entries.map(([k, x, sub]) => (k === undefined ? "" : JSON.stringify(k) + (indent ? ": " : ":")) + write(x, sub, depth + 1));
438
- if (!indent)
439
- return open + parts.join(",") + close;
482
+ const parts = entries.map(([k, x, sub]) => (k === undefined ? "" : JSON.stringify(k) + (one ? one.colon : ": ")) + write(x, sub, depth + 1));
483
+ if (one) {
484
+ const [inOpen, inClose] = Array.isArray(v) ? one.arr : one.obj;
485
+ return open + inOpen + parts.join(one.comma) + inClose + close;
486
+ }
440
487
  const pad = (d) => indent.repeat(d);
441
488
  return open + nl + parts.map((p) => pad(depth + 1) + p).join("," + nl) + nl + pad(depth) + close;
442
489
  };
@@ -457,10 +504,11 @@ function writeMade(file, m) {
457
504
  const files = m.files && Object.keys(m.files).length ? m.files : undefined;
458
505
  const dirs = m.dirs?.length ? [...new Set(m.dirs)].sort() : undefined;
459
506
  const json = m.json && Object.keys(m.json).length ? m.json : undefined;
460
- if (!files && !dirs && !json)
507
+ const toml = m.toml && Object.keys(m.toml).length ? m.toml : undefined;
508
+ if (!files && !dirs && !json && !toml)
461
509
  fs.rmSync(file, { force: true });
462
510
  else
463
- writeFileAtomic(file, JSON.stringify({ ...(files ? { files } : {}), ...(dirs ? { dirs } : {}), ...(json ? { json } : {}) }, null, 2) + "\n");
511
+ writeFileAtomic(file, JSON.stringify({ ...(files ? { files } : {}), ...(dirs ? { dirs } : {}), ...(json ? { json } : {}), ...(toml ? { toml } : {}) }, null, 2) + "\n");
464
512
  }
465
513
  catch {
466
514
  // the user config not writable: the files stay as an uninstall without the record leaves them
@@ -484,13 +532,17 @@ function hasStrom(t, text) {
484
532
  }
485
533
  /**
486
534
  * Recorded after a write: the file created now, or the person's (one without strom's part); the folders made for it;
487
- * in a settings file, what strom made in it (json).
535
+ * in a settings file, what strom made in it (json); in config.toml, whether a new block went after a blank line (gap).
488
536
  */
489
- function noteWritten(t, before, firstDir, json) {
537
+ function noteWritten(t, before, firstDir, json, gap) {
490
538
  const m = readMade(t.made);
491
539
  const files = { ...(m.files ?? {}) };
492
540
  const dirs = [...(m.dirs ?? [])];
493
541
  const jsons = { ...(m.json ?? {}) };
542
+ const tomls = { ...(m.toml ?? {}) };
543
+ // a new block put now (one there already, brought to this version where it is, keeps what is recorded)
544
+ if (gap !== undefined)
545
+ tomls[t.file] = { gap };
494
546
  const fresh = before === undefined || !hasStrom(t, before);
495
547
  if (before === undefined)
496
548
  files[t.file] = "created";
@@ -512,8 +564,9 @@ function noteWritten(t, before, firstDir, json) {
512
564
  if (d === firstDir || path.dirname(d) === d)
513
565
  break;
514
566
  }
515
- if (JSON.stringify(files) !== JSON.stringify(m.files ?? {}) || dirs.length !== (m.dirs ?? []).length || JSON.stringify(jsons) !== JSON.stringify(m.json ?? {}))
516
- writeMade(t.made, { files, dirs, json: jsons });
567
+ const changed = (a, b) => JSON.stringify(a) !== JSON.stringify(b ?? {});
568
+ if (changed(files, m.files) || dirs.length !== (m.dirs ?? []).length || changed(jsons, m.json) || changed(tomls, m.toml))
569
+ writeMade(t.made, { files, dirs, json: jsons, toml: tomls });
517
570
  }
518
571
  /** Its part taken away: the file is the person's again (or gone); the folders strom made for it go when empty. */
519
572
  function noteRemoved(t) {
@@ -522,6 +575,8 @@ function noteRemoved(t) {
522
575
  delete files[t.file];
523
576
  const json = { ...(m.json ?? {}) };
524
577
  delete json[t.file];
578
+ const toml = { ...(m.toml ?? {}) };
579
+ delete toml[t.file];
525
580
  const here = path.dirname(t.file);
526
581
  const within = (d) => here === d || here.startsWith(d.endsWith(path.sep) ? d : d + path.sep);
527
582
  // deepest first: a folder strom made in another it made
@@ -538,18 +593,27 @@ function noteRemoved(t) {
538
593
  }
539
594
  return false;
540
595
  });
541
- writeMade(t.made, { files, dirs, json });
596
+ writeMade(t.made, { files, dirs, json, toml });
597
+ }
598
+ /**
599
+ * Whether strom put a blank line before its block in this config.toml, as recorded; unknown for an installation that
600
+ * recorded nothing, and for a file strom created (what the person adds to it after its block stands alone again).
601
+ */
602
+ function tomlGap(t, m) {
603
+ return m.files?.[t.file] === "created" ? undefined : m.toml?.[t.file]?.gap;
542
604
  }
543
605
  /** Write it for one agent; false when it was already there as it is. */
544
606
  export function installGlobal(t) {
545
607
  const cur = read(t.file);
546
608
  let next;
547
609
  let json;
610
+ let gap;
548
611
  if (t.kind === "allow" && t.agent === "grok") {
549
- const with_ = grokWith(cur ?? "");
612
+ const with_ = grokWith(cur ?? "", tomlGap(t, readMade(t.made)));
550
613
  if (with_ === undefined)
551
614
  return false;
552
- next = with_;
615
+ next = with_.text;
616
+ gap = with_.gap;
553
617
  }
554
618
  else if (t.kind === "allow") {
555
619
  const s = settingsOf(cur);
@@ -571,13 +635,13 @@ export function installGlobal(t) {
571
635
  return false;
572
636
  const firstDir = fs.mkdirSync(path.dirname(t.file), { recursive: true });
573
637
  writeFileAtomic(t.file, next);
574
- noteWritten(t, cur, firstDir, json);
638
+ noteWritten(t, cur, firstDir, json, gap);
575
639
  return true;
576
640
  }
577
641
  /** Take it away again; false when there was nothing of strom's. */
578
642
  export function uninstallGlobal(t) {
579
643
  const m = readMade(t.made);
580
- const removed = takeAway(t, m.files?.[t.file], m.json?.[t.file]);
644
+ const removed = takeAway(t, m.files?.[t.file], m.json?.[t.file], tomlGap(t, m));
581
645
  if (removed)
582
646
  noteRemoved(t);
583
647
  return removed;
@@ -587,7 +651,7 @@ export function uninstallGlobal(t) {
587
651
  * an installation that recorded nothing stays as it did before the record — a settings file kept as {}, a text file
588
652
  * with nothing but strom's part gone.
589
653
  */
590
- function takeAway(t, made, rec) {
654
+ function takeAway(t, made, rec, gap) {
591
655
  const cur = read(t.file);
592
656
  if (cur === undefined)
593
657
  return false;
@@ -595,7 +659,7 @@ function takeAway(t, made, rec) {
595
659
  if (!grokBlock(cur.split("\n")))
596
660
  return false;
597
661
  // the person's text before and after strom's block as it was
598
- const rest = grokWithout(cur);
662
+ const rest = grokWithout(cur, gap);
599
663
  if (rest.trim() || made === "existed")
600
664
  writeFileAtomic(t.file, rest);
601
665
  else
@@ -17,7 +17,7 @@ import { describeView, makeView, partRegion, viewRegion, VIEW_MAX } from "../cor
17
17
  import { listConnectors, missingConsents } from "../core/connector.js";
18
18
  import { now } from "../core/tree.js";
19
19
  import { originalMax, parseRegion, takeOriginal } from "../core/originals.js";
20
- import { batchFull, batchPath, noteBatch, openBatch } from "../core/batches.js";
20
+ import { batchFull, batchPath, batchRoom, noteBatch, openBatch } from "../core/batches.js";
21
21
  import { readEntry, readZip } from "../core/zip.js";
22
22
  const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".tif", ".tiff", ".gif", ".webp", ".heic", ".jp2", ".bmp"]);
23
23
  /**
@@ -510,13 +510,13 @@ register({
510
510
  throw new UsageError(`no such file: ${args[0]}`);
511
511
  const sha = typeof opts.sha === "string" ? opts.sha.toLowerCase() : fileSha256(file);
512
512
  if (!/^[0-9a-f]{64}$/.test(sha) || fileSha256(file) !== sha)
513
- throw new UsageError("the file is not the one named: its SHA-256 differs", { hint: "send it again" });
513
+ throw new UsageError("the file is not the one named: its SHA-256 differs", { hint: "send it again", code: "media.sha-differs", params: { sha: sha.slice(0, 64) } });
514
514
  const persons = (Array.isArray(opts.person) ? opts.person : opts.person ? [opts.person] : []).flatMap((p) => String(p).split(",")).map((p) => p.trim().toUpperCase()).filter(Boolean);
515
515
  const name = typeof opts.name === "string" ? opts.name : path.basename(file);
516
516
  const batch = typeof opts.batch === "string" ? opts.batch : undefined;
517
517
  const where = batchPath(typeof opts.path === "string" ? opts.path : undefined) ?? batchPath(name) ?? name;
518
518
  if (opts.zip && !batch)
519
- throw new UsageError("a ZIP comes in a batch", { hint: "--batch <id>" });
519
+ throw new UsageError("a ZIP comes in a batch", { hint: "--batch <id>", code: "batch.zip-alone" });
520
520
  const got = tree.withTreeLock(() => {
521
521
  if (!batch)
522
522
  return takeOriginal(tree, shared, file, sha, {
@@ -531,9 +531,9 @@ register({
531
531
  b = { ...b, persons };
532
532
  if (!opts.zip) {
533
533
  const size = fs.statSync(file).size;
534
- const full = batchFull(b, size, ctx.env);
534
+ const full = batchRoom(b, size, ctx.env);
535
535
  if (full)
536
- throw new UsageError(full);
536
+ throw new UsageError(full.text, { code: full.code, params: full.params });
537
537
  try {
538
538
  const one = takeOriginal(tree, shared, file, sha, { name: path.basename(where), persons, batch, path: where, note: typeof opts.note === "string" ? opts.note : undefined });
539
539
  noteBatch(tree.root, b, one.known ? { known: one.known } : { input: one.input, bytes: size });
@@ -559,7 +559,7 @@ function takeZip(ctx, tree, shared, file, zipPath, start, persons) {
559
559
  }
560
560
  catch (e) {
561
561
  fs.rmSync(file, { force: true });
562
- throw new UsageError(`${path.basename(zipPath)}: ${e.message}`, { hint: "unpack it in the system and send the folder" });
562
+ throw new UsageError(`${path.basename(zipPath)}: ${e.message}`, { hint: "unpack it in the system and send the folder", code: "batch.zip-unreadable", params: { name: path.basename(zipPath) } });
563
563
  }
564
564
  const max = originalMax(ctx.env);
565
565
  let b = start;
@@ -68,10 +68,10 @@ export function batchPath(raw) {
68
68
  /** A batch to add to: made at its first file; one closed takes nothing more. */
69
69
  export function openBatch(root, id) {
70
70
  if (!BATCH_ID.test(id))
71
- throw new UsageError(`not a batch: "${id.slice(0, 64)}"`);
71
+ throw new UsageError(`not a batch: "${id.slice(0, 64)}"`, { code: "batch.bad-id", params: { batch: id.slice(0, 64) } });
72
72
  const b = readBatch(root, id);
73
73
  if (b?.done)
74
- throw new UsageError(`the batch ${id} is closed already`, { hint: "send the rest as a new batch" });
74
+ throw new UsageError(`the batch ${id} is closed already`, { hint: "send the rest as a new batch", code: "batch.closed", params: { batch: id } });
75
75
  const t = new Date().toISOString();
76
76
  return b ?? { id, created: t, updated: t, inputs: [], known: [], bytes: 0, refused: [], nested: [] };
77
77
  }
@@ -91,11 +91,16 @@ export function noteBatch(root, b, add) {
91
91
  }
92
92
  /** Whether a batch has room for one more file of this size; why not, else undefined. */
93
93
  export function batchFull(b, bytes, env) {
94
+ return batchRoom(b, bytes, env)?.text;
95
+ }
96
+ /** Why a batch has no room for one more file of this size — the English words and, for the app, its code — else undefined. */
97
+ export function batchRoom(b, bytes, env) {
94
98
  const lim = batchLimits(env);
95
99
  if (b.inputs.length + b.known.length >= lim.files)
96
- return `the batch has ${lim.files} files already (the most one takes)`;
100
+ return { text: `the batch has ${lim.files} files already (the most one takes)`, code: "batch.full-files", params: { batch: b.id, files: String(lim.files) } };
101
+ const gb = String(Math.round(lim.bytes / 1024 ** 3));
97
102
  if (b.bytes + bytes > lim.bytes)
98
- return `the batch would be larger than ${Math.round(lim.bytes / 1024 ** 3)} GB (the most one takes)`;
103
+ return { text: `the batch would be larger than ${gb} GB (the most one takes)`, code: "batch.full-bytes", params: { batch: b.id, gb } };
99
104
  return undefined;
100
105
  }
101
106
  /** The folder of a file in its batch that it is sorted with: its first one (a ZIP's: the ZIP and its first one), else none. */
@@ -114,7 +119,7 @@ const IMAGE = /^image\//;
114
119
  export function closeBatch(tree, id, opts) {
115
120
  const b = readBatch(tree.root, id);
116
121
  if (!b)
117
- throw new UsageError(`no batch ${id} here`, { hint: "strom input batch" });
122
+ throw new UsageError(`no batch ${id} here`, { hint: "strom input batch", code: "batch.none", params: { batch: id } });
118
123
  if (b.done)
119
124
  return { ...b, tasks: b.tasks ?? [] };
120
125
  const lang = tree.lang;
package/dist/core/live.js CHANGED
@@ -78,7 +78,7 @@ import { EXIT, StromError } from "./errors.js";
78
78
  import { acquireLock } from "./lock.js";
79
79
  import { material } from "./material.js";
80
80
  import { checkOriginalMeta, freeBytes, knownOriginal, materialWaiting, originalMax, ORIGINAL_RESERVE, ORIGINAL_TYPES, parseRegion } from "./originals.js";
81
- import { BATCH_ID, batchEstimate, batchFull, batchLimits, batchPath, batchStatus, idleBatches, noteBatch, openBatch, readBatch } from "./batches.js";
81
+ import { BATCH_ID, batchEstimate, batchLimits, batchRoom, batchPath, batchStatus, idleBatches, noteBatch, openBatch, readBatch } from "./batches.js";
82
82
  /** New images for the app, made for at most so long when the tree changed (the rest the next time). */
83
83
  const LIVE_IMAGES_MS = 20_000;
84
84
  import { liveWorkers } from "./workers.js";
@@ -122,6 +122,38 @@ const REFUSED = {
122
122
  function said(lang, key, params = {}) {
123
123
  return { error: ui(lang, key, params), code: CODES[key], text: ui("en", key, params), ...(Object.keys(params).length ? { params } : {}) };
124
124
  }
125
+ /**
126
+ * An upload of the app refused (PUT /media/<sha256>, a batch's file, POST /batch/<id>/done, GET /media/<sha256>): the
127
+ * English sentence (`error`, `text`) and its stable `code` with `params` for the app to say it in the person's
128
+ * language (feature `media.codes`). Each code, its answer and its params:
129
+ * - `app.only` 403 — not from the Strom app's pages
130
+ * - `media.bad-sha` 400 — the address names no SHA-256
131
+ * - `media.bad-header` 400 {header} — a header that is not what it should be (X-Strom-Person, -Source, -Batch, -Zip,
132
+ * -Path, -Name, -Note)
133
+ * - `media.bad-region` 400 {region} — X-Strom-Region is no part of an image
134
+ * - `media.no-person` 404 {person} — no such person in the research; `media.no-source` 404 {source} — no such source
135
+ * - `media.no-shared` 500 — the research has no shared folder for files (strom setup)
136
+ * - `media.large` 413 {mb} (and `max`) — larger than the research takes; `media.full` 507 (and `free`) — no room
137
+ * (these two: `error` in the research's language, `text` English — said() above)
138
+ * - `media.sha-differs` 409 {sha} — the file is not the one its address names
139
+ * - `media.type` 415 {name} — not a kind of file the research takes
140
+ * - `media.cut-short` 422 {name, kind: JPEG|PNG|PDF}; `media.too-small` 422 {name, bytes} — the file is not whole
141
+ * - `media.refused` 400 — refused for another reason (the English sentence says why); `media.failed` 500 — went wrong
142
+ * - `media.gone` 410 {known} — GET …?file=1: the research knows it, its file is not on this computer
143
+ * - `research.busy` 503 (and Retry-After, `retry`) — another strom holds the research: send it again in a while
144
+ * - `batch.bad-id` 400 {batch}; `batch.zip-alone` 400 — a ZIP outside a batch; `batch.zip-unreadable` 400 {name}
145
+ * - `batch.closed` 409 {batch} — the batch is closed already: send the rest as a new batch
146
+ * - `batch.full-files` 413 {batch, files}; `batch.full-bytes` 413 {batch, gb} — the most a batch takes
147
+ * - `batch.none` 404 {batch} — POST /batch/<id>/done: no such batch here; `batch.bad-body` 400 — its body no JSON
148
+ */
149
+ function refusal(error, code, params = {}) {
150
+ return { error, code, text: error, ...(Object.keys(params).length ? { params } : {}) };
151
+ }
152
+ /** What a strom of its own said as it failed (its --json: message, code, params): the code it gave, else `fallback`. */
153
+ function refusalOf(why, data, fallback) {
154
+ const params = data.params && typeof data.params === "object" ? Object.fromEntries(Object.entries(data.params).map(([k, v]) => [k, String(v)])) : {};
155
+ return refusal(why, typeof data.code === "string" && data.code ? data.code : fallback, params);
156
+ }
125
157
  /**
126
158
  * The environment of a strom the bridge runs for the Strom app (a send written, the app's tree taken in, an original, a
127
159
  * batch closed): what it writes is the person's in the app — never logged as an agent's or a session's, whatever the
@@ -484,7 +516,7 @@ function idsOf(applied, known) {
484
516
  return Object.keys(ids.persons).length || Object.keys(ids.sources).length ? { ids } : {};
485
517
  }
486
518
  /** What the bridge does that an app may ask about (each added once, never taken away). */
487
- export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles"];
519
+ export const BRIDGE_FEATURES = ["sync.again", "sync.undoneSince", "sync.takenBack", "sync.conflictEdit", "sync.since", "sync.ids", "family.noCouple", "family.alone", "adopt.transfer", "adopt.empty", "material.list", "person.titles", "media.codes"];
488
520
  export function history(root, tree, range = [`-n${LOG_MAX}`]) {
489
521
  const r = runGit(root, ["log", ...range, "--format=%x1e%H%x1f%cI%x1f%s%x1f%b%x1f", "--name-only"]);
490
522
  if (r.status !== 0)
@@ -1057,7 +1089,7 @@ export function serveLive(root, env) {
1057
1089
  else if (!/[?&]file=1(?:&|$)/.test(req.url ?? ""))
1058
1090
  json(200, { known: known.id, kind: known.kind, mime: known.mime, bytes: known.bytes, ...(known.name ? { name: known.name } : {}), here: !!known.file && fs.existsSync(known.file) });
1059
1091
  else if (!known.file || !fs.existsSync(known.file))
1060
- json(410, { known: known.id, error: "its file is not on this computer" });
1092
+ json(410, { known: known.id, ...refusal("its file is not on this computer", "media.gone", { known: known.id }) });
1061
1093
  else {
1062
1094
  const size = fs.statSync(known.file).size;
1063
1095
  res.writeHead(200, { "Content-Type": known.mime, "Content-Length": String(size), "Cache-Control": "no-store", "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(known.name ?? path.basename(known.file))}` });
@@ -1493,10 +1525,10 @@ export function serveLive(root, env) {
1493
1525
  req.resume();
1494
1526
  };
1495
1527
  if (!origin)
1496
- return refuse(403, { error: "only the Strom app may send a file here" });
1528
+ return refuse(403, refusal("only the Strom app may send a file here", "app.only"));
1497
1529
  const sha = rawSha.toLowerCase();
1498
1530
  if (!/^[0-9a-f]{64}$/.test(sha))
1499
- return refuse(400, { error: "the address names no SHA-256" });
1531
+ return refuse(400, refusal("the address names no SHA-256", "media.bad-sha"));
1500
1532
  const header = (n) => {
1501
1533
  const v = req.headers[n];
1502
1534
  return typeof v === "string" ? v : undefined;
@@ -1509,33 +1541,45 @@ export function serveLive(root, env) {
1509
1541
  let where;
1510
1542
  const zip = header("x-strom-zip") === "1";
1511
1543
  const tree = Tree.open(root, env);
1544
+ // the header read when it failed (its value not URI-encoded too): said by its name
1545
+ let at = "X-Strom-Batch";
1512
1546
  try {
1513
1547
  batch = header("x-strom-batch")?.trim();
1514
1548
  if (batch !== undefined && !BATCH_ID.test(batch))
1515
1549
  throw new Error("X-Strom-Batch: the batch's mark (letters, digits, -)");
1550
+ at = "X-Strom-Zip";
1516
1551
  if (zip && !batch)
1517
1552
  throw new Error("X-Strom-Zip: a ZIP comes in a batch (X-Strom-Batch)");
1553
+ at = "X-Strom-Path";
1518
1554
  where = header("x-strom-path") ? batchPath(decodeURIComponent(header("x-strom-path"))) : undefined;
1555
+ at = "X-Strom-Name";
1519
1556
  name = decodeURIComponent(header("x-strom-name") ?? "") || sha.slice(0, 12);
1557
+ at = "X-Strom-Note";
1520
1558
  note = header("x-strom-note") ? decodeURIComponent(header("x-strom-note")).slice(0, 500) : undefined;
1559
+ at = "X-Strom-Person";
1521
1560
  const persons = (header("x-strom-person") ?? "").split(",").map((p) => p.trim().toUpperCase()).filter(Boolean);
1522
1561
  if (persons.some((p) => !/^P\d{1,9}$/.test(p)))
1523
1562
  throw new Error("X-Strom-Person: IDs of the research's people (P…), with commas");
1563
+ at = "X-Strom-Source";
1524
1564
  const source = header("x-strom-source")?.trim().toUpperCase();
1525
1565
  if (source && !/^S\d{1,9}$/.test(source))
1526
1566
  throw new Error("X-Strom-Source: the ID of a source of the research (S…)");
1567
+ at = "X-Strom-Region";
1527
1568
  region = header("x-strom-region")?.trim();
1528
1569
  parseRegion(region);
1529
1570
  meta = checkOriginalMeta(tree, { persons, source });
1530
1571
  }
1531
1572
  catch (e) {
1532
- return refuse(/^no (person|source)/.test(e.message) ? 404 : 400, { error: e.message });
1573
+ // a person or a source the research has not got: 404, its JSON body telling it from an address that is no bridge's
1574
+ const m = e.message;
1575
+ const coded = e instanceof StromError && e.code ? refusal(m, e.code, e.params ?? {}) : refusal(m, "media.bad-header", { header: at });
1576
+ return refuse(/^no (person|source)/.test(m) ? 404 : 400, coded);
1533
1577
  }
1534
1578
  const settings = new Settings(env, {});
1535
1579
  const shared = settings.shared()?.value;
1536
1580
  // not set up (no shared folder): sending again does not help — /status says accepts.media null
1537
1581
  if (!shared)
1538
- return refuse(500, { error: "the research has no shared folder for files (strom setup)" });
1582
+ return refuse(500, refusal("the research has no shared folder for files (strom setup)", "media.no-shared"));
1539
1583
  // a batch: open, with room for it
1540
1584
  let open;
1541
1585
  const length = Number(header("content-length") ?? NaN);
@@ -1544,11 +1588,11 @@ export function serveLive(root, env) {
1544
1588
  open = openBatch(root, batch);
1545
1589
  }
1546
1590
  catch (e) {
1547
- return refuse(409, { error: e.message, batch });
1591
+ return refuse(409, { ...refusalOf(e.message, e instanceof StromError ? e : {}, "batch.closed"), batch });
1548
1592
  }
1549
- const full = batchFull(open, Number.isFinite(length) ? length : 0, env);
1593
+ const full = batchRoom(open, Number.isFinite(length) ? length : 0, env);
1550
1594
  if (full)
1551
- return refuse(413, { error: full, batch });
1595
+ return refuse(413, { ...refusal(full.text, full.code, full.params), batch });
1552
1596
  }
1553
1597
  // the same content: only said (an image of another source given again is taken as one more of that source)
1554
1598
  const known = zip ? undefined : knownOriginal(tree, shared, sha);
@@ -1586,7 +1630,7 @@ export function serveLive(root, env) {
1586
1630
  if (code !== 0)
1587
1631
  noteLive(root, `what came again for ${known.id} was not added (exit ${code})`);
1588
1632
  if (code === EXIT.locked)
1589
- return reply(503, { error: "the research is busy — send it again in a while", retry: 30 });
1633
+ return reply(503, { ...refusal("the research is busy — send it again in a while", "research.busy"), retry: 30 });
1590
1634
  reply(200, { known: known.id, kind: known.kind, ...(code === 0 && data.added ? { added: data.added } : {}), ...(code === 0 && data.task ? { task: data.task } : {}), ...(code === 0 ? { head: head(root) } : {}) });
1591
1635
  });
1592
1636
  return;
@@ -1647,7 +1691,7 @@ export function serveLive(root, env) {
1647
1691
  out.end(() => safely(req, res, () => {
1648
1692
  if (hash.digest("hex") !== sha) {
1649
1693
  drop();
1650
- reply(409, { error: "the file is not the one its address names (its SHA-256 differs) — send it again" });
1694
+ reply(409, refusal("the file is not the one its address names (its SHA-256 differs) — send it again", "media.sha-differs", { sha }));
1651
1695
  return;
1652
1696
  }
1653
1697
  // taken by a strom of its own: its lock, its log and its commit
@@ -1668,7 +1712,7 @@ export function serveLive(root, env) {
1668
1712
  child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
1669
1713
  child.on("error", (e) => {
1670
1714
  drop();
1671
- reply(500, { error: errorText(e) });
1715
+ reply(500, refusal(errorText(e), "media.failed"));
1672
1716
  });
1673
1717
  child.on("close", (code) => {
1674
1718
  let data = {};
@@ -1687,8 +1731,8 @@ export function serveLive(root, env) {
1687
1731
  noteLive(root, `an original was not taken (exit ${code}): ${why}`);
1688
1732
  // the research busy (another strom holding it longer than a writer waits): the app sends it again later
1689
1733
  if (code === EXIT.locked)
1690
- return reply(503, { error: why, retry: 30 });
1691
- reply(/not a kind of file/.test(why) ? 415 : /cut short|too small/.test(why) ? 422 : /the most one takes/.test(why) ? 413 : /closed already/.test(why) ? 409 : 400, { error: why });
1734
+ return reply(503, { ...refusal(why, "research.busy"), retry: 30 });
1735
+ reply(/not a kind of file/.test(why) ? 415 : /cut short|too small/.test(why) ? 422 : /the most one takes/.test(why) ? 413 : /closed already/.test(why) ? 409 : 400, refusalOf(why, data, "media.refused"));
1692
1736
  });
1693
1737
  }));
1694
1738
  });
@@ -1713,7 +1757,7 @@ export function serveLive(root, env) {
1713
1757
  child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
1714
1758
  child.on("error", (e) => {
1715
1759
  closing.delete(id);
1716
- reply(500, { error: errorText(e) });
1760
+ reply(500, { ...refusal(errorText(e), "media.failed"), batch: id });
1717
1761
  });
1718
1762
  child.on("close", (code) => {
1719
1763
  closing.delete(id);
@@ -1729,8 +1773,8 @@ export function serveLive(root, env) {
1729
1773
  const why = String(data.message ?? stderr.trim().split("\n").find((l) => l.startsWith("error:"))?.slice(6).trim() ?? "the batch was not closed");
1730
1774
  noteLive(root, `the batch ${id} was not closed (exit ${code}): ${why}`);
1731
1775
  if (code === EXIT.locked)
1732
- return reply(503, { error: why, retry: 30, batch: id });
1733
- reply(/no batch/.test(why) ? 404 : 400, { error: why, batch: id });
1776
+ return reply(503, { ...refusal(why, "research.busy"), retry: 30, batch: id });
1777
+ reply(/no batch/.test(why) ? 404 : 400, { ...refusalOf(why, data, /no batch/.test(why) ? "batch.none" : "media.refused"), batch: id });
1734
1778
  });
1735
1779
  };
1736
1780
  /** The app says a batch is whole: {name, files, person, note} — its files become sorting tasks (the note told in them). */
@@ -1740,10 +1784,10 @@ export function serveLive(root, env) {
1740
1784
  res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", ...(code === 503 ? { "Retry-After": "30" } : {}) }).end(JSON.stringify(body));
1741
1785
  };
1742
1786
  if (!origin)
1743
- return reply(403, { error: "only the Strom app may close a batch" });
1787
+ return reply(403, refusal("only the Strom app may close a batch", "app.only"));
1744
1788
  if (!BATCH_ID.test(id) || !readBatch(root, id)) {
1745
1789
  req.resume();
1746
- return reply(404, { error: `no batch ${id.slice(0, 64)} here`, batch: id });
1790
+ return reply(404, { ...refusal(`no batch ${id.slice(0, 64)} here`, "batch.none", { batch: id.slice(0, 64) }), batch: id });
1747
1791
  }
1748
1792
  let body = "";
1749
1793
  req.on("data", (c) => {
@@ -1756,7 +1800,7 @@ export function serveLive(root, env) {
1756
1800
  said = body.trim() ? JSON.parse(body) : {};
1757
1801
  }
1758
1802
  catch {
1759
- return reply(400, { error: "the body: JSON {name, files, person}" });
1803
+ return reply(400, { ...refusal("the body: JSON {name, files, person}", "batch.bad-body"), batch: id });
1760
1804
  }
1761
1805
  const persons = (Array.isArray(said.person) ? said.person : said.person ? [said.person] : []).map((p) => String(p).trim().toUpperCase()).filter((p) => /^P\d{1,9}$/.test(p));
1762
1806
  const extra = [
@@ -98,11 +98,14 @@ export function originalType(file, name) {
98
98
  return undefined;
99
99
  return named;
100
100
  }
101
- /** Why a file of that type is not whole (a JPEG or PNG cut short, a PDF without its end), or undefined. */
101
+ /**
102
+ * Why a file of that type is not whole (a JPEG or PNG cut short, a PDF without its end), or undefined — the English
103
+ * words and, for the app to say it in its own language, its code (media.too-small {bytes}, media.cut-short {kind}).
104
+ */
102
105
  export function originalProblem(file, mime) {
103
106
  const size = fs.statSync(file).size;
104
107
  if (size < 16)
105
- return `${size} bytes — too small`;
108
+ return { text: `${size} bytes — too small`, code: "media.too-small", params: { bytes: String(size) } };
106
109
  const tail = Buffer.alloc(Math.min(2048, size));
107
110
  const fd = fs.openSync(file, "r");
108
111
  try {
@@ -111,12 +114,13 @@ export function originalProblem(file, mime) {
111
114
  finally {
112
115
  fs.closeSync(fd);
113
116
  }
117
+ const short = (kind, text) => ({ text, code: "media.cut-short", params: { kind } });
114
118
  if (mime === "image/jpeg" && !tail.includes(Buffer.from([0xff, 0xd9])))
115
- return "a JPEG cut short (no end marker)";
119
+ return short("JPEG", "a JPEG cut short (no end marker)");
116
120
  if (mime === "image/png" && !tail.includes(Buffer.from("IEND")))
117
- return "a PNG cut short (no end)";
121
+ return short("PNG", "a PNG cut short (no end)");
118
122
  if (mime === "application/pdf" && !tail.includes(Buffer.from("%%EOF")))
119
- return "a PDF cut short (no end)";
123
+ return short("PDF", "a PDF cut short (no end)");
120
124
  return undefined;
121
125
  }
122
126
  /** What the research has of this content: an input or an image (by SHA-256), with where its file is. */
@@ -137,7 +141,7 @@ export function parseRegion(text) {
137
141
  const n = text.split(",").map((x) => Number(x.trim()));
138
142
  const [x, y, w, h] = n;
139
143
  if (n.length !== 4 || n.some((v) => !Number.isFinite(v)) || x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > 1.001 || y + h > 1.001)
140
- throw new UsageError(`not a part of an image: "${text.slice(0, 60)}"`, { hint: "x,y,w,h as fractions of the image (0–1) from its top left corner" });
144
+ throw new UsageError(`not a part of an image: "${text.slice(0, 60)}"`, { hint: "x,y,w,h as fractions of the image (0–1) from its top left corner", code: "media.bad-region", params: { region: text.slice(0, 60) } });
141
145
  return { x: x, y: y, w: Math.min(w, 1 - x), h: Math.min(h, 1 - y) };
142
146
  }
143
147
  /** Where an original came from (an input's `from`). */
@@ -154,7 +158,7 @@ function personOf(tree, id) {
154
158
  for (let hops = 0; p?.mergedInto && hops < 10; hops++)
155
159
  p = tree.get(p.mergedInto);
156
160
  if (!p || p.type !== "person" || p.retracted)
157
- throw new UsageError(`no person ${id} in this research`, { hint: "the person's REFN in the Strom app" });
161
+ throw new UsageError(`no person ${id} in this research`, { hint: "the person's REFN in the Strom app", code: "media.no-person", params: { person: id } });
158
162
  return p;
159
163
  }
160
164
  function sourceOf(tree, id) {
@@ -162,7 +166,7 @@ function sourceOf(tree, id) {
162
166
  for (let hops = 0; s?.mergedInto && hops < 10; hops++)
163
167
  s = tree.get(s.mergedInto);
164
168
  if (!s || s.type !== "source" || s.retracted)
165
- throw new UsageError(`no source ${id} in this research`, { hint: "the source's REFN in the Strom app" });
169
+ throw new UsageError(`no source ${id} in this research`, { hint: "the source's REFN in the Strom app", code: "media.no-source", params: { source: id } });
166
170
  return s;
167
171
  }
168
172
  /** Check what an original is for before it comes: the people and the source the research has, the part of the image. */
@@ -182,10 +186,10 @@ export function takeOriginal(tree, shared, file, sha, metaIn) {
182
186
  const name = path.basename(metaIn.name.normalize("NFC").replace(/[\\/]+/g, "/")).replace(/[\u0000-\u001f]/g, "").slice(0, 200) || `${sha.slice(0, 12)}`;
183
187
  const type = originalType(file, name);
184
188
  if (!type)
185
- throw new UsageError(`${name}: not a kind of file the research takes`, { hint: `images (JPEG, PNG, TIFF, HEIC, WebP), PDF, texts and documents, sound — ${ORIGINAL_TYPES.map((t) => t.ext[0]).join(" ")}` });
189
+ throw new UsageError(`${name}: not a kind of file the research takes`, { hint: `images (JPEG, PNG, TIFF, HEIC, WebP), PDF, texts and documents, sound — ${ORIGINAL_TYPES.map((t) => t.ext[0]).join(" ")}`, code: "media.type", params: { name } });
186
190
  const problem = originalProblem(file, type.mime);
187
191
  if (problem)
188
- throw new UsageError(`${name}: ${problem} — not taken; send it again`);
192
+ throw new UsageError(`${name}: ${problem.text} — not taken; send it again`, { code: problem.code, params: { name, ...problem.params } });
189
193
  const lang = tree.lang;
190
194
  const known = knownOriginal(tree, shared, sha);
191
195
  const s = meta.source && !meta.batch ? tree.get(meta.source) : undefined;
package/dist/core/tree.js CHANGED
@@ -27,7 +27,7 @@ import { lastBackup } from "./backup.js";
27
27
  export const TREE_FILE = "strom.json";
28
28
  // between releases the candidate being made (a bridge run from the sources says what it is: the Strom app goes by it);
29
29
  // at a release the release's own (package.json)
30
- export const VERSION = "1.13.0-beta.7";
30
+ export const VERSION = "1.13.0-beta.8";
31
31
  const GITIGNORE = `# Strom Research — local state and large files are not versioned
32
32
  .strom/
33
33
  /media/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strom-research",
3
- "version": "1.13.0-beta.7",
3
+ "version": "1.13.0-beta.8",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {