@chatpanel/events 0.52.0 → 0.54.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/knowledge.js CHANGED
@@ -120,18 +120,27 @@ const isoDay = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '');
120
120
  * (design §7.6 — no second retrieval stack), so it has to render to text like one.
121
121
  */
122
122
  export function briefToText(brief) {
123
- if (!brief) return '';
123
+ if (!brief?.subject?.name) return '';
124
+ // Every collection is read DEFENSIVELY. This used to assume the shape its own deriver
125
+ // produces, which was true while the only caller was that deriver — and stopped being true
126
+ // the moment briefs started arriving from a backup file, where a field can be absent
127
+ // because it was written by an older build. A missing `records` threw and took the whole
128
+ // restore's brief section with it.
124
129
  const L = [`BRIEF: ${brief.subject.name}`];
125
- if (brief.subject.aliases?.length) L.push(`Also known as: ${brief.subject.aliases.join(', ')}`);
126
- L.push(`Kind: ${brief.kind}`);
130
+ const aliases = brief.subject.aliases;
131
+ if (Array.isArray(aliases) && aliases.length) L.push(`Also known as: ${aliases.join(', ')}`);
132
+ L.push(`Kind: ${brief.kind || 'topic'}`);
127
133
  L.push('');
128
- for (const c of brief.claims) {
134
+ for (const c of brief.claims || []) {
135
+ if (!c?.text) continue;
129
136
  L.push(`- ${c.supersededBy ? '[superseded] ' : ''}${c.text}`);
130
- if (c.refs.length) L.push(` (${c.refs.map((r) => `${r.kind}:${r.id}`).join(', ')})`);
137
+ const refs = Array.isArray(c.refs) ? c.refs : [];
138
+ if (refs.length) L.push(` (${refs.map((r) => `${r.kind}:${r.id}`).join(', ')})`);
131
139
  }
132
- if (brief.records.length) {
140
+ const records = Array.isArray(brief.records) ? brief.records : [];
141
+ if (records.length) {
133
142
  L.push('', 'RECORDS:');
134
- for (const r of brief.records.slice(-40).reverse()) L.push(`- ${r.type}: ${r.title || 'untitled'}`);
143
+ for (const r of records.slice(-40).reverse()) L.push(`- ${r.type}: ${r.title || 'untitled'}`);
135
144
  }
136
145
  return L.join('\n').slice(0, MAX_BRIEF_CHARS);
137
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.52.0",
3
+ "version": "0.54.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts — typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/sync-plan.js CHANGED
@@ -64,6 +64,25 @@ function indexById(entries) {
64
64
  * 'push' — local is newer, or remote has never seen it; send ours
65
65
  * 'conflict' — both changed since the last common state and neither is clearly newer
66
66
  */
67
+ /**
68
+ * Is this entry the COMPLETE record, or a flattened stand-in for one?
69
+ *
70
+ * A warm/indexed copy of a chat is its transcript as one blob of text: enough to search,
71
+ * read and cite, and not enough to open as a conversation. Callers stamp that with
72
+ * `meta.lossy` (or `meta.origin: 'warm'`); a stamp row may carry `lossy` directly, because a
73
+ * sync plan works on stamps and should not have to load bodies to make this decision.
74
+ *
75
+ * Returns 1 for full fidelity and 0 for lossy — an ordering, so the rule below is a
76
+ * comparison rather than a pile of branches.
77
+ */
78
+ export function fidelityOf(entry) {
79
+ if (!entry) return 0;
80
+ if (entry.lossy === true) return 0;
81
+ const meta = entry.meta || null;
82
+ if (meta && (meta.lossy === true || meta.origin === 'warm')) return 0;
83
+ return 1;
84
+ }
85
+
67
86
  export function decide(local, remote, { tolerance = CLOCK_TOLERANCE_MS, base = null } = {}) {
68
87
  if (!local && !remote) return 'none';
69
88
  if (!remote) return 'push';
@@ -71,6 +90,30 @@ export function decide(local, remote, { tolerance = CLOCK_TOLERANCE_MS, base = n
71
90
 
72
91
  const l = stampOf(local);
73
92
  const r = stampOf(remote);
93
+
94
+ // FIDELITY BEATS RECENCY, BOTH WAYS.
95
+ //
96
+ // The two sides are not always describing the same kind of thing. One may hold the whole
97
+ // record and the other a flattened stand-in for it — and those two arrive with the SAME
98
+ // timestamp, because they describe the same moment. Last-write-wins then reads them as
99
+ // equal and answers 'none', which is how a complete conversation restored from a backup
100
+ // gets silently refused in favour of a search-index summary of itself that is already
101
+ // there. The user restores, is told it worked, and still sees the flattened copy.
102
+ //
103
+ // So a complete record always wins over a partial one whatever the clocks say. The
104
+ // converse — never let a partial overwrite a complete one — is the same rule read the
105
+ // other way, and it is the one callers usually remember to enforce by hand.
106
+ //
107
+ // DELETIONS ARE EXEMPT. A tombstone carries no body, so it is 'lossy' by any measure, and
108
+ // resurrecting deleted records because their replacement looks fuller would be a far worse
109
+ // bug than the one this fixes. When either side is deleted, the stamps decide.
110
+ const deleted = num(local.deletedAt) > 0 || num(remote.deletedAt) > 0;
111
+ if (!deleted) {
112
+ const lf = fidelityOf(local);
113
+ const rf = fidelityOf(remote);
114
+ if (lf !== rf) return rf > lf ? 'pull' : 'push';
115
+ }
116
+
74
117
  const delta = l - r;
75
118
  if (Math.abs(delta) <= tolerance) return 'none';
76
119