@chatpanel/events 0.33.0 → 0.46.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/promotion.js ADDED
@@ -0,0 +1,171 @@
1
+ // PROMOTION — the gate between "a model wrote this" and "the brief says this".
2
+ //
3
+ // The single most repeated finding in the community threads was that unreviewed agent
4
+ // writing compounds into confident nonsense (C2), and the convergent answer was to separate
5
+ // CAPTURE from PROMOTION: agents draft freely, promotion needs review. This module is that
6
+ // separation as code. Class-R claims never pass through it — a backlink is not an opinion —
7
+ // and class-C claims never get around it.
8
+ //
9
+ // proposed → promoted only via accept()
10
+ // proposed → rejected via reject()
11
+ //
12
+ // A proposal is its own object rather than a mutation of the brief, so the queue survives a
13
+ // rebuild (I-K2 throws briefs away; it must not throw away the user's pending decisions),
14
+ // and so accepting shows a DIFF against what the brief said before rather than a fait
15
+ // accompli.
16
+ //
17
+ // `converge()` is W7's rule written before W7 exists: N independent drafts agree on a claim
18
+ // ⇒ it may be auto-proposed; disagreement ⇒ the human queue. The reviewer is a different
19
+ // appointment from the drafters or convergence measures nothing.
20
+
21
+ import { contentHash } from './knowledge.js';
22
+ import { normalizeSubject } from './subject-name.js';
23
+
24
+ export const PROPOSAL_STATES = Object.freeze(['proposed', 'accepted', 'rejected']);
25
+
26
+ /** A new claim replaces an old one when this much of its vocabulary overlaps. */
27
+ export const SUPERSEDE_OVERLAP = 0.5;
28
+
29
+ /**
30
+ * BACKLINKS — the deterministic post-pass that turns cited prose into a linked wiki.
31
+ *
32
+ * A synthesised claim cites RECORDS, never another brief, so an accepted "Jordan Blake owns
33
+ * the Atlas rollback plan" told a reader nothing about where Jordan's page was. This finds
34
+ * every other subject named in a claim's text — whole-word, by canonical name or alias — and
35
+ * adds a `brief:` ref beside the record refs. No model: the names are known and the text is
36
+ * in front of us, which is exactly the kind of work the design says must stay class R.
37
+ *
38
+ * `subjects` is the brief index: `[{ id, name, aliases?, kind? }]`. The claim's own brief is
39
+ * never linked to itself. Refs already present are kept and not duplicated.
40
+ */
41
+ export function linkClaims(claims = [], subjects = [], { selfId = '' } = {}) {
42
+ const targets = [];
43
+ for (const s of subjects) {
44
+ if (!s?.id || s.id === selfId) continue;
45
+ const names = [s.name, ...(s.aliases || [])].map(normalizeSubject).filter((n) => n && n.length >= 3);
46
+ if (names.length) targets.push({ id: s.id, names });
47
+ }
48
+ if (!targets.length) return claims;
49
+ return claims.map((c) => {
50
+ const hay = ` ${normalizeSubject(c.text)} `;
51
+ const have = new Set((c.refs || []).map((r) => `${r.kind}:${r.id}`));
52
+ const added = [];
53
+ for (const t of targets) {
54
+ if (have.has(`brief:${t.id}`)) continue;
55
+ if (t.names.some((n) => hay.includes(` ${n} `))) {
56
+ added.push({ kind: 'brief', id: t.id, hash: contentHash(t.id) });
57
+ have.add(`brief:${t.id}`);
58
+ }
59
+ }
60
+ return added.length ? { ...c, refs: [...(c.refs || []), ...added] } : c;
61
+ });
62
+ }
63
+
64
+ /** A pending synthesis for one brief. Never carries `promoted`. */
65
+ export function propose({ briefId, claims = [], summary = '', by = 'model', now = 0, newId = null } = {}) {
66
+ if (!briefId) throw new TypeError('propose: briefId required');
67
+ const bad = claims.find((c) => c?.cls !== 'C' || !c?.refs?.length);
68
+ if (bad) throw new TypeError('propose: only class-C claims with refs may be proposed');
69
+ return {
70
+ id: newId ? newId() : `p-${briefId}-${now}`,
71
+ briefId,
72
+ by: String(by),
73
+ at: now,
74
+ state: 'proposed',
75
+ claims: claims.map((c) => ({ ...c, state: 'proposed' })),
76
+ summary: String(summary || ''),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Accept: the proposal's claims join the brief as promoted class-C claims. The ONLY path.
82
+ * Returns a new brief and the settled proposal; mutates neither input.
83
+ */
84
+ export function accept(brief, proposal, { now = 0, subjects = [] } = {}) {
85
+ if (!brief || !proposal || proposal.briefId !== brief.id) throw new TypeError('accept: proposal does not belong to this brief');
86
+ if (proposal.state !== 'proposed') throw new TypeError(`accept: proposal is ${proposal.state}`);
87
+ let promoted = proposal.claims.map((c) => ({ ...c, state: 'promoted', lastConfirmed: now }));
88
+ // Backlinks, at the moment of acceptance — the one point where the claim is final and the
89
+ // set of other pages is known.
90
+ promoted = linkClaims(promoted, subjects, { selfId: brief.id });
91
+
92
+ // SUPERSESSION. "Cutover is planned for Q3" and "Cutover moved to Q4" must not sit on one
93
+ // page as two contradicting lines. The old claim is not deleted — that would lose "when did
94
+ // this change" — it is marked superseded by the new one, and keeps its refs, so the brief
95
+ // reads as a history rather than an argument with itself. The rule is the same overlap
96
+ // diffProposal shows the reviewer, so what they accepted is what happens.
97
+ const diff = diffProposal(brief, proposal);
98
+ const superseded = new Map(); // old text -> new claim id
99
+ diff.forEach((d, i) => { if (d.replaces && promoted[i]) superseded.set(d.replaces, promoted[i].id); });
100
+ const kept = brief.claims.map((c) => (superseded.has(c.text) && !c.supersededBy
101
+ ? { ...c, supersededBy: superseded.get(c.text), supersededAt: now }
102
+ : c));
103
+ promoted = promoted.map((c, i) => (diff[i]?.replaces ? { ...c, supersedes: brief.claims.find((o) => o.text === diff[i].replaces)?.id || null } : c));
104
+
105
+ const next = {
106
+ ...brief,
107
+ claims: [...kept, ...promoted],
108
+ summary: proposal.summary || brief.summary || '',
109
+ updatedAt: now,
110
+ };
111
+ return { brief: next, proposal: { ...proposal, state: 'accepted', settledAt: now, claims: promoted } };
112
+ }
113
+
114
+ export function reject(proposal, { now = 0, why = '' } = {}) {
115
+ if (!proposal || proposal.state !== 'proposed') throw new TypeError('reject: not a pending proposal');
116
+ return { ...proposal, state: 'rejected', settledAt: now, why: String(why || '') };
117
+ }
118
+
119
+ /**
120
+ * The diff a reviewer sees: what is new against the brief, and what a new claim
121
+ * contradicts or supersedes. Deterministic and cheap — same-subject, same-`when`-or-later
122
+ * claims that share enough words are shown as "replaces", which is a suggestion the
123
+ * reviewer confirms, never an edit.
124
+ */
125
+ export function diffProposal(brief, proposal) {
126
+ const existing = (brief?.claims || []).map((c) => c.text);
127
+ const tokens = (t) => new Set(String(t).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 3));
128
+ return proposal.claims.map((c) => {
129
+ const mine = tokens(c.text);
130
+ let best = null; let bestScore = 0;
131
+ for (const e of existing) {
132
+ const theirs = tokens(e);
133
+ let hit = 0; for (const w of mine) if (theirs.has(w)) hit += 1;
134
+ const score = mine.size ? hit / mine.size : 0;
135
+ if (score > bestScore) { bestScore = score; best = e; }
136
+ }
137
+ return { claim: c, replaces: bestScore >= SUPERSEDE_OVERLAP ? best : null, overlap: bestScore };
138
+ });
139
+ }
140
+
141
+ /**
142
+ * W7's rule. Given several INDEPENDENT drafts of the same subject, the claims that at least
143
+ * `minAgree` drafts made (by near-identical text) may be proposed automatically; the rest go
144
+ * to the human queue. "Agree" is a text-overlap test, not equality — two models never phrase
145
+ * a thing identically, and requiring it would make convergence measure nothing.
146
+ */
147
+ export function converge(drafts = [], { minAgree = 2, threshold = 0.6 } = {}) {
148
+ const tokens = (t) => new Set(String(t).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 3));
149
+ const all = drafts.flatMap((d, i) => (d?.claims || []).map((c) => ({ c, draft: i, t: tokens(c.text) })));
150
+ const agreed = [];
151
+ const disputed = [];
152
+ const used = new Set();
153
+ for (let i = 0; i < all.length; i += 1) {
154
+ if (used.has(i)) continue;
155
+ const group = [i];
156
+ for (let j = i + 1; j < all.length; j += 1) {
157
+ if (used.has(j) || all[j].draft === all[i].draft) continue;
158
+ let hit = 0; for (const w of all[i].t) if (all[j].t.has(w)) hit += 1;
159
+ const score = all[i].t.size ? hit / all[i].t.size : 0;
160
+ if (score >= threshold) group.push(j);
161
+ }
162
+ const drafters = new Set(group.map((g) => all[g].draft));
163
+ for (const g of group) used.add(g);
164
+ // The union of refs across agreeing drafts: agreement on the claim is evidence, and so
165
+ // is every record any of them cited for it.
166
+ const refs = [...new Map(group.flatMap((g) => all[g].c.refs || []).map((r) => [`${r.kind}:${r.id}`, r])).values()];
167
+ const merged = { ...all[i].c, refs, agreedBy: drafters.size };
168
+ (drafters.size >= minAgree ? agreed : disputed).push(merged);
169
+ }
170
+ return { agreed, disputed };
171
+ }
@@ -0,0 +1,61 @@
1
+ // Recognising a redaction placeholder — one predicate, and nothing else in the module.
2
+ //
3
+ // It is its own file for the reason `distance.js` is: three unrelated places need this
4
+ // question answered — the subject resolver, the wikilink parser, and the extension's note
5
+ // index — and the first two live in `entity.js`, which has since grown entity resolution,
6
+ // merge suggestions and a Levenshtein dependency. Importing 25 KB of that into a notes page
7
+ // to ask "is this string a placeholder" is the same 120 KB mistake in a smaller coat.
8
+
9
+ /**
10
+ * A REDACTION PLACEHOLDER IS NOT A SUBJECT — and this is the sharpest edge in the module.
11
+ *
12
+ * `@chatpanel/pii` writes `[[PERSON_1]]`, `[[EMAIL_2]]`, `[[LOCATION_1]]`, which is
13
+ * character-for-character the `[[wikilink]]` grammar. So a redacted transcript reads as a
14
+ * document full of links to pages that do not exist, and every one of them earned a "wanted
15
+ * page" brief. The name of a person we deliberately did not learn was being filed as a thing
16
+ * we know about.
17
+ *
18
+ * The tempting fix is to treat the token as a pseudonymous identity — it IS stable, and
19
+ * within one conversation `PERSON_1` really does mean one person. It must not become a
20
+ * subject anyway, because **the vault is scoped to a conversation**: `PERSON_1` in Monday's
21
+ * chat and `PERSON_1` in Friday's are different people, and a global subject would merge
22
+ * strangers under one page and attribute one person's decisions to another. That is the same
23
+ * reason `aliasMap` refuses a bare token two people could claim — here it is guaranteed
24
+ * rather than possible.
25
+ *
26
+ * So the placeholder is dropped from subject candidacy, and `curate.js` counts what was
27
+ * dropped so the loss is REPORTED rather than silent. Never resolved against a vault into
28
+ * anything derived and persisted, either: that would put the PII back on disk in a second
29
+ * place, which is the one thing redaction exists to prevent.
30
+ *
31
+ * The pattern is duplicated from `@chatpanel/pii` rather than imported — this package ships
32
+ * zero dependencies so the bridge can vendor it file by file, the same constraint that makes
33
+ * the entitlement JWK live in two clients. `CLAUDE.md` lists `[[TYPE_n]]` as a wire contract
34
+ * that only changes additively, and the extension (which vendors both) carries the drift
35
+ * guard that fails when these two stop agreeing.
36
+ */
37
+ export const REDACTION_TOKEN_TYPES = Object.freeze([
38
+ 'PERSON', 'ORG', 'LOCATION', 'ADDRESS', 'EMAIL', 'PHONE', 'ID', 'SSN', 'IBAN',
39
+ 'CREDITCARD', 'CARD', 'POST', 'FAC', 'GROUP', 'NRP', 'ENTITY', 'KEY', 'SECRET',
40
+ 'TERM', 'PII', 'OTHER',
41
+ ]);
42
+
43
+ const BARE_TOKEN_RE = /^([A-Z][A-Z0-9]*)_\d+$/;
44
+ const unwrap = (v) => String(v ?? '').trim().replace(/^\[{1,2}|\]{1,2}$/g, '');
45
+
46
+ /**
47
+ * Is this BARE string one of the redaction placeholders?
48
+ *
49
+ * Matched against the type vocabulary rather than the `[A-Z]+_\d+` shape, and everywhere —
50
+ * inside brackets too. The shape alone eats real subjects: `[[Q3_2026]]` and `[[PHASE_2]]`
51
+ * are links people genuinely write, and silently dropping them would trade one invisible bug
52
+ * for another. A custom dictionary type is the accepted gap: it is user-chosen, so filing it
53
+ * is a name the user picked, not a stranger's identity.
54
+ *
55
+ * Bracket-tolerant, because a wikilink parser has already stripped them by the time we ask,
56
+ * and a model echoing a placeholder into JSON routinely mangles them.
57
+ */
58
+ export function isRedactionToken(value) {
59
+ const m = BARE_TOKEN_RE.exec(unwrap(value));
60
+ return !!m && REDACTION_TOKEN_TYPES.includes(m[1]);
61
+ }
package/ref.js CHANGED
@@ -9,7 +9,10 @@
9
9
  // version of the note, because that would make replay quietly wrong instead of loudly
10
10
  // incomplete.
11
11
 
12
- export const REF_KINDS = Object.freeze(['note', 'meeting', 'chat', 'page', 'result', 'blob']);
12
+ // `memory` and `brief` joined when the derived layer landed: a brief's claim cites the
13
+ // record it came from, and a claim the USER stated cites the memory they stated it in.
14
+ // Additive — no existing ref changes meaning.
15
+ export const REF_KINDS = Object.freeze(['note', 'meeting', 'chat', 'page', 'result', 'blob', 'memory', 'brief']);
13
16
 
14
17
  export const RESOLUTION = Object.freeze({
15
18
  EXACT: 'exact', // blob present, hash matches
@@ -0,0 +1,5 @@
1
+ // The subject vocabulary, alone in a file so both halves of subject identity can name it
2
+ // without either importing the other.
3
+
4
+ /** What a subject can be. `title` is a record title someone linked to with [[…]]. */
5
+ export const SUBJECT_KINDS = Object.freeze(['person', 'topic', 'tag', 'title']);
@@ -0,0 +1,96 @@
1
+ // Naming a subject, and when one is big enough to deserve a page.
2
+ //
3
+ // The small, dependency-free half of subject identity: fold a name to its canonical form,
4
+ // strip the decoration a directory hangs off it, and hold the evidence thresholds. Nothing
5
+ // here resolves aliases, proposes merges or reaches for a Levenshtein — that is `entity.js`,
6
+ // which imports this.
7
+ //
8
+ // The split is a load-time one, and it is the third time the lesson has come up here (see
9
+ // `distance.js` and `redaction-tokens.js`). `knowledge.js` needs exactly `normalizeSubject`
10
+ // to build a brief id, and the extension's brief store needs exactly `DEFAULT_THRESHOLD` —
11
+ // and both are on the MV3 service worker's graph. Reaching them through `entity.js` put
12
+ // entity resolution and merge suggestion on a worker that will never run either.
13
+
14
+ import { SUBJECT_KINDS } from './subject-kinds.js';
15
+
16
+ /**
17
+ * A subject earns a brief with EVIDENCE, not on first sight (I-K4).
18
+ *
19
+ * PROVISIONAL. These numbers are the W0 measurement's whole point: `surveyCorpus()` reports
20
+ * how many subjects clear them so they can be set from a real corpus instead of taste. Do
21
+ * not treat them as decided until that report has been run.
22
+ */
23
+ export const DEFAULT_THRESHOLD = Object.freeze({ records: 3, mentions: 5 });
24
+
25
+ /** Ceiling on the set of briefs, for the same reason memory.js caps memories. Provisional. */
26
+ export const MAX_SUBJECTS = 500;
27
+ /** Longest name we will treat as a subject — past this it is a sentence, not a subject. */
28
+ export const MAX_SUBJECT_CHARS = 60;
29
+ /**
30
+ * Labels a meeting platform uses for the person holding the microphone.
31
+ *
32
+ * Zoom, Meet and Teams all write the local participant as "You" — so the user appears in
33
+ * their own corpus under a name that is not a name, alongside however their colleagues'
34
+ * clients spelled them. Resolving these needs one fact only the host has: who "you" IS.
35
+ * `resolveSubjects` takes it rather than guessing, and with no `self` supplied these stay
36
+ * unresolved instead of collapsing every meeting's local speaker into one fictional person.
37
+ */
38
+ export const SELF_LABELS = Object.freeze(['you', 'me', 'myself', 'yourself', 'i']);
39
+
40
+ /**
41
+ * Is this the platform's label for the local participant?
42
+ *
43
+ * Exported because two layers need the SAME exception and getting the order wrong is subtle:
44
+ * a self-label fails `isSubjectCandidate` (it is a pronoun), so any pass that filters
45
+ * candidacy BEFORE `resolveSubjects` can fold it has already thrown the user away. `curate.js
46
+ * mentionsFrom` keeps them for exactly this reason and lets resolution decide.
47
+ */
48
+ export function isSelfLabel(name) {
49
+ return SELF_LABELS.includes(normalizeSubject(name));
50
+ }
51
+
52
+ /**
53
+ * Strip the decoration a directory or a conference client hangs off a person's name.
54
+ *
55
+ * The same human arrives as "Alex Rivera", "Alex Rivera (ACME)", "Alex Rivera - Host" and
56
+ * "Alex Rivera (he/him)" depending on which client wrote the label. The part in parentheses
57
+ * or after a dash is an org, a role or a pronoun set — decoration, never identity — so it is
58
+ * removed before folding.
59
+ *
60
+ * NOT removed for non-person subjects: "Migration (Phase 2)" is a different topic from
61
+ * "Migration", where "Alex Rivera (ACME)" is not a different person from "Alex Rivera".
62
+ */
63
+ export function stripQualifiers(name) {
64
+ return String(name ?? '')
65
+ .replace(/\s*[([{][^)\]}]*[)\]}]\s*/g, ' ') // (ACME), [external], {guest}
66
+ .replace(/\s+[-–—|·,]\s+.*$/, '') // - Host, — Guest, | ACME
67
+ .replace(/\s+/g, ' ')
68
+ .trim();
69
+ }
70
+ /**
71
+ * Fold a name to its canonical form: lowercase, Unicode-aware, separators collapsed.
72
+ *
73
+ * Spaces survive as spaces (unlike normalizeTag, which folds them to '-') because a person's
74
+ * name is read back to the user and "alex rivera" has to be recognisable as one.
75
+ */
76
+ export function normalizeSubject(name) {
77
+ const raw = String(name ?? '').normalize('NFKC').trim().replace(/^[#@]+/, '');
78
+ if (!raw) return '';
79
+ return raw
80
+ .toLowerCase()
81
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
82
+ .trim()
83
+ .slice(0, MAX_SUBJECT_CHARS)
84
+ .trim();
85
+ }
86
+ /** `person:alex rivera` — the identity a brief is filed under. '' when nothing survives. */
87
+ export function subjectKey(kind, name) {
88
+ const norm = normalizeSubject(name);
89
+ if (!norm || !SUBJECT_KINDS.includes(kind)) return '';
90
+ return `${kind}:${norm}`;
91
+ }
92
+ /** Tokens of a canonical name. */
93
+ export function subjectTokens(name) {
94
+ const norm = normalizeSubject(name);
95
+ return norm ? norm.split(' ').filter(Boolean) : [];
96
+ }
package/sync-plan.js ADDED
@@ -0,0 +1,170 @@
1
+ // RECONCILING TWO COPIES OF THE LIBRARY — the pure half of two-way sync.
2
+ //
3
+ // The extension holds the corpus in chrome.storage; a desktop app holds it in SQLite; the
4
+ // gateway holds a warm copy. Each pair of those needs the same question answered: given what
5
+ // I have and what you have, what do I pull, what do I push, and what genuinely conflicts.
6
+ //
7
+ // It lives here because the answer must not depend on WHO is asking. If the extension
8
+ // decided ties one way and the desktop the other, two clients syncing the same record would
9
+ // flap — each would keep "winning" and re-pushing, forever. One rule, both sides.
10
+ //
11
+ // WHAT MAKES THIS SAFE TO RUN REPEATEDLY: the plan is a pure function of two index lists, so
12
+ // applying it and re-planning yields an empty plan. That is the property to hold on to —
13
+ // every bug in a sync engine eventually shows up as "running it twice does something".
14
+ //
15
+ // TOMBSTONES ARE NOT OPTIONAL, and this is the sharpest edge in the module. A record that is
16
+ // merely ABSENT from one side is indistinguishable from one that side has never seen. So a
17
+ // deletion has to be representable as a value — `deletedAt` — or "delete" and "not synced
18
+ // yet" are the same input with opposite correct answers. The extension's warm sync already
19
+ // hit this and chose, deliberately, not to tombstone (a missing browser record may simply
20
+ // have aged out of IndexedDB while remaining in a backup). That choice is why warm sync is
21
+ // one-way. A two-way sync cannot make it.
22
+ //
23
+ // Pure over arrays of `{ id, updatedAt, deletedAt }`. No storage, no network, no clock —
24
+ // `now` is never consulted, because a plan that depends on when you asked is not a plan.
25
+
26
+ export class SyncError extends Error {
27
+ constructor(message) { super(message); this.name = 'SyncError'; }
28
+ }
29
+
30
+ /**
31
+ * How far apart two timestamps may be and still count as "the same edit".
32
+ *
33
+ * Clocks on two machines are not identical, and a record copied between them can come back
34
+ * with a millisecond of drift. Without a tolerance every round trip looks like a fresh edit
35
+ * and the two sides push at each other indefinitely.
36
+ */
37
+ export const CLOCK_TOLERANCE_MS = 1000;
38
+
39
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
40
+
41
+ /** The comparable stamp of a record: when it last changed, deleted or not. */
42
+ export function stampOf(entry) {
43
+ if (!entry) return 0;
44
+ return Math.max(num(entry.updatedAt), num(entry.deletedAt));
45
+ }
46
+
47
+ function indexById(entries) {
48
+ const m = new Map();
49
+ for (const e of entries || []) {
50
+ if (!e || !e.id) continue;
51
+ // Later duplicates win: a caller concatenating pages should not be punished for it.
52
+ m.set(e.id, e);
53
+ }
54
+ return m;
55
+ }
56
+
57
+ /**
58
+ * Decide one record. Exported because it is the whole rule, and a caller that streams
59
+ * records one at a time should not have to build two full indexes to use it.
60
+ *
61
+ * Returns one of:
62
+ * 'none' — the two sides agree
63
+ * 'pull' — remote is newer; take theirs
64
+ * 'push' — local is newer, or remote has never seen it; send ours
65
+ * 'conflict' — both changed since the last common state and neither is clearly newer
66
+ */
67
+ export function decide(local, remote, { tolerance = CLOCK_TOLERANCE_MS, base = null } = {}) {
68
+ if (!local && !remote) return 'none';
69
+ if (!remote) return 'push';
70
+ if (!local) return 'pull';
71
+
72
+ const l = stampOf(local);
73
+ const r = stampOf(remote);
74
+ const delta = l - r;
75
+ if (Math.abs(delta) <= tolerance) return 'none';
76
+
77
+ // With a recorded base (what both sides last agreed on) we can tell a genuine divergence
78
+ // from a simple fast-forward: a conflict is when BOTH moved. Without a base the newer
79
+ // stamp wins, which is last-write-wins and is all a first sync can honestly offer.
80
+ if (base) {
81
+ const b = stampOf(base);
82
+ const localMoved = l - b > tolerance;
83
+ const remoteMoved = r - b > tolerance;
84
+ if (localMoved && remoteMoved) return 'conflict';
85
+ }
86
+ return delta > 0 ? 'push' : 'pull';
87
+ }
88
+
89
+ /**
90
+ * Plan a full two-way reconcile.
91
+ *
92
+ * `bases` is optional — a map (or array) of the stamps both sides last agreed on, which is
93
+ * what upgrades last-write-wins into real conflict detection. Callers that keep a sync
94
+ * journal pass it; callers that do not get LWW and no false conflicts.
95
+ *
96
+ * Returns `{ pull, push, conflicts, unchanged }` as arrays of ids, plus `counts`.
97
+ */
98
+ export function planSync(localEntries, remoteEntries, {
99
+ tolerance = CLOCK_TOLERANCE_MS, bases = null,
100
+ } = {}) {
101
+ const local = indexById(localEntries);
102
+ const remote = indexById(remoteEntries);
103
+ const baseMap = bases instanceof Map ? bases : indexById(bases);
104
+
105
+ const pull = [];
106
+ const push = [];
107
+ const conflicts = [];
108
+ let unchanged = 0;
109
+
110
+ for (const id of new Set([...local.keys(), ...remote.keys()])) {
111
+ const verdict = decide(local.get(id), remote.get(id), { tolerance, base: baseMap.get(id) || null });
112
+ if (verdict === 'pull') pull.push(id);
113
+ else if (verdict === 'push') push.push(id);
114
+ else if (verdict === 'conflict') conflicts.push(id);
115
+ else unchanged += 1;
116
+ }
117
+
118
+ // Stable order so two runs over the same input produce byte-identical plans — which is
119
+ // what makes a plan diffable in a log and testable without sorting at every assertion.
120
+ pull.sort();
121
+ push.sort();
122
+ conflicts.sort();
123
+
124
+ return {
125
+ pull, push, conflicts, unchanged,
126
+ counts: { pull: pull.length, push: push.length, conflicts: conflicts.length, unchanged },
127
+ };
128
+ }
129
+
130
+ /** Nothing to do — the property a correct sync reaches and stays at. */
131
+ export function isSettled(plan) {
132
+ return !!plan && plan.pull.length === 0 && plan.push.length === 0 && plan.conflicts.length === 0;
133
+ }
134
+
135
+ /**
136
+ * Resolve a conflict by keeping both: the loser is preserved under a new id rather than
137
+ * overwritten.
138
+ *
139
+ * Silently discarding one side of a conflict is how a sync engine loses the paragraph
140
+ * someone wrote on a plane. The caller supplies `newId` because id minting is a host
141
+ * concern; this only decides WHAT the two resulting records are.
142
+ */
143
+ export function forkConflict(localRecord, remoteRecord, { newId, label = 'conflicted copy' } = {}) {
144
+ if (typeof newId !== 'function') throw new SyncError('forkConflict needs a newId() function');
145
+ const keep = stampOf(localRecord) >= stampOf(remoteRecord) ? localRecord : remoteRecord;
146
+ const fork = keep === localRecord ? remoteRecord : localRecord;
147
+ const parsedKind = String(fork.id || '').split(':')[0];
148
+ return {
149
+ keep,
150
+ fork: {
151
+ ...fork,
152
+ id: `${parsedKind}:${newId()}`,
153
+ title: `${fork.title || 'Untitled'} (${label})`,
154
+ meta: { ...(fork.meta || {}), conflictOf: keep.id },
155
+ },
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Fold applied ids back into a base map, so the NEXT plan can tell a fast-forward from a
161
+ * divergence. Returns a new Map; the input is not mutated.
162
+ */
163
+ export function advanceBases(bases, applied = []) {
164
+ const next = bases instanceof Map ? new Map(bases) : indexById(bases);
165
+ for (const entry of applied) {
166
+ if (!entry || !entry.id) continue;
167
+ next.set(entry.id, { id: entry.id, updatedAt: num(entry.updatedAt), deletedAt: num(entry.deletedAt) });
168
+ }
169
+ return next;
170
+ }
package/synthesis.js ADDED
@@ -0,0 +1,123 @@
1
+ // SYNTHESIS — the first class-C claims: prose about a subject, written by a model.
2
+ //
3
+ // Everything a brief said until now was class R: a restatement of something a record
4
+ // already held, with nothing to review. This is the layer the wiki pattern is actually
5
+ // about — "what was decided about Atlas, and when did it change" — and it is exactly the
6
+ // layer the community warned about: agent writing nobody reviewed compounds into confident
7
+ // nonsense that a lint pass cannot tell apart from truth six months on (C2).
8
+ //
9
+ // So two rules do all the defending, and both are enforced here rather than promised:
10
+ //
11
+ // I-K1 EVERY CLAIM CITES A RECORD IT WAS SHOWN. The model sees excerpts labelled with
12
+ // their ids and must attach at least one to each claim. A claim citing nothing, or
13
+ // citing an id that was not in the excerpt set, is REFUSED — not downgraded, not
14
+ // flagged, refused — because a citation a reader cannot open is worse than no claim.
15
+ // I-K3 NOTHING HERE PROMOTES. `claimsFromSynthesis` produces claims in state `proposed`
16
+ // and `promotion.js` is the only path to `promoted`. There is no flag to skip it.
17
+ //
18
+ // The model call itself is not made here (no network, per the package rule). This module
19
+ // owns the SHAPE — one schema, one prompt, one parser — so the extension, the gateway and a
20
+ // desktop client ask the same question and read the answer the same way. It is written as
21
+ // a single appointment on purpose: a team appointment (W7 — drafters that must agree) has
22
+ // the same contract, with `converge()` in promotion.js deciding what to propose.
23
+
24
+ import { defineSchema, describeSchema } from './structured.js';
25
+
26
+ export const MAX_SYNTHESIS_CLAIMS = 8;
27
+ export const MAX_CLAIM_CHARS = 240;
28
+ export const MAX_EXCERPT_CHARS = 1200;
29
+ export const MAX_EXCERPTS = 24;
30
+
31
+ export const SYNTHESIS_SCHEMA = defineSchema({
32
+ name: 'brief_synthesis',
33
+ purpose: 'What the records establish about one subject, as separate cited claims.',
34
+ fields: {
35
+ claims: {
36
+ type: 'object[]', maxItems: MAX_SYNTHESIS_CLAIMS,
37
+ describe: 'the things the records establish about the subject — decisions, roles, status, changes — one per entry, each cited',
38
+ fields: {
39
+ text: { type: 'string', required: true, max: MAX_CLAIM_CHARS, describe: 'one specific claim in plain prose, past tense for events, present for standing facts' },
40
+ refs: { type: 'string[]', required: true, maxItems: 6, describe: 'the record ids (exactly as labelled, e.g. meeting:m_12) that support this claim — at least one, only ones you were shown' },
41
+ when: { type: 'string', max: 10, describe: 'YYYY-MM-DD of the record that establishes it, if one does' },
42
+ },
43
+ },
44
+ summary: { type: 'string', max: 600, describe: 'two or three sentences of what a colleague should know about this subject right now' },
45
+ },
46
+ // "The records do not establish anything beyond what is already listed" is a legitimate
47
+ // answer and must not be read as a failure — read as one, a caller might fall back to the
48
+ // model's prose without the schema, which is the exact unstructured path this replaces.
49
+ nothing: { claims: [], summary: '' },
50
+ });
51
+
52
+ /**
53
+ * The prompt. The subject, what the deterministic layer already says (so the model adds to
54
+ * it rather than restating it), and the excerpts it may cite — each labelled with the id it
55
+ * must use. Excerpts are capped per record and in count, because this is a bounded call by
56
+ * design (I-K4): a subject with two hundred records gets its most recent two dozen, and a
57
+ * later pass can take the rest.
58
+ */
59
+ export function synthesisPrompt({ subject, existing = [], excerpts = [] } = {}) {
60
+ const name = String(subject?.name || 'the subject');
61
+ const kind = String(subject?.kind || 'subject');
62
+ const shown = excerpts.slice(0, MAX_EXCERPTS);
63
+ const lines = [
64
+ `Subject: ${name} (${kind})${subject?.aliases?.length ? ` — also written as ${subject.aliases.join(', ')}` : ''}.`,
65
+ '',
66
+ 'Already established (do not repeat these):',
67
+ ...(existing.length ? existing.map((c) => `- ${c}`) : ['- nothing yet']),
68
+ '',
69
+ `Records you may cite (${shown.length}). Cite ONLY these ids, exactly as written:`,
70
+ ...shown.map((e) => `[${e.id}] ${e.title || ''}${e.date ? ` (${e.date})` : ''}\n${String(e.text || '').slice(0, MAX_EXCERPT_CHARS)}`),
71
+ '',
72
+ 'Write what these records ESTABLISH about the subject: decisions, ownership, status, and',
73
+ 'anything that changed over time (say what it was before and after). Every claim must cite',
74
+ 'at least one of the ids above. If the records establish nothing beyond what is already',
75
+ 'listed, return no claims.',
76
+ '',
77
+ describeSchema(SYNTHESIS_SCHEMA),
78
+ ];
79
+ return lines.join('\n');
80
+ }
81
+
82
+ /**
83
+ * The model's answer → claims a brief could carry, with the refusals listed.
84
+ *
85
+ * `knownIds` is the set of ids the model was shown. A claim citing anything outside it is
86
+ * refused whole — the model invented or misremembered a citation, and a claim we cannot
87
+ * trace is a claim we cannot keep (I-K1). `hashOf(id)` gives the record's drift hash so the
88
+ * ref can later say "this record changed since I cited it".
89
+ *
90
+ * Returns claims in state `proposed`, class C. There is no argument that produces
91
+ * `promoted`; that is promotion.js's job and nobody else's (I-K3).
92
+ */
93
+ export function claimsFromSynthesis(value, { knownIds, hashOf = () => 'unhashed', now = 0, newId = null } = {}) {
94
+ const known = knownIds instanceof Set ? knownIds : new Set(knownIds || []);
95
+ const out = [];
96
+ const refused = [];
97
+ let n = 0;
98
+ const id = () => (newId ? newId() : `s${now}-${(n += 1)}`);
99
+ for (const raw of value?.claims || []) {
100
+ const text = String(raw?.text || '').trim();
101
+ const refs = [...new Set((raw?.refs || []).map((r) => String(r || '').trim()).filter(Boolean))];
102
+ if (!text) { refused.push({ text, why: 'empty' }); continue; }
103
+ if (!refs.length) { refused.push({ text, why: 'no citation' }); continue; }
104
+ const unknown = refs.filter((r) => !known.has(r));
105
+ if (unknown.length) { refused.push({ text, why: `cites a record it was not shown: ${unknown.join(', ')}` }); continue; }
106
+ out.push({
107
+ id: id(),
108
+ kind: 'synthesis',
109
+ text: text.slice(0, MAX_CLAIM_CHARS),
110
+ refs: refs.map((r) => {
111
+ const idx = r.indexOf(':');
112
+ return { kind: r.slice(0, idx), id: r.slice(idx + 1), hash: hashOf(r) };
113
+ }),
114
+ when: /^\d{4}-\d{2}-\d{2}$/.test(String(raw?.when || '')) ? raw.when : '',
115
+ firstSeen: now,
116
+ lastConfirmed: now,
117
+ confidence: 0.6, // a model's read, not a record's word — lower than any class-R claim
118
+ cls: 'C',
119
+ state: 'proposed',
120
+ });
121
+ }
122
+ return { claims: out, summary: String(value?.summary || '').trim().slice(0, 600), refused };
123
+ }