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

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.
@@ -769,6 +769,7 @@
769
769
  "ui.sync.do.title.user": "platí úprava z aplikace (verze výzkumu zůstane v historii)",
770
770
  "ui.sync.sex.changed": "{n}. {who}: pohlaví {text}",
771
771
  "ui.sync.note.new": "{n}. {who}: poznámka — „{text}“",
772
+ "ui.sync.note.changed": "{n}. {who}: poznámka — „{old}“ → „{new}“",
772
773
  "ui.sync.source.new": "{n}. pramen: „{text}“",
773
774
  "ui.sync.source.changed": "{n}. pramen z aplikace „{text}“: opravený přepis nebo stránka",
774
775
  "ui.sync.source.reading": "{n}. pramen výzkumu „{text}“: v aplikaci přečtený jinak",
@@ -809,6 +810,7 @@
809
810
  "ui.sync.place.located": "{n}. {place}: poloha na mapě {at}",
810
811
  "ui.sync.do.add": "přidá se jako vodítko",
811
812
  "ui.sync.do.correct": "opraví vodítko výzkumu",
813
+ "ui.sync.do.note.changed": "ten řádek poznámky výzkumu se opraví",
812
814
  "ui.sync.do.detail": "doplní se k údaji s odkazem na strom z aplikace",
813
815
  "ui.sync.do.refine": "doplní se k údaji s odkazem na strom z aplikace (záznamu nic neodporuje)",
814
816
  "ui.sync.do.conflict": "rozpor k rozhodnutí (výzkum to má ze záznamu)",
@@ -1283,6 +1285,7 @@
1283
1285
  "log.repository.retract": "Odvolaný archiv: {name}",
1284
1286
  "log.name.remove": "Odebrané jméno: {name} – {who}",
1285
1287
  "log.note.remove": "Odebraná poznámka: „{note}“ – {name}",
1288
+ "log.note.edit": "Opravená poznámka: {name}",
1286
1289
  "log.event.status": "Změněná jistota údaje: {who} – {fact}",
1287
1290
  "log.event.restore": "Údaj znovu platí: {who} – {fact}",
1288
1291
  "log.input.sync.undo": "Vrácený rodokmen: {name}",
@@ -769,6 +769,7 @@
769
769
  "ui.sync.do.title.user": "Die eigene Änderung gilt (die der Forschung bleibt in ihrer Geschichte)",
770
770
  "ui.sync.sex.changed": "{n}. {who}: Geschlecht {text}",
771
771
  "ui.sync.note.new": "{n}. {who}: eine Notiz — „{text}“",
772
+ "ui.sync.note.changed": "{n}. {who}: eine Notiz — „{old}“ → „{new}“",
772
773
  "ui.sync.source.new": "{n}. eine Quelle: „{text}“",
773
774
  "ui.sync.source.changed": "{n}. eigene Quelle „{text}“: Abschrift oder Seite korrigiert",
774
775
  "ui.sync.source.reading": "{n}. die Quelle der Forschung „{text}“: anders gelesen",
@@ -809,6 +810,7 @@
809
810
  "ui.sync.place.located": "{n}. {place}: Lage auf der Karte {at}",
810
811
  "ui.sync.do.add": "als Hinweis übernommen",
811
812
  "ui.sync.do.correct": "berichtigt den Hinweis der Forschung",
813
+ "ui.sync.do.note.changed": "diese Zeile der Notiz der Forschung wird berichtigt",
812
814
  "ui.sync.do.detail": "wird der Angabe hinzugefügt, mit Verweis auf den eigenen Stammbaum",
813
815
  "ui.sync.do.refine": "wird der Angabe hinzugefügt, mit Verweis auf den eigenen Stammbaum (nichts widerspricht dem Eintrag)",
814
816
  "ui.sync.do.conflict": "ein Widerspruch zum Entscheiden (die Forschung hat es aus einem Eintrag)",
@@ -1283,6 +1285,7 @@
1283
1285
  "log.repository.retract": "Archiv zurückgezogen: {name}",
1284
1286
  "log.name.remove": "Name entfernt: {name} – {who}",
1285
1287
  "log.note.remove": "Notiz entfernt: „{note}“ – {name}",
1288
+ "log.note.edit": "Notiz berichtigt: {name}",
1286
1289
  "log.event.status": "Sicherheit einer Angabe geändert: {who} – {fact}",
1287
1290
  "log.event.restore": "Angabe wieder gültig: {who} – {fact}",
1288
1291
  "log.input.sync.undo": "Stammbaum zurückgenommen: {name}",
@@ -93,36 +93,100 @@ function allowRules(agent) {
93
93
  */
94
94
  const TOML_BEGIN = "# strom: begin (strom agents install; removed by strom agents uninstall)";
95
95
  const TOML_END = "# strom: end";
96
+ const isTomlTable = (l) => /^\s*\[/.test(l);
97
+ const isPermissionHeader = (l) => /^\s*\[permission\]\s*(#.*)?$/.test(l);
98
+ const hasOwnAllow = (lines) => lines.some((l) => /^\s*allow\s*=/.test(l));
99
+ /** A line of strom's own in config.toml: its [permission] header, or an allow list of nothing but strom's rules. */
100
+ function isGrokStromLine(l) {
101
+ if (isPermissionHeader(l))
102
+ return true;
103
+ const m = /^\s*allow\s*=\s*\[(.*)\]\s*$/.exec(l);
104
+ const rules = m ? [...m[1].matchAll(/"([^"]*)"/g)].map((r) => r[1]) : [];
105
+ return rules.length > 0 && rules.every((r) => isStromRule("grok", r));
106
+ }
107
+ /** Strom's block among the lines of config.toml (split at \n): the lines of its begin and its end, or undefined. */
108
+ function grokBlock(lines) {
109
+ const b = lines.findIndex((l) => l.trim() === TOML_BEGIN);
110
+ if (b < 0)
111
+ return undefined;
112
+ const e = lines.findIndex((l, i) => i > b && l.trim() === TOML_END);
113
+ if (e > b)
114
+ return [b, e];
115
+ // its end line lost: strom's own lines after its begin, never the person's that follow
116
+ let last = b;
117
+ while (last + 1 < lines.length && isGrokStromLine(lines[last + 1]))
118
+ last++;
119
+ return [b, last];
120
+ }
121
+ /**
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.
124
+ */
96
125
  function grokWithout(text) {
97
- const lines = text.split("\n");
98
- const out = [];
99
- let inside = false;
100
- for (const l of lines) {
101
- if (l.trim() === TOML_BEGIN)
102
- inside = true;
103
- else if (l.trim() === TOML_END)
104
- inside = false;
105
- else if (!inside)
106
- out.push(l);
126
+ let lines = text.split("\n");
127
+ const blank = (i) => i >= 0 && i < lines.length && lines[i].trim() === "";
128
+ for (let at = grokBlock(lines); at; at = grokBlock(lines)) {
129
+ const [b, e] = at;
130
+ let from = b;
131
+ 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;
138
+ // 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
+ const tail = to >= lines.length && from > 0 ? [lines[from - 1].replace(/\r$/, "")] : lines.slice(from - 1, from);
140
+ lines = [...lines.slice(0, Math.max(0, from - 1)), ...tail, ...lines.slice(to)];
107
141
  }
108
- return out.join("\n");
142
+ return lines.join("\n");
109
143
  }
110
144
  function grokWith(text) {
111
- const rest = grokWithout(text);
112
145
  const allow = `allow = [${allowRules("grok").map((r) => JSON.stringify(r)).join(", ")}]`;
113
- const lines = rest.split("\n");
114
- const header = lines.findIndex((l) => /^\s*\[permission\]\s*(#.*)?$/.test(l));
146
+ const nl = text.includes("\r\n") ? "\r\n" : "\n";
147
+ const cr = nl === "\r\n" ? "\r" : "";
148
+ const lines = text.split("\n");
149
+ const at = grokBlock(lines);
150
+ if (at) {
151
+ // strom's block there already: brought to this version where it is, the person's text around it untouched
152
+ const [b, e] = at;
153
+ const header = lines.findIndex((l, i) => (i < b || i > e) && isPermissionHeader(l));
154
+ let inner;
155
+ if (header < 0)
156
+ inner = ["[permission]", allow];
157
+ else if (header < b && !lines.slice(header + 1, b).some(isTomlTable)) {
158
+ // in the person's own table: up to the next table
159
+ let end = lines.findIndex((l, i) => i > e && isTomlTable(l));
160
+ if (end < 0)
161
+ end = lines.length;
162
+ if (hasOwnAllow([...lines.slice(header + 1, b), ...lines.slice(e + 1, end)]))
163
+ return undefined;
164
+ inner = [allow];
165
+ }
166
+ if (inner) {
167
+ const last = e === lines.length - 1; // the file ends with strom's end line: no line break after it
168
+ 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");
170
+ }
171
+ // a table of strom's beside the person's own [permission] table (two would break the file): into theirs
172
+ }
173
+ const rest = at ? grokWithout(text) : text;
174
+ const rl = rest.split("\n");
175
+ const header = rl.findIndex(isPermissionHeader);
115
176
  if (header < 0) {
116
- const body = rest.replace(/\s+$/, "");
117
- return `${body ? `${body}\n\n` : ""}${TOML_BEGIN}\n[permission]\n${allow}\n${TOML_END}\n`;
177
+ // appended after a blank line, the person's last line ending as it did (grokWithout takes exactly this away)
178
+ const block = [TOML_BEGIN, "[permission]", allow, TOML_END].join(nl);
179
+ if (!rest)
180
+ return block + nl;
181
+ return rest.endsWith("\n") ? `${rest}${nl}${block}${nl}` : `${rest}${nl}${nl}${block}`;
118
182
  }
119
183
  // the user's table: up to the next table
120
- let end = lines.findIndex((l, i) => i > header && /^\s*\[/.test(l));
184
+ let end = rl.findIndex((l, i) => i > header && isTomlTable(l));
121
185
  if (end < 0)
122
- end = lines.length;
123
- if (lines.slice(header + 1, end).some((l) => /^\s*allow\s*=/.test(l)))
186
+ end = rl.length;
187
+ if (hasOwnAllow(rl.slice(header + 1, end)))
124
188
  return undefined;
125
- return [...lines.slice(0, header + 1), TOML_BEGIN, allow, TOML_END, ...lines.slice(header + 1)].join("\n");
189
+ return [...rl.slice(0, header + 1), ...[TOML_BEGIN, allow, TOML_END].map((l) => l + cr), ...rl.slice(header + 1)].join("\n");
126
190
  }
127
191
  /** A rule of strom's in Claude Code's or Antigravity's settings, whichever installation wrote it. */
128
192
  function isStromRule(agent, rule) {
@@ -223,30 +287,54 @@ function hasAllow(t, s) {
223
287
  const own = path.join(path.dirname(t.file), "strom.md");
224
288
  return isObject(bash) && bash[OPENCODE_STROM] === "allow" && Array.isArray(s.instructions) && s.instructions.includes(own);
225
289
  }
290
+ /** Strom allowed in the settings; what it made there to do it. */
226
291
  function addAllow(t, s) {
292
+ const made = [];
293
+ const plain = [];
227
294
  if (t.agent !== "opencode") {
295
+ if (!isObject(s.permissions))
296
+ made.push("permissions");
297
+ const perms = isObject(s.permissions) ? s.permissions : {};
298
+ if (!Array.isArray(perms.allow))
299
+ made.push("permissions.allow");
228
300
  const have = allowList(s);
229
- s.permissions = { ...(s.permissions ?? {}), allow: [...have, ...allowRules(t.agent).filter((r) => !have.includes(r))] };
230
- return;
301
+ s.permissions = { ...perms, allow: [...have, ...allowRules(t.agent).filter((r) => !have.includes(r))] };
302
+ return { made };
231
303
  }
304
+ if (!isObject(s.permission))
305
+ (typeof s.permission === "string" ? plain : made).push("permission");
232
306
  const perm = isObject(s.permission) ? s.permission : typeof s.permission === "string" ? { "*": s.permission } : {};
233
307
  // A plain value for bash ("ask") becomes the general rule; strom's comes after it (the last match counts).
308
+ if (!isObject(perm.bash))
309
+ (typeof perm.bash === "string" ? plain : made).push("permission.bash");
234
310
  const bash = isObject(perm.bash) ? perm.bash : typeof perm.bash === "string" ? { "*": perm.bash } : {};
235
311
  s.permission = { ...perm, bash: { ...bash, [OPENCODE_STROM]: "allow" } };
236
312
  const own = path.join(path.dirname(t.file), "strom.md");
313
+ if (!Array.isArray(s.instructions))
314
+ made.push("instructions");
237
315
  const list = Array.isArray(s.instructions) ? s.instructions : [];
238
316
  if (!list.includes(own))
239
317
  s.instructions = [...list, own];
318
+ return { made, plain };
240
319
  }
241
- function removeAllow(t, s) {
320
+ /**
321
+ * Strom's rules out of the settings. What strom made there (rec) goes when empty, and nothing else; a value it turned
322
+ * into an object becomes plain again. Nothing recorded (an installation before the record): every container left
323
+ * empty goes, as before.
324
+ */
325
+ function removeAllow(t, s, rec) {
326
+ const goes = (p) => (rec ? (rec.made ?? []).includes(p) : true);
327
+ const wasPlain = (p, v) => (rec?.plain ?? []).includes(p) && Object.keys(v).length === 1 && "*" in v;
242
328
  if (t.agent !== "opencode") {
329
+ if (!isObject(s.permissions))
330
+ return;
243
331
  const rest = allowList(s).filter((r) => !isStromRule(t.agent, r));
244
332
  const perms = { ...s.permissions };
245
- if (rest.length)
333
+ if (rest.length || !goes("permissions.allow"))
246
334
  perms.allow = rest;
247
335
  else
248
336
  delete perms.allow;
249
- if (Object.keys(perms).length)
337
+ if (Object.keys(perms).length || !goes("permissions"))
250
338
  s.permissions = perms;
251
339
  else
252
340
  delete s.permissions;
@@ -255,7 +343,7 @@ function removeAllow(t, s) {
255
343
  const own = path.join(path.dirname(t.file), "strom.md");
256
344
  if (Array.isArray(s.instructions)) {
257
345
  const rest = s.instructions.filter((i) => i !== own);
258
- if (rest.length)
346
+ if (rest.length || !goes("instructions"))
259
347
  s.instructions = rest;
260
348
  else
261
349
  delete s.instructions;
@@ -264,16 +352,97 @@ function removeAllow(t, s) {
264
352
  const bash = { ...s.permission.bash };
265
353
  delete bash[OPENCODE_STROM];
266
354
  const perm = { ...s.permission };
267
- if (Object.keys(bash).length)
355
+ if (wasPlain("permission.bash", bash))
356
+ perm.bash = bash["*"];
357
+ else if (Object.keys(bash).length || !goes("permission.bash"))
268
358
  perm.bash = bash;
269
359
  else
270
360
  delete perm.bash;
271
- if (Object.keys(perm).length)
361
+ if (wasPlain("permission", perm))
362
+ s.permission = perm["*"];
363
+ else if (Object.keys(perm).length || !goes("permission"))
272
364
  s.permission = perm;
273
365
  else
274
366
  delete s.permission;
275
367
  }
276
368
  }
369
+ /** The spans of the JSON value at i of a text JSON.parse read (so nothing here needs to check it). */
370
+ function jsonSpan(text, i) {
371
+ const ws = (k) => {
372
+ while (k < text.length && /\s/.test(text[k]))
373
+ k++;
374
+ return k;
375
+ };
376
+ const strEnd = (k) => {
377
+ for (k++; text[k] !== '"'; k++)
378
+ if (text[k] === "\\")
379
+ k++;
380
+ return k + 1;
381
+ };
382
+ const start = ws(i);
383
+ const c = text[start];
384
+ if (c === '"')
385
+ return { start, end: strEnd(start) };
386
+ if (c === "{" || c === "[") {
387
+ const keys = new Map();
388
+ const items = [];
389
+ let k = ws(start + 1);
390
+ while (text[k] !== (c === "{" ? "}" : "]")) {
391
+ if (c === "{") {
392
+ const keyEnd = strEnd(k);
393
+ const key = JSON.parse(text.slice(k, keyEnd));
394
+ const v = jsonSpan(text, ws(keyEnd) + 1); // after the colon
395
+ keys.set(key, v);
396
+ k = ws(v.end);
397
+ }
398
+ else {
399
+ const v = jsonSpan(text, k);
400
+ items.push(v);
401
+ k = ws(v.end);
402
+ }
403
+ if (text[k] === ",")
404
+ k = ws(k + 1);
405
+ }
406
+ return c === "{" ? { start, end: k + 1, keys } : { start, end: k + 1, items };
407
+ }
408
+ let end = start;
409
+ while (end < text.length && !/[\s,\]}]/.test(text[end]))
410
+ end++;
411
+ return { start, end };
412
+ }
413
+ /**
414
+ * 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.
417
+ */
418
+ function formatJson(s, text) {
419
+ if (text === undefined || !text.trim())
420
+ return JSON.stringify(s, null, 2) + "\n";
421
+ const body = text.trim();
422
+ 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)
424
+ const indent = /\n([ \t]+)\S/.exec(body)?.[1] ?? (body.includes("\n") || /^\{\s*\}$/.test(body) ? " " : "");
425
+ const same = (v, at) => JSON.stringify(v) === JSON.stringify(JSON.parse(text.slice(at.start, at.end)));
426
+ const write = (v, at, depth) => {
427
+ if (at && same(v, at))
428
+ return text.slice(at.start, at.end);
429
+ if (v === null || typeof v !== "object")
430
+ return JSON.stringify(v) ?? "null";
431
+ const entries = Array.isArray(v)
432
+ ? v.map((x, i) => [undefined, x, at?.items?.[i]])
433
+ : Object.entries(v).map(([k, x]) => [k, x, at?.keys?.get(k)]);
434
+ const [open, close] = Array.isArray(v) ? ["[", "]"] : ["{", "}"];
435
+ if (!entries.length)
436
+ 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;
440
+ const pad = (d) => indent.repeat(d);
441
+ return open + nl + parts.map((p) => pad(depth + 1) + p).join("," + nl) + nl + pad(depth) + close;
442
+ };
443
+ const lead = text.slice(0, text.length - text.trimStart().length);
444
+ return lead + write(s, jsonSpan(text, 0), 0) + text.slice(text.trimEnd().length);
445
+ }
277
446
  function readMade(file) {
278
447
  try {
279
448
  const v = JSON.parse(fs.readFileSync(file, "utf8"));
@@ -287,10 +456,11 @@ function writeMade(file, m) {
287
456
  try {
288
457
  const files = m.files && Object.keys(m.files).length ? m.files : undefined;
289
458
  const dirs = m.dirs?.length ? [...new Set(m.dirs)].sort() : undefined;
290
- if (!files && !dirs)
459
+ const json = m.json && Object.keys(m.json).length ? m.json : undefined;
460
+ if (!files && !dirs && !json)
291
461
  fs.rmSync(file, { force: true });
292
462
  else
293
- writeFileAtomic(file, JSON.stringify({ ...(files ? { files } : {}), ...(dirs ? { dirs } : {}) }, null, 2) + "\n");
463
+ writeFileAtomic(file, JSON.stringify({ ...(files ? { files } : {}), ...(dirs ? { dirs } : {}), ...(json ? { json } : {}) }, null, 2) + "\n");
294
464
  }
295
465
  catch {
296
466
  // the user config not writable: the files stay as an uninstall without the record leaves them
@@ -312,31 +482,46 @@ function hasStrom(t, text) {
312
482
  const own = path.join(path.dirname(t.file), "strom.md");
313
483
  return (Array.isArray(s.instructions) && s.instructions.includes(own)) || (isObject(s.permission) && isObject(s.permission.bash) && OPENCODE_STROM in s.permission.bash);
314
484
  }
315
- /** Recorded after a write: the file created now, or the person's (one without strom's part); the folders made for it. */
316
- function noteWritten(t, before, firstDir) {
485
+ /**
486
+ * 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).
488
+ */
489
+ function noteWritten(t, before, firstDir, json) {
317
490
  const m = readMade(t.made);
318
491
  const files = { ...(m.files ?? {}) };
319
492
  const dirs = [...(m.dirs ?? [])];
493
+ const jsons = { ...(m.json ?? {}) };
494
+ const fresh = before === undefined || !hasStrom(t, before);
320
495
  if (before === undefined)
321
496
  files[t.file] = "created";
322
497
  // there without strom's part: the person's — also one recorded created, deleted since and made again by the person
323
- else if (!hasStrom(t, before))
498
+ else if (fresh)
324
499
  files[t.file] = "existed";
325
500
  // strom's part already there: what is recorded stands; nothing recorded, an older installation's file — whose, nobody can tell
501
+ const prev = jsons[t.file];
502
+ const both = (a, b) => [...new Set([...(a ?? []), ...(b ?? [])])];
503
+ const kept = (j) => ({ ...(j.made?.length ? { made: j.made } : {}), ...(j.plain?.length ? { plain: j.plain } : {}), ...(j.blank !== undefined ? { blank: j.blank } : {}) });
504
+ if (json && fresh)
505
+ jsons[t.file] = kept(json);
506
+ else if (json && prev)
507
+ jsons[t.file] = kept({ ...prev, made: both(prev.made, json.made), plain: both(prev.plain, json.plain) });
508
+ // strom's part there and nothing recorded of it: an older installation's, taken away as it was before the record
326
509
  if (firstDir)
327
510
  for (let d = path.dirname(t.file);; d = path.dirname(d)) {
328
511
  dirs.push(d);
329
512
  if (d === firstDir || path.dirname(d) === d)
330
513
  break;
331
514
  }
332
- if (JSON.stringify(files) !== JSON.stringify(m.files ?? {}) || dirs.length !== (m.dirs ?? []).length)
333
- writeMade(t.made, { files, dirs });
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 });
334
517
  }
335
518
  /** Its part taken away: the file is the person's again (or gone); the folders strom made for it go when empty. */
336
519
  function noteRemoved(t) {
337
520
  const m = readMade(t.made);
338
521
  const files = { ...(m.files ?? {}) };
339
522
  delete files[t.file];
523
+ const json = { ...(m.json ?? {}) };
524
+ delete json[t.file];
340
525
  const here = path.dirname(t.file);
341
526
  const within = (d) => here === d || here.startsWith(d.endsWith(path.sep) ? d : d + path.sep);
342
527
  // deepest first: a folder strom made in another it made
@@ -353,12 +538,13 @@ function noteRemoved(t) {
353
538
  }
354
539
  return false;
355
540
  });
356
- writeMade(t.made, { files, dirs });
541
+ writeMade(t.made, { files, dirs, json });
357
542
  }
358
543
  /** Write it for one agent; false when it was already there as it is. */
359
544
  export function installGlobal(t) {
360
545
  const cur = read(t.file);
361
546
  let next;
547
+ let json;
362
548
  if (t.kind === "allow" && t.agent === "grok") {
363
549
  const with_ = grokWith(cur ?? "");
364
550
  if (with_ === undefined)
@@ -369,8 +555,11 @@ export function installGlobal(t) {
369
555
  const s = settingsOf(cur);
370
556
  if (!s || hasAllow(t, s))
371
557
  return false;
372
- addAllow(t, s);
373
- next = JSON.stringify(s, null, 2) + "\n";
558
+ json = addAllow(t, s);
559
+ if (cur !== undefined && !cur.trim())
560
+ json.blank = cur;
561
+ // written as the person's file is: its indentation, its line breaks, a line break at its end or none
562
+ next = formatJson(s, cur);
374
563
  }
375
564
  else if (t.kind === "own")
376
565
  next = t.agent === "claude" || t.agent === "grok" ? skill(t.app) : globalText(t.app);
@@ -382,12 +571,13 @@ export function installGlobal(t) {
382
571
  return false;
383
572
  const firstDir = fs.mkdirSync(path.dirname(t.file), { recursive: true });
384
573
  writeFileAtomic(t.file, next);
385
- noteWritten(t, cur, firstDir);
574
+ noteWritten(t, cur, firstDir, json);
386
575
  return true;
387
576
  }
388
577
  /** Take it away again; false when there was nothing of strom's. */
389
578
  export function uninstallGlobal(t) {
390
- const removed = takeAway(t, readMade(t.made).files?.[t.file]);
579
+ const m = readMade(t.made);
580
+ const removed = takeAway(t, m.files?.[t.file], m.json?.[t.file]);
391
581
  if (removed)
392
582
  noteRemoved(t);
393
583
  return removed;
@@ -397,18 +587,17 @@ export function uninstallGlobal(t) {
397
587
  * an installation that recorded nothing stays as it did before the record — a settings file kept as {}, a text file
398
588
  * with nothing but strom's part gone.
399
589
  */
400
- function takeAway(t, made) {
590
+ function takeAway(t, made, rec) {
401
591
  const cur = read(t.file);
402
592
  if (cur === undefined)
403
593
  return false;
404
594
  if (t.kind === "allow" && t.agent === "grok") {
405
- if (!cur.includes(TOML_BEGIN))
595
+ if (!grokBlock(cur.split("\n")))
406
596
  return false;
597
+ // the person's text before and after strom's block as it was
407
598
  const rest = grokWithout(cur);
408
- if (rest.trim())
409
- writeFileAtomic(t.file, rest.replace(/\n{3,}/g, "\n\n").replace(/\s*$/, "\n"));
410
- else if (made === "existed")
411
- writeFileAtomic(t.file, "");
599
+ if (rest.trim() || made === "existed")
600
+ writeFileAtomic(t.file, rest);
412
601
  else
413
602
  fs.rmSync(t.file);
414
603
  return true;
@@ -419,11 +608,15 @@ function takeAway(t, made) {
419
608
  const any = t.agent === "opencode" ? s && hasAllow(t, s) : s && allowList(s).some((r) => isStromRule(t.agent, r));
420
609
  if (!s || !any)
421
610
  return false;
422
- removeAllow(t, s);
611
+ removeAllow(t, s, rec);
423
612
  if (!Object.keys(s).length && made === "created")
424
613
  fs.rmSync(t.file);
614
+ // the person's blank file: blank again
615
+ else if (!Object.keys(s).length && rec?.blank !== undefined)
616
+ writeFileAtomic(t.file, rec.blank);
617
+ // written as the person's file is
425
618
  else
426
- writeFileAtomic(t.file, JSON.stringify(s, null, 2) + "\n");
619
+ writeFileAtomic(t.file, formatJson(s, cur));
427
620
  return true;
428
621
  }
429
622
  if (t.kind === "own") {
@@ -455,8 +648,9 @@ export function isInstalled(t) {
455
648
  const cur = read(t.file);
456
649
  if (cur === undefined)
457
650
  return false;
651
+ // strom's block there, wherever the person's text puts it (an install brings it to this version where it is)
458
652
  if (t.kind === "allow" && t.agent === "grok")
459
- return cur.includes(TOML_BEGIN) && grokWith(cur) === cur;
653
+ return grokBlock(cur.split("\n")) !== undefined;
460
654
  if (t.kind === "allow")
461
655
  return hasAllow(t, settingsOf(cur) ?? {});
462
656
  return t.kind === "own" || cur.includes(BEGIN);
package/dist/cli/ui.js CHANGED
@@ -676,6 +676,7 @@ export const UI = {
676
676
  "ui.sync.do.title.user": "the edit wins (the research's in its history)",
677
677
  "ui.sync.sex.changed": "{n}. {who}: sex {text}",
678
678
  "ui.sync.note.new": "{n}. {who}: a note — “{text}”",
679
+ "ui.sync.note.changed": "{n}. {who}: a note — “{old}” → “{new}”",
679
680
  "ui.sync.source.new": "{n}. a source: “{text}”",
680
681
  "ui.sync.source.changed": "{n}. the file's source “{text}”: its transcript or page corrected",
681
682
  "ui.sync.source.reading": "{n}. the research's source “{text}”: read otherwise in the file",
@@ -725,6 +726,7 @@ export const UI = {
725
726
  "ui.sync.place.located": "{n}. {place}: on the map at {at}",
726
727
  "ui.sync.do.add": "added as a lead",
727
728
  "ui.sync.do.correct": "corrects the research's lead",
729
+ "ui.sync.do.note.changed": "that line of the research's note corrected",
728
730
  "ui.sync.do.detail": "added to the fact, citing the tree sent",
729
731
  "ui.sync.do.refine": "added to the fact, citing the tree sent (nothing in it disagrees with the record)",
730
732
  "ui.sync.do.conflict": "a conflict to decide (the research has it from a record)",
@@ -102,8 +102,8 @@ function changeLine(tree, c, incoming, lang) {
102
102
  now: c.kind === "fact.detail" ? detailText(c.fact, lang, short) : factText(c.fact, lang, short),
103
103
  when: [humanDate(c.fact?.date, lang), humanPlace(c.fact?.place, undefined, lang)].filter(Boolean).join(", "),
104
104
  text: truncate(c.text ?? "", 120),
105
- old: c.title ? truncate(c.title.was, 80) || "—" : shownName(c.wasName),
106
- new: c.title ? truncate(c.text ?? "", 80) || "—" : shownName(c.text),
105
+ old: c.note ? truncate(c.note.was, 80) : c.title ? truncate(c.title.was, 80) || "—" : shownName(c.wasName),
106
+ new: c.note ? truncate(c.text ?? "", 80) : c.title ? truncate(c.text ?? "", 80) || "—" : shownName(c.text),
107
107
  title: c.title ? ui(lang, c.title.part === "before" ? "ui.conflict.titleBefore" : "ui.conflict.titleAfter") : "",
108
108
  child: name(c.child),
109
109
  // a child's tie to each parent: one word for both, else each parent's
@@ -138,17 +138,19 @@ function changeLine(tree, c, incoming, lang) {
138
138
  ? "ui.sync.do.name.user"
139
139
  : c.kind === "name.title" && (c.action === "add" || c.action === "user")
140
140
  ? `ui.sync.do.title${c.action === "user" ? ".user" : ""}`
141
- : c.kind === "family.union" && c.action !== "report"
142
- ? "ui.sync.do.family.union"
143
- : c.kind === "fact.detail" && c.action === "add"
144
- ? "ui.sync.do.detail"
145
- : c.kind === "fact.part" && (c.action === "add" || c.action === "user")
146
- ? `ui.sync.do.part${c.action === "user" ? ".user" : ""}`
147
- : c.kind.startsWith("source.") || c.kind === "fact.cite" || c.kind === "person.cite" || c.kind === "family.cite"
148
- ? `ui.sync.do.${c.kind}${c.reads || (c.kind === "source.new" && newReads(c, incoming)) ? ".read" : ""}`
149
- : c.kind === "fact.new" && c.reads
150
- ? "ui.sync.do.reading"
151
- : `ui.sync.do.${c.action}`;
141
+ : c.kind === "note.changed" && c.action !== "pick"
142
+ ? "ui.sync.do.note.changed"
143
+ : c.kind === "family.union" && c.action !== "report"
144
+ ? "ui.sync.do.family.union"
145
+ : c.kind === "fact.detail" && c.action === "add"
146
+ ? "ui.sync.do.detail"
147
+ : c.kind === "fact.part" && (c.action === "add" || c.action === "user")
148
+ ? `ui.sync.do.part${c.action === "user" ? ".user" : ""}`
149
+ : c.kind.startsWith("source.") || c.kind === "fact.cite" || c.kind === "person.cite" || c.kind === "family.cite"
150
+ ? `ui.sync.do.${c.kind}${c.reads || (c.kind === "source.new" && newReads(c, incoming)) ? ".read" : ""}`
151
+ : c.kind === "fact.new" && c.reads
152
+ ? "ui.sync.do.reading"
153
+ : `ui.sync.do.${c.action}`;
152
154
  // an archive: nobody reads a record there, nor is an agent told — said without it
153
155
  const said = isArchive(tree) && `${does}.archive` in UI ? `${does}.archive` : does;
154
156
  return `${ui(lang, key, v)} → ${ui(lang, said)}`;
@@ -54,6 +54,7 @@ const SAID = {
54
54
  "story.approve": "log.story.approve",
55
55
  "story.discard": "log.story.discard",
56
56
  "note.add": "log.note.add",
57
+ "note.edit": "log.note.edit",
57
58
  "input.add": "log.input.add",
58
59
  "input.sync": "log.input.sync",
59
60
  "input.done": "log.input.done",
@@ -173,6 +173,7 @@ export const PHRASES = {
173
173
  "log.repository.retract": "Archive withdrawn: {name}",
174
174
  "log.name.remove": "Name taken off: {name} – {who}",
175
175
  "log.note.remove": "Note taken off: \"{note}\" – {name}",
176
+ "log.note.edit": "Note corrected: {name}",
176
177
  "log.event.status": "How sure a fact is, changed: {who} – {fact}",
177
178
  "log.event.restore": "A fact back again: {who} – {fact}",
178
179
  "log.input.sync.undo": "Family tree taken back: {name}",
package/dist/core/sync.js CHANGED
@@ -386,6 +386,39 @@ function knownNote(text, said) {
386
386
  // a word or two of the app's own ("Birth:", "Address:") around what the research said — never a short note all new
387
387
  return unknown.length <= w.size * 0.2 || (unknown.length < 3 && unknown.length * 2 <= w.size);
388
388
  }
389
+ /** A line of a note as compared: NFC, its spaces one (the app joins what GEDCOM splits). */
390
+ const noteLine = (t) => t.normalize("NFC").replace(/\s+/g, " ").trim();
391
+ /** The words of a line as the user wrote them (lower case, accents kept, a letter alone too): what an edit changes. */
392
+ const lineWords = (t) => new Set(t.normalize("NFC").toLowerCase().match(/[\p{L}\p{M}\p{N}]+/gu) ?? []);
393
+ /**
394
+ * Of the research's lines, the one this line is an edit of: at least `share` of the words of the longer of the two in
395
+ * both (a year changed, a name corrected, a word added or taken away), the most of them.
396
+ */
397
+ function closest(ours, line, share) {
398
+ const w = lineWords(line);
399
+ let best;
400
+ let most = 0;
401
+ for (const x of ours) {
402
+ const v = lineWords(x.line);
403
+ const both = [...w].filter((y) => v.has(y)).length;
404
+ if (both && both >= share * Math.max(w.size, v.size) && both > most)
405
+ [best, most] = [x, both];
406
+ }
407
+ return best;
408
+ }
409
+ /** A note with one of its lines (as compared) another: the first such line, the others as they are. */
410
+ function swapLine(text, was, now) {
411
+ const lines = text.split("\n");
412
+ const i = lines.findIndex((l) => noteLine(l) === was);
413
+ if (i >= 0)
414
+ lines[i] = now;
415
+ return lines.join("\n");
416
+ }
417
+ /** The line only adds words around the research's ("Address: čp. 35" of "čp. 35"): the app's own shape, no line of its own. */
418
+ function onlyAdds(ours, line) {
419
+ const w = lineWords(line);
420
+ return [...lineWords(ours)].every((y) => w.has(y));
421
+ }
389
422
  /**
390
423
  * A tree the Strom app sent the bridge, with the version the app said with it (X-Strom-App-Version, ?app=) as its HEAD's
391
424
  * 2 VERS under 1 SOUR STROM, where the file names no version of the app (the app writes "1.0" there): the file is read
@@ -1988,13 +2021,52 @@ export function planSync(tree, incoming, edits, opts = {}) {
1988
2021
  if (fresh.length)
1989
2022
  push({ kind: owner.family || owner.partners ? "family.cite" : "person.cite", action: "add", ...owner, cites: fresh, ...(fresh.some((c) => reads(c.source)) ? { reads: true } : {}) });
1990
2023
  };
1991
- /** A note coming back holds the research's too (the app joins them): only its lines the research has not said. */
1992
- const notesDiff = (owner, inc, said, facts) => {
2024
+ /**
2025
+ * A note coming back holds the research's too (the app joins them): only its lines the research has not said. A line
2026
+ * of the research's own note the user edited in the app (a year, a name: hardly a word else — N38, found with the
2027
+ * app's 3.10.0-beta.13: "nothing new taken", the edit lost) is no line the research has said: the user's own note
2028
+ * (typed by the user, or the app's) corrected, a session's kept and the user's line beside it (sync.edits user, an
2029
+ * archive: corrected too); a line like one the note still has, a line of its own. A line as the app was given it
2030
+ * (the base) is no edit: the research's since stays.
2031
+ */
2032
+ const notesDiff = (owner, inc, said, facts, mine) => {
1993
2033
  const words = new Set(wordsOf([...said, ...facts.map((f) => [f.place, f.value, f.label].join(" "))].join("\n")));
2034
+ // what the research says, and what the app was given (the base: a copy older than the research's edit is no edit)
2035
+ const exactly = new Set(said.flatMap((t) => t.split(/\n+/)).map(noteLine).filter(Boolean));
2036
+ const sent = new Set((inc ?? []).flatMap((t) => t.split(/\n+/)).map(noteLine).filter(Boolean));
2037
+ const ours = (mine ?? []).flatMap((n) => n.text.split(/\n+/).map(noteLine).filter(Boolean).map((line) => ({ note: n, line })));
2038
+ const used = new Set();
1994
2039
  for (const t of inc ?? []) {
1995
- const fresh = t.split(/\n+/).filter((line) => line.trim() && !knownNote(line, words));
1996
- if (fresh.length && !knownNote(fresh.join("\n"), words))
1997
- push({ kind: "note.new", action: "add", ...owner, text: fresh.join("\n") });
2040
+ const lines = t.split(/\n+/).map((line) => line.trim()).filter(Boolean);
2041
+ const own = new Set();
2042
+ const fresh = lines.filter((line) => {
2043
+ const l = noteLine(line);
2044
+ if (exactly.has(l))
2045
+ return false;
2046
+ // an edit of a line the note no longer has; a line beside one it still has, like it but for a word or two
2047
+ const edit = closest(ours.filter((x) => !used.has(x) && !sent.has(x.line)), l, 0.5);
2048
+ const like = !edit && closest(ours.filter((x) => sent.has(x.line) && !onlyAdds(x.line, l)), l, 0.6);
2049
+ if (edit) {
2050
+ used.add(edit);
2051
+ // a file that names no commit it stands on (an older export, another program's): which is newer nobody can
2052
+ // tell — only shown, taken with --only (N39: an older copy set the user's correction back)
2053
+ if (!base) {
2054
+ push({ kind: "note.changed", action: "pick", ...owner, text: line.normalize("NFC"), note: { at: edit.note.at, was: edit.line } });
2055
+ return false;
2056
+ }
2057
+ if (edit.note.by === "user" || edits === "user") {
2058
+ push({ kind: "note.changed", action: "correct", ...owner, text: line.normalize("NFC"), note: { at: edit.note.at, was: edit.line } });
2059
+ return false;
2060
+ }
2061
+ }
2062
+ if (edit || like)
2063
+ own.add(line);
2064
+ return !edit && !like && !knownNote(line, words);
2065
+ });
2066
+ const take = fresh.length > 0 && !knownNote(fresh.join("\n"), words);
2067
+ const out = lines.filter((line) => own.has(line) || (take && fresh.includes(line)));
2068
+ if (out.length)
2069
+ push({ kind: "note.new", action: "add", ...owner, text: out.join("\n") });
1998
2070
  }
1999
2071
  };
2000
2072
  // people
@@ -2081,7 +2153,7 @@ export function planSync(tree, incoming, edits, opts = {}) {
2081
2153
  const overGuess = mySex === "U" && guessed !== undefined && !incoming.sexU;
2082
2154
  push({ kind: "sex.changed", action: b ? (edits === "user" ? "user" : backed || since || overGuess ? "conflict" : "correct") : "report", person: id, name, text: p.sex });
2083
2155
  }
2084
- notesDiff({ person: id, name }, p.notes, [o.said, b?.said ?? "", ...o.names], o.facts);
2156
+ notesDiff({ person: id, name }, p.notes, [o.said, b?.said ?? "", ...o.names], o.facts, tree.get(id)?.notes);
2085
2157
  }
2086
2158
  if (base && !partial)
2087
2159
  for (const [key, b] of base.persons)
@@ -2264,7 +2336,7 @@ export function planSync(tree, incoming, edits, opts = {}) {
2264
2336
  const both = [...new Set([...o.partners, ...partners])];
2265
2337
  factsDiff({ family: fam.id, partners: both }, f.facts, o.facts, b?.facts, true);
2266
2338
  citesDiff({ family: fam.id, partners: both }, f.cites, fam, b?.cites);
2267
- notesDiff({ family: fam.id, partners: both }, f.notes, [o.said ?? "", b?.said ?? "", ...both.map((k) => ours.persons.get(k)?.names.join(" ") ?? "")], o.facts);
2339
+ notesDiff({ family: fam.id, partners: both }, f.notes, [o.said ?? "", b?.said ?? "", ...both.map((k) => ours.persons.get(k)?.names.join(" ") ?? "")], o.facts, fam.notes);
2268
2340
  // how the couple is bound (no record proves it): the user's word where it changed since given, or where the research
2269
2341
  // has none; another word with nothing to stand on only said; none in the file takes nothing away (an older app)
2270
2342
  // (the one partner's married or divorced is the couple's facts' to say once the unknown one is named)
@@ -3410,6 +3482,23 @@ export function applySync(tree, plan, incoming, source, only) {
3410
3482
  applied.push({ do: "note.add", id: owner, before: n.at });
3411
3483
  break;
3412
3484
  }
3485
+ case "note.changed": {
3486
+ // the user's edit of a line of the research's note: that line of it corrected, the rest of the note as it was
3487
+ if (!owner || !c.text || !c.note)
3488
+ break;
3489
+ const { at, was } = c.note;
3490
+ const edited = noteLine(c.text);
3491
+ const rec = tree.get(owner);
3492
+ if (!rec?.notes.some((n) => n.at === at && n.text.split("\n").some((l) => noteLine(l) === was)))
3493
+ break;
3494
+ update(tree, owner, typeOfId(owner) === "family" ? "family" : "person", (o) => ({ ...o, notes: o.notes.map((n) => (n.at === at ? { ...n, text: swapLine(n.text, was, edited) } : n)) }), {
3495
+ op: "note.edit",
3496
+ summary: `${owner} note "${truncateText(was, 50)}" → "${truncateText(edited, 50)}": the user's edit (${reason})`,
3497
+ reason,
3498
+ });
3499
+ applied.push({ do: "note.edit", id: owner, before: JSON.stringify({ at, now: edited, was }) });
3500
+ break;
3501
+ }
3413
3502
  case "sex.changed": {
3414
3503
  if (!owner || !c.text)
3415
3504
  break;
@@ -3911,6 +4000,19 @@ export function undoSync(tree, input) {
3911
4000
  : { op: "person.edit", summary: `${a.id} shown by its name before again: ${reason}`, reason });
3912
4001
  break;
3913
4002
  }
4003
+ case "note.edit": {
4004
+ // the line as it was, back in its note (the note as it is now otherwise)
4005
+ const b = JSON.parse(String(a.before ?? "{}"));
4006
+ const rec = tree.get(a.id);
4007
+ if (!b.at || !b.now || !b.was || !rec?.notes.some((n) => n.at === b.at && n.text.split("\n").some((l) => noteLine(l) === b.now)))
4008
+ break;
4009
+ update(tree, a.id, typeOfId(a.id) === "family" ? "family" : "person", (o) => ({ ...o, notes: o.notes.map((n) => (n.at === b.at ? { ...n, text: swapLine(n.text, b.now, b.was) } : n)) }), {
4010
+ op: "note.edit",
4011
+ summary: `${a.id} note "${truncateText(b.now, 50)}" → "${truncateText(b.was, 50)}" again: ${reason}`,
4012
+ reason,
4013
+ });
4014
+ break;
4015
+ }
3914
4016
  case "note.add":
3915
4017
  update(tree, a.id, typeOfId(a.id) ?? "person", (o) => ({ ...o, notes: o.notes.filter((x) => x.at !== a.before) }), { op: "note.remove", summary: `${a.id} note "${noteText(tree, a.id, a.before)}" removed: ${reason}`, reason });
3916
4018
  break;
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.6";
30
+ export const VERSION = "1.13.0-beta.7";
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.6",
3
+ "version": "1.13.0-beta.7",
4
4
  "description": "Genealogical research toolkit for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {