klypix-mcp 1.94.0 → 1.96.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 +20 -0
- package/README.md +15 -0
- package/bin/klypix-worker.mjs +1 -1
- package/package.json +3 -3
- package/src/app-lease.mjs +1 -1
- package/src/app-tools.mjs +13 -5
- package/src/klypix-core.mjs +8 -5
- package/src/klypix-format.mjs +263 -16
- package/src/merge-brains.mjs +65 -17
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
|
|
@@ -1137,6 +1143,15 @@ independently validated.
|
|
|
1137
1143
|
0.43, top-1 33%. The stratum that matters most is the honest one: **paraphrase questions that share
|
|
1138
1144
|
no words with their card reach recall@5 44%**; status, temporal and multi-hop questions sit at
|
|
1139
1145
|
81–93% because they still share vocabulary. Lexical-only scores 0% on the same set.
|
|
1146
|
+
- **The caller's words (`brain_ask`, 1.96).** A question can end with one more line,
|
|
1147
|
+
`Also: <words a card answering it would likely use>`; the calling agent is itself a model, so it
|
|
1148
|
+
can guess the card's wording at no extra cost. The words join the meaning query only. Measured
|
|
1149
|
+
2026-10-11 on the same brain grown to 3,829 cards (102 of the frozen questions still answerable),
|
|
1150
|
+
with words written by Gemini 2.5 Flash as a stand-in for the calling agent: recall@5 54% → 59%
|
|
1151
|
+
(8 found, 3 lost), recall@8 61% → 68%, recall@20 68% → 76%, MRR 0.375 → 0.424. With no local
|
|
1152
|
+
model (a fresh install, the Claude plugin) the words count as extra keywords at half weight:
|
|
1153
|
+
recall@5 19% → 24% (5 found, none lost), recall@10 25% → 31%. Without the line, ranking is
|
|
1154
|
+
unchanged byte for byte.
|
|
1140
1155
|
- **Second brain.** The same ranker on a different project's brain (1,601 cards, 19 hand-written
|
|
1141
1156
|
paraphrases, first cross-brain run): recall@5 **42%** (CI 23–64), top-1 11%, MRR 0.25. Paraphrase
|
|
1142
1157
|
recall transfers between brains; the ranker is not tuned to the brain it was built on. It is
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -508,7 +508,7 @@ server.registerTool('search_all_brains', {
|
|
|
508
508
|
|
|
509
509
|
server.registerTool('brain_ask', {
|
|
510
510
|
title: 'Ask the project brain a question (whole-brain, correction-aware answer)',
|
|
511
|
-
description: 'Answer a natural-language question from the WHOLE project brain — "what did we decide about X?", "where did the auth work land?", "why did we drop Y?". Ranks every card lexically, blended with on-device semantic similarity ONLY when the optional local model is installed (a fresh install is lexical; it degrades cleanly). INCLUDES superseded/archived history (flagged, so you can see how a decision changed), and attaches each stale card\'s live CORRECTION so the answer reflects the current truth, not an outdated card. Returns a synthesis-ready context (full cards + provenance + lifecycle) for you to turn into a direct, cited answer — it does not itself write prose. Prefer this over search_canvases when the user asks a QUESTION (not a keyword lookup). Optional as_of (YYYY-MM-DD) answers "what was true then". Defaults to the project brain ("brain").',
|
|
511
|
+
description: 'Answer a natural-language question from the WHOLE project brain — "what did we decide about X?", "where did the auth work land?", "why did we drop Y?". Ranks every card lexically, blended with on-device semantic similarity ONLY when the optional local model is installed (a fresh install is lexical; it degrades cleanly). INCLUDES superseded/archived history (flagged, so you can see how a decision changed), and attaches each stale card\'s live CORRECTION so the answer reflects the current truth, not an outdated card. Returns a synthesis-ready context (full cards + provenance + lifecycle) for you to turn into a direct, cited answer — it does not itself write prose. Prefer this over search_canvases when the user asks a QUESTION (not a keyword lookup). Optional as_of (YYYY-MM-DD) answers "what was true then". Cards are often worded differently from the question, so end the question with one more line, `Also: <8–14 words a card answering it would likely use: synonyms, feature, file or tool names>`; they widen the search while the question still leads (measured: right card in the top 5 for 60 of 102 test questions instead of 55; 24 instead of 19 when no local model is installed). Defaults to the project brain ("brain").',
|
|
512
512
|
inputSchema: {
|
|
513
513
|
question: z.string().describe('The natural-language question to answer from the brain.'),
|
|
514
514
|
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.96.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/brain-ask-words.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.
|
|
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: "
|
|
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),
|
|
34
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/klypix-core.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
findUnrecordedMigrations, captureIntoBrain, tidyBrain, noteToCaptureInput,
|
|
31
31
|
selectGardenCandidates, applyGarden, detectContradictions,
|
|
32
32
|
findDuplicatePartialNotes, collapseDuplicatePartialNotes,
|
|
33
|
-
rankForQuestion, questionContextToMarkdown, findLegacyShipCards,
|
|
33
|
+
rankForQuestion, splitAskWords, questionContextToMarkdown, findLegacyShipCards,
|
|
34
34
|
challengeBrain, challengeContextToMarkdown, buildRenderSpec, structToBrief,
|
|
35
35
|
brainLensData, lensToMarkdown, deathDateOfCard,
|
|
36
36
|
statusContextToMarkdown, findFulfillmentCandidates, findStaleOpenCards, releaseFulfilledOpens, openStatusSummary, perAreaTableToMarkdown,
|
|
@@ -766,7 +766,9 @@ export async function opBrainTaskContext({
|
|
|
766
766
|
}
|
|
767
767
|
|
|
768
768
|
export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log = () => {} }) {
|
|
769
|
-
|
|
769
|
+
// A last line `Also: <words>` is the caller's guess at the card's wording; it
|
|
770
|
+
// joins the meaning query only (splitAskWords in klypix-format.mjs).
|
|
771
|
+
const { question: q, words } = splitAskWords(question);
|
|
770
772
|
if (!q) return err('brain_ask needs a question.');
|
|
771
773
|
const t = brainTarget(vault, canvas);
|
|
772
774
|
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
@@ -783,9 +785,9 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
|
|
|
783
785
|
try {
|
|
784
786
|
const pipe = await getEmbedderForUse(log, 20_000);
|
|
785
787
|
if (pipe) {
|
|
786
|
-
const [qv] = await embedTexts(pipe, [q], { kind: 'query' });
|
|
788
|
+
const [qv] = await embedTexts(pipe, [words ? `${q}\n${words}` : q], { kind: 'query' });
|
|
787
789
|
const vecs = await vectorsForBrain(pipe, t.file, struct.cards);
|
|
788
|
-
if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); cardVecs = vecs; mode =
|
|
790
|
+
if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); cardVecs = vecs; mode = `semantic+lexical (on-device)${words ? ' + your Also words' : ''}`; }
|
|
789
791
|
}
|
|
790
792
|
} catch { semantic = null; cardVecs = null; }
|
|
791
793
|
// Parity with search_all_brains (8445e9c): never say "warming — retry" to a
|
|
@@ -799,6 +801,7 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
|
|
|
799
801
|
if (!semantic) {
|
|
800
802
|
fallbackNotice = semanticFallbackNotice(false);
|
|
801
803
|
if (fallbackNotice) mode = fallbackNotice.startsWith('LEXICAL ONLY') ? 'lexical-only (no semantic runtime)' : 'lexical (semantic warming)';
|
|
804
|
+
if (words) mode += ' + your Also words';
|
|
802
805
|
}
|
|
803
806
|
const kk = Math.max(1, Math.min(20, k || 10));
|
|
804
807
|
const timeTravel = asOfTs != null;
|
|
@@ -811,7 +814,7 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
|
|
|
811
814
|
// vectors that rank the question, reused; null degrades pairing to its
|
|
812
815
|
// lexical anchor/coverage fallback (works on every host, embeddings or not).
|
|
813
816
|
const pairSim = cardVecs ? (a, b) => { const va = cardVecs.get(a), vb = cardVecs.get(b); return va && vb ? dot(va, vb) : null; } : null;
|
|
814
|
-
const result = rankForQuestion(struct, q, { semantic, k: wantRerank ? Math.max(kk, 50) : kk, as_of: timeTravel ? as_of : null, pairSim });
|
|
817
|
+
const result = rankForQuestion(struct, q, { semantic, k: wantRerank ? Math.max(kk, 50) : kk, as_of: timeTravel ? as_of : null, pairSim, words });
|
|
815
818
|
if (wantRerank && result.hits.length > 1) {
|
|
816
819
|
try {
|
|
817
820
|
const reranked = await withRerankerForUse(log, 8_000, rr => rerankHits(rr, q, result.hits));
|
package/src/klypix-format.mjs
CHANGED
|
@@ -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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
|
@@ -3840,14 +4042,51 @@ export const deathDateOfCard = (card) => {
|
|
|
3840
4042
|
const m = /(?:↩︎ superseded|↩ superseded|✅|⤵ consolidated) (\d{4}-\d{2}-\d{2})/.exec(String(c.text || ''));
|
|
3841
4043
|
return m ? Date.parse(m[1]) : null;
|
|
3842
4044
|
};
|
|
3843
|
-
|
|
4045
|
+
// THE CALLER'S WORDS (1.96.0). A question is worded the asker's way; the card
|
|
4046
|
+
// that answers it was worded the day it was written ("magnification controls"
|
|
4047
|
+
// vs a card that says "zoom"). The calling agent is itself a model, so brain_ask
|
|
4048
|
+
// invites it to add one last line `Also: <words a card answering this would
|
|
4049
|
+
// likely use>`. Those words join the MEANING query only (embedded together with
|
|
4050
|
+
// the question, one vector); the keyword anchor test, status shape and the
|
|
4051
|
+
// rendered question all stay the question's own. Measured 2026-10-11 on the
|
|
4052
|
+
// real 3,829-card brain, frozen v2 set (102 answerable), production embedder +
|
|
4053
|
+
// warm enrichment-aware vectors, model-written words: recall@5 55 → 60
|
|
4054
|
+
// (+8 −3), @8 62 → 69, @20 69 → 78, MRR .375 → .424. Fusing a separate words
|
|
4055
|
+
// vector by rank instead did less (+5 −2 at equal weight). Carried inside
|
|
4056
|
+
// `question` because no existing tool's input schema may change (see
|
|
4057
|
+
// test/tool-schema-freeze.mjs) — a new property would force every host to
|
|
4058
|
+
// reconnect; a line in the text works the moment the worker hot-swaps.
|
|
4059
|
+
export const ASK_WORDS_MAX = 240;
|
|
4060
|
+
// Keyword-only mode (no meaning model), same brain and set, words as extra
|
|
4061
|
+
// tokens, through splitAskWords: recall@5 19 → 24 of 102 at half weight with
|
|
4062
|
+
// none lost (@10 26 → 32); full weight lost a question the question alone
|
|
4063
|
+
// found. Measured 2026-10-11.
|
|
4064
|
+
export const ASK_WORDS_LEXICAL_WEIGHT = 0.5;
|
|
4065
|
+
export function splitAskWords(question) {
|
|
4066
|
+
const whole = String(question || '').trim();
|
|
4067
|
+
const lines = whole.split(/\r?\n/);
|
|
4068
|
+
const last = lines.length > 1 ? /^\s*also\s*:\s*(.*)$/i.exec(lines[lines.length - 1]) : null;
|
|
4069
|
+
const rest = last ? lines.slice(0, -1).join('\n').trim() : '';
|
|
4070
|
+
// No words line, or nothing above it: the text is the question, untouched.
|
|
4071
|
+
if (!last || !rest) return { question: whole, words: '' };
|
|
4072
|
+
const words = last[1].replace(/\s+/g, ' ').trim().slice(0, ASK_WORDS_MAX).trim();
|
|
4073
|
+
// An empty `Also:` line is dropped; words equal to the question add nothing.
|
|
4074
|
+
return { question: rest, words: words.toLowerCase() === rest.toLowerCase() ? '' : words };
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
export function rankForQuestion(struct, question, { semantic = null, k = 10, as_of = null, now = Date.now(), recentDays = 30, pairSim = null, words = '' } = {}) {
|
|
3844
4078
|
// Status-shaped questions score by their CONTENT tokens only — "remaining"
|
|
3845
4079
|
// must never lexically select the stale cards that say "remaining:".
|
|
3846
4080
|
const { content: tokens, statusShaped, strong: statusStrong, areaFamilies = [] } = splitQueryTokens(question);
|
|
4081
|
+
// The caller's Also words (splitAskWords). With the meaning model they ride
|
|
4082
|
+
// the query vector (opBrainAsk); WITHOUT it — a fresh install, the Claude
|
|
4083
|
+
// plugin — they are extra keyword tokens at ASK_WORDS_LEXICAL_WEIGHT, after
|
|
4084
|
+
// the question's own, so the question still leads.
|
|
4085
|
+
const extra = words && !semantic ? splitQueryTokens(words).content.filter(t => !tokens.includes(t)) : [];
|
|
3847
4086
|
// Area scope (1.85.0): this function HAS the struct, so it resolves the
|
|
3848
4087
|
// prompt's static families to exact area titles for the status renderer.
|
|
3849
4088
|
const areas = statusStrong ? areaHintsFromPrompt(struct, question) : null;
|
|
3850
|
-
if (!struct || !Array.isArray(struct.cards) || (!tokens.length && !semantic)) return { hits: [], total: 0, tokens, statusShaped, statusStrong, areaFamilies, areas };
|
|
4089
|
+
if (!struct || !Array.isArray(struct.cards) || (!tokens.length && !extra.length && !semantic)) return { hits: [], total: 0, tokens, statusShaped, statusStrong, areaFamilies, areas };
|
|
3851
4090
|
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
3852
4091
|
const asOfTs = as_of ? Date.parse(as_of) : null;
|
|
3853
4092
|
const timeTravel = asOfTs != null && Number.isFinite(asOfTs);
|
|
@@ -3885,6 +4124,10 @@ export function rankForQuestion(struct, question, { semantic = null, k = 10, as_
|
|
|
3885
4124
|
else if (wordMatches(tagWords, tagStems, tok)) lex += 3;
|
|
3886
4125
|
else if (wordMatches(bodyW, bodyStems, tok)) lex += lenNorm;
|
|
3887
4126
|
}
|
|
4127
|
+
for (const tok of extra) {
|
|
4128
|
+
if (wordMatches(titleW, titleStems, tok) || wordMatches(tagWords, tagStems, tok)) lex += 3 * ASK_WORDS_LEXICAL_WEIGHT;
|
|
4129
|
+
else if (wordMatches(bodyW, bodyStems, tok)) lex += lenNorm * ASK_WORDS_LEXICAL_WEIGHT;
|
|
4130
|
+
}
|
|
3888
4131
|
const sem = semantic ? (semantic.get(c.id) ?? null) : null;
|
|
3889
4132
|
if (!semantic && lex <= 0) continue;
|
|
3890
4133
|
if (semantic && lex <= 0 && sem == null) continue;
|
|
@@ -9381,13 +9624,17 @@ export function cardContentsHint(it, hasReading = false) {
|
|
|
9381
9624
|
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
9625
|
}
|
|
9383
9626
|
if (it.type !== 'file') return null;
|
|
9384
|
-
|
|
9385
|
-
|
|
9627
|
+
// A document KLYPIX has read comes back with its saved text first — only
|
|
9628
|
+
// while that text is of the bytes the card holds — and the file by path.
|
|
9629
|
+
const read = savedDocumentText(it) !== null;
|
|
9630
|
+
const saved = read ? "KLYPIX's saved text of the document, and " : '';
|
|
9631
|
+
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" : ''}`;
|
|
9632
|
+
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
9633
|
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
|
|
9634
|
+
const preview = read ? null : it.previewSheet ? "KLYPIX's saved first rows of the sheet" : it.previewHtml ? "KLYPIX's saved opening text" : null;
|
|
9635
|
+
return `${saved}a local path to the file to open with your file-reading tool${preview ? `, and ${preview}` : ''}`;
|
|
9389
9636
|
}
|
|
9390
|
-
return
|
|
9637
|
+
return `${saved}a local path to the file to open with your file-reading tool`;
|
|
9391
9638
|
}
|
|
9392
9639
|
|
|
9393
9640
|
/**
|
package/src/merge-brains.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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) =>
|
|
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 &&
|
|
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]) =>
|
|
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';
|