@chatpanel/events 0.33.1 → 0.47.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/backup-envelope.js +221 -0
- package/curate.js +509 -0
- package/distance.js +124 -0
- package/entitlement.js +298 -0
- package/entity.js +354 -0
- package/flowchart.js +350 -97
- package/index.js +78 -0
- package/knowledge-derive.js +267 -0
- package/knowledge.js +221 -0
- package/library.js +265 -0
- package/omni.js +125 -0
- package/package.json +43 -11
- package/promotion.js +171 -0
- package/redaction-tokens.js +61 -0
- package/ref.js +4 -1
- package/subject-kinds.js +5 -0
- package/subject-name.js +96 -0
- package/sync-plan.js +170 -0
- package/synthesis.js +123 -0
- package/theme.js +155 -0
- package/voice-intents.js +5 -23
package/entitlement.js
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// PLANS, GATES AND THE SIGNED ENTITLEMENT — one definition, every client.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS SHARED, and it is the sharpest example in the package. "Is this user Pro" is
|
|
4
|
+
// currently answered in four places: the extension (`js/license.js`), the bridge
|
|
5
|
+
// (`src/entitlement.js`), the gateway (`src/entitlement.js`) and now a desktop app. Three of
|
|
6
|
+
// those already carry their own copy of the SAME public key, which is why `CLAUDE.md` has to
|
|
7
|
+
// warn that rotating the signing key means editing every client by hand.
|
|
8
|
+
//
|
|
9
|
+
// Duplicating a feature matrix is worse than duplicating a helper: it does not fail loudly,
|
|
10
|
+
// it fails as "Pro works in the browser but not on the desktop", which reads to the person
|
|
11
|
+
// paying for it as the product being broken. Defining it once makes the two clients agree
|
|
12
|
+
// BY CONSTRUCTION rather than by diligence.
|
|
13
|
+
//
|
|
14
|
+
// WHAT IS STILL THE CLIENT'S JOB (P8, as everywhere else): storing the licence, minting and
|
|
15
|
+
// keeping an install id, opening a browser for checkout, and counting local usage against
|
|
16
|
+
// the free limits. Those need storage and a platform. Everything here is pure.
|
|
17
|
+
//
|
|
18
|
+
// THE PRIVATE KEY IS NOT HERE AND NEVER WILL BE. The worker holds it; this file holds only
|
|
19
|
+
// the public half, which is what lets every client verify OFFLINE — chat traffic never
|
|
20
|
+
// touches the licence server, and a client that cannot reach the network keeps working.
|
|
21
|
+
|
|
22
|
+
export class EntitlementError extends Error {
|
|
23
|
+
constructor(message) { super(message); this.name = 'EntitlementError'; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const PLANS = Object.freeze(['free', 'pro', 'team']);
|
|
27
|
+
const RANK = Object.freeze({ free: 0, pro: 1, team: 2 });
|
|
28
|
+
|
|
29
|
+
/** Where the licence server lives. One base; the endpoints derive from it. */
|
|
30
|
+
export const API_BASE = 'https://api.chatpanel.net';
|
|
31
|
+
export const ENDPOINTS = Object.freeze({
|
|
32
|
+
verify: `${API_BASE}/license/verify`,
|
|
33
|
+
entitlement: `${API_BASE}/entitlement`,
|
|
34
|
+
claim: `${API_BASE}/entitlement/claim`,
|
|
35
|
+
release: `${API_BASE}/entitlement/release`,
|
|
36
|
+
restore: `${API_BASE}/restore`,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The PUBLIC half of the entitlement signing key.
|
|
41
|
+
*
|
|
42
|
+
* Byte-for-byte the key the extension and bridge already embed. If it is ever rotated it is
|
|
43
|
+
* rotated HERE, and every client that takes this package inherits it on its next sync —
|
|
44
|
+
* which is the entire reason this module exists.
|
|
45
|
+
*/
|
|
46
|
+
export const ENTITLEMENT_PUBLIC_JWK = Object.freeze({
|
|
47
|
+
kty: 'EC',
|
|
48
|
+
crv: 'P-256',
|
|
49
|
+
x: 'CmgKLC4e3xDMvwhbjVqF7jbDe1JhC1KKQi8JN3qVX_4',
|
|
50
|
+
y: 'r40l6fQiyCcJYqW-SvB4VoSyn4F36yhSt82ZAOSo78E',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** Where "Upgrade" sends people. A stable URL, so pricing can change without a release. */
|
|
54
|
+
export const UPGRADE_URL = 'https://chatpanel.net/#pricing';
|
|
55
|
+
|
|
56
|
+
export function checkoutUrl(plan = 'pro', installId = '', client = '') {
|
|
57
|
+
const u = new URL('https://chatpanel.net/');
|
|
58
|
+
if (installId) u.searchParams.set('install_id', installId);
|
|
59
|
+
if (plan) u.searchParams.set('plan', plan);
|
|
60
|
+
// Which client sent them, so a desktop purchase can be attributed and, later, seated
|
|
61
|
+
// differently from a browser one. Additive: the site ignores what it does not read.
|
|
62
|
+
if (client) u.searchParams.set('client', client);
|
|
63
|
+
u.hash = 'pricing';
|
|
64
|
+
return u.toString();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --------------------------------------------------------------------------
|
|
68
|
+
// The matrix
|
|
69
|
+
// --------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every gated feature → the minimum plan that unlocks it. Anything absent is free.
|
|
73
|
+
*
|
|
74
|
+
* Features a given client cannot perform are still listed: the desktop cannot capture a
|
|
75
|
+
* meeting, but it can READ one, and `liveMeetings` gates the dashboard and history search
|
|
76
|
+
* as well as capture. A client omitting a key it "does not need" is how the two drift.
|
|
77
|
+
*/
|
|
78
|
+
export const FEATURE_TIER = Object.freeze({
|
|
79
|
+
localAgents: 'free',
|
|
80
|
+
byoModels: 'free',
|
|
81
|
+
urlContext: 'free',
|
|
82
|
+
liveMeetings: 'free',
|
|
83
|
+
|
|
84
|
+
multiTab: 'pro',
|
|
85
|
+
unlimitedAgents: 'pro',
|
|
86
|
+
customSkills: 'pro',
|
|
87
|
+
customAgents: 'pro',
|
|
88
|
+
advancedAgent: 'pro',
|
|
89
|
+
unlimitedNotes: 'pro',
|
|
90
|
+
unlimitedMeetings: 'pro',
|
|
91
|
+
structuredInsert: 'pro',
|
|
92
|
+
exportChats: 'pro',
|
|
93
|
+
autoBackup: 'pro',
|
|
94
|
+
promptLibrary: 'pro',
|
|
95
|
+
fileAttachments: 'pro',
|
|
96
|
+
watch: 'pro',
|
|
97
|
+
|
|
98
|
+
cloudSync: 'team',
|
|
99
|
+
sharedLibrary: 'team',
|
|
100
|
+
hostedBridge: 'team',
|
|
101
|
+
sso: 'team',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const PRO_FEATURES = Object.freeze({
|
|
105
|
+
unlimitedNotes: 'Unlimited notes — Free keeps your first 10',
|
|
106
|
+
unlimitedMeetings: 'Unlimited meetings — Free keeps your first 10',
|
|
107
|
+
multiTab: 'Attach several tabs at once',
|
|
108
|
+
unlimitedAgents: 'Unlimited custom agents',
|
|
109
|
+
customSkills: 'Create & edit your own skills',
|
|
110
|
+
advancedAgent: 'Per-agent system prompts & working directories',
|
|
111
|
+
structuredInsert: 'Clean diagrams on Excalidraw, draw.io & tldraw — shapes placed as data, not pixel-drawn',
|
|
112
|
+
exportChats: 'Export conversations as Markdown',
|
|
113
|
+
autoBackup: 'Automatic daily backup of all your data to disk',
|
|
114
|
+
watch: 'Watch a page & act on changes — the agent reacts as the page updates',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export const TEAM_FEATURES = Object.freeze({
|
|
118
|
+
cloudSync: 'Sync chats across your devices',
|
|
119
|
+
sharedLibrary: 'Shared team agents & skills',
|
|
120
|
+
hostedBridge: 'Hosted agents — no local bridge to run',
|
|
121
|
+
sso: 'SSO & admin controls',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Free-tier ceilings. LIFETIME counts where noted — see the anti-cheat note in each client. */
|
|
125
|
+
export const FREE_LIMITS = Object.freeze({
|
|
126
|
+
notes: 10,
|
|
127
|
+
meetings: 10,
|
|
128
|
+
apiEndpoints: 1,
|
|
129
|
+
bridgeAgents: 1,
|
|
130
|
+
customAgents: 1,
|
|
131
|
+
attachmentsPerMessage: 1,
|
|
132
|
+
mcpServers: 1,
|
|
133
|
+
gatewayDestinations: 1,
|
|
134
|
+
webSearchEngines: 3,
|
|
135
|
+
webSearchesPerDay: 50,
|
|
136
|
+
fullRedactions: 25,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// --------------------------------------------------------------------------
|
|
140
|
+
// Resolution
|
|
141
|
+
// --------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The plan actually in force.
|
|
145
|
+
*
|
|
146
|
+
* An expired licence is `free`, checked against an injected `now` so a client cannot be
|
|
147
|
+
* tested only at the moment it happens to be run.
|
|
148
|
+
*/
|
|
149
|
+
export function planOf(license, now = Date.now()) {
|
|
150
|
+
if (!license || !PLANS.includes(license.plan)) return 'free';
|
|
151
|
+
if (license.expiresAt && now > license.expiresAt) return 'free';
|
|
152
|
+
return license.plan;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function planLabel(license, now = Date.now()) {
|
|
156
|
+
return { free: 'Free', pro: 'Pro', team: 'Team' }[planOf(license, now)];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isPro(license, now = Date.now()) {
|
|
160
|
+
return RANK[planOf(license, now)] >= RANK.pro;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isTeam(license, now = Date.now()) {
|
|
164
|
+
return planOf(license, now) === 'team';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Gate a feature. `count` expresses "this would be my Nth", for the counted allowances. */
|
|
168
|
+
export function can(license, feature, count = 0, now = Date.now()) {
|
|
169
|
+
const plan = planOf(license, now);
|
|
170
|
+
if (feature === 'unlimitedAgents' && RANK[plan] < RANK.pro) {
|
|
171
|
+
return count < FREE_LIMITS.customAgents;
|
|
172
|
+
}
|
|
173
|
+
const need = FEATURE_TIER[feature] || 'free';
|
|
174
|
+
return RANK[plan] >= RANK[need];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The minimum plan a feature needs — for an upgrade prompt that names the right tier. */
|
|
178
|
+
export function tierFor(feature) {
|
|
179
|
+
return FEATURE_TIER[feature] || 'free';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Is this the Nth use of a lifetime-capped free allowance?
|
|
184
|
+
*
|
|
185
|
+
* The count is MONOTONIC — "how many have ever been created", not "how many exist now" —
|
|
186
|
+
* because a cap on the current count is lifted by deleting things, which is not a limit.
|
|
187
|
+
*/
|
|
188
|
+
export function withinFreeLimit(license, key, everCount, now = Date.now()) {
|
|
189
|
+
if (isPro(license, now)) return true;
|
|
190
|
+
const cap = FREE_LIMITS[key];
|
|
191
|
+
return typeof cap !== 'number' || everCount < cap;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --------------------------------------------------------------------------
|
|
195
|
+
// The signed token
|
|
196
|
+
// --------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
function b64urlToBytes(s) {
|
|
199
|
+
const pad = '='.repeat((4 - (String(s).length % 4)) % 4);
|
|
200
|
+
const b64 = String(s).replace(/-/g, '+').replace(/_/g, '/') + pad;
|
|
201
|
+
if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
202
|
+
const bin = atob(b64);
|
|
203
|
+
const out = new Uint8Array(bin.length);
|
|
204
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function decodePayload(head) {
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(new TextDecoder().decode(b64urlToBytes(head)));
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let _keyPromise = null;
|
|
217
|
+
|
|
218
|
+
async function verifyKey(subtle) {
|
|
219
|
+
const s = subtle || globalThis.crypto?.subtle;
|
|
220
|
+
if (!s) throw new EntitlementError('no WebCrypto available on this runtime');
|
|
221
|
+
if (subtle) {
|
|
222
|
+
// An injected implementation must not be memoised into the shared slot, or a test that
|
|
223
|
+
// passes one would poison every later call in the process.
|
|
224
|
+
return s.importKey('jwk', ENTITLEMENT_PUBLIC_JWK, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
|
225
|
+
}
|
|
226
|
+
if (!_keyPromise) {
|
|
227
|
+
_keyPromise = s.importKey('jwk', ENTITLEMENT_PUBLIC_JWK, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
|
|
228
|
+
}
|
|
229
|
+
return _keyPromise;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Verify a server entitlement token and return its payload, or `null`.
|
|
234
|
+
*
|
|
235
|
+
* Three checks, and all three matter: the SIGNATURE (it came from the worker), the INSTALL
|
|
236
|
+
* BINDING (it was issued to this device, so a token copied from a forum does not grant Pro),
|
|
237
|
+
* and EXPIRY. Returning null rather than throwing is deliberate — a caller resolving a
|
|
238
|
+
* licence at startup must degrade to free, not crash the app.
|
|
239
|
+
*/
|
|
240
|
+
export async function verifyEntitlement(token, installId, { subtle = null, now = Date.now() } = {}) {
|
|
241
|
+
if (!token || typeof token !== 'string' || token.indexOf('.') < 0) return null;
|
|
242
|
+
const [head, sig] = token.split('.');
|
|
243
|
+
if (!head || !sig) return null;
|
|
244
|
+
|
|
245
|
+
let ok = false;
|
|
246
|
+
try {
|
|
247
|
+
const s = subtle || globalThis.crypto?.subtle;
|
|
248
|
+
ok = await s.verify(
|
|
249
|
+
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
250
|
+
await verifyKey(subtle),
|
|
251
|
+
b64urlToBytes(sig),
|
|
252
|
+
new TextEncoder().encode(head),
|
|
253
|
+
);
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
if (!ok) return null;
|
|
258
|
+
|
|
259
|
+
const payload = decodePayload(head);
|
|
260
|
+
if (!payload) return null;
|
|
261
|
+
if (payload.exp && now > Number(payload.exp)) return null;
|
|
262
|
+
// `install` binds the token to one device. A token with no binding is refused rather than
|
|
263
|
+
// treated as universal — "unbound" must never be the permissive case.
|
|
264
|
+
if (!payload.install || (installId && payload.install !== installId)) return null;
|
|
265
|
+
if (!PLANS.includes(payload.plan)) return null;
|
|
266
|
+
return payload;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** A verified payload → the licence record a client stores. */
|
|
270
|
+
export function licenseFromPayload(payload, { at = Date.now() } = {}) {
|
|
271
|
+
if (!payload) return { plan: 'free' };
|
|
272
|
+
return {
|
|
273
|
+
plan: payload.plan,
|
|
274
|
+
expiresAt: Number(payload.exp) || 0,
|
|
275
|
+
installId: payload.install || '',
|
|
276
|
+
seats: Number(payload.seats) || 0,
|
|
277
|
+
checkedAt: at,
|
|
278
|
+
source: payload.source || 'entitlement',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Should this client re-check with the server yet?
|
|
284
|
+
*
|
|
285
|
+
* Offline verification is the point, so the cadence is generous — but a licence that has
|
|
286
|
+
* NEVER been checked, or one whose expiry is close, is worth a call. Pure so the schedule is
|
|
287
|
+
* testable without waiting a day.
|
|
288
|
+
*/
|
|
289
|
+
export const RECHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
|
|
290
|
+
|
|
291
|
+
export function needsRecheck(license, { now = Date.now(), interval = RECHECK_INTERVAL_MS } = {}) {
|
|
292
|
+
if (!license || !license.checkedAt) return true;
|
|
293
|
+
if (now - license.checkedAt > interval) return true;
|
|
294
|
+
// Inside the last day of validity, check more eagerly: this is where a renewal lands and
|
|
295
|
+
// where a silent lapse would otherwise surprise someone mid-sentence.
|
|
296
|
+
if (license.expiresAt && license.expiresAt - now < 24 * 60 * 60 * 1000) return true;
|
|
297
|
+
return false;
|
|
298
|
+
}
|
package/entity.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// ENTITY IDENTITY — forty mentions of one person are one subject, or they are nothing.
|
|
2
|
+
//
|
|
3
|
+
// Extraction we already have: `meeting-people.js` names the speakers on a call,
|
|
4
|
+
// `extraction.js` pulls topics and ENTITIES, `tags.js` folds the filing vocabulary. What
|
|
5
|
+
// none of them answer is the next question: are "Alex Rivera", "alex rivera" and the
|
|
6
|
+
// "Alex" who spoke in yesterday's standup the SAME subject? Without that answer a derived
|
|
7
|
+
// layer cannot exist — a brief is by definition an accumulation across records, so it needs
|
|
8
|
+
// something to accumulate *about*.
|
|
9
|
+
//
|
|
10
|
+
// Two rules do almost all the work, and both are deliberately conservative:
|
|
11
|
+
//
|
|
12
|
+
// • CANONICAL FORM IS LOSSY, IDENTITY IS NOT. `subjectKey()` folds case, punctuation and
|
|
13
|
+
// spacing — filing noise, exactly as in tags.js — but never folds two different names
|
|
14
|
+
// together. The display name stays whatever the corpus said most often, so the user
|
|
15
|
+
// reads "Alex Rivera" and not "alex-rivera".
|
|
16
|
+
// • AN ALIAS IS ONLY AN ALIAS WHEN IT IS UNAMBIGUOUS. "Alex" folds into "Alex Rivera"
|
|
17
|
+
// when Alex Rivera is the only Alex in the corpus. The moment a second Alex appears,
|
|
18
|
+
// the bare token stops resolving to either — for good, and retroactively. Guessing here
|
|
19
|
+
// is how a brief ends up attributing one person's decisions to another, which is the
|
|
20
|
+
// single most expensive mistake this layer can make.
|
|
21
|
+
//
|
|
22
|
+
// Pure input → output: no storage, no clock, no model. The extension, the gateway and a
|
|
23
|
+
// future mobile client must agree on what "the same subject" is, and three implementations
|
|
24
|
+
// would mean three answers — the argument tags.js already makes for tags.
|
|
25
|
+
|
|
26
|
+
import { blockedPairs, editDistance } from './distance.js';
|
|
27
|
+
// The placeholder recognizer lives on its own (see that file for why). Re-exported so every
|
|
28
|
+
// existing caller of entity.js is unchanged.
|
|
29
|
+
import { isRedactionToken } from './redaction-tokens.js';
|
|
30
|
+
|
|
31
|
+
export { REDACTION_TOKEN_TYPES, isRedactionToken } from './redaction-tokens.js';
|
|
32
|
+
|
|
33
|
+
// Naming and sizing a subject live next door, so a caller that needs only those does not
|
|
34
|
+
// drag alias resolution and a Levenshtein along. Re-exported: no caller of entity.js changes.
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_THRESHOLD, MAX_SUBJECTS, MAX_SUBJECT_CHARS, SELF_LABELS,
|
|
37
|
+
isSelfLabel, normalizeSubject, stripQualifiers,
|
|
38
|
+
} from './subject-name.js';
|
|
39
|
+
import { SUBJECT_KINDS } from './subject-kinds.js';
|
|
40
|
+
|
|
41
|
+
export { SUBJECT_KINDS } from './subject-kinds.js';
|
|
42
|
+
export {
|
|
43
|
+
DEFAULT_THRESHOLD, MAX_SUBJECTS, MAX_SUBJECT_CHARS, SELF_LABELS, isSelfLabel,
|
|
44
|
+
normalizeSubject, stripQualifiers, subjectKey, subjectTokens,
|
|
45
|
+
} from './subject-name.js';
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
// Words that are never a subject on their own. A one-token candidate has to survive this
|
|
49
|
+
// list before it can become a page, because "notes", "meeting" and "update" appear in every
|
|
50
|
+
// record and would each clear any threshold instantly.
|
|
51
|
+
const STOP_SUBJECTS = new Set([
|
|
52
|
+
'chat', 'chats', 'note', 'notes', 'meeting', 'meetings', 'call', 'calls', 'update',
|
|
53
|
+
'updates', 'summary', 'summaries', 'agenda', 'todo', 'todos', 'task', 'tasks', 'item',
|
|
54
|
+
'items', 'thing', 'things', 'stuff', 'misc', 'other', 'general', 'test', 'testing',
|
|
55
|
+
'untitled', 'draft', 'drafts', 'new', 'old', 'today', 'yesterday', 'tomorrow', 'week',
|
|
56
|
+
'day', 'month', 'year', 'time', 'people', 'person', 'team', 'work',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Is this string worth considering as a subject at all?
|
|
61
|
+
*
|
|
62
|
+
* Rejects blanks, over-long phrases, pure numbers and the stop list above. A multi-token
|
|
63
|
+
* phrase is allowed even when one of its tokens is a stop word ("design review" is a real
|
|
64
|
+
* topic, "review" alone is not).
|
|
65
|
+
*/
|
|
66
|
+
export function isSubjectCandidate(name, { kind = 'topic' } = {}) {
|
|
67
|
+
// Length is judged BEFORE folding: normalizeSubject truncates at MAX_SUBJECT_CHARS, so a
|
|
68
|
+
// check on its output can never fire and a whole sentence would slip through as a subject.
|
|
69
|
+
if (String(name ?? '').trim().length > MAX_SUBJECT_CHARS) return false;
|
|
70
|
+
// Checked on the RAW value, before folding: normalizeSubject lowercases, and the pattern
|
|
71
|
+
// is upper-case by construction.
|
|
72
|
+
if (isRedactionToken(name)) return false;
|
|
73
|
+
// "You" is a pronoun, not a person. Without a `self` name to fold it into (resolveSubjects
|
|
74
|
+
// takes one), it must not become a subject of its own — every meeting has a "You" and they
|
|
75
|
+
// are not all the same participant.
|
|
76
|
+
if (kind === 'person' && isSelfLabel(name)) return false;
|
|
77
|
+
const norm = normalizeSubject(name);
|
|
78
|
+
if (!norm || norm.length < 2) return false;
|
|
79
|
+
const tokens = norm.split(' ').filter(Boolean);
|
|
80
|
+
if (!tokens.length) return false;
|
|
81
|
+
if (tokens.every((t) => /^\p{N}+$/u.test(t))) return false;
|
|
82
|
+
if (tokens.length === 1 && STOP_SUBJECTS.has(tokens[0])) return false;
|
|
83
|
+
// A person needs at least two characters of actual letters — "j r" is initials, not an
|
|
84
|
+
// identity we can accumulate against.
|
|
85
|
+
if (kind === 'person' && !/\p{L}{2}/u.test(norm)) return false;
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve short forms to full names, but ONLY when the corpus leaves no doubt.
|
|
91
|
+
*
|
|
92
|
+
* Given the canonical names seen for one kind, returns `alias -> canonical`. A bare token
|
|
93
|
+
* maps to a multi-token name when it is that name's first or last token AND exactly one
|
|
94
|
+
* name in the corpus claims it. Two people called Alex means neither owns "alex", so the
|
|
95
|
+
* bare token maps to nothing and stays a subject of its own — visible, unmerged, and
|
|
96
|
+
* therefore checkable, which is the failure mode we want.
|
|
97
|
+
*/
|
|
98
|
+
export function aliasMap(names) {
|
|
99
|
+
const canonical = new Set();
|
|
100
|
+
for (const n of names || []) {
|
|
101
|
+
const norm = normalizeSubject(n);
|
|
102
|
+
if (norm && norm.includes(' ')) canonical.add(norm);
|
|
103
|
+
}
|
|
104
|
+
const claims = new Map(); // token -> Set(full names claiming it)
|
|
105
|
+
for (const full of canonical) {
|
|
106
|
+
const tokens = full.split(' ');
|
|
107
|
+
for (const t of [tokens[0], tokens[tokens.length - 1]]) {
|
|
108
|
+
if (!t || t.length < 2 || STOP_SUBJECTS.has(t)) continue;
|
|
109
|
+
if (!claims.has(t)) claims.set(t, new Set());
|
|
110
|
+
claims.get(t).add(full);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const out = new Map();
|
|
114
|
+
for (const [token, owners] of claims) {
|
|
115
|
+
if (owners.size !== 1) continue; // ambiguous — resolve to nothing, on purpose
|
|
116
|
+
out.set(token, [...owners][0]);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fold a list of raw mentions into subjects.
|
|
123
|
+
*
|
|
124
|
+
* `mentions` is `[{ kind, name, recordId }]` — whatever the corpus said, in whatever form.
|
|
125
|
+
* The result is one entry per identity, carrying every surface form that reached it, the
|
|
126
|
+
* distinct records it appeared in, and the display name the corpus used most often.
|
|
127
|
+
*
|
|
128
|
+
* Aliases are resolved per KIND: two topics can share a word without being the same topic,
|
|
129
|
+
* and the person rule above must not leak into tags.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveSubjects(mentions = [], { merges = null, self = '' } = {}) {
|
|
132
|
+
// The user's own name, however they told us. Everything the platforms call "you" folds
|
|
133
|
+
// into it — and with no name supplied, nothing does.
|
|
134
|
+
const selfName = String(self ?? '').trim();
|
|
135
|
+
const selfCanonical = normalizeSubject(selfName);
|
|
136
|
+
// User-authored merges: `alias canonical form -> the canonical form it belongs to`. These
|
|
137
|
+
// are an INPUT to derivation, never an edit to its output, which is what keeps I-K2 true —
|
|
138
|
+
// a rebuild that dropped the user's corrections would teach them not to make any.
|
|
139
|
+
const merged = new Map();
|
|
140
|
+
for (const [from, to] of merges instanceof Map ? merges : Object.entries(merges || {})) {
|
|
141
|
+
const a = normalizeSubject(from); const b = normalizeSubject(to);
|
|
142
|
+
if (a && b && a !== b) merged.set(a, b);
|
|
143
|
+
}
|
|
144
|
+
// One hop only. A chain (a→b, b→c) is resolved here rather than at read time, and a cycle
|
|
145
|
+
// simply stops, because a merge loop must not hang a rebuild.
|
|
146
|
+
const resolveMerge = (key) => {
|
|
147
|
+
let cur = key;
|
|
148
|
+
for (let i = 0; i < 8 && merged.has(cur); i += 1) {
|
|
149
|
+
const next = merged.get(cur);
|
|
150
|
+
if (next === cur) break;
|
|
151
|
+
cur = next;
|
|
152
|
+
}
|
|
153
|
+
return cur;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const byKind = new Map();
|
|
157
|
+
for (const m of mentions) {
|
|
158
|
+
const kind = m?.kind;
|
|
159
|
+
if (!SUBJECT_KINDS.includes(kind)) continue;
|
|
160
|
+
// A self-label survives candidacy ONLY when there is a name to fold it into. Otherwise
|
|
161
|
+
// it is a pronoun, and every meeting's "You" would pile into one fictional participant.
|
|
162
|
+
const isSelf = kind === 'person' && !!selfCanonical && isSelfLabel(m.name);
|
|
163
|
+
if (!isSelf && !isSubjectCandidate(m.name, { kind })) continue;
|
|
164
|
+
if (!byKind.has(kind)) byKind.set(kind, []);
|
|
165
|
+
byKind.get(kind).push(m);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const subjects = new Map();
|
|
169
|
+
for (const [kind, list] of byKind) {
|
|
170
|
+
// Only PERSON names carry the short-form rule. A topic named "design" is not the
|
|
171
|
+
// "design review" topic, and folding them would silently merge two pages.
|
|
172
|
+
const aliases = kind === 'person'
|
|
173
|
+
? aliasMap(list.map((m) => stripQualifiers(m.name)).filter((n) => !isSelfLabel(n)))
|
|
174
|
+
: new Map();
|
|
175
|
+
for (const m of list) {
|
|
176
|
+
const raw = String(m.name ?? '').trim();
|
|
177
|
+
// For a PERSON, the qualifier is decoration and the platform's "You" is the user.
|
|
178
|
+
// Both are resolved before the alias rule, so "Alex Rivera (ACME)" and "You" reach the
|
|
179
|
+
// same identity that a bare "Alex" does.
|
|
180
|
+
let norm = normalizeSubject(kind === 'person' ? stripQualifiers(raw) : raw);
|
|
181
|
+
if (kind === 'person' && selfCanonical && SELF_LABELS.includes(norm)) norm = selfCanonical;
|
|
182
|
+
const canonical = resolveMerge(aliases.get(norm) || norm);
|
|
183
|
+
const key = `${kind}:${canonical}`;
|
|
184
|
+
let s = subjects.get(key);
|
|
185
|
+
if (!s) {
|
|
186
|
+
s = { key, kind, name: '', canonical, aliases: [], records: new Set(), mentions: 0, forms: new Map() };
|
|
187
|
+
subjects.set(key, s);
|
|
188
|
+
}
|
|
189
|
+
s.mentions += 1;
|
|
190
|
+
if (m.recordId) s.records.add(m.recordId);
|
|
191
|
+
// The SURFACE FORM a person is displayed under is the stripped one. The canonical was
|
|
192
|
+
// already right — "Sam Okonkwo [ACME - Platform]" resolved to person:sam okonkwo — but the
|
|
193
|
+
// raw string was recorded as the display form, so a subject seen only that way got a
|
|
194
|
+
// page titled with the directory's decoration. A qualifier is not part of a name.
|
|
195
|
+
const display = String(kind === 'person' ? stripQualifiers(raw) : raw).normalize('NFKC').trim();
|
|
196
|
+
if (display) s.forms.set(display, (s.forms.get(display) || 0) + 1);
|
|
197
|
+
if (norm !== canonical && !s.aliases.includes(norm)) s.aliases.push(norm);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const s of subjects.values()) {
|
|
202
|
+
// The name the user reads is a surface form of the CANONICAL identity, most common
|
|
203
|
+
// first, ties broken alphabetically so the choice is stable across runs rather than
|
|
204
|
+
// insertion-ordered. Forms that only reached this subject through an alias are ranked
|
|
205
|
+
// last: `person:alex rivera` displayed as "Alex" because the short form happened to be
|
|
206
|
+
// one mention commoner would be a page whose title is not the subject's name.
|
|
207
|
+
const forms = [...s.forms.entries()]
|
|
208
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
209
|
+
s.name = forms.find(([f]) => normalizeSubject(f) === s.canonical)?.[0]
|
|
210
|
+
|| forms[0]?.[0]
|
|
211
|
+
|| s.canonical;
|
|
212
|
+
s.aliases.sort();
|
|
213
|
+
delete s.forms;
|
|
214
|
+
}
|
|
215
|
+
return subjects;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Does this subject have enough evidence to deserve a page? */
|
|
219
|
+
export function earnsBrief(subject, threshold = DEFAULT_THRESHOLD) {
|
|
220
|
+
if (!subject) return false;
|
|
221
|
+
const records = subject.records instanceof Set ? subject.records.size : (subject.records?.length || 0);
|
|
222
|
+
const mentions = Number(subject.mentions) || 0;
|
|
223
|
+
return records >= (threshold.records ?? DEFAULT_THRESHOLD.records)
|
|
224
|
+
&& mentions >= (threshold.mentions ?? DEFAULT_THRESHOLD.mentions);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Rank subjects and keep the ones that earned a page, strongest first.
|
|
229
|
+
*
|
|
230
|
+
* `limit` is the I-K4 count ceiling made concrete: a corpus with 4000 qualifying subjects
|
|
231
|
+
* does not get 4000 briefs, it gets the best `limit` of them, and the rest stay subjects
|
|
232
|
+
* without pages until the evidence moves.
|
|
233
|
+
*/
|
|
234
|
+
export function rankSubjects(subjects, { threshold = DEFAULT_THRESHOLD, limit = MAX_SUBJECTS } = {}) {
|
|
235
|
+
const list = [...(subjects instanceof Map ? subjects.values() : subjects || [])];
|
|
236
|
+
return list
|
|
237
|
+
.filter((s) => earnsBrief(s, threshold))
|
|
238
|
+
.map((s) => ({ ...s, recordCount: s.records instanceof Set ? s.records.size : (s.records?.length || 0) }))
|
|
239
|
+
.sort((a, b) => b.recordCount - a.recordCount || b.mentions - a.mentions || a.key.localeCompare(b.key))
|
|
240
|
+
.slice(0, Math.max(0, limit));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* PROPOSE merges; never apply them.
|
|
245
|
+
*
|
|
246
|
+
* The alias rule folds what it can prove. Everything it cannot is left visible — "A. Rivera"
|
|
247
|
+
* beside "Alex Rivera", an initialism beside a full name, a second spelling of a project —
|
|
248
|
+
* and a user staring at two pages for one person has no way to say so. This is that way: a
|
|
249
|
+
* deterministic, model-free list of pairs worth asking about, ranked by how likely they are.
|
|
250
|
+
*
|
|
251
|
+
* PROPOSING is the whole design. The pairs below are exactly the ones the alias rule refuses
|
|
252
|
+
* to decide on its own, because deciding wrongly merges two people permanently and silently.
|
|
253
|
+
* A human answers in one click, the answer is stored as a merge rule, and every later rebuild
|
|
254
|
+
* applies it — so the correction survives I-K2 rather than being erased by the next pass.
|
|
255
|
+
*
|
|
256
|
+
* Three signals, strongest first:
|
|
257
|
+
* • initials — "A. Rivera" against "Alex Rivera"
|
|
258
|
+
* • containment — one name's tokens are a subset of the other's
|
|
259
|
+
* • near-spelling — one edit apart, or a transposition, which catches the common typos
|
|
260
|
+
*
|
|
261
|
+
* A shared SURNAME is deliberately not a signal: two people with one last name are usually
|
|
262
|
+
* two people, and proposing every such pair would bury the real suggestions.
|
|
263
|
+
*/
|
|
264
|
+
export function suggestMerges(subjects, { limit = 40, distance = 1 } = {}) {
|
|
265
|
+
const list = [...(subjects instanceof Map ? subjects.values() : subjects || [])]
|
|
266
|
+
.filter((s) => s && s.canonical);
|
|
267
|
+
|
|
268
|
+
// BLOCKED, not pairwise. Comparing every pair did not finish 12,000 subjects in two
|
|
269
|
+
// minutes — 6.8s at 2,000, 32s at 6,000, 141s at 12,000 — and a corpus with a few thousand
|
|
270
|
+
// distinct people, topics and tags reaches that easily. This pass runs on the UI thread,
|
|
271
|
+
// so that is a hung page, not a slow one.
|
|
272
|
+
//
|
|
273
|
+
// All three signals need either a shared end or a shared last token, which is precisely
|
|
274
|
+
// what `blockKeys` files on, so the findings survive the change: an abbreviated first name
|
|
275
|
+
// shares the surname, a contained name shares a prefix, a typo shares whichever end it is
|
|
276
|
+
// not in.
|
|
277
|
+
const byCanonical = new Map();
|
|
278
|
+
for (const s of list) {
|
|
279
|
+
if (!byCanonical.has(s.canonical)) byCanonical.set(s.canonical, []);
|
|
280
|
+
byCanonical.get(s.canonical).push(s);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const out = [];
|
|
284
|
+
for (const [ca, cb] of blockedPairs([...byCanonical.keys()])) {
|
|
285
|
+
// One reason per canonical PAIR, computed before the (rare) fan-out over subjects that
|
|
286
|
+
// share a canonical form across kinds.
|
|
287
|
+
for (const a of byCanonical.get(ca)) {
|
|
288
|
+
for (const b of byCanonical.get(cb)) {
|
|
289
|
+
if (a.kind !== b.kind) continue; // a person and a topic are never the same subject
|
|
290
|
+
const reason = mergeReason(a.canonical, b.canonical, a.kind, distance);
|
|
291
|
+
if (!reason) continue;
|
|
292
|
+
// The better-evidenced side is proposed as the survivor: it has more records behind
|
|
293
|
+
// it and is more likely the name the user actually thinks in.
|
|
294
|
+
const [keep, drop] = countOf(a) >= countOf(b) ? [a, b] : [b, a];
|
|
295
|
+
out.push({ kind: a.kind, keep: keep.key, keepName: keep.name, drop: drop.key, dropName: drop.name, reason });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const rank = { initials: 0, containment: 1, spelling: 2 };
|
|
301
|
+
return out
|
|
302
|
+
.sort((x, y) => (rank[x.reason] - rank[y.reason]) || x.keepName.localeCompare(y.keepName))
|
|
303
|
+
.slice(0, Math.max(0, limit));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function countOf(s) {
|
|
307
|
+
return s.records instanceof Set ? s.records.size : (s.records?.length || 0);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const sortedChars = (s) => [...s.replace(/ /g, '')].sort().join('');
|
|
311
|
+
// "q3 planning" and "q4 planning" are one edit apart and are not the same subject; nor are
|
|
312
|
+
// "phase 2" and "phase 3", or "atlas sync 1" and "atlas sync 2". If masking the digits makes
|
|
313
|
+
// two names identical, the difference IS the digits, and that is a SERIES. duplicateTitles
|
|
314
|
+
// makes the same argument for trailing numbers; this is the in-word case.
|
|
315
|
+
const digitSeries = (a, b) => a !== b && a.replace(/\d/g, '#') === b.replace(/\d/g, '#');
|
|
316
|
+
|
|
317
|
+
function mergeReason(a, b, kind, distance) {
|
|
318
|
+
if (a === b) return null;
|
|
319
|
+
const ta = a.split(' ').filter(Boolean);
|
|
320
|
+
const tb = b.split(' ').filter(Boolean);
|
|
321
|
+
|
|
322
|
+
// "a. rivera" / "ar" against "alex rivera" — same last token, first token abbreviates.
|
|
323
|
+
const last = ta[ta.length - 1] === tb[tb.length - 1];
|
|
324
|
+
if (last && ta.length > 1 && tb.length > 1) {
|
|
325
|
+
const [fa, fb] = [ta[0], tb[0]];
|
|
326
|
+
if (fa !== fb && (fa.startsWith(fb) || fb.startsWith(fa))) return 'initials';
|
|
327
|
+
}
|
|
328
|
+
if (ta.length === 1 && tb.length === 1 && ta[0] !== tb[0]) {
|
|
329
|
+
const [short, long] = ta[0].length <= tb[0].length ? [ta[0], tb[0]] : [tb[0], ta[0]];
|
|
330
|
+
if (short.length >= 2 && long.startsWith(short)) return 'initials';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// One name's tokens are all present in the other's.
|
|
334
|
+
const setA = new Set(ta); const setB = new Set(tb);
|
|
335
|
+
const [small, big] = setA.size <= setB.size ? [setA, setB] : [setB, setA];
|
|
336
|
+
if (small.size && [...small].every((t) => big.has(t)) && small.size !== big.size) return 'containment';
|
|
337
|
+
|
|
338
|
+
if (digitSeries(a, b)) return null;
|
|
339
|
+
|
|
340
|
+
// Too short for an edit to mean anything — see duplicateTitles for the same guard.
|
|
341
|
+
if (Math.min(a.length, b.length) > distance * 3) {
|
|
342
|
+
if (editDistance(a, b, distance) <= distance) return 'spelling';
|
|
343
|
+
// A transposition costs TWO edits in Levenshtein, and "atals" for "atlas" is the single
|
|
344
|
+
// commonest typo there is. Admitted only when the two are anagrams, so widening the
|
|
345
|
+
// budget cannot also admit "q3 planning" against "q4 planning".
|
|
346
|
+
if (editDistance(a, b, 2) <= 2 && sortedChars(a) === sortedChars(b)) return 'spelling';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// A SHARED SURNAME IS NOT A SIGNAL, and it was tempting. Two people with the same last
|
|
350
|
+
// name are usually two people — colleagues, relatives — so proposing every such pair on
|
|
351
|
+
// every rebuild would bury the three real suggestions under thirty. A list nobody reads
|
|
352
|
+
// is worse than no list, which is the same finding that keeps briefs bounded.
|
|
353
|
+
return null;
|
|
354
|
+
}
|