@chatpanel/events 0.102.0 → 0.103.1

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.
Files changed (138) hide show
  1. package/adapters.js +1 -83
  2. package/adaptive-tool-policy.js +1 -45
  3. package/agent.js +2 -255
  4. package/attribution.js +1 -132
  5. package/backup-envelope.js +1 -221
  6. package/board-tool.js +2 -165
  7. package/budget.js +1 -116
  8. package/capability.js +1 -126
  9. package/citations.js +5 -127
  10. package/client-prefs.js +1 -148
  11. package/context-attachments.js +8 -144
  12. package/cowriter-router.js +1 -83
  13. package/cowriter-writer.js +19 -193
  14. package/cowriter.js +1 -190
  15. package/curate.js +3 -509
  16. package/distance.js +1 -124
  17. package/engine.js +1 -131
  18. package/entitlement.js +1 -332
  19. package/entity.js +1 -354
  20. package/event.js +1 -175
  21. package/extraction.js +4 -293
  22. package/failover.js +1 -93
  23. package/find-tool.js +1 -66
  24. package/flowchart.js +2 -644
  25. package/gate.js +1 -74
  26. package/harness.js +4 -101
  27. package/index.js +1 -345
  28. package/invariants.js +1 -174
  29. package/job.js +3 -147
  30. package/kernel.js +1 -255
  31. package/knowledge-derive.js +1 -267
  32. package/knowledge.js +3 -230
  33. package/library.js +11 -280
  34. package/loop.js +1 -132
  35. package/manifest.js +1 -107
  36. package/markdown-authoring.js +11 -288
  37. package/markdown-render.js +0 -0
  38. package/mcp-client.js +8 -358
  39. package/mcp-dispatch.js +1 -52
  40. package/mcp-errors.js +1 -106
  41. package/mcp-manager.js +1 -127
  42. package/media-transcript.js +8 -531
  43. package/meeting-analyzers.js +1 -83
  44. package/meeting-insights.js +7 -164
  45. package/meeting-shape.js +1 -282
  46. package/meeting-text.js +5 -94
  47. package/memory.js +2 -638
  48. package/model-candidates.js +1 -358
  49. package/model-health.js +1 -182
  50. package/model-ledger.js +1 -228
  51. package/model-picker.js +1 -188
  52. package/note-actions.js +15 -246
  53. package/note-graph.js +1 -158
  54. package/note-links.js +1 -68
  55. package/note-mentions.js +8 -114
  56. package/note-plan.js +9 -107
  57. package/note-research.js +1 -135
  58. package/observability.js +1 -119
  59. package/omni.js +1 -125
  60. package/order.js +1 -78
  61. package/package.json +1 -4
  62. package/pdf-layout.js +7 -253
  63. package/project-run.js +20 -444
  64. package/project.js +1 -170
  65. package/promotion.js +1 -171
  66. package/queue.js +1 -98
  67. package/reach.js +1 -23
  68. package/recipe-tool.js +2 -161
  69. package/recipe.js +1 -264
  70. package/record-list.js +2 -110
  71. package/recruit.js +3 -456
  72. package/redaction-tokens.js +1 -61
  73. package/ref.js +1 -55
  74. package/registry.js +1 -240
  75. package/route-graph.js +1 -115
  76. package/route-strategies.js +2 -232
  77. package/router.js +2 -880
  78. package/rrf.js +1 -89
  79. package/rules.js +1 -142
  80. package/schedule.js +1 -663
  81. package/scm-connection.js +1 -180
  82. package/scopes.js +1 -12
  83. package/scorecard.js +1 -335
  84. package/search-engines.js +1 -81
  85. package/skill-manifest.js +1 -330
  86. package/skill-scan.js +3 -209
  87. package/skill-sources.js +1 -162
  88. package/skill-vars.js +2 -252
  89. package/slash-commands.js +1 -158
  90. package/source-gate.js +1 -92
  91. package/sources-retrieval.js +5 -186
  92. package/sources.js +1 -273
  93. package/store.js +1 -171
  94. package/structured.js +4 -901
  95. package/subject-kinds.js +1 -5
  96. package/subject-name.js +1 -96
  97. package/sync-plan.js +1 -213
  98. package/synthesis.js +3 -123
  99. package/tags.js +1 -203
  100. package/team-board.js +6 -337
  101. package/team-cache.js +2 -63
  102. package/team-observe.js +1 -118
  103. package/team-org.js +1 -442
  104. package/team-plan.js +4 -131
  105. package/team-record.js +1 -225
  106. package/team-run.js +9 -652
  107. package/team-subtask.js +2 -191
  108. package/team-task.js +2 -124
  109. package/team-tool.js +2 -196
  110. package/team-trail.js +2 -73
  111. package/team-worklog.js +2 -149
  112. package/team.js +1 -302
  113. package/text-search.js +1 -155
  114. package/theme.js +17 -154
  115. package/titles.js +4 -285
  116. package/tool-discovery.js +1 -87
  117. package/tool-dispatch.js +5 -230
  118. package/tool-groups.js +1 -81
  119. package/tool-hints.js +10 -185
  120. package/tool-loop-guard.js +4 -182
  121. package/tool-need.js +2 -124
  122. package/tool-result.js +2 -438
  123. package/tool-round.js +0 -0
  124. package/tool-schema.js +1 -155
  125. package/tool-traits.js +1 -182
  126. package/toolset.js +2 -88
  127. package/trajectory.js +1 -530
  128. package/turn-loop.js +4 -440
  129. package/upcast.js +1 -37
  130. package/vault.js +2 -250
  131. package/view.js +1 -88
  132. package/voice-intents.js +2 -1488
  133. package/voice-speaker.js +1 -98
  134. package/weather-tool.js +1 -65
  135. package/weather.js +2 -211
  136. package/web-search-tool.js +14 -102
  137. package/web-search.js +1 -160
  138. package/widget.js +1 -153
package/entity.js CHANGED
@@ -1,354 +1 @@
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
- }
1
+ import{blockedPairs as L,editDistance as T}from"./distance.js";import{isRedactionToken as z}from"./redaction-tokens.js";import{REDACTION_TOKEN_TYPES as j,isRedactionToken as Q}from"./redaction-tokens.js";import{DEFAULT_THRESHOLD as h,MAX_SUBJECTS as A,MAX_SUBJECT_CHARS as N,SELF_LABELS as x,isSelfLabel as w,normalizeSubject as S,stripQualifiers as E}from"./subject-name.js";import{SUBJECT_KINDS as U}from"./subject-kinds.js";import{SUBJECT_KINDS as q}from"./subject-kinds.js";import{DEFAULT_THRESHOLD as V,MAX_SUBJECTS as Z,MAX_SUBJECT_CHARS as ee,SELF_LABELS as ne,isSelfLabel as te,normalizeSubject as oe,stripQualifiers as se,subjectKey as re,subjectTokens as ie}from"./subject-name.js";const y=new Set(["chat","chats","note","notes","meeting","meetings","call","calls","update","updates","summary","summaries","agenda","todo","todos","task","tasks","item","items","thing","things","stuff","misc","other","general","test","testing","untitled","draft","drafts","new","old","today","yesterday","tomorrow","week","day","month","year","time","people","person","team","work"]);function D(n,{kind:r="topic"}={}){if(String(n??"").trim().length>N||z(n)||r==="person"&&w(n))return!1;const l=S(n);if(!l||l.length<2)return!1;const f=l.split(" ").filter(Boolean);return!(!f.length||f.every(e=>new RegExp("^\\p{N}+$","u").test(e))||f.length===1&&y.has(f[0])||r==="person"&&!new RegExp("\\p{L}{2}","u").test(l))}function R(n){const r=new Set;for(const e of n||[]){const o=S(e);o&&o.includes(" ")&&r.add(o)}const l=new Map;for(const e of r){const o=e.split(" ");for(const m of[o[0],o[o.length-1]])!m||m.length<2||y.has(m)||(l.has(m)||l.set(m,new Set),l.get(m).add(e))}const f=new Map;for(const[e,o]of l)o.size===1&&f.set(e,[...o][0]);return f}function b(n=[],{merges:r=null,self:l=""}={}){const f=String(l??"").trim(),e=S(f),o=new Map;for(const[t,s]of r instanceof Map?r:Object.entries(r||{})){const i=S(t),a=S(s);i&&a&&i!==a&&o.set(i,a)}const m=t=>{let s=t;for(let i=0;i<8&&o.has(s);i+=1){const a=o.get(s);if(a===s)break;s=a}return s},c=new Map;for(const t of n){const s=t?.kind;!U.includes(s)||!(s==="person"&&e&&w(t.name))&&!D(t.name,{kind:s})||(c.has(s)||c.set(s,[]),c.get(s).push(t))}const u=new Map;for(const[t,s]of c){const i=t==="person"?R(s.map(a=>E(a.name)).filter(a=>!w(a))):new Map;for(const a of s){const d=String(a.name??"").trim();let g=S(t==="person"?E(d):d);t==="person"&&e&&x.includes(g)&&(g=e);const k=m(i.get(g)||g),C=`${t}:${k}`;let p=u.get(C);p||(p={key:C,kind:t,name:"",canonical:k,aliases:[],records:new Set,mentions:0,forms:new Map},u.set(C,p)),p.mentions+=1,a.recordId&&p.records.add(a.recordId);const M=String(t==="person"?E(d):d).normalize("NFKC").trim();M&&p.forms.set(M,(p.forms.get(M)||0)+1),g!==k&&!p.aliases.includes(g)&&p.aliases.push(g)}}for(const t of u.values()){const s=[...t.forms.entries()].sort((i,a)=>a[1]-i[1]||i[0].localeCompare(a[0]));t.name=s.find(([i])=>S(i)===t.canonical)?.[0]||s[0]?.[0]||t.canonical,t.aliases.sort(),delete t.forms}return u}function v(n,r=h){if(!n)return!1;const l=n.records instanceof Set?n.records.size:n.records?.length||0,f=Number(n.mentions)||0;return l>=(r.records??h.records)&&f>=(r.mentions??h.mentions)}function X(n,{threshold:r=h,limit:l=A}={}){return[...n instanceof Map?n.values():n||[]].filter(e=>v(e,r)).map(e=>({...e,recordCount:e.records instanceof Set?e.records.size:e.records?.length||0})).sort((e,o)=>o.recordCount-e.recordCount||o.mentions-e.mentions||e.key.localeCompare(o.key)).slice(0,Math.max(0,l))}function P(n,{limit:r=40,distance:l=1}={}){const f=[...n instanceof Map?n.values():n||[]].filter(c=>c&&c.canonical),e=new Map;for(const c of f)e.has(c.canonical)||e.set(c.canonical,[]),e.get(c.canonical).push(c);const o=[];for(const[c,u]of L([...e.keys()]))for(const t of e.get(c))for(const s of e.get(u)){if(t.kind!==s.kind)continue;const i=O(t.canonical,s.canonical,t.kind,l);if(!i)continue;const[a,d]=_(t)>=_(s)?[t,s]:[s,t];o.push({kind:t.kind,keep:a.key,keepName:a.name,drop:d.key,dropName:d.name,reason:i})}const m={initials:0,containment:1,spelling:2};return o.sort((c,u)=>m[c.reason]-m[u.reason]||c.keepName.localeCompare(u.keepName)).slice(0,Math.max(0,r))}function _(n){return n.records instanceof Set?n.records.size:n.records?.length||0}const B=n=>[...n.replace(/ /g,"")].sort().join(""),J=(n,r)=>n!==r&&n.replace(/\d/g,"#")===r.replace(/\d/g,"#");function O(n,r,l,f){if(n===r)return null;const e=n.split(" ").filter(Boolean),o=r.split(" ").filter(Boolean);if(e[e.length-1]===o[o.length-1]&&e.length>1&&o.length>1){const[i,a]=[e[0],o[0]];if(i!==a&&(i.startsWith(a)||a.startsWith(i)))return"initials"}if(e.length===1&&o.length===1&&e[0]!==o[0]){const[i,a]=e[0].length<=o[0].length?[e[0],o[0]]:[o[0],e[0]];if(i.length>=2&&a.startsWith(i))return"initials"}const c=new Set(e),u=new Set(o),[t,s]=c.size<=u.size?[c,u]:[u,c];return t.size&&[...t].every(i=>s.has(i))&&t.size!==s.size?"containment":J(n,r)?null:Math.min(n.length,r.length)>f*3&&(T(n,r,f)<=f||T(n,r,2)<=2&&B(n)===B(r))?"spelling":null}export{V as DEFAULT_THRESHOLD,Z as MAX_SUBJECTS,ee as MAX_SUBJECT_CHARS,j as REDACTION_TOKEN_TYPES,ne as SELF_LABELS,q as SUBJECT_KINDS,R as aliasMap,v as earnsBrief,Q as isRedactionToken,te as isSelfLabel,D as isSubjectCandidate,oe as normalizeSubject,X as rankSubjects,b as resolveSubjects,se as stripQualifiers,re as subjectKey,ie as subjectTokens,P as suggestMerges};
package/event.js CHANGED
@@ -1,175 +1 @@
1
- // The event envelopedurable facts only.
2
- //
3
- // ORDER WITHOUT CLOCKS. `at` is wall time and is ADVISORY: it is shown to humans and
4
- // never consulted for ordering. `(host, seq)` plus `causes` are the authority, because
5
- // once two hosts append concurrently a timestamp is not an order (see order.js).
6
- //
7
- // EPHEMERAL STREAMS ARE NOT THIS TYPE. Market ticks, live captions and DOM mutations
8
- // live in an in-memory ring buffer with no id, no seq and no persistence; only the
9
- // windowed aggregate that entered a model request or crossed the device boundary is
10
- // promoted to an Event. That is structural rather than a rule to remember: durably
11
- // logging a caption stream is ~3 MB per meeting, ~5.5 GB/year, and there is no Event
12
- // type that would accept one.
13
- //
14
- // METADATA ONLY. Payloads carry Refs and counts, never content. `privacy.redacted`
15
- // carries how many of each entity type were redacted and never the values — a log of
16
- // what was redacted must not itself contain the redacted data.
17
-
18
- import { isRef } from './ref.js';
19
-
20
- export const CURRENT_VERSION = 1;
21
-
22
- /** The seven families of v1. Adding is cheap; removing is not. */
23
- export const EVENT_TYPES = Object.freeze({
24
- turn: ['started', 'ended'],
25
- // What the model was SHOWN and what it SAID. Deliberately arriving as refs, not text —
26
- // see the note on the validators below.
27
- assistant: ['prompted', 'message', 'reasoning'],
28
- context: ['assembled', 'attached', 'expanded'],
29
- capability: ['offered', 'granted', 'denied', 'activated', 'revoked', 'invoked', 'resulted'],
30
- privacy: ['redacted', 'egress'],
31
- policy: ['changed', 'guard_denied'],
32
- data: ['created', 'updated', 'deleted'],
33
- automation: ['fired', 'suppressed'],
34
- });
35
-
36
- export const ALL_TYPES = Object.freeze(
37
- Object.entries(EVENT_TYPES).flatMap(([fam, kinds]) => kinds.map((k) => `${fam}.${k}`)),
38
- );
39
-
40
- // 'channel' is a message arriving from a paired external surface — Telegram, WhatsApp — that
41
- // drives a turn the same way a person pressing send does. It is turn-independent for the same
42
- // reason 'schedule' and 'agent' are: nobody is sitting in the panel when it fires, so consent
43
- // and reach have to be settled at pairing time, not at the keystroke. The actor.id carries the
44
- // surface and the sender, e.g. 'telegram:8412…'. See chatpanel-channels for the invoker.
45
- export const ACTOR_KINDS = Object.freeze(['user', 'rule', 'schedule', 'model', 'agent', 'channel']);
46
- export const SCOPE_KINDS = Object.freeze(['global', 'site', 'tab', 'session', 'agent']);
47
- export const CLASSES = Object.freeze(['R', 'M', 'L', 'C', 'A', 'X', 'H']);
48
- export const EFFECTS = Object.freeze(['pure', 'idempotent', 'replay-safe', 'non-replayable']);
49
- export const EGRESS = Object.freeze(['none', 'redacted', 'delegated']);
50
-
51
- export class EventError extends Error {
52
- constructor(code, message, detail = null) {
53
- super(message);
54
- this.name = 'EventError';
55
- this.code = code;
56
- this.detail = detail;
57
- }
58
- }
59
-
60
- const str = (v) => typeof v === 'string' && v.length > 0;
61
- const num = (v) => typeof v === 'number' && Number.isFinite(v);
62
- const arr = (v) => Array.isArray(v);
63
-
64
- function actorOk(a) { return !!a && ACTOR_KINDS.includes(a.kind) && str(a.id); }
65
- function scopeOk(s) { return !!s && SCOPE_KINDS.includes(s.kind) && str(s.id); }
66
-
67
- // Per-type payload requirements. Deliberately light — enough that a malformed event
68
- // cannot enter an append-only log, not a full schema language.
69
- const PAYLOAD = {
70
- 'turn.started': (p) => str(p.turnId),
71
- 'turn.ended': (p) => str(p.turnId),
72
-
73
- // ── assistant ─────────────────────────────────────────────────────────────
74
- // "Model-visible means logged" was true of the toolset and false of the conversation:
75
- // the log could say a turn happened and what it cost, but never what was asked or
76
- // answered. That is the half a trajectory view needs, and the half replay cannot be
77
- // checked without.
78
- //
79
- // CONTENT IS NEVER IN THE EVENT. Each of these carries a Ref — a content hash — and the
80
- // bytes live in the blob store. Three reasons, in order of how much they matter:
81
- // 1. The log stays metadata, so exporting or replicating it does not export the user's
82
- // conversations by accident. That property is the whole reason an event log is safe
83
- // to keep at all.
84
- // 2. Deletion stays honest: an append-only log cannot unsay a message, but a blob can
85
- // be crypto-shredded and the ref then resolves to verified-but-unavailable. Inlined
86
- // text would make "delete my data" a lie the schema enforces forever.
87
- // 3. Repeated content (the same system prompt on every turn) is stored once.
88
- 'assistant.prompted': (p) => str(p.turnId) && isRef(p.ref),
89
- 'assistant.message': (p) => str(p.turnId) && isRef(p.ref),
90
- 'assistant.reasoning': (p) => str(p.turnId) && isRef(p.ref),
91
-
92
- 'context.assembled': (p) => num(p.budget) && num(p.used) && !!p.parts && typeof p.parts === 'object'
93
- && arr(p.resident) && p.resident.every(isRef) && num(p.reachableCount),
94
- 'context.attached': (p) => isRef(p.ref),
95
- 'context.expanded': (p) => isRef(p.ref) && num(p.tokens),
96
-
97
- 'capability.offered': (p) => str(p.capability) && str(p.reason),
98
- 'capability.granted': (p) => str(p.capability) && actorOk(p.actor),
99
- 'capability.denied': (p) => str(p.capability) && actorOk(p.actor),
100
- 'capability.activated': (p) => str(p.capability) && CLASSES.includes(p.classUsed),
101
- 'capability.revoked': (p) => str(p.capability) && str(p.cause),
102
- // I3 is enforced here structurally: a non-pure invocation without a key is not a
103
- // valid event, so it cannot reach the log at all.
104
- 'capability.invoked': (p) => str(p.capability) && actorOk(p.actor) && scopeOk(p.scope)
105
- && EFFECTS.includes(p.effects) && (p.effects === 'pure' || str(p.idempotencyKey)),
106
- 'capability.resulted': (p) => str(p.capability) && typeof p.ok === 'boolean'
107
- && CLASSES.includes(p.classUsed) && !!p.cost && num(p.cost.ms),
108
-
109
- // counts only — never values
110
- 'privacy.redacted': (p) => !!p.counts && typeof p.counts === 'object'
111
- && Object.values(p.counts).every(num),
112
- 'privacy.egress': (p) => str(p.host) && typeof p.redacted === 'boolean'
113
- && typeof p.controlled === 'boolean',
114
-
115
- 'policy.changed': (p) => str(p.dial) && actorOk(p.actor) && 'from' in p && 'to' in p,
116
- 'policy.guard_denied': (p) => str(p.capability) && str(p.reason),
117
-
118
- 'data.created': (p) => isRef(p.ref),
119
- 'data.updated': (p) => isRef(p.ref),
120
- 'data.deleted': (p) => isRef(p.ref) && typeof p.shredded === 'boolean',
121
-
122
- 'automation.fired': (p) => str(p.ruleId) && CLASSES.includes(p.classUsed),
123
- 'automation.suppressed': (p) => str(p.ruleId) && str(p.reason),
124
- };
125
-
126
- /** Validate a durable event. Throws EventError; never mutates. */
127
- export function validateEvent(e) {
128
- if (!e || typeof e !== 'object') throw new EventError('SHAPE', 'event must be an object');
129
- if (e.v !== CURRENT_VERSION) throw new EventError('VERSION', `expected v=${CURRENT_VERSION}, got ${e.v} — upcast first`, e.v);
130
- if (!str(e.id)) throw new EventError('SHAPE', 'id required');
131
- if (!str(e.host)) throw new EventError('SHAPE', 'host required');
132
- if (!Number.isInteger(e.seq) || e.seq < 0) throw new EventError('SHAPE', 'seq must be a non-negative integer');
133
- if (!arr(e.causes) || !e.causes.every(str)) throw new EventError('SHAPE', 'causes must be string[]');
134
- if (!num(e.at)) throw new EventError('SHAPE', 'at required (advisory wall clock)');
135
- if (!ALL_TYPES.includes(e.type)) throw new EventError('TYPE', `unknown type ${e.type}`, e.type);
136
- if (!e.payload || typeof e.payload !== 'object') throw new EventError('SHAPE', 'payload required');
137
- const check = PAYLOAD[e.type];
138
- if (check && !check(e.payload)) throw new EventError('PAYLOAD', `payload invalid for ${e.type}`, e.type);
139
- return e;
140
- }
141
-
142
- export function isValidEvent(e) {
143
- try { validateEvent(e); return true; } catch { return false; }
144
- }
145
-
146
- /**
147
- * A per-host append cursor. Owns `seq` — the only monotonic thing in the system — so
148
- * callers cannot skip or reuse one.
149
- *
150
- * `now` and `newId` are injected so tests are deterministic and so the package holds no
151
- * ambient dependency on a clock or a crypto implementation.
152
- */
153
- export function createAppender({ host, seq = 0, now = () => Date.now(), newId }) {
154
- if (!str(host)) throw new EventError('SHAPE', 'host required');
155
- const genId = newId || (() => globalThis.crypto.randomUUID());
156
- let n = seq;
157
- return {
158
- get host() { return host; },
159
- get seq() { return n; },
160
- /** Build + validate the next event for this host. Does not persist — the caller stores it. */
161
- append(type, payload, causes = []) {
162
- const e = Object.freeze({
163
- v: CURRENT_VERSION,
164
- id: genId(),
165
- host,
166
- seq: n++,
167
- causes: Object.freeze([...causes]),
168
- at: now(),
169
- type,
170
- payload,
171
- });
172
- return validateEvent(e);
173
- },
174
- };
175
- }
1
+ import{isRef as a}from"./ref.js";const c=1,m=Object.freeze({turn:["started","ended"],assistant:["prompted","message","reasoning"],context:["assembled","attached","expanded"],capability:["offered","granted","denied","activated","revoked","invoked","resulted"],privacy:["redacted","egress"],policy:["changed","guard_denied"],data:["created","updated","deleted"],automation:["fired","suppressed"]}),v=Object.freeze(Object.entries(m).flatMap(([e,n])=>n.map(s=>`${e}.${s}`))),w=Object.freeze(["user","rule","schedule","model","agent","channel"]),S=Object.freeze(["global","site","tab","session","agent"]),l=Object.freeze(["R","M","L","C","A","X","H"]),x=Object.freeze(["pure","idempotent","replay-safe","non-replayable"]),I=Object.freeze(["none","redacted","delegated"]);class r extends Error{constructor(n,s,i=null){super(s),this.name="EventError",this.code=n,this.detail=i}}const t=e=>typeof e=="string"&&e.length>0,o=e=>typeof e=="number"&&Number.isFinite(e),f=e=>Array.isArray(e);function d(e){return!!e&&w.includes(e.kind)&&t(e.id)}function A(e){return!!e&&S.includes(e.kind)&&t(e.id)}const O={"turn.started":e=>t(e.turnId),"turn.ended":e=>t(e.turnId),"assistant.prompted":e=>t(e.turnId)&&a(e.ref),"assistant.message":e=>t(e.turnId)&&a(e.ref),"assistant.reasoning":e=>t(e.turnId)&&a(e.ref),"context.assembled":e=>o(e.budget)&&o(e.used)&&!!e.parts&&typeof e.parts=="object"&&f(e.resident)&&e.resident.every(a)&&o(e.reachableCount),"context.attached":e=>a(e.ref),"context.expanded":e=>a(e.ref)&&o(e.tokens),"capability.offered":e=>t(e.capability)&&t(e.reason),"capability.granted":e=>t(e.capability)&&d(e.actor),"capability.denied":e=>t(e.capability)&&d(e.actor),"capability.activated":e=>t(e.capability)&&l.includes(e.classUsed),"capability.revoked":e=>t(e.capability)&&t(e.cause),"capability.invoked":e=>t(e.capability)&&d(e.actor)&&A(e.scope)&&x.includes(e.effects)&&(e.effects==="pure"||t(e.idempotencyKey)),"capability.resulted":e=>t(e.capability)&&typeof e.ok=="boolean"&&l.includes(e.classUsed)&&!!e.cost&&o(e.cost.ms),"privacy.redacted":e=>!!e.counts&&typeof e.counts=="object"&&Object.values(e.counts).every(o),"privacy.egress":e=>t(e.host)&&typeof e.redacted=="boolean"&&typeof e.controlled=="boolean","policy.changed":e=>t(e.dial)&&d(e.actor)&&"from"in e&&"to"in e,"policy.guard_denied":e=>t(e.capability)&&t(e.reason),"data.created":e=>a(e.ref),"data.updated":e=>a(e.ref),"data.deleted":e=>a(e.ref)&&typeof e.shredded=="boolean","automation.fired":e=>t(e.ruleId)&&l.includes(e.classUsed),"automation.suppressed":e=>t(e.ruleId)&&t(e.reason)};function y(e){if(!e||typeof e!="object")throw new r("SHAPE","event must be an object");if(e.v!==c)throw new r("VERSION",`expected v=${c}, got ${e.v} upcast first`,e.v);if(!t(e.id))throw new r("SHAPE","id required");if(!t(e.host))throw new r("SHAPE","host required");if(!Number.isInteger(e.seq)||e.seq<0)throw new r("SHAPE","seq must be a non-negative integer");if(!f(e.causes)||!e.causes.every(t))throw new r("SHAPE","causes must be string[]");if(!o(e.at))throw new r("SHAPE","at required (advisory wall clock)");if(!v.includes(e.type))throw new r("TYPE",`unknown type ${e.type}`,e.type);if(!e.payload||typeof e.payload!="object")throw new r("SHAPE","payload required");const n=O[e.type];if(n&&!n(e.payload))throw new r("PAYLOAD",`payload invalid for ${e.type}`,e.type);return e}function P(e){try{return y(e),!0}catch{return!1}}function k({host:e,seq:n=0,now:s=()=>Date.now(),newId:i}){if(!t(e))throw new r("SHAPE","host required");const p=i||(()=>globalThis.crypto.randomUUID());let u=n;return{get host(){return e},get seq(){return u},append(b,h,E=[]){const g=Object.freeze({v:c,id:p(),host:e,seq:u++,causes:Object.freeze([...E]),at:s(),type:b,payload:h});return y(g)}}}export{w as ACTOR_KINDS,v as ALL_TYPES,l as CLASSES,c as CURRENT_VERSION,x as EFFECTS,I as EGRESS,m as EVENT_TYPES,r as EventError,S as SCOPE_KINDS,k as createAppender,P as isValidEvent,y as validateEvent};