@chatpanel/events 0.101.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 -348
  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/curate.js CHANGED
@@ -1,509 +1,3 @@
1
- // THE MAINTENANCE PASS the half that costs nothing.
2
- //
3
- // The wiki pattern's lint step is usually described as a model reading the whole corpus
4
- // looking for contradictions, which is both expensive and O(N²). Most of what it would
5
- // "notice" is mechanical, though: a [[link]] pointing at nothing, a record connected to
6
- // nothing, two titles that differ by a space, a tag spelled two ways, a subject mentioned
7
- // everywhere with no page. None of that needs a model, and running it first is what keeps
8
- // the model half small enough to bound — only candidates this pass FLAGGED are ever
9
- // adjudicated, and only within one subject's claims (never N×N over the corpus).
10
- //
11
- // So this module is the deterministic layer, and `surveyCorpus()` is its first caller: the
12
- // W0 read-only report over an existing corpus. It answers, for a real user's data, the
13
- // question the design cannot answer from a chair — is this corpus dense enough that a
14
- // derived layer beats re-deriving from retrieval every time? If the numbers are thin the
15
- // honest conclusion is to finish retrieval instead, and this report is what says so.
16
- //
17
- // Pure input → output. No storage, no clock, no network, no model — it runs in a service
18
- // worker, in Node against a backup, and in the gateway against the warm store, and gives
19
- // the same answer in all three.
20
-
21
- import { normalizeTag } from './tags.js';
22
- // The bounded Levenshtein, from the module that holds only it. Two implementations of "how
23
- // far apart are these strings" become two answers to "is this the same title" — but see
24
- // distance.js for why it is not imported from voice-intents.js, which is where it grew up.
25
- import { blockedPairs, editDistance } from './distance.js';
26
- import {
27
- DEFAULT_THRESHOLD, MAX_SUBJECTS, isSelfLabel, isSubjectCandidate,
28
- normalizeSubject, rankSubjects, resolveSubjects,
29
- } from './entity.js';
30
- import { isRedactionToken } from './redaction-tokens.js';
31
-
32
- /** Wikilink syntax, matching store-notes.js `extractLinks` exactly — one grammar, not two. */
33
- const WIKILINK_RE = /\[\[([^[\]\n]+)\]\]/g;
34
-
35
- /** Below this, two normalized titles are "the same title typed twice". */
36
- export const NEAR_TITLE_DISTANCE = 2;
37
-
38
- /** How much term overlap counts as a record answering a question, in `spanningQuestions`. */
39
- export const SPAN_MIN_TERMS = 2;
40
-
41
- /**
42
- * Accept either the extension's Source shape (`meta.tags` / `meta.terms` / `meta.people`)
43
- * or a flat record, so a caller does not have to reshape a corpus to survey it.
44
- */
45
- export function normalizeRecord(rec) {
46
- if (!rec || !rec.id) return null;
47
- const meta = rec.meta || {};
48
- const list = (v) => (Array.isArray(v) ? v.map((x) => String(x || '').trim()).filter(Boolean) : []);
49
- return {
50
- id: String(rec.id),
51
- type: String(rec.type || rec.kind || '').toLowerCase() || 'record',
52
- title: String(rec.title || '').trim(),
53
- date: Number(rec.date || rec.updatedAt || rec.startedAt || 0) || 0,
54
- text: String(rec.text || rec.contentText || ''),
55
- tags: list(rec.tags ?? meta.tags),
56
- topics: list(rec.topics ?? meta.terms ?? meta.topics),
57
- people: list(rec.people ?? meta.people),
58
- };
59
- }
60
-
61
- export function normalizeRecords(records) {
62
- // Tolerates null as well as an omitted argument: a caller reading a corpus that has not
63
- // loaded yet passes null, and a survey that throws there reads as a broken tool rather
64
- // than as an empty corpus.
65
- return (Array.isArray(records) ? records : []).map(normalizeRecord).filter(Boolean);
66
- }
67
-
68
- /**
69
- * Every `[[target]]` in a string, de-duplicated, in first-seen order — MINUS the redaction
70
- * placeholders, which share the syntax exactly.
71
- *
72
- * `@chatpanel/pii` writes `[[PERSON_1]]`, so a redacted transcript looks like a document
73
- * full of links to pages nobody wrote. Counting those as wanted pages filed the people we
74
- * deliberately did not learn about as things we know. Matched by TYPE, not by shape, so a
75
- * real `[[Q3_2026]]` link still resolves.
76
- */
77
- export function wikilinksIn(text) {
78
- const out = [];
79
- WIKILINK_RE.lastIndex = 0;
80
- let m;
81
- while ((m = WIKILINK_RE.exec(String(text || '')))) {
82
- // `[[Title|alias]]` is Obsidian's display form — the LINK is the part before the pipe.
83
- const target = m[1].split('|')[0].trim();
84
- if (!target || isRedactionToken(target)) continue;
85
- if (!out.includes(target)) out.push(target);
86
- }
87
- return out;
88
- }
89
-
90
- /**
91
- * The placeholders a text carries, by type — what redaction COST the graph.
92
- *
93
- * Reported rather than silently dropped, because the loss is real and the user is the only
94
- * one who can decide about it. `PERSON_1` is a genuine, stable entity inside its own
95
- * conversation; what makes it unusable as a subject is that the vault is scoped to that
96
- * conversation and is never persisted, so Monday's `PERSON_1` and Friday's are different
97
- * people and merging them would attribute one person's decisions to another.
98
- *
99
- * Seeing "412 redacted mentions across 38 records" is what tells someone their redaction
100
- * level is costing them a connected graph — a trade only they can make.
101
- */
102
- export function redactedTokensIn(text) {
103
- const out = new Map();
104
- WIKILINK_RE.lastIndex = 0;
105
- let m;
106
- while ((m = WIKILINK_RE.exec(String(text || '')))) {
107
- const target = m[1].split('|')[0].trim();
108
- if (!isRedactionToken(target)) continue;
109
- const type = /^([A-Z][A-Z0-9]*)_/.exec(target.replace(/^\[{1,2}|\]{1,2}$/g, ''))?.[1] || 'OTHER';
110
- out.set(type, (out.get(type) || 0) + 1);
111
- }
112
- return out;
113
- }
114
-
115
- /** Corpus-wide: how many placeholders, of which types, across how many records. */
116
- export function redactionCost(records = []) {
117
- const byType = {};
118
- let total = 0;
119
- let recordsAffected = 0;
120
- for (const r of normalizeRecords(records)) {
121
- const found = redactedTokensIn(r.text);
122
- if (!found.size) continue;
123
- recordsAffected += 1;
124
- for (const [type, n] of found) { byType[type] = (byType[type] || 0) + n; total += n; }
125
- }
126
- return { total, records: recordsAffected, byType };
127
- }
128
-
129
- /**
130
- * Links that point at nothing — the corpus telling you which pages it wants.
131
- *
132
- * This is the highest-signal candidate source in the whole pass: a human already decided
133
- * the subject was worth naming, and typed it. Returns most-wanted first.
134
- */
135
- export function wantedPages(records = []) {
136
- const recs = normalizeRecords(records);
137
- const titles = new Set(recs.map((r) => normalizeSubject(r.title)).filter(Boolean));
138
- const wanted = new Map();
139
- for (const r of recs) {
140
- for (const target of wikilinksIn(r.text)) {
141
- const norm = normalizeSubject(target);
142
- if (!norm || titles.has(norm)) continue;
143
- let w = wanted.get(norm);
144
- if (!w) { w = { target, norm, count: 0, records: new Set() }; wanted.set(norm, w); }
145
- w.count += 1;
146
- w.records.add(r.id);
147
- }
148
- }
149
- return [...wanted.values()]
150
- .map((w) => ({ target: w.target, norm: w.norm, count: w.count, recordCount: w.records.size }))
151
- .sort((a, b) => b.recordCount - a.recordCount || b.count - a.count || a.norm.localeCompare(b.norm));
152
- }
153
-
154
- /**
155
- * Records connected to nothing else.
156
- *
157
- * "Connected" is deliberately generous — a resolvable wikilink in either direction, or a
158
- * shared tag, or a shared topic. A record that fails all three is genuinely marooned: no
159
- * path leads to it except full-text search, which is exactly the state a derived layer is
160
- * supposed to fix. Notes' Stats tab computes a notes-only version of this today; this is
161
- * the same question asked across chats, meetings and notes at once.
162
- */
163
- export function orphanRecords(records = []) {
164
- const recs = normalizeRecords(records);
165
- const byTitle = new Map();
166
- for (const r of recs) {
167
- const norm = normalizeSubject(r.title);
168
- if (norm && !byTitle.has(norm)) byTitle.set(norm, r.id);
169
- }
170
- const linked = new Set();
171
- const shared = new Map(); // term -> record ids
172
- for (const r of recs) {
173
- for (const target of wikilinksIn(r.text)) {
174
- const hit = byTitle.get(normalizeSubject(target));
175
- if (hit && hit !== r.id) { linked.add(r.id); linked.add(hit); }
176
- }
177
- for (const t of [...r.tags.map(normalizeTag), ...r.topics.map(normalizeSubject)]) {
178
- if (!t) continue;
179
- if (!shared.has(t)) shared.set(t, new Set());
180
- shared.get(t).add(r.id);
181
- }
182
- }
183
- for (const ids of shared.values()) {
184
- if (ids.size > 1) for (const id of ids) linked.add(id);
185
- }
186
- return recs.filter((r) => !linked.has(r.id)).map((r) => ({ id: r.id, type: r.type, title: r.title, date: r.date }));
187
- }
188
-
189
- /**
190
- * "Atlas sync 3" and "Atlas sync 4" are a SERIES, not a typo of each other — and so are
191
- * "Chat 1" … "Chat 8". They sit one or two edits apart, so without this guard the near-match
192
- * pass reports every recurring meeting and every default chat title as one duplicate group,
193
- * which is precisely the noise that makes a maintenance report get ignored.
194
- */
195
- function sameSeries(a, b) {
196
- const stem = (s) => s.replace(/\s*\d+$/, '').trim();
197
- const sa = stem(a), sb = stem(b);
198
- return sa !== a || sb !== b ? sa === sb : false;
199
- }
200
-
201
- /**
202
- * Titles that are the same thing written twice — exact collisions after normalization, and
203
- * near-misses within `NEAR_TITLE_DISTANCE`. Each group is a merge candidate, never a merge:
204
- * "Q3 Planning" and "Q4 Planning" are one character apart and must NOT be merged, which is
205
- * precisely why this pass reports and the promotion gate decides.
206
- */
207
- export function duplicateTitles(records = [], { distance = NEAR_TITLE_DISTANCE } = {}) {
208
- const recs = normalizeRecords(records).filter((r) => normalizeSubject(r.title));
209
- const groups = new Map();
210
- for (const r of recs) {
211
- const norm = normalizeSubject(r.title);
212
- if (!groups.has(norm)) groups.set(norm, []);
213
- groups.get(norm).push(r);
214
- }
215
- const norms = [...groups.keys()].sort();
216
-
217
- // Near-misses first, over BLOCKED candidate pairs rather than every pair — see
218
- // MAX_PAIR_COMPARISONS for what that replaced.
219
- const nearOf = new Map();
220
- for (const [a, b] of blockedPairs(norms)) {
221
- // A title short enough that `distance` edits rewrite most of it is not a near-miss.
222
- if (Math.min(a.length, b.length) <= distance * 2) continue;
223
- if (sameSeries(a, b)) continue;
224
- if (editDistance(a, b, distance) > distance) continue;
225
- if (!nearOf.has(a)) nearOf.set(a, []);
226
- if (!nearOf.has(b)) nearOf.set(b, []);
227
- nearOf.get(a).push(b);
228
- nearOf.get(b).push(a);
229
- }
230
-
231
- const out = [];
232
- const merged = new Set();
233
- for (const norm of norms) {
234
- if (merged.has(norm)) continue;
235
- const cluster = [norm];
236
- for (const other of nearOf.get(norm) || []) {
237
- if (merged.has(other) || other === norm) continue;
238
- cluster.push(other);
239
- merged.add(other);
240
- }
241
- const items = cluster.flatMap((n) => groups.get(n));
242
- if (items.length > 1) {
243
- out.push({
244
- norm,
245
- titles: [...new Set(items.map((r) => r.title))],
246
- ids: items.map((r) => r.id),
247
- });
248
- }
249
- }
250
- return out.sort((a, b) => b.ids.length - a.ids.length || a.norm.localeCompare(b.norm));
251
- }
252
-
253
- /**
254
- * One vocabulary spelled several ways — "design-review" and "designreview" filed apart.
255
- *
256
- * Tags already normalize (tags.js), so a collision here is a real divergence in what the
257
- * user typed, not a normalization bug. Topics come from a model and drift harder.
258
- */
259
- export function vocabularyDrift(records = [], { distance = 1 } = {}) {
260
- const recs = normalizeRecords(records);
261
- const freq = new Map();
262
- for (const r of recs) {
263
- for (const raw of r.tags) {
264
- const t = normalizeTag(raw);
265
- if (t) freq.set(t, (freq.get(t) || 0) + 1);
266
- }
267
- for (const raw of r.topics) {
268
- const t = normalizeTag(raw);
269
- if (t) freq.set(t, (freq.get(t) || 0) + 1);
270
- }
271
- }
272
- const terms = [...freq.keys()].sort();
273
- const bare = (t) => t.replace(/-/g, '');
274
-
275
- // Same blocking as duplicateTitles. A vocabulary is smaller than a corpus, so this has not
276
- // bitten yet — but it is the identical shape, and the identical shape is what bites.
277
- const nearOf = new Map();
278
- for (const [a, b] of blockedPairs(terms)) {
279
- if (Math.min(a.length, b.length) <= 3) continue;
280
- if (bare(a) !== bare(b) && editDistance(a, b, distance) > distance) continue;
281
- if (!nearOf.has(a)) nearOf.set(a, []);
282
- if (!nearOf.has(b)) nearOf.set(b, []);
283
- nearOf.get(a).push(b);
284
- nearOf.get(b).push(a);
285
- }
286
-
287
- const out = [];
288
- const taken = new Set();
289
- for (const term of terms) {
290
- if (taken.has(term)) continue;
291
- const near = (nearOf.get(term) || []).filter((t) => !taken.has(t) && t !== term);
292
- for (const t of near) taken.add(t);
293
- if (near.length) {
294
- out.push({ terms: [term, ...near].map((t) => ({ term: t, count: freq.get(t) || 0 })) });
295
- }
296
- }
297
- return out.sort((a, b) => b.terms.length - a.terms.length);
298
- }
299
-
300
- /**
301
- * Every mention the corpus offers, without a model: who was on a call, what it was tagged,
302
- * what topics were extracted for it, what someone [[linked]] to, and what the records are
303
- * titled. These are the raw inputs `resolveSubjects()` folds into identities.
304
- */
305
- export function mentionsFrom(records = []) {
306
- const recs = normalizeRecords(records);
307
- const out = [];
308
- for (const r of recs) {
309
- for (const name of r.people) out.push({ kind: 'person', name, recordId: r.id });
310
- for (const name of r.tags) out.push({ kind: 'tag', name, recordId: r.id });
311
- for (const name of r.topics) out.push({ kind: 'topic', name, recordId: r.id });
312
- for (const name of wikilinksIn(r.text)) out.push({ kind: 'title', name, recordId: r.id });
313
- }
314
- // A person's self-label ("You") survives candidacy HERE and is decided by
315
- // `resolveSubjects`, which is the only layer that knows whether we have a name to fold it
316
- // into. Filtering it out at this level threw the user out of their own corpus.
317
- return out.filter((m) => isSubjectCandidate(m.name, { kind: m.kind })
318
- || (m.kind === 'person' && isSelfLabel(m.name)));
319
- }
320
-
321
- /** Query terms, folded the way `sources-retrieval.js queryTerms` folds them. */
322
- function termsOf(text, { min = 3 } = {}) {
323
- return [...new Set(
324
- String(text || '').toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length >= min),
325
- )];
326
- }
327
-
328
- /**
329
- * How often does answering a question require more than one record?
330
- *
331
- * The measurement that decides whether this layer is worth building at all. If almost every
332
- * question is answered by a single record, retrieval already wins and synthesis buys
333
- * nothing; if the typical question touches four, then every asking of it re-derives the
334
- * same join, forever, and a maintained page pays for itself.
335
- *
336
- * Deterministic and crude on purpose — term overlap, no model, no embeddings. It is a
337
- * ratio, not a search engine, and a ratio only has to be honest.
338
- */
339
- export function spanningQuestions(records = [], questions = [], { minTerms = SPAN_MIN_TERMS, span = 3 } = {}) {
340
- const recs = normalizeRecords(records);
341
- const index = recs.map((r) => ({ id: r.id, terms: new Set(termsOf(`${r.title}\n${r.text}`)) }));
342
- let spanning = 0;
343
- let considered = 0;
344
- const hits = [];
345
- for (const q of questions) {
346
- const terms = termsOf(q);
347
- if (terms.length < minTerms) continue; // "thanks!" is not a question about the corpus
348
- considered += 1;
349
- let n = 0;
350
- for (const rec of index) {
351
- let overlap = 0;
352
- for (const t of terms) if (rec.terms.has(t)) overlap += 1;
353
- if (overlap >= minTerms) n += 1;
354
- }
355
- hits.push(n);
356
- if (n > span) spanning += 1;
357
- }
358
- hits.sort((a, b) => a - b);
359
- return {
360
- considered,
361
- spanning,
362
- fraction: considered ? spanning / considered : 0,
363
- medianRecords: hits.length ? hits[Math.floor(hits.length / 2)] : 0,
364
- span,
365
- };
366
- }
367
-
368
- /**
369
- * W0 — the whole deterministic pass, as one read-only report.
370
- *
371
- * Nothing here writes, and nothing here calls a model. Run it before building the derived
372
- * layer, and let its numbers set the thresholds in `entity.js` rather than the other way
373
- * round.
374
- */
375
- export function surveyCorpus(records = [], { questions = [], threshold = DEFAULT_THRESHOLD, limit = MAX_SUBJECTS } = {}) {
376
- const recs = normalizeRecords(records);
377
- const byType = {};
378
- let chars = 0;
379
- for (const r of recs) {
380
- byType[r.type] = (byType[r.type] || 0) + 1;
381
- chars += r.text.length;
382
- }
383
- const mentions = mentionsFrom(recs);
384
- const subjects = resolveSubjects(mentions);
385
- const qualifying = rankSubjects(subjects, { threshold, limit });
386
- const byKind = {};
387
- for (const s of qualifying) byKind[s.kind] = (byKind[s.kind] || 0) + 1;
388
- const wanted = wantedPages(recs);
389
- const orphans = orphanRecords(recs);
390
- const redaction = redactionCost(recs);
391
-
392
- return {
393
- corpus: {
394
- records: recs.length,
395
- byType,
396
- chars,
397
- oldest: recs.reduce((m, r) => (r.date && (!m || r.date < m) ? r.date : m), 0),
398
- newest: recs.reduce((m, r) => Math.max(m, r.date || 0), 0),
399
- },
400
- subjects: {
401
- total: subjects.size,
402
- qualifying: qualifying.length,
403
- byKind,
404
- threshold: { records: threshold.records ?? DEFAULT_THRESHOLD.records, mentions: threshold.mentions ?? DEFAULT_THRESHOLD.mentions },
405
- capped: subjects.size > 0 && qualifying.length === limit,
406
- top: qualifying.slice(0, 25).map((s) => ({
407
- key: s.key, kind: s.kind, name: s.name, aliases: s.aliases,
408
- records: s.recordCount, mentions: s.mentions,
409
- })),
410
- },
411
- wantedPages: { total: wanted.length, top: wanted.slice(0, 25) },
412
- orphans: {
413
- total: orphans.length,
414
- fraction: recs.length ? orphans.length / recs.length : 0,
415
- byType: orphans.reduce((acc, r) => ({ ...acc, [r.type]: (acc[r.type] || 0) + 1 }), {}),
416
- sample: orphans.slice(0, 10),
417
- },
418
- redaction,
419
- duplicateTitles: duplicateTitles(recs),
420
- vocabularyDrift: vocabularyDrift(recs),
421
- questions: spanningQuestions(recs, questions),
422
- };
423
- }
424
-
425
- /**
426
- * Sensitivity — the same corpus at several thresholds, so the number is CHOSEN rather than
427
- * inherited. A threshold that yields 4 subjects is useless and one that yields 4000 is a
428
- * second corpus (C5); the report should make both visible at a glance.
429
- */
430
- export function thresholdSweep(records = [], grid = [
431
- { records: 2, mentions: 2 }, { records: 3, mentions: 5 }, { records: 4, mentions: 8 }, { records: 6, mentions: 12 },
432
- ]) {
433
- const subjects = resolveSubjects(mentionsFrom(records));
434
- return grid.map((threshold) => {
435
- const ranked = rankSubjects(subjects, { threshold, limit: Infinity });
436
- const byKind = {};
437
- for (const s of ranked) byKind[s.kind] = (byKind[s.kind] || 0) + 1;
438
- return { threshold, qualifying: ranked.length, byKind };
439
- });
440
- }
441
-
442
- const pct = (x) => `${(x * 100).toFixed(1)}%`;
443
- const day = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '—');
444
-
445
- /** The report as plain text — the form W0 is actually read in. */
446
- export function formatSurvey(report, { sweep = null } = {}) {
447
- if (!report) return 'no report';
448
- const L = [];
449
- const { corpus, subjects, wantedPages: wp, orphans, duplicateTitles: dupes, vocabularyDrift: drift, questions } = report;
450
-
451
- L.push('CORPUS');
452
- L.push(` ${corpus.records} records (${Object.entries(corpus.byType).map(([k, v]) => `${v} ${k}`).join(', ') || 'none'})`);
453
- L.push(` ${corpus.chars.toLocaleString()} chars · ${day(corpus.oldest)} → ${day(corpus.newest)}`);
454
-
455
- L.push('');
456
- L.push(`SUBJECTS (earn a page at ≥${subjects.threshold.records} records and ≥${subjects.threshold.mentions} mentions)`);
457
- L.push(` ${subjects.qualifying} of ${subjects.total} candidates qualify${subjects.capped ? ' (AT THE CEILING — raise the threshold)' : ''}`);
458
- L.push(` by kind: ${Object.entries(subjects.byKind).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'}`);
459
- for (const s of subjects.top) {
460
- L.push(` ${String(s.records).padStart(4)} rec ${String(s.mentions).padStart(5)} men ${s.kind}: ${s.name}${s.aliases.length ? ` (aka ${s.aliases.join(', ')})` : ''}`);
461
- }
462
-
463
- if (sweep?.length) {
464
- L.push('');
465
- L.push('THRESHOLD SWEEP');
466
- for (const row of sweep) {
467
- L.push(` ≥${row.threshold.records} rec / ≥${row.threshold.mentions} men → ${String(row.qualifying).padStart(5)} subjects (${Object.entries(row.byKind).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'})`);
468
- }
469
- }
470
-
471
- L.push('');
472
- L.push(`WANTED PAGES ([[links]] resolving to nothing) — ${wp.total}`);
473
- for (const w of wp.top) L.push(` ${String(w.recordCount).padStart(4)} rec ${w.target}`);
474
- if (!wp.total) L.push(' none — either nobody uses [[links]] yet, or every one resolves');
475
-
476
- L.push('');
477
- L.push(`ORPHANS (no link, no shared tag, no shared topic) — ${orphans.total} of ${corpus.records} (${pct(orphans.fraction)})`);
478
- L.push(` by type: ${Object.entries(orphans.byType).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'}`);
479
-
480
- L.push('');
481
- L.push(`REDACTED MENTIONS (placeholders that cannot become subjects) — ${report.redaction.total} across ${report.redaction.records} records`);
482
- L.push(` by type: ${Object.entries(report.redaction.byType).map(([k, v]) => `${k} ${v}`).join(', ') || 'none'}`);
483
- if (report.redaction.total) {
484
- L.push(' → the vault is per-conversation and is not persisted, so one PERSON_1 is not another.');
485
- L.push(' Lowering the redaction level is what buys these back as real subjects.');
486
- }
487
-
488
- L.push('');
489
- L.push(`DUPLICATE / NEAR-DUPLICATE TITLES — ${dupes.length} groups`);
490
- for (const g of dupes.slice(0, 10)) L.push(` ${g.ids.length}× ${g.titles.join(' | ')}`);
491
-
492
- L.push('');
493
- L.push(`VOCABULARY DRIFT (one term filed several ways) — ${drift.length} clusters`);
494
- for (const g of drift.slice(0, 10)) L.push(` ${g.terms.map((t) => `${t.term}(${t.count})`).join(' | ')}`);
495
-
496
- L.push('');
497
- L.push('QUESTIONS THAT SPAN RECORDS');
498
- if (!questions.considered) {
499
- L.push(' no questions supplied — pass the recent user turns to measure this');
500
- } else {
501
- L.push(` ${questions.spanning} of ${questions.considered} (${pct(questions.fraction)}) touch more than ${questions.span} records`);
502
- L.push(` median records touched: ${questions.medianRecords}`);
503
- L.push('');
504
- L.push(questions.fraction >= 0.3
505
- ? ' → synthesis has something to compound: most asking re-derives a join across records.'
506
- : ' → THIN. Most questions are answered by one record; finish retrieval (docs/retrieval.md) before building briefs.');
507
- }
508
- return L.join('\n');
509
- }
1
+ import{normalizeTag as S}from"./tags.js";import{blockedPairs as T,editDistance as k}from"./distance.js";import{DEFAULT_THRESHOLD as b,MAX_SUBJECTS as O,isSelfLabel as v,isSubjectCandidate as M,normalizeSubject as g,rankSubjects as x,resolveSubjects as E}from"./entity.js";import{isRedactionToken as j}from"./redaction-tokens.js";const $=/\[\[([^[\]\n]+)\]\]/g,L=2,N=2;function D(r){if(!r||!r.id)return null;const c=r.meta||{},t=e=>Array.isArray(e)?e.map(n=>String(n||"").trim()).filter(Boolean):[];return{id:String(r.id),type:String(r.type||r.kind||"").toLowerCase()||"record",title:String(r.title||"").trim(),date:Number(r.date||r.updatedAt||r.startedAt||0)||0,text:String(r.text||r.contentText||""),tags:t(r.tags??c.tags),topics:t(r.topics??c.terms??c.topics),people:t(r.people??c.people)}}function m(r){return(Array.isArray(r)?r:[]).map(D).filter(Boolean)}function w(r){const c=[];$.lastIndex=0;let t;for(;t=$.exec(String(r||""));){const e=t[1].split("|")[0].trim();!e||j(e)||c.includes(e)||c.push(e)}return c}function P(r){const c=new Map;$.lastIndex=0;let t;for(;t=$.exec(String(r||""));){const e=t[1].split("|")[0].trim();if(!j(e))continue;const n=/^([A-Z][A-Z0-9]*)_/.exec(e.replace(/^\[{1,2}|\]{1,2}$/g,""))?.[1]||"OTHER";c.set(n,(c.get(n)||0)+1)}return c}function q(r=[]){const c={};let t=0,e=0;for(const n of m(r)){const i=P(n.text);if(i.size){e+=1;for(const[a,u]of i)c[a]=(c[a]||0)+u,t+=u}}return{total:t,records:e,byType:c}}function z(r=[]){const c=m(r),t=new Set(c.map(n=>g(n.title)).filter(Boolean)),e=new Map;for(const n of c)for(const i of w(n.text)){const a=g(i);if(!a||t.has(a))continue;let u=e.get(a);u||(u={target:i,norm:a,count:0,records:new Set},e.set(a,u)),u.count+=1,u.records.add(n.id)}return[...e.values()].map(n=>({target:n.target,norm:n.norm,count:n.count,recordCount:n.records.size})).sort((n,i)=>i.recordCount-n.recordCount||i.count-n.count||n.norm.localeCompare(i.norm))}function H(r=[]){const c=m(r),t=new Map;for(const i of c){const a=g(i.title);a&&!t.has(a)&&t.set(a,i.id)}const e=new Set,n=new Map;for(const i of c){for(const a of w(i.text)){const u=t.get(g(a));u&&u!==i.id&&(e.add(i.id),e.add(u))}for(const a of[...i.tags.map(S),...i.topics.map(g)])a&&(n.has(a)||n.set(a,new Set),n.get(a).add(i.id))}for(const i of n.values())if(i.size>1)for(const a of i)e.add(a);return c.filter(i=>!e.has(i.id)).map(i=>({id:i.id,type:i.type,title:i.title,date:i.date}))}function _(r,c){const t=i=>i.replace(/\s*\d+$/,"").trim(),e=t(r),n=t(c);return e!==r||n!==c?e===n:!1}function U(r=[],{distance:c=L}={}){const t=m(r).filter(p=>g(p.title)),e=new Map;for(const p of t){const o=g(p.title);e.has(o)||e.set(o,[]),e.get(o).push(p)}const n=[...e.keys()].sort(),i=new Map;for(const[p,o]of T(n))Math.min(p.length,o.length)<=c*2||_(p,o)||k(p,o,c)>c||(i.has(p)||i.set(p,[]),i.has(o)||i.set(o,[]),i.get(p).push(o),i.get(o).push(p));const a=[],u=new Set;for(const p of n){if(u.has(p))continue;const o=[p];for(const d of i.get(p)||[])u.has(d)||d===p||(o.push(d),u.add(d));const s=o.flatMap(d=>e.get(d));s.length>1&&a.push({norm:p,titles:[...new Set(s.map(d=>d.title))],ids:s.map(d=>d.id)})}return a.sort((p,o)=>o.ids.length-p.ids.length||p.norm.localeCompare(o.norm))}function B(r=[],{distance:c=1}={}){const t=m(r),e=new Map;for(const o of t){for(const s of o.tags){const d=S(s);d&&e.set(d,(e.get(d)||0)+1)}for(const s of o.topics){const d=S(s);d&&e.set(d,(e.get(d)||0)+1)}}const n=[...e.keys()].sort(),i=o=>o.replace(/-/g,""),a=new Map;for(const[o,s]of T(n))Math.min(o.length,s.length)<=3||i(o)!==i(s)&&k(o,s,c)>c||(a.has(o)||a.set(o,[]),a.has(s)||a.set(s,[]),a.get(o).push(s),a.get(s).push(o));const u=[],p=new Set;for(const o of n){if(p.has(o))continue;const s=(a.get(o)||[]).filter(d=>!p.has(d)&&d!==o);for(const d of s)p.add(d);s.length&&u.push({terms:[o,...s].map(d=>({term:d,count:e.get(d)||0}))})}return u.sort((o,s)=>s.terms.length-o.terms.length)}function A(r=[]){const c=m(r),t=[];for(const e of c){for(const n of e.people)t.push({kind:"person",name:n,recordId:e.id});for(const n of e.tags)t.push({kind:"tag",name:n,recordId:e.id});for(const n of e.topics)t.push({kind:"topic",name:n,recordId:e.id});for(const n of w(e.text))t.push({kind:"title",name:n,recordId:e.id})}return t.filter(e=>M(e.name,{kind:e.kind})||e.kind==="person"&&v(e.name))}function I(r,{min:c=3}={}){return[...new Set(String(r||"").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(t=>t.length>=c))]}function K(r=[],c=[],{minTerms:t=N,span:e=3}={}){const i=m(r).map(o=>({id:o.id,terms:new Set(I(`${o.title}
2
+ ${o.text}`))}));let a=0,u=0;const p=[];for(const o of c){const s=I(o);if(s.length<t)continue;u+=1;let d=0;for(const l of i){let y=0;for(const f of s)l.terms.has(f)&&(y+=1);y>=t&&(d+=1)}p.push(d),d>e&&(a+=1)}return p.sort((o,s)=>o-s),{considered:u,spanning:a,fraction:u?a/u:0,medianRecords:p.length?p[Math.floor(p.length/2)]:0,span:e}}function Q(r=[],{questions:c=[],threshold:t=b,limit:e=O}={}){const n=m(r),i={};let a=0;for(const f of n)i[f.type]=(i[f.type]||0)+1,a+=f.text.length;const u=A(n),p=E(u),o=x(p,{threshold:t,limit:e}),s={};for(const f of o)s[f.kind]=(s[f.kind]||0)+1;const d=z(n),l=H(n),y=q(n);return{corpus:{records:n.length,byType:i,chars:a,oldest:n.reduce((f,h)=>h.date&&(!f||h.date<f)?h.date:f,0),newest:n.reduce((f,h)=>Math.max(f,h.date||0),0)},subjects:{total:p.size,qualifying:o.length,byKind:s,threshold:{records:t.records??b.records,mentions:t.mentions??b.mentions},capped:p.size>0&&o.length===e,top:o.slice(0,25).map(f=>({key:f.key,kind:f.kind,name:f.name,aliases:f.aliases,records:f.recordCount,mentions:f.mentions}))},wantedPages:{total:d.length,top:d.slice(0,25)},orphans:{total:l.length,fraction:n.length?l.length/n.length:0,byType:l.reduce((f,h)=>({...f,[h.type]:(f[h.type]||0)+1}),{}),sample:l.slice(0,10)},redaction:y,duplicateTitles:U(n),vocabularyDrift:B(n),questions:K(n,c)}}function Z(r=[],c=[{records:2,mentions:2},{records:3,mentions:5},{records:4,mentions:8},{records:6,mentions:12}]){const t=E(A(r));return c.map(e=>{const n=x(t,{threshold:e,limit:1/0}),i={};for(const a of n)i[a.kind]=(i[a.kind]||0)+1;return{threshold:e,qualifying:n.length,byKind:i}})}const C=r=>`${(r*100).toFixed(1)}%`,R=r=>r?new Date(r).toISOString().slice(0,10):"—";function V(r,{sweep:c=null}={}){if(!r)return"no report";const t=[],{corpus:e,subjects:n,wantedPages:i,orphans:a,duplicateTitles:u,vocabularyDrift:p,questions:o}=r;t.push("CORPUS"),t.push(` ${e.records} records (${Object.entries(e.byType).map(([s,d])=>`${d} ${s}`).join(", ")||"none"})`),t.push(` ${e.chars.toLocaleString()} chars · ${R(e.oldest)} → ${R(e.newest)}`),t.push(""),t.push(`SUBJECTS (earn a page at ≥${n.threshold.records} records and ≥${n.threshold.mentions} mentions)`),t.push(` ${n.qualifying} of ${n.total} candidates qualify${n.capped?" (AT THE CEILING — raise the threshold)":""}`),t.push(` by kind: ${Object.entries(n.byKind).map(([s,d])=>`${s} ${d}`).join(", ")||"none"}`);for(const s of n.top)t.push(` ${String(s.records).padStart(4)} rec ${String(s.mentions).padStart(5)} men ${s.kind}: ${s.name}${s.aliases.length?` (aka ${s.aliases.join(", ")})`:""}`);if(c?.length){t.push(""),t.push("THRESHOLD SWEEP");for(const s of c)t.push(` ≥${s.threshold.records} rec / ≥${s.threshold.mentions} men → ${String(s.qualifying).padStart(5)} subjects (${Object.entries(s.byKind).map(([d,l])=>`${d} ${l}`).join(", ")||"none"})`)}t.push(""),t.push(`WANTED PAGES ([[links]] resolving to nothing) — ${i.total}`);for(const s of i.top)t.push(` ${String(s.recordCount).padStart(4)} rec ${s.target}`);i.total||t.push(" none — either nobody uses [[links]] yet, or every one resolves"),t.push(""),t.push(`ORPHANS (no link, no shared tag, no shared topic) — ${a.total} of ${e.records} (${C(a.fraction)})`),t.push(` by type: ${Object.entries(a.byType).map(([s,d])=>`${s} ${d}`).join(", ")||"none"}`),t.push(""),t.push(`REDACTED MENTIONS (placeholders that cannot become subjects) — ${r.redaction.total} across ${r.redaction.records} records`),t.push(` by type: ${Object.entries(r.redaction.byType).map(([s,d])=>`${s} ${d}`).join(", ")||"none"}`),r.redaction.total&&(t.push(" → the vault is per-conversation and is not persisted, so one PERSON_1 is not another."),t.push(" Lowering the redaction level is what buys these back as real subjects.")),t.push(""),t.push(`DUPLICATE / NEAR-DUPLICATE TITLES — ${u.length} groups`);for(const s of u.slice(0,10))t.push(` ${s.ids.length}× ${s.titles.join(" | ")}`);t.push(""),t.push(`VOCABULARY DRIFT (one term filed several ways) — ${p.length} clusters`);for(const s of p.slice(0,10))t.push(` ${s.terms.map(d=>`${d.term}(${d.count})`).join(" | ")}`);return t.push(""),t.push("QUESTIONS THAT SPAN RECORDS"),o.considered?(t.push(` ${o.spanning} of ${o.considered} (${C(o.fraction)}) touch more than ${o.span} records`),t.push(` median records touched: ${o.medianRecords}`),t.push(""),t.push(o.fraction>=.3?" → synthesis has something to compound: most asking re-derives a join across records.":" → THIN. Most questions are answered by one record; finish retrieval (docs/retrieval.md) before building briefs.")):t.push(" no questions supplied — pass the recent user turns to measure this"),t.join(`
3
+ `)}export{L as NEAR_TITLE_DISTANCE,N as SPAN_MIN_TERMS,U as duplicateTitles,V as formatSurvey,A as mentionsFrom,D as normalizeRecord,m as normalizeRecords,H as orphanRecords,P as redactedTokensIn,q as redactionCost,K as spanningQuestions,Q as surveyCorpus,Z as thresholdSweep,B as vocabularyDrift,z as wantedPages,w as wikilinksIn};
package/distance.js CHANGED
@@ -1,124 +1 @@
1
- // How far apart are two strings one answer, and nothing else in the module.
2
- //
3
- // It exists as its own file for a load-time reason, and the reason is worth recording
4
- // because it is the second time this rule has been learned here. `voice-intents.js` needed
5
- // a bounded Levenshtein for wake-word matching and grew one. When the maintenance pass
6
- // needed the same question answered for near-duplicate titles, importing it from there was
7
- // the correct instinct — reuse, don't reinvent — and it pulled `voice-intents.js` (79 KB)
8
- // and its `structured.js` dependency (41 KB) onto the MV3 service worker's cold start, for
9
- // forty lines of arithmetic.
10
- //
11
- // So: a primitive two unrelated features need belongs in a module of its own, not in
12
- // whichever feature happened to need it first. Reuse is right; reuse through a large module
13
- // is a 120 KB import of one function.
14
-
15
- /**
16
- * Bounded Levenshtein. Returns early once the distance cannot come in under `max`, so a
17
- * wake scan over a long transcript — or a pairwise title sweep over a corpus — stays linear
18
- * in practice rather than paying for an exact answer nobody reads.
19
- *
20
- * Past `max` the return is `max + 1`: "far", not a number. Callers compare against `max`.
21
- */
22
- export function editDistance(a, b, max = Infinity) {
23
- if (a === b) return 0;
24
- if (Math.abs(a.length - b.length) > max) return max + 1;
25
- let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
26
- for (let i = 1; i <= a.length; i++) {
27
- const cur = [i];
28
- let best = i;
29
- for (let j = 1; j <= b.length; j++) {
30
- cur[j] = Math.min(
31
- prev[j] + 1,
32
- cur[j - 1] + 1,
33
- prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
34
- );
35
- if (cur[j] < best) best = cur[j];
36
- }
37
- if (best > max) return max + 1;
38
- prev = cur;
39
- }
40
- return prev[b.length];
41
- }
42
-
43
- /**
44
- * A ceiling on pairwise work, and the reason it exists.
45
- *
46
- * Comparing every pair is the N×N scan the knowledge design forbids, and it behaves exactly
47
- * as that rule predicts. Two passes learned it the hard way: near-duplicate titles ran 40s
48
- * over 12,000 records, and merge suggestions did not finish 12,000 SUBJECTS in two minutes.
49
- * Both on the UI thread, which is an unresponsive tab rather than a slow report.
50
- */
51
- export const MAX_PAIR_COMPARISONS = 200_000;
52
-
53
- const BLOCK_KEY_CHARS = 4;
54
- // Every block gets at least this many comparisons before the budget can starve it.
55
- const MIN_BLOCK_BUDGET = 2_000;
56
-
57
- /**
58
- * The keys a string is filed under for candidate generation.
59
- *
60
- * Three, and each earns its place: two strings within a couple of edits still agree on their
61
- * first few characters unless the typo is at the front — in which case they agree on their
62
- * last few — and two forms of one person's name ("alex rivera", "a rivera") agree on the
63
- * LAST TOKEN even when neither end matches. Drop the third and abbreviated first names stop
64
- * being found at all.
65
- */
66
- export function blockKeys(norm) {
67
- const s = String(norm || '');
68
- if (!s) return [];
69
- const keys = new Set([`p:${s.slice(0, BLOCK_KEY_CHARS)}`, `s:${s.slice(-BLOCK_KEY_CHARS)}`]);
70
- const last = s.split(' ').filter(Boolean).pop();
71
- if (last && last.length >= 2) keys.add(`t:${last}`);
72
- return [...keys];
73
- }
74
-
75
- /**
76
- * BLOCKING — the standard record-linkage answer to "which pairs are worth comparing".
77
- *
78
- * Files every string under `blockKeys` and yields only pairs that share one, so the work is
79
- * proportional to the corpus rather than to its square. Each unordered pair is yielded at
80
- * most once even when two strings share several keys.
81
- *
82
- * `budget` is the backstop for the pathological case — ten thousand titles that all start the
83
- * same way land in one block, and a block is compared pairwise. A weird corpus then costs a
84
- * truncated report instead of a hung page.
85
- */
86
- export function* blockedPairs(values, { budget = MAX_PAIR_COMPARISONS } = {}) {
87
- const blocks = new Map();
88
- for (const v of values) {
89
- for (const key of blockKeys(v)) {
90
- if (!blocks.has(key)) blocks.set(key, []);
91
- blocks.get(key).push(v);
92
- }
93
- }
94
- let spent = 0;
95
- const seen = new Set();
96
- // SMALLEST BLOCKS FIRST, and a per-block share of the budget. Both are about RECALL, not
97
- // speed, and the first version got this wrong: nine thousand subjects all beginning
98
- // "Unrelated Person" land in one bucket under the prefix key, and that single
99
- // non-discriminating block spent the entire budget before the buckets holding the real
100
- // findings were ever reached — so a big corpus returned five hundred suggestions and not
101
- // one of the ones that mattered.
102
- //
103
- // A small block is a discriminating one: "rivera" as a last token says far more than
104
- // "unre" as a prefix. Working through them in size order means the specific evidence is
105
- // spent first and the vague evidence gets whatever is left.
106
- const buckets = [...blocks.values()].filter((b) => b.length > 1).sort((a, b) => a.length - b.length);
107
- for (const bucket of buckets) {
108
- if (spent >= budget) return;
109
- // No single block may consume the whole budget, however it is ordered.
110
- const blockBudget = Math.min(budget - spent, Math.max(MIN_BLOCK_BUDGET, Math.floor(budget / 8)));
111
- let blockSpent = 0;
112
- for (let i = 0; i < bucket.length && blockSpent < blockBudget; i += 1) {
113
- for (let j = i + 1; j < bucket.length && blockSpent < blockBudget; j += 1) {
114
- const a = bucket[i]; const b = bucket[j];
115
- const key = a < b ? `${a}\u0000${b}` : `${b}\u0000${a}`;
116
- if (seen.has(key)) continue;
117
- seen.add(key);
118
- blockSpent += 1;
119
- spent += 1;
120
- yield [a, b];
121
- }
122
- }
123
- }
124
- }
1
+ function y(c,e,o=1/0){if(c===e)return 0;if(Math.abs(c.length-e.length)>o)return o+1;let s=Array.from({length:e.length+1},(l,r)=>r);for(let l=1;l<=c.length;l++){const r=[l];let n=l;for(let t=1;t<=e.length;t++)r[t]=Math.min(s[t]+1,r[t-1]+1,s[t-1]+(c[l-1]===e[t-1]?0:1)),r[t]<n&&(n=r[t]);if(n>o)return o+1;s=r}return s[e.length]}const k=2e5,p=4,M=2e3;function _(c){const e=String(c||"");if(!e)return[];const o=new Set([`p:${e.slice(0,p)}`,`s:${e.slice(-p)}`]),s=e.split(" ").filter(Boolean).pop();return s&&s.length>=2&&o.add(`t:${s}`),[...o]}function*S(c,{budget:e=k}={}){const o=new Map;for(const n of c)for(const t of _(n))o.has(t)||o.set(t,[]),o.get(t).push(n);let s=0;const l=new Set,r=[...o.values()].filter(n=>n.length>1).sort((n,t)=>n.length-t.length);for(const n of r){if(s>=e)return;const t=Math.min(e-s,Math.max(M,Math.floor(e/8)));let u=0;for(let i=0;i<n.length&&u<t;i+=1)for(let g=i+1;g<n.length&&u<t;g+=1){const f=n[i],h=n[g],a=f<h?`${f}\0${h}`:`${h}\0${f}`;l.has(a)||(l.add(a),u+=1,s+=1,yield[f,h])}}}export{k as MAX_PAIR_COMPARISONS,_ as blockKeys,S as blockedPairs,y as editDistance};