@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.
- package/adapters.js +1 -83
- package/adaptive-tool-policy.js +1 -45
- package/agent.js +2 -255
- package/attribution.js +1 -132
- package/backup-envelope.js +1 -221
- package/board-tool.js +2 -165
- package/budget.js +1 -116
- package/capability.js +1 -126
- package/citations.js +5 -127
- package/client-prefs.js +1 -148
- package/context-attachments.js +8 -144
- package/cowriter-router.js +1 -83
- package/cowriter-writer.js +19 -193
- package/cowriter.js +1 -190
- package/curate.js +3 -509
- package/distance.js +1 -124
- package/engine.js +1 -131
- package/entitlement.js +1 -332
- package/entity.js +1 -354
- package/event.js +1 -175
- package/extraction.js +4 -293
- package/failover.js +1 -93
- package/find-tool.js +1 -66
- package/flowchart.js +2 -644
- package/gate.js +1 -74
- package/harness.js +4 -101
- package/index.js +1 -345
- package/invariants.js +1 -174
- package/job.js +3 -147
- package/kernel.js +1 -255
- package/knowledge-derive.js +1 -267
- package/knowledge.js +3 -230
- package/library.js +11 -280
- package/loop.js +1 -132
- package/manifest.js +1 -107
- package/markdown-authoring.js +11 -288
- package/markdown-render.js +0 -0
- package/mcp-client.js +8 -358
- package/mcp-dispatch.js +1 -52
- package/mcp-errors.js +1 -106
- package/mcp-manager.js +1 -127
- package/media-transcript.js +8 -531
- package/meeting-analyzers.js +1 -83
- package/meeting-insights.js +7 -164
- package/meeting-shape.js +1 -282
- package/meeting-text.js +5 -94
- package/memory.js +2 -638
- package/model-candidates.js +1 -358
- package/model-health.js +1 -182
- package/model-ledger.js +1 -228
- package/model-picker.js +1 -188
- package/note-actions.js +15 -246
- package/note-graph.js +1 -158
- package/note-links.js +1 -68
- package/note-mentions.js +8 -114
- package/note-plan.js +9 -107
- package/note-research.js +1 -135
- package/observability.js +1 -119
- package/omni.js +1 -125
- package/order.js +1 -78
- package/package.json +1 -4
- package/pdf-layout.js +7 -253
- package/project-run.js +20 -444
- package/project.js +1 -170
- package/promotion.js +1 -171
- package/queue.js +1 -98
- package/reach.js +1 -23
- package/recipe-tool.js +2 -161
- package/recipe.js +1 -264
- package/record-list.js +2 -110
- package/recruit.js +3 -456
- package/redaction-tokens.js +1 -61
- package/ref.js +1 -55
- package/registry.js +1 -240
- package/route-graph.js +1 -115
- package/route-strategies.js +2 -232
- package/router.js +2 -880
- package/rrf.js +1 -89
- package/rules.js +1 -142
- package/schedule.js +1 -663
- package/scm-connection.js +1 -180
- package/scopes.js +1 -12
- package/scorecard.js +1 -335
- package/search-engines.js +1 -81
- package/skill-manifest.js +1 -330
- package/skill-scan.js +3 -209
- package/skill-sources.js +1 -162
- package/skill-vars.js +2 -252
- package/slash-commands.js +1 -158
- package/source-gate.js +1 -92
- package/sources-retrieval.js +5 -186
- package/sources.js +1 -273
- package/store.js +1 -171
- package/structured.js +4 -901
- package/subject-kinds.js +1 -5
- package/subject-name.js +1 -96
- package/sync-plan.js +1 -213
- package/synthesis.js +3 -123
- package/tags.js +1 -203
- package/team-board.js +6 -337
- package/team-cache.js +2 -63
- package/team-observe.js +1 -118
- package/team-org.js +1 -442
- package/team-plan.js +4 -131
- package/team-record.js +1 -225
- package/team-run.js +9 -652
- package/team-subtask.js +2 -191
- package/team-task.js +2 -124
- package/team-tool.js +2 -196
- package/team-trail.js +2 -73
- package/team-worklog.js +2 -149
- package/team.js +1 -302
- package/text-search.js +1 -155
- package/theme.js +17 -154
- package/titles.js +4 -285
- package/tool-discovery.js +1 -87
- package/tool-dispatch.js +5 -230
- package/tool-groups.js +1 -81
- package/tool-hints.js +10 -185
- package/tool-loop-guard.js +4 -182
- package/tool-need.js +2 -124
- package/tool-result.js +2 -438
- package/tool-round.js +0 -0
- package/tool-schema.js +1 -155
- package/tool-traits.js +1 -182
- package/toolset.js +2 -88
- package/trajectory.js +1 -530
- package/turn-loop.js +4 -440
- package/upcast.js +1 -37
- package/vault.js +2 -250
- package/view.js +1 -88
- package/voice-intents.js +2 -1488
- package/voice-speaker.js +1 -98
- package/weather-tool.js +1 -65
- package/weather.js +2 -211
- package/web-search-tool.js +14 -102
- package/web-search.js +1 -160
- package/widget.js +1 -153
package/entity.js
CHANGED
|
@@ -1,354 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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};
|