klypix-mcp 1.94.0 → 1.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/FORMAT.md CHANGED
@@ -170,6 +170,26 @@ writing, so two copies of an identical card differ in it. Any comparison that de
170
170
  learned this the hard way: a byte-compare spawned conflict twins for cards nobody
171
171
  touched. `mergeBrains` exports `sameMeaning(a, b)` as the single correct comparison.
172
172
 
173
+ **A saved reading is DERIVED — not content either.** KLYPIX keeps what it read from a
174
+ card's file on the card: `derivedText`, with `derivedTextKind` (`document-text`,
175
+ `audio-transcript`, `video-transcript`, `video-analysis`, `image-understanding`),
176
+ `derivedTextSource` (`local` | `cloud`) and, depending on the kind, `derivedTextAt`,
177
+ `derivedTextVisuals` and `derivedTextSha`. A reading is made again from the bytes whenever
178
+ it is missing, so `sameMeaning` strips these fields too: two copies that differ only in
179
+ their reading are one card (no conflict twin), and a reading made on one side never brings
180
+ back a card the other side deleted. A merge still keeps readings. Of two such copies it
181
+ writes the one with the better reading: a current one first, then one with visuals, then one
182
+ made by cloud AI, then the longer text. When one side's edit wins, the other side's reading
183
+ is carried onto it as long as it is still a reading of the card's bytes. Identities do not
184
+ change: `itemSignature`, and with it bin-entry ids (`rid`), conflict-twin ids and revived ids,
185
+ hashes the card with its reading, exactly as before.
186
+
187
+ A document's reading (`derivedTextKind: "document-text"`, on a `file` card) carries
188
+ `derivedTextSha`: the card's `assetSha`, else its `assetId`, when it was read. It is current
189
+ only while that still names the card's bytes; a reader must not serve a reading of earlier
190
+ bytes as what the file says. A credential file's reading (`.env`, `.pem`, `.key`, `.npmrc` …)
191
+ is never served — KLYPIX does not keep one.
192
+
173
193
  ## Bytes: when they are embedded vs referenced
174
194
 
175
195
  **Embedded** — the bytes live inside the ZIP under `assets/`, and the item JSON
package/README.md CHANGED
@@ -707,6 +707,12 @@ the KLYPIX app). Pass the ids `read_canvas` prints; every card with something in
707
707
  | Folder | Its file list. Add `entry_paths` (up to 8 file paths from the list) to get those files the same way. Paths that would leave the folder are refused; an entry over 64 MB, or one that inflates more than 200 times its stored size, is refused with its size. A folder kept on disk (not embedded) is read from where it is, never outside it. |
708
708
  | Audio, video | The reading KLYPIX saved, when there is one. Otherwise a local path and a plain statement that nothing in the answer says what it contains; a video's saved poster frame is attached and labelled as one frame. |
709
709
 
710
+ A PDF, Office or text file that KLYPIX has already read also comes back with the text KLYPIX
711
+ saved from it, first (`method: document_text`), and the file by path beside it. That text is
712
+ used only while the card still holds the file it was read from: a reading of an earlier version
713
+ of the file is never served, shown by `read_canvas` or matched by `search_canvases`, and the
714
+ file itself is handed over as above.
715
+
710
716
  Limits, and why:
711
717
 
712
718
  - **One answer stays under 1 MB.** Claude Desktop refuses a whole tool result over 1 MB, so the images
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.94.0",
3
+ "version": "1.95.0",
4
4
  "mcpName": "io.github.dahshanlabs/klypix-mcp",
5
5
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
6
6
  "type": "module",
@@ -86,7 +86,7 @@
86
86
  "bench": "node bin/klypix-mcp.mjs bench",
87
87
  "test:bench": "node test/bench.mjs",
88
88
  "pretest": "node test/publish-workflow.mjs",
89
- "test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/session-reopen.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
89
+ "test": "node test/publish-verdict.mjs && node test/npx-owned-names.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/session-mailbox.mjs && node test/session-reopen.mjs && node test/lane-write-retry.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/capture-gap.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
90
90
  "test:memory": "node test/memory-runtime.mjs",
91
91
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
92
92
  "runtime": "node bin/klypix-runtime.mjs",
@@ -96,7 +96,7 @@
96
96
  },
97
97
  "dependencies": {
98
98
  "@modelcontextprotocol/ext-apps": "^1.7.5",
99
- "@modelcontextprotocol/sdk": "^1.30.0",
99
+ "@modelcontextprotocol/sdk": "^1.31.0",
100
100
  "fractional-indexing": "^3.2.0",
101
101
  "jpeg-js": "^0.4.4",
102
102
  "jszip": "^3.10.1",
package/src/app-lease.mjs CHANGED
@@ -181,7 +181,7 @@ export const TELL_USER = {
181
181
  // only, so a photo, a video or a file card needs a different human step.
182
182
  NOT_READ_IMAGE: 'Right-click the photo in KLYPIX and choose Extract text (OCR). After the canvas saves, ask me again.',
183
183
  NOT_READ_MEDIA: "Open the canvas in KLYPIX and ask KLYPIX's AI about that card; KLYPIX saves what it reads on the card. After the canvas saves, ask me again.",
184
- NOT_READ_DOCUMENT: "KLYPIX does not save the text of a document card yet, so I can't read it from the saved canvas. Open it in KLYPIX if you need its text.",
184
+ NOT_READ_DOCUMENT: "Open the canvas in KLYPIX and ask KLYPIX's AI about that card; KLYPIX saves the text it reads from a document on the card. After the canvas saves, ask me again.",
185
185
  };
186
186
 
187
187
  export function tellUser(code, vars = {}) {
package/src/app-tools.mjs CHANGED
@@ -30,8 +30,10 @@
30
30
  // runtime missing it degrades to file mode instead of losing these tools.
31
31
  //
32
32
  // P0 FILE MODE: both tools read what the KLYPIX app has already saved
33
- // into a canvas — transcripts on media cards (derivedText), Read contents result
34
- // cards, OCR cards, folder listings — and the lease file a running KLYPIX
33
+ // into a canvas — transcripts on media cards (derivedText), a document's text
34
+ // on its file card (derivedText, served only while its derivedTextSha names the
35
+ // bytes the card holds), Read contents result cards, OCR cards, folder
36
+ // listings — and the lease file a running KLYPIX
35
37
  // writes. Nothing here reads a page, a reel or a document itself: that is the
36
38
  // app's own value (its readers, keys, consents and credits), and from P1 an AI
37
39
  // tool reaches it through the app, never around it.
@@ -66,7 +68,7 @@ import {
66
68
  } from './app-lease.mjs';
67
69
  import { brainTarget, canvasTitleOf, resolveCanvasDetailed, walkVault } from './klypix-core.mjs';
68
70
  import {
69
- classifyLinkTier, derivedReading, escapeInstructionLines, fenceContent, parseKlypix, readManifestCheap, recordedAuthor, savedReadingIndex, scopeLockedView,
71
+ classifyLinkTier, derivedReading, DOCUMENT_TEXT_KIND, escapeInstructionLines, fenceContent, parseKlypix, readManifestCheap, recordedAuthor, savedReadingIndex, scopeLockedView,
70
72
  } from './klypix-format.mjs';
71
73
  import {
72
74
  LIMITS, assetBytes, cacheBytes, dataUrlImage, decodeText, diskBytes, extOf, findZipEntry, fitImage, htmlPreviewToText,
@@ -407,6 +409,9 @@ function linkMethod(url, partial) {
407
409
  return { method: 'page_fetch', ranOn: 'this_pc' };
408
410
  }
409
411
  function derivedMethod(item, reading) {
412
+ // A document's text: derivedReading served it only because it is of the
413
+ // bytes the card holds now (savedDocumentText).
414
+ if (reading.kind === DOCUMENT_TEXT_KIND) return 'document_text';
410
415
  if (reading.kind === 'image-understanding') return 'image_understanding';
411
416
  if (reading.kind === 'video-transcript' || (item.type === 'video' && reading.visuals === false)) return 'audio_only_transcript';
412
417
  return 'media_transcript';
@@ -729,8 +734,11 @@ export async function readCardContents({ vault, canvas, card_ids, read_new, refr
729
734
  if (truncated) anyTruncated = true;
730
735
  const entry = { card_id: id, card_type: type, ...result, text: shown, truncated, file: fileInfo, ...(entries ? { entries } : {}), ...(frozenIds.has(id) ? { frozen: true } : {}) };
731
736
  if (cardSentence) { entry.code = notReadCode; entry.tell_user = cardSentence; sentences.push(cardSentence); }
732
- // refresh: in file mode a fresh reading is the person's step in KLYPIX.
733
- if (refresh === true && !['card_text', 'folder_listing', 'file_text', 'image', 'file_path', 'saved_preview'].includes(result.method)) {
737
+ // refresh: in file mode a fresh reading is the person's step in KLYPIX —
738
+ // but not for a document's text: it is served only while it is of the
739
+ // bytes the card holds, and a new read of those bytes gives the same
740
+ // words (the file itself goes by path beside it), so no step is sent.
741
+ if (refresh === true && !['card_text', 'folder_listing', 'file_text', 'image', 'file_path', 'saved_preview', 'document_text'].includes(result.method)) {
734
742
  const sentence = stepFor(item);
735
743
  if (sentence) { entry.tell_user = sentence; sentences.push(sentence); }
736
744
  }
@@ -159,6 +159,27 @@ export const shard = (id) => id.replace(/^[a-z]+[_:]/i, '').toLowerCase().slice(
159
159
  // bearing: a real edit to any of them is still a real conflict.
160
160
  export const VOLATILE_ITEM_FIELDS = ['updatedAt', 'zIndex', 'editedAt'];
161
161
 
162
+ // READING = a card's saved reading: the words KLYPIX's own readers took out of
163
+ // the card's file — a document's text, a video's or a voice note's transcript
164
+ // or analysis (the app's READING_FIELD_KEYS, items/documentReadingFields.ts).
165
+ // Derived data, made again from the card's bytes whenever it is missing, so it
166
+ // is no part of what a card MEANS (meaningSignature, sameMeaning):
167
+ // • two copies whose only difference is their reading are one card — no
168
+ // conflict twin, no content conflict;
169
+ // • a reading made on one side is not an edit, so it never brings back a
170
+ // card the other side deleted: the delete wins.
171
+ // It IS part of a card's identity, exactly as in every engine up to 1.94:
172
+ // itemSignature — and with it bin identities (fullEntryRid, receiptIdentity),
173
+ // conflict-twin ids and revived ids — hashes the card with its reading, so a
174
+ // receipt or a twin an older engine minted names the same bytes here. A check
175
+ // of a copy against a stored identity also accepts the copy with a reading
176
+ // KLYPIX made since (holdsEntryBytes).
177
+ // A merge keeps a reading whenever it keeps the card: of two copies that mean
178
+ // the same it writes the one with the better reading (pickReadingCopy), and
179
+ // where one side's edit wins it carries the other side's reading onto it while
180
+ // that reading is still of the bytes the card holds (carryReading).
181
+ export const READING_ITEM_FIELDS = ['derivedText', 'derivedTextKind', 'derivedTextSource', 'derivedTextSha', 'derivedTextAt', 'derivedTextVisuals'];
182
+
162
183
  // Key-sorted JSON, so two writers' key orders can never fake a difference.
163
184
  const stableJson = (v) => JSON.stringify(v, (_k, val) =>
164
185
  (val && typeof val === 'object' && !Array.isArray(val))
@@ -184,28 +205,128 @@ export function pickThreeWay(o, t, b) {
184
205
  * or any strings): true when the first wins. */
185
206
  export const canonicalFirst = (a, b) => a > b;
186
207
 
187
- /** A card's MEANING as a canonical string: parsed, volatile fields stripped,
188
- * key-sorted. Unparseable JSON falls back to byte identity — a malformed item
189
- * must never crash a merge. */
190
- export function itemSignature(json) {
208
+ // ── Readings in a merge ──────────────────────────────────────────────────────
209
+ // Which of two readings is the better one, best first: a reading with visuals
210
+ // (a video analysis, or a transcript made with the frames), then one made by
211
+ // cloud AI (it cost money; a local transcript can be made again for free), then
212
+ // the longer text; two that tie on all three go by their key-sorted JSON. From
213
+ // the two readings alone — never the base, never the side — so every machine
214
+ // and both transports pick the same one, and a merge never trades a paid or a
215
+ // richer reading for a poorer one.
216
+ const readingRank = (f) => [
217
+ f.derivedTextKind === 'video-analysis' || f.derivedTextVisuals === true ? 1 : 0,
218
+ f.derivedTextSource === 'cloud' ? 1 : 0,
219
+ typeof f.derivedText === 'string' ? f.derivedText.length : 0,
220
+ ];
221
+ /** True when reading fields `a` rank above the different reading fields `b`. */
222
+ function readingBeats(a, b) {
223
+ const ra = readingRank(a), rb = readingRank(b);
224
+ for (let i = 0; i < ra.length; i++) if (ra[i] !== rb[i]) return ra[i] > rb[i];
225
+ return canonicalFirst(stableJson(a), stableJson(b));
226
+ }
227
+ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
228
+ const readingFieldsIn = (item) => {
229
+ const fields = {};
230
+ for (const f of READING_ITEM_FIELDS) if (item[f] !== undefined) fields[f] = item[f];
231
+ return fields;
232
+ };
233
+ // A card's reading fields, and whether that reading is current. Most cards have
234
+ // none, so a JSON string that never names the field is not parsed.
235
+ function readingFieldsOf(json) {
236
+ const none = { fields: {}, current: false };
237
+ if (typeof json !== 'string' || !json.includes('derivedText')) return none;
238
+ let item;
239
+ try { item = JSON.parse(json); } catch { return none; }
240
+ if (!isPlainObject(item)) return none;
241
+ return { fields: readingFieldsIn(item), current: readingIsCurrent(item) };
242
+ }
243
+
244
+ /** Of two copies of one card that mean the same (sameMeaning), the one a merge
245
+ * writes: 'ours' or 'theirs'. They differ at most in volatile and reading
246
+ * fields, and the reading is what AI tools are served, so the reading
247
+ * decides — from the two copies alone, the same way on every machine
248
+ * whichever side is ours (git and Brain Sync compare readings, so an
249
+ * ours-first pick would hand two readings back and forth between the repo
250
+ * and the cloud): a current reading (readingIsCurrent: a document's text only
251
+ * while derivedTextSha names the bytes the card holds) beats none and beats
252
+ * a stale one; then the better reading (readingBeats). Copies with the same
253
+ * reading keep ours, as before: they differ in volatile fields only. */
254
+ export function pickReadingCopy(o, t) {
255
+ if (o === t) return 'ours';
256
+ const ro = readingFieldsOf(o), rt = readingFieldsOf(t);
257
+ if (stableJson(ro.fields) === stableJson(rt.fields)) return 'ours';
258
+ if (ro.current !== rt.current) return ro.current ? 'ours' : 'theirs';
259
+ return readingBeats(ro.fields, rt.fields) ? 'ours' : 'theirs';
260
+ }
261
+
262
+ /** The copy a merge writes when `winner` — one side's copy of a card, kept
263
+ * whole because that side changed the card — meets `donor`, the other side's
264
+ * copy of it: the winner, with the donor's reading in place of its own when
265
+ * the donor's is still a reading of the winner's bytes and is the better one.
266
+ * A document's text goes over only while its derivedTextSha names the
267
+ * winner's bytes (an edit that replaced the file leaves it behind as stale);
268
+ * any other reading only between copies of one type holding the same asset
269
+ * (assetId and assetSha). The edit is a person's and the reading is derived
270
+ * from the bytes, so this keeps both: a paid reading (a cloud video analysis)
271
+ * is not thrown away because the card was renamed on the other machine. The
272
+ * result depends on the two copies alone, whichever side either is. */
273
+ export function carryReading(winner, donor) {
274
+ if (typeof winner !== 'string' || typeof donor !== 'string' || winner === donor || !donor.includes('derivedText')) return winner;
275
+ let w, d;
276
+ try { w = JSON.parse(winner); d = JSON.parse(donor); } catch { return winner; }
277
+ if (!isPlainObject(w) || !isPlainObject(d) || w.type !== d.type) return winner;
278
+ const rw = readingFieldsIn(w), rd = readingFieldsIn(d);
279
+ if (stableJson(rw) === stableJson(rd)) return winner;
280
+ const carried = { ...w };
281
+ for (const f of READING_ITEM_FIELDS) delete carried[f];
282
+ Object.assign(carried, rd);
283
+ // Current on the WINNER's bytes: a document's text by its derivedTextSha
284
+ // (readingIsCurrent → savedDocumentText), any other reading by the asset.
285
+ const ofTheseBytes = readingIsCurrent(carried) && (carried.derivedTextKind === DOCUMENT_TEXT_KIND
286
+ || ((w.assetId ?? null) === (d.assetId ?? null) && (w.assetSha ?? null) === (d.assetSha ?? null)));
287
+ if (!ofTheseBytes) return winner;
288
+ if (readingIsCurrent(w) && readingBeats(rw, rd)) return winner;
289
+ return JSON.stringify(carried);
290
+ }
291
+
292
+ // A card as a canonical string: parsed, the given fields stripped, key-sorted.
293
+ // Unparseable JSON falls back to byte identity — a malformed item must never
294
+ // crash a merge.
295
+ function signatureWithout(json, fieldLists) {
191
296
  if (json == null) return null;
192
297
  try {
193
298
  const obj = JSON.parse(json);
194
- for (const f of VOLATILE_ITEM_FIELDS) delete obj[f];
299
+ for (const list of fieldLists) for (const f of list) delete obj[f];
195
300
  return stableJson(obj);
196
301
  } catch {
197
302
  return json;
198
303
  }
199
304
  }
200
305
 
201
- /** True when two item JSON strings mean the same thing (volatile fields aside).
306
+ /** A card's IDENTITY as a canonical string: volatile fields stripped,
307
+ * key-sorted — byte for byte what every engine since 1.86 hashes into bin
308
+ * identities (fullEntryRid), conflict-twin ids and revived ids. Reading fields
309
+ * stay in: an identity names exact bytes, and hashing anything else would
310
+ * make every receipt and twin an older engine minted name different bytes. */
311
+ export function itemSignature(json) {
312
+ return signatureWithout(json, [VOLATILE_ITEM_FIELDS]);
313
+ }
314
+
315
+ /** A card's MEANING as a canonical string: volatile AND reading fields
316
+ * stripped, key-sorted. What sameMeaning compares. */
317
+ export function meaningSignature(json) {
318
+ return signatureWithout(json, [VOLATILE_ITEM_FIELDS, READING_ITEM_FIELDS]);
319
+ }
320
+
321
+ /** True when two item JSON strings mean the same thing (volatile and reading
322
+ * fields aside).
202
323
  * The single definition of "did this card actually change": the git merge
203
324
  * driver, the merge engine and the Brain Sync core all decide with it, so all
204
325
  * three transports agree on what an edit is. */
205
326
  export const sameMeaning = (a, b) => {
206
327
  if (a === b) return true; // fast path: byte-identical
207
328
  if (a == null || b == null) return false;
208
- return itemSignature(a) === itemSignature(b);
329
+ return meaningSignature(a) === meaningSignature(b);
209
330
  };
210
331
 
211
332
  const compactText = (value, max = 140) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max);
@@ -286,6 +407,20 @@ const sha256Hex = (s) => crypto.createHash('sha256').update(String(s)).digest('h
286
407
  // An R receipt keeps its F's identity: those bytes are live again, nothing leaks.
287
408
  export const fullEntryRid = (id, json) => 'r_' + sha256Hex(`${id}\n${itemSignature(json) ?? ''}`).slice(0, 16);
288
409
 
410
+ /** True when `json` holds the bytes a full entry's identity `rid` was minted
411
+ * from (`id` is the card the entry was minted for): byte for byte, reading
412
+ * fields included, as fullEntryRid mints it in every engine — or those bytes
413
+ * with a reading KLYPIX made on the card since (the entry's bytes had none).
414
+ * Every check of a copy against a stored rid ("is this the deleted card", "is
415
+ * this what the restore put back") goes through here, so a copy is never
416
+ * called edited only because KLYPIX read it. */
417
+ export function holdsEntryBytes(id, json, rid) {
418
+ if (typeof rid !== 'string' || !rid || json == null) return false;
419
+ if (fullEntryRid(id, json) === rid) return true;
420
+ const meaning = meaningSignature(json);
421
+ return meaning !== itemSignature(json) && 'r_' + sha256Hex(`${id}\n${meaning ?? ''}`).slice(0, 16) === rid;
422
+ }
423
+
289
424
  /** The identity of one bin entry: its stored rid, else the one it would have
290
425
  * been minted with. A pre-rid F shares its identity with a new F of the same
291
426
  * bytes; a Stage-1 P (no rid) gets one from fields every copy holds verbatim,
@@ -1091,6 +1226,8 @@ export function fenceContent({ cardId, source, author, text }) {
1091
1226
  // ── Saved readings (P0 agent parity) ─────────────────────────────────────────
1092
1227
  // What KLYPIX has already read, from the saved file alone:
1093
1228
  // • derivedText on a media card (transcript / analysis / image understanding);
1229
+ // • derivedText on a document's file card (its text, kind 'document-text'),
1230
+ // only while its derivedTextSha names the bytes the card holds;
1094
1231
  // • a Read contents result: a text card tagged `link` (+ `partial`), with an
1095
1232
  // arrow FROM the link card TO it (KlypixCanvas readLinkContents);
1096
1233
  // • an OCR result: a text card tagged `ocr`, arrowed from the image.
@@ -1147,16 +1284,78 @@ export function savedReadingIndex(parsed, hiddenIds = new Set()) {
1147
1284
  return { bySource, byResult };
1148
1285
  }
1149
1286
 
1287
+ // A DOCUMENT's reading (the KLYPIX app's agent/documentReading.ts): the text
1288
+ // KLYPIX extracted from a file card — PDF, Word, Excel, text, code — saved on
1289
+ // the card as derivedText with derivedTextKind 'document-text',
1290
+ // derivedTextSource 'local' and derivedTextSha: the bytes it was read from,
1291
+ // named by the card's assetSha, else its assetId (an asset id's bytes change
1292
+ // only through a repack, and a repack stamps assetSha). It carries no
1293
+ // derivedTextAt, so two machines reading the same bytes write the same fields.
1294
+ // The reading is CURRENT only while the card still holds those bytes. KLYPIX
1295
+ // drops it when the bytes change, but a file written by an older KLYPIX or by
1296
+ // another program can still carry one of earlier bytes, and no reader here
1297
+ // serves that as what the file says. These are the app's own rules
1298
+ // (items/documentReadingFields.ts), restated so this module stays standalone.
1299
+ export const DOCUMENT_TEXT_KIND = 'document-text';
1300
+
1301
+ /** Which bytes a card holds, as two copies of the card name them: its
1302
+ * assetSha, else its assetId; null for a card with no bytes of its own. */
1303
+ export function documentBytesIdentity(item) {
1304
+ if (!item) return null;
1305
+ if (typeof item.assetSha === 'string' && item.assetSha) return item.assetSha;
1306
+ if (typeof item.assetId === 'string' && item.assetId) return item.assetId;
1307
+ return null;
1308
+ }
1309
+
1310
+ const SECRET_FILE_EXTENSIONS = new Set(['env', 'pem', 'key', 'p12', 'pfx', 'keystore', 'npmrc', 'netrc', 'htpasswd']);
1311
+ /** A file whose words are credentials (.env, keys, .npmrc …). KLYPIX never
1312
+ * keeps its text on the card, and a reading one carries is never served. */
1313
+ export function isSecretBearingFile(item) {
1314
+ if (!item) return false;
1315
+ if (SECRET_FILE_EXTENSIONS.has(String(item.extension || '').toLowerCase().replace(/^\./, ''))) return true;
1316
+ const base = (String(item.fileName || '').split(/[\\/]/).pop() || '').toLowerCase();
1317
+ if (/^\.env(?:\..*)?$/.test(base)) return true;
1318
+ if (/^\.?(?:npmrc|netrc|htpasswd)$/.test(base)) return true;
1319
+ const dot = base.lastIndexOf('.');
1320
+ return dot >= 0 && SECRET_FILE_EXTENSIONS.has(base.slice(dot + 1));
1321
+ }
1322
+
1323
+ /** A document card's saved text when it was read from the bytes the card
1324
+ * holds now; null when there is none, when it is another kind of reading,
1325
+ * when the file changed after it was read (a stale reading), or when the
1326
+ * file is one whose text is never kept. */
1327
+ export function savedDocumentText(item) {
1328
+ if (!item || item.type !== 'file' || item.isFolder) return null;
1329
+ if (item.derivedTextKind !== DOCUMENT_TEXT_KIND) return null;
1330
+ const text = item.derivedText;
1331
+ if (typeof text !== 'string' || !text.trim()) return null;
1332
+ if (isSecretBearingFile(item)) return null;
1333
+ const identity = documentBytesIdentity(item);
1334
+ return identity !== null && item.derivedTextSha === identity ? text : null;
1335
+ }
1336
+
1337
+ /** True when a card carries a reading a reader may serve as current: a
1338
+ * document's text only while savedDocumentText says so; a transcript or any
1339
+ * other reading whenever it is there (those are not tied to the bytes). */
1340
+ export function readingIsCurrent(item) {
1341
+ if (!item || typeof item.derivedText !== 'string' || !item.derivedText.trim()) return false;
1342
+ if (item.derivedTextKind === DOCUMENT_TEXT_KIND) return savedDocumentText(item) !== null;
1343
+ return true;
1344
+ }
1345
+
1150
1346
  const DERIVED_KIND_LABEL = {
1151
1347
  'image-understanding': 'image understanding',
1152
1348
  'audio-transcript': 'audio transcript',
1153
1349
  'video-transcript': 'video transcript',
1154
1350
  'video-analysis': 'video analysis',
1351
+ 'document-text': 'document text',
1155
1352
  };
1156
- /** A media card's saved reading (derivedText), normalised, or null. */
1353
+ /** A card's saved reading (derivedText), normalised, or null — null too for a
1354
+ * document's reading of bytes the card no longer holds (readingIsCurrent). */
1157
1355
  export function derivedReading(item) {
1158
1356
  const text = typeof item?.derivedText === 'string' ? item.derivedText : '';
1159
1357
  if (!text.trim()) return null;
1358
+ if (!readingIsCurrent(item)) return null;
1160
1359
  const kind = typeof item.derivedTextKind === 'string' ? item.derivedTextKind : null;
1161
1360
  return {
1162
1361
  text,
@@ -1175,7 +1374,10 @@ export function notReadStep(item) {
1175
1374
  if (item.type === 'link') return 'select it in KLYPIX and press Enter (Read contents), then let the canvas save';
1176
1375
  if (item.type === 'image') return 'right-click it in KLYPIX and choose Extract text (OCR), then let the canvas save';
1177
1376
  if (item.type === 'video' || item.type === 'audio') return "ask KLYPIX's AI about it in KLYPIX (KLYPIX saves what it reads on the card), then let the canvas save";
1178
- if (item.type === 'file' && !item.isFolder) return 'KLYPIX does not save the text of document cards yet';
1377
+ if (item.type === 'file' && !item.isFolder) {
1378
+ // KLYPIX never keeps a credential file's text, so no step promises it.
1379
+ return isSecretBearingFile(item) ? null : "ask KLYPIX's AI about it in KLYPIX (KLYPIX saves the text it reads from a document on the card), then let the canvas save";
1380
+ }
1179
1381
  return null;
1180
1382
  }
1181
1383
 
@@ -9381,13 +9583,17 @@ export function cardContentsHint(it, hasReading = false) {
9381
9583
  return hasReading ? "KLYPIX's whole saved reading" : `a local path to the ${it.type} file (no reading is saved yet, and ${it.type} cannot be attached)`;
9382
9584
  }
9383
9585
  if (it.type !== 'file') return null;
9384
- if (ext === 'pdf') return `a local path to the PDF to open with your file-reading tool${it.previewDataUrl ? ", and KLYPIX's saved image of page 1" : ''}`;
9385
- if (TEXT_FILE_EXT_RE.test(ext)) return "the file's text";
9586
+ // A document KLYPIX has read comes back with its saved text first — only
9587
+ // while that text is of the bytes the card holds — and the file by path.
9588
+ const read = savedDocumentText(it) !== null;
9589
+ const saved = read ? "KLYPIX's saved text of the document, and " : '';
9590
+ if (ext === 'pdf') return `${saved}a local path to the PDF to open with your file-reading tool${it.previewDataUrl ? ", and KLYPIX's saved image of page 1" : ''}`;
9591
+ if (TEXT_FILE_EXT_RE.test(ext)) return read ? `${saved}a local path to the file to open with your file-reading tool` : "the file's text";
9386
9592
  if (OFFICE_FILE_EXT_RE.test(ext)) {
9387
- const preview = it.previewSheet ? "KLYPIX's saved first rows of the sheet" : it.previewHtml ? "KLYPIX's saved opening text" : null;
9388
- return `a local path to the file to open with your file-reading tool${preview ? `, and ${preview}` : ''}`;
9593
+ const preview = read ? null : it.previewSheet ? "KLYPIX's saved first rows of the sheet" : it.previewHtml ? "KLYPIX's saved opening text" : null;
9594
+ return `${saved}a local path to the file to open with your file-reading tool${preview ? `, and ${preview}` : ''}`;
9389
9595
  }
9390
- return 'a local path to the file to open with your file-reading tool';
9596
+ return `${saved}a local path to the file to open with your file-reading tool`;
9391
9597
  }
9392
9598
 
9393
9599
  /**
@@ -164,7 +164,7 @@ import { createHash } from 'node:crypto';
164
164
  import {
165
165
  parseKlypix, shard, sameMeaning, itemSignature, twinIdFor, binEntryFor,
166
166
  entryKind, receiptIdentity, revivedIdFor, pickBinEntry, contentFreeReceiptFor, PURGED_BODY, fullEntryRid,
167
- pickThreeWay, canonicalFirst,
167
+ pickThreeWay, canonicalFirst, pickReadingCopy, carryReading, holdsEntryBytes,
168
168
  } from './klypix-format.mjs';
169
169
  import { generateKeyBetween } from 'fractional-indexing';
170
170
 
@@ -217,9 +217,9 @@ const ARCHIVE = /^archive$/i;
217
217
  // against 1.86 — the git driver, the API-5 KLYPIX sync core — import them from
218
218
  // this module, and the KLYPIX core reads the whole engine off this namespace.
219
219
  export {
220
- sameMeaning, itemSignature, VOLATILE_ITEM_FIELDS, twinIdFor, revivedIdFor, receiptIdentity, entryKind,
221
- isContentFreeReceipt, pickBinEntry, PURGED_BODY, binEntryFor, contentFreeReceiptFor, fullEntryRid,
222
- pickThreeWay,
220
+ sameMeaning, itemSignature, meaningSignature, VOLATILE_ITEM_FIELDS, READING_ITEM_FIELDS, twinIdFor, revivedIdFor, receiptIdentity, entryKind,
221
+ isContentFreeReceipt, pickBinEntry, PURGED_BODY, binEntryFor, contentFreeReceiptFor, fullEntryRid, holdsEntryBytes,
222
+ pickThreeWay, pickReadingCopy, carryReading,
223
223
  } from './klypix-format.mjs';
224
224
  export { purgeGraveyard, restoreFromGraveyard, listGraveyard } from './brain-graveyard.mjs';
225
225
 
@@ -258,6 +258,7 @@ export const MERGE_ENGINE_FEATURES = Object.freeze({
258
258
  arrangeReceipts: true, // arrangeBrain buries what it collapses, survivor from content and ids (E-8)
259
259
  revivalMap: true, // revivalMap, and brainDelta's lastKnown/collectLive, for the live watcher
260
260
  sideFreeTies: true, // option modes: a tie between two sides is broken from the values alone (pickThreeWay)
261
+ derivedReadings: true, // every caller: a card's saved reading (READING_ITEM_FIELDS) is no part of its meaning — no twin, no conflict, a delete wins; the better reading is kept (pickReadingCopy) and carried over a one-sided edit (carryReading); identities still hash it, as in 1.94 (holdsEntryBytes checks a copy against one)
261
262
  options: OPTION_VALUES,
262
263
  });
263
264
 
@@ -318,11 +319,31 @@ const sha12 = (s) => createHash('sha256').update(String(s)).digest('hex').slice(
318
319
  // list (updatedAt, zIndex, editedAt) — lives in klypix-format.mjs since 1.89,
319
320
  // so the bin identity (receiptIdentity) and twin ids hash exactly the meaning
320
321
  // this merge compares.
322
+ //
323
+ // A card's saved READING (READING_ITEM_FIELDS: derivedText and its kind,
324
+ // source, sha, time and visuals — a document's text, a transcript) is no part
325
+ // of its meaning: sameMeaning compares meaningSignature, which strips it. It is
326
+ // derived from the card's bytes and made again when missing. Two machines that
327
+ // read one card between syncs wrote those fields independently, and counted as
328
+ // content they kept a conflict twin of the card (a media reading's time stamp
329
+ // alone did it), or brought back a card the other side had deleted (a reading
330
+ // counted as the edit the deleter never saw). Now a reading-only difference is
331
+ // one card and a reading never beats a delete. The reading still decides which
332
+ // copy is written — it is what AI tools are served, and git and Brain Sync
333
+ // compare it: pickReadingCopy keeps the better reading, from the copies alone,
334
+ // and carryReading moves a current reading over an edit the other side made.
335
+ // IDENTITIES do not change: itemSignature, fullEntryRid, twinIdFor and
336
+ // revivedIdFor hash the card with its reading, byte for byte as 1.94 does, so
337
+ // the receipts and twins older engines minted keep naming the same bytes; a
338
+ // copy is checked against a stored rid with holdsEntryBytes, which also
339
+ // accepts the copy with a reading made since.
321
340
 
322
341
  // Load one .klypix buffer into a flat, comparison-friendly shape. Item JSON is
323
342
  // kept VERBATIM (the merge must write back exactly what a side held); whether
324
343
  // two versions actually differ is decided by sameMeaning(), never by these
325
- // bytes — see its note on volatile fields.
344
+ // bytes — see its note on volatile fields. The one exception is carryReading:
345
+ // a card one side edited, with the other side's saved reading in place of its
346
+ // own, is a combination of the two copies (the same one on every machine).
326
347
  async function loadSide(buf) {
327
348
  if (!buf) return null;
328
349
  const { zip, canvas, manifest, struct } = await parseKlypix(buf);
@@ -631,7 +652,7 @@ function mergeUnion(B, O, T, del, deletedMeta) {
631
652
  if (inO && inT) {
632
653
  // Change + divergence are judged by MEANING, not bytes (see sameMeaning):
633
654
  // a restamped `updatedAt` is not an edit, and two copies of one card that
634
- // differ only in volatile fields are not in conflict.
655
+ // differ only in volatile or reading fields are not in conflict.
635
656
  const oChg = !inB || !sameMeaning(O.items[id], baseItem(id));
636
657
  const tChg = !inB || !sameMeaning(T.items[id], baseItem(id));
637
658
  const diverged = !sameMeaning(O.items[id], T.items[id]);
@@ -642,7 +663,7 @@ function mergeUnion(B, O, T, del, deletedMeta) {
642
663
  const record = { id, kind: 'content', keptLive: 'ours', twin: null };
643
664
  conflicts.push(record);
644
665
  twinRequests.push({ k: id, v: T.items[id], srcPos: T.positions[id] || O.positions[id], record });
645
- } else if (tChg && !oChg) { json = T.items[id]; side = 'theirs'; delta.updated.push(id); }
666
+ } else if (tChg && !oChg) { json = carryReading(T.items[id], O.items[id]); side = 'theirs'; delta.updated.push(id); }
646
667
  else if (!inB && diverged) {
647
668
  if (!B) {
648
669
  // With NO baseline we cannot prove which meaning is newer or whether
@@ -659,10 +680,23 @@ function mergeUnion(B, O, T, del, deletedMeta) {
659
680
  // can be present on both sides after live-apply and re-serialized with
660
681
  // slightly different bytes. It is one card, not a conflict — keep the
661
682
  // disk/agent bytes and never create the historical duplicate twin.
662
- json = T.items[id]; side = 'theirs';
683
+ json = carryReading(T.items[id], O.items[id]); side = 'theirs';
663
684
  }
664
685
  }
665
- else { json = O.items[id]; side = 'ours'; }
686
+ else if (!diverged) {
687
+ // One card: the copies differ in volatile and reading fields at most.
688
+ // The one with the better reading is written (pickReadingCopy) — the
689
+ // same copy whichever side is ours, so a reading theirs made is kept.
690
+ side = pickReadingCopy(O.items[id], T.items[id]);
691
+ json = side === 'ours' ? O.items[id] : T.items[id];
692
+ if (side === 'theirs') delta.updated.push(id);
693
+ }
694
+ else {
695
+ // Ours changed the card and theirs did not: ours' copy, with the
696
+ // reading theirs holds carried over while it is still of these bytes.
697
+ json = carryReading(O.items[id], T.items[id]); side = 'ours';
698
+ if (json !== O.items[id]) delta.updated.push(id);
699
+ }
666
700
  } else if (inT) {
667
701
  json = T.items[id]; side = 'theirs';
668
702
  if (!inB) delta.added.push(id); // agent added since open — the anti-clobber core
@@ -794,7 +828,7 @@ function mergeOptionMode(B, O, T, del, deletedMeta, opt) {
794
828
  // twin. Both are counted in purgedCopies.
795
829
  const via = reached.get(id);
796
830
  if (via && via.rid && E === via.entry) {
797
- if (fullEntryRid(via.from, v) !== via.rid) conflicts.push({ id, kind: 'purge-vs-edit', side, purgedWith: via.root });
831
+ if (!holdsEntryBytes(via.from, v, via.rid)) conflicts.push({ id, kind: 'purge-vs-edit', side, purgedWith: via.root });
798
832
  else conflicts.push({ id, kind: 'purge-reached-restore', side, purgedWith: via.root });
799
833
  } else purgeVsEdit(side, id, v);
800
834
  return;
@@ -989,7 +1023,7 @@ function mergeOptionMode(B, O, T, del, deletedMeta, opt) {
989
1023
  const ownsFullEntry = (S) => { const e = S?.graveyard?.[x]; return !!e && entryKind(e.meta) === 'F'; };
990
1024
  const keptByPurger = (root) => [O, T].some((S) => ownsPurge(S, root) && (live(S, x) || ownsFullEntry(S)));
991
1025
  const via = [...(reachedVia?.rid ? [reachedVia] : []), ...(rootRestores.get(parent) || []).filter((c) => !keptByPurger(c.root))]
992
- .find((c) => copies.every((v) => fullEntryRid(c.from, v) === c.rid));
1026
+ .find((c) => copies.every((v) => holdsEntryBytes(c.from, v, c.rid)));
993
1027
  if (via) reachedTwins.push([x, via]);
994
1028
  }
995
1029
  for (const [x, via] of reachedTwins) {
@@ -1098,7 +1132,7 @@ function mergeOptionMode(B, O, T, del, deletedMeta, opt) {
1098
1132
  if (fate.get(y)?.entry !== via.entry) continue;
1099
1133
  const typedInto = [O, T].some((S) => {
1100
1134
  const F = live(S, y) ? null : eOf(S, y);
1101
- return !!F && entryKind(F.meta) === 'F' && !(via.rid && fullEntryRid(via.from, F.json) === via.rid);
1135
+ return !!F && entryKind(F.meta) === 'F' && !(via.rid && holdsEntryBytes(via.from, F.json, via.rid));
1102
1136
  });
1103
1137
  if (!typedInto) continue;
1104
1138
  conflicts.push({ id: y, kind: 'purge-vs-edit', side: 'bin', purgedWith: via.root }); binEditsPurged++;
@@ -1178,7 +1212,7 @@ function mergeOptionMode(B, O, T, del, deletedMeta, opt) {
1178
1212
  (restoredBytes.get(at) || restoredBytes.set(at, []).get(at)).push([d, e.meta.rid]);
1179
1213
  restoredHere.get(S)?.add(at);
1180
1214
  }
1181
- const asRestored = (id, v) => (restoredBytes.get(id) || []).some(([d, rid]) => fullEntryRid(d, v) === rid);
1215
+ const asRestored = (id, v) => (restoredBytes.get(id) || []).some(([d, rid]) => holdsEntryBytes(d, v, rid));
1182
1216
  const repoint = (S, from, to) => {
1183
1217
  for (const c of S.connections) { if (c.fromId === from) c.fromId = to; if (c.toId === from) c.toId = to; }
1184
1218
  for (const [cid, p] of Object.entries(S.positions)) if (p && p.parentId === from) S.positions[cid] = { ...p, parentId: to };
@@ -1453,15 +1487,15 @@ function mergeOptionMode(B, O, T, del, deletedMeta, opt) {
1453
1487
  // newer — unordered, so it is kept beside the card, never over it.
1454
1488
  json = O.items[id]; side = 'ours';
1455
1489
  twinOf(id, T.items[id], srcPos, 'content-unverified');
1456
- } else { json = T.items[id]; side = 'theirs'; delta.updated.push(id); }
1490
+ } else { json = carryReading(T.items[id], O.items[id]); side = 'theirs'; delta.updated.push(id); }
1457
1491
  } else if (!cb && diverged && B && !unverified && editsItsRestore(id)) {
1458
1492
  // One restore made on both sides — the same deletion, so the same id —
1459
1493
  // is new on both since the base, yet its receipt names the bytes it
1460
1494
  // came back with: the side still holding them has not touched the
1461
1495
  // card, so the other side's text is an edit of it, not a conflict.
1462
1496
  // (With no base, both are kept as ever: such a copy may be a revert.)
1463
- if (asRestored(id, O.items[id])) { json = T.items[id]; side = 'theirs'; delta.updated.push(id); }
1464
- else { json = O.items[id]; side = 'ours'; }
1497
+ if (asRestored(id, O.items[id])) { json = carryReading(T.items[id], O.items[id]); side = 'theirs'; delta.updated.push(id); }
1498
+ else { json = carryReading(O.items[id], T.items[id]); side = 'ours'; if (json !== O.items[id]) delta.updated.push(id); }
1465
1499
  } else if (!cb && diverged) {
1466
1500
  if (!B) {
1467
1501
  json = O.items[id]; side = 'ours';
@@ -1472,7 +1506,21 @@ function mergeOptionMode(B, O, T, del, deletedMeta, opt) {
1472
1506
  // (S10, the app-save rule) would lose ours silently.
1473
1507
  json = O.items[id]; side = 'ours';
1474
1508
  twinOf(id, T.items[id], srcPos, 'content-new-both');
1475
- } else { json = T.items[id]; side = 'theirs'; }
1509
+ } else { json = carryReading(T.items[id], O.items[id]); side = 'theirs'; }
1510
+ } else if (!diverged && !unverified) {
1511
+ // One card: the copies differ in volatile and reading fields at most.
1512
+ // The one with the better reading is written (pickReadingCopy),
1513
+ // decided from the two copies alone — never "ours", or git and Brain
1514
+ // Sync would hand two readings back and forth. Foreign bytes
1515
+ // (unverified) never replace our card, so there ours stays.
1516
+ side = pickReadingCopy(O.items[id], T.items[id]);
1517
+ json = side === 'ours' ? O.items[id] : T.items[id];
1518
+ if (side === 'theirs') delta.updated.push(id);
1519
+ } else if (!unverified) {
1520
+ // Ours changed the card and theirs did not: ours' copy, with the
1521
+ // reading theirs holds carried over while it is still of these bytes.
1522
+ json = carryReading(O.items[id], T.items[id]); side = 'ours';
1523
+ if (json !== O.items[id]) delta.updated.push(id);
1476
1524
  } else { json = O.items[id]; side = 'ours'; }
1477
1525
  } else if (inT) {
1478
1526
  json = T.items[id]; side = 'theirs';