@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.
- 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 -348
- 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/subject-kinds.js
CHANGED
|
@@ -1,5 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// without either importing the other.
|
|
3
|
-
|
|
4
|
-
/** What a subject can be. `title` is a record title someone linked to with [[…]]. */
|
|
5
|
-
export const SUBJECT_KINDS = Object.freeze(['person', 'topic', 'tag', 'title']);
|
|
1
|
+
const e=Object.freeze(["person","topic","tag","title"]);export{e as SUBJECT_KINDS};
|
package/subject-name.js
CHANGED
|
@@ -1,96 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// The small, dependency-free half of subject identity: fold a name to its canonical form,
|
|
4
|
-
// strip the decoration a directory hangs off it, and hold the evidence thresholds. Nothing
|
|
5
|
-
// here resolves aliases, proposes merges or reaches for a Levenshtein — that is `entity.js`,
|
|
6
|
-
// which imports this.
|
|
7
|
-
//
|
|
8
|
-
// The split is a load-time one, and it is the third time the lesson has come up here (see
|
|
9
|
-
// `distance.js` and `redaction-tokens.js`). `knowledge.js` needs exactly `normalizeSubject`
|
|
10
|
-
// to build a brief id, and the extension's brief store needs exactly `DEFAULT_THRESHOLD` —
|
|
11
|
-
// and both are on the MV3 service worker's graph. Reaching them through `entity.js` put
|
|
12
|
-
// entity resolution and merge suggestion on a worker that will never run either.
|
|
13
|
-
|
|
14
|
-
import { SUBJECT_KINDS } from './subject-kinds.js';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A subject earns a brief with EVIDENCE, not on first sight (I-K4).
|
|
18
|
-
*
|
|
19
|
-
* PROVISIONAL. These numbers are the W0 measurement's whole point: `surveyCorpus()` reports
|
|
20
|
-
* how many subjects clear them so they can be set from a real corpus instead of taste. Do
|
|
21
|
-
* not treat them as decided until that report has been run.
|
|
22
|
-
*/
|
|
23
|
-
export const DEFAULT_THRESHOLD = Object.freeze({ records: 3, mentions: 5 });
|
|
24
|
-
|
|
25
|
-
/** Ceiling on the set of briefs, for the same reason memory.js caps memories. Provisional. */
|
|
26
|
-
export const MAX_SUBJECTS = 500;
|
|
27
|
-
/** Longest name we will treat as a subject — past this it is a sentence, not a subject. */
|
|
28
|
-
export const MAX_SUBJECT_CHARS = 60;
|
|
29
|
-
/**
|
|
30
|
-
* Labels a meeting platform uses for the person holding the microphone.
|
|
31
|
-
*
|
|
32
|
-
* Zoom, Meet and Teams all write the local participant as "You" — so the user appears in
|
|
33
|
-
* their own corpus under a name that is not a name, alongside however their colleagues'
|
|
34
|
-
* clients spelled them. Resolving these needs one fact only the host has: who "you" IS.
|
|
35
|
-
* `resolveSubjects` takes it rather than guessing, and with no `self` supplied these stay
|
|
36
|
-
* unresolved instead of collapsing every meeting's local speaker into one fictional person.
|
|
37
|
-
*/
|
|
38
|
-
export const SELF_LABELS = Object.freeze(['you', 'me', 'myself', 'yourself', 'i']);
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Is this the platform's label for the local participant?
|
|
42
|
-
*
|
|
43
|
-
* Exported because two layers need the SAME exception and getting the order wrong is subtle:
|
|
44
|
-
* a self-label fails `isSubjectCandidate` (it is a pronoun), so any pass that filters
|
|
45
|
-
* candidacy BEFORE `resolveSubjects` can fold it has already thrown the user away. `curate.js
|
|
46
|
-
* mentionsFrom` keeps them for exactly this reason and lets resolution decide.
|
|
47
|
-
*/
|
|
48
|
-
export function isSelfLabel(name) {
|
|
49
|
-
return SELF_LABELS.includes(normalizeSubject(name));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Strip the decoration a directory or a conference client hangs off a person's name.
|
|
54
|
-
*
|
|
55
|
-
* The same human arrives as "Alex Rivera", "Alex Rivera (ACME)", "Alex Rivera - Host" and
|
|
56
|
-
* "Alex Rivera (he/him)" depending on which client wrote the label. The part in parentheses
|
|
57
|
-
* or after a dash is an org, a role or a pronoun set — decoration, never identity — so it is
|
|
58
|
-
* removed before folding.
|
|
59
|
-
*
|
|
60
|
-
* NOT removed for non-person subjects: "Migration (Phase 2)" is a different topic from
|
|
61
|
-
* "Migration", where "Alex Rivera (ACME)" is not a different person from "Alex Rivera".
|
|
62
|
-
*/
|
|
63
|
-
export function stripQualifiers(name) {
|
|
64
|
-
return String(name ?? '')
|
|
65
|
-
.replace(/\s*[([{][^)\]}]*[)\]}]\s*/g, ' ') // (ACME), [external], {guest}
|
|
66
|
-
.replace(/\s+[-–—|·,]\s+.*$/, '') // - Host, — Guest, | ACME
|
|
67
|
-
.replace(/\s+/g, ' ')
|
|
68
|
-
.trim();
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Fold a name to its canonical form: lowercase, Unicode-aware, separators collapsed.
|
|
72
|
-
*
|
|
73
|
-
* Spaces survive as spaces (unlike normalizeTag, which folds them to '-') because a person's
|
|
74
|
-
* name is read back to the user and "alex rivera" has to be recognisable as one.
|
|
75
|
-
*/
|
|
76
|
-
export function normalizeSubject(name) {
|
|
77
|
-
const raw = String(name ?? '').normalize('NFKC').trim().replace(/^[#@]+/, '');
|
|
78
|
-
if (!raw) return '';
|
|
79
|
-
return raw
|
|
80
|
-
.toLowerCase()
|
|
81
|
-
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
82
|
-
.trim()
|
|
83
|
-
.slice(0, MAX_SUBJECT_CHARS)
|
|
84
|
-
.trim();
|
|
85
|
-
}
|
|
86
|
-
/** `person:alex rivera` — the identity a brief is filed under. '' when nothing survives. */
|
|
87
|
-
export function subjectKey(kind, name) {
|
|
88
|
-
const norm = normalizeSubject(name);
|
|
89
|
-
if (!norm || !SUBJECT_KINDS.includes(kind)) return '';
|
|
90
|
-
return `${kind}:${norm}`;
|
|
91
|
-
}
|
|
92
|
-
/** Tokens of a canonical name. */
|
|
93
|
-
export function subjectTokens(name) {
|
|
94
|
-
const norm = normalizeSubject(name);
|
|
95
|
-
return norm ? norm.split(' ').filter(Boolean) : [];
|
|
96
|
-
}
|
|
1
|
+
import{SUBJECT_KINDS as n}from"./subject-kinds.js";const u=Object.freeze({records:3,mentions:5}),p=500,s=60,c=Object.freeze(["you","me","myself","yourself","i"]);function l(e){return c.includes(t(e))}function f(e){return String(e??"").replace(/\s*[([{][^)\]}]*[)\]}]\s*/g," ").replace(/\s+[-–—|·,]\s+.*$/,"").replace(/\s+/g," ").trim()}function t(e){const r=String(e??"").normalize("NFKC").trim().replace(/^[#@]+/,"");return r?r.toLowerCase().replace(/[^\p{L}\p{N}]+/gu," ").trim().slice(0,s).trim():""}function m(e,r){const o=t(r);return!o||!n.includes(e)?"":`${e}:${o}`}function S(e){const r=t(e);return r?r.split(" ").filter(Boolean):[]}export{u as DEFAULT_THRESHOLD,p as MAX_SUBJECTS,s as MAX_SUBJECT_CHARS,c as SELF_LABELS,l as isSelfLabel,t as normalizeSubject,f as stripQualifiers,m as subjectKey,S as subjectTokens};
|
package/sync-plan.js
CHANGED
|
@@ -1,213 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// The extension holds the corpus in chrome.storage; a desktop app holds it in SQLite; the
|
|
4
|
-
// gateway holds a warm copy. Each pair of those needs the same question answered: given what
|
|
5
|
-
// I have and what you have, what do I pull, what do I push, and what genuinely conflicts.
|
|
6
|
-
//
|
|
7
|
-
// It lives here because the answer must not depend on WHO is asking. If the extension
|
|
8
|
-
// decided ties one way and the desktop the other, two clients syncing the same record would
|
|
9
|
-
// flap — each would keep "winning" and re-pushing, forever. One rule, both sides.
|
|
10
|
-
//
|
|
11
|
-
// WHAT MAKES THIS SAFE TO RUN REPEATEDLY: the plan is a pure function of two index lists, so
|
|
12
|
-
// applying it and re-planning yields an empty plan. That is the property to hold on to —
|
|
13
|
-
// every bug in a sync engine eventually shows up as "running it twice does something".
|
|
14
|
-
//
|
|
15
|
-
// TOMBSTONES ARE NOT OPTIONAL, and this is the sharpest edge in the module. A record that is
|
|
16
|
-
// merely ABSENT from one side is indistinguishable from one that side has never seen. So a
|
|
17
|
-
// deletion has to be representable as a value — `deletedAt` — or "delete" and "not synced
|
|
18
|
-
// yet" are the same input with opposite correct answers. The extension's warm sync already
|
|
19
|
-
// hit this and chose, deliberately, not to tombstone (a missing browser record may simply
|
|
20
|
-
// have aged out of IndexedDB while remaining in a backup). That choice is why warm sync is
|
|
21
|
-
// one-way. A two-way sync cannot make it.
|
|
22
|
-
//
|
|
23
|
-
// Pure over arrays of `{ id, updatedAt, deletedAt }`. No storage, no network, no clock —
|
|
24
|
-
// `now` is never consulted, because a plan that depends on when you asked is not a plan.
|
|
25
|
-
|
|
26
|
-
export class SyncError extends Error {
|
|
27
|
-
constructor(message) { super(message); this.name = 'SyncError'; }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* How far apart two timestamps may be and still count as "the same edit".
|
|
32
|
-
*
|
|
33
|
-
* Clocks on two machines are not identical, and a record copied between them can come back
|
|
34
|
-
* with a millisecond of drift. Without a tolerance every round trip looks like a fresh edit
|
|
35
|
-
* and the two sides push at each other indefinitely.
|
|
36
|
-
*/
|
|
37
|
-
export const CLOCK_TOLERANCE_MS = 1000;
|
|
38
|
-
|
|
39
|
-
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
40
|
-
|
|
41
|
-
/** The comparable stamp of a record: when it last changed, deleted or not. */
|
|
42
|
-
export function stampOf(entry) {
|
|
43
|
-
if (!entry) return 0;
|
|
44
|
-
return Math.max(num(entry.updatedAt), num(entry.deletedAt));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function indexById(entries) {
|
|
48
|
-
const m = new Map();
|
|
49
|
-
for (const e of entries || []) {
|
|
50
|
-
if (!e || !e.id) continue;
|
|
51
|
-
// Later duplicates win: a caller concatenating pages should not be punished for it.
|
|
52
|
-
m.set(e.id, e);
|
|
53
|
-
}
|
|
54
|
-
return m;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Decide one record. Exported because it is the whole rule, and a caller that streams
|
|
59
|
-
* records one at a time should not have to build two full indexes to use it.
|
|
60
|
-
*
|
|
61
|
-
* Returns one of:
|
|
62
|
-
* 'none' — the two sides agree
|
|
63
|
-
* 'pull' — remote is newer; take theirs
|
|
64
|
-
* 'push' — local is newer, or remote has never seen it; send ours
|
|
65
|
-
* 'conflict' — both changed since the last common state and neither is clearly newer
|
|
66
|
-
*/
|
|
67
|
-
/**
|
|
68
|
-
* Is this entry the COMPLETE record, or a flattened stand-in for one?
|
|
69
|
-
*
|
|
70
|
-
* A warm/indexed copy of a chat is its transcript as one blob of text: enough to search,
|
|
71
|
-
* read and cite, and not enough to open as a conversation. Callers stamp that with
|
|
72
|
-
* `meta.lossy` (or `meta.origin: 'warm'`); a stamp row may carry `lossy` directly, because a
|
|
73
|
-
* sync plan works on stamps and should not have to load bodies to make this decision.
|
|
74
|
-
*
|
|
75
|
-
* Returns 1 for full fidelity and 0 for lossy — an ordering, so the rule below is a
|
|
76
|
-
* comparison rather than a pile of branches.
|
|
77
|
-
*/
|
|
78
|
-
export function fidelityOf(entry) {
|
|
79
|
-
if (!entry) return 0;
|
|
80
|
-
if (entry.lossy === true) return 0;
|
|
81
|
-
const meta = entry.meta || null;
|
|
82
|
-
if (meta && (meta.lossy === true || meta.origin === 'warm')) return 0;
|
|
83
|
-
return 1;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function decide(local, remote, { tolerance = CLOCK_TOLERANCE_MS, base = null } = {}) {
|
|
87
|
-
if (!local && !remote) return 'none';
|
|
88
|
-
if (!remote) return 'push';
|
|
89
|
-
if (!local) return 'pull';
|
|
90
|
-
|
|
91
|
-
const l = stampOf(local);
|
|
92
|
-
const r = stampOf(remote);
|
|
93
|
-
|
|
94
|
-
// FIDELITY BEATS RECENCY, BOTH WAYS.
|
|
95
|
-
//
|
|
96
|
-
// The two sides are not always describing the same kind of thing. One may hold the whole
|
|
97
|
-
// record and the other a flattened stand-in for it — and those two arrive with the SAME
|
|
98
|
-
// timestamp, because they describe the same moment. Last-write-wins then reads them as
|
|
99
|
-
// equal and answers 'none', which is how a complete conversation restored from a backup
|
|
100
|
-
// gets silently refused in favour of a search-index summary of itself that is already
|
|
101
|
-
// there. The user restores, is told it worked, and still sees the flattened copy.
|
|
102
|
-
//
|
|
103
|
-
// So a complete record always wins over a partial one whatever the clocks say. The
|
|
104
|
-
// converse — never let a partial overwrite a complete one — is the same rule read the
|
|
105
|
-
// other way, and it is the one callers usually remember to enforce by hand.
|
|
106
|
-
//
|
|
107
|
-
// DELETIONS ARE EXEMPT. A tombstone carries no body, so it is 'lossy' by any measure, and
|
|
108
|
-
// resurrecting deleted records because their replacement looks fuller would be a far worse
|
|
109
|
-
// bug than the one this fixes. When either side is deleted, the stamps decide.
|
|
110
|
-
const deleted = num(local.deletedAt) > 0 || num(remote.deletedAt) > 0;
|
|
111
|
-
if (!deleted) {
|
|
112
|
-
const lf = fidelityOf(local);
|
|
113
|
-
const rf = fidelityOf(remote);
|
|
114
|
-
if (lf !== rf) return rf > lf ? 'pull' : 'push';
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const delta = l - r;
|
|
118
|
-
if (Math.abs(delta) <= tolerance) return 'none';
|
|
119
|
-
|
|
120
|
-
// With a recorded base (what both sides last agreed on) we can tell a genuine divergence
|
|
121
|
-
// from a simple fast-forward: a conflict is when BOTH moved. Without a base the newer
|
|
122
|
-
// stamp wins, which is last-write-wins and is all a first sync can honestly offer.
|
|
123
|
-
if (base) {
|
|
124
|
-
const b = stampOf(base);
|
|
125
|
-
const localMoved = l - b > tolerance;
|
|
126
|
-
const remoteMoved = r - b > tolerance;
|
|
127
|
-
if (localMoved && remoteMoved) return 'conflict';
|
|
128
|
-
}
|
|
129
|
-
return delta > 0 ? 'push' : 'pull';
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Plan a full two-way reconcile.
|
|
134
|
-
*
|
|
135
|
-
* `bases` is optional — a map (or array) of the stamps both sides last agreed on, which is
|
|
136
|
-
* what upgrades last-write-wins into real conflict detection. Callers that keep a sync
|
|
137
|
-
* journal pass it; callers that do not get LWW and no false conflicts.
|
|
138
|
-
*
|
|
139
|
-
* Returns `{ pull, push, conflicts, unchanged }` as arrays of ids, plus `counts`.
|
|
140
|
-
*/
|
|
141
|
-
export function planSync(localEntries, remoteEntries, {
|
|
142
|
-
tolerance = CLOCK_TOLERANCE_MS, bases = null,
|
|
143
|
-
} = {}) {
|
|
144
|
-
const local = indexById(localEntries);
|
|
145
|
-
const remote = indexById(remoteEntries);
|
|
146
|
-
const baseMap = bases instanceof Map ? bases : indexById(bases);
|
|
147
|
-
|
|
148
|
-
const pull = [];
|
|
149
|
-
const push = [];
|
|
150
|
-
const conflicts = [];
|
|
151
|
-
let unchanged = 0;
|
|
152
|
-
|
|
153
|
-
for (const id of new Set([...local.keys(), ...remote.keys()])) {
|
|
154
|
-
const verdict = decide(local.get(id), remote.get(id), { tolerance, base: baseMap.get(id) || null });
|
|
155
|
-
if (verdict === 'pull') pull.push(id);
|
|
156
|
-
else if (verdict === 'push') push.push(id);
|
|
157
|
-
else if (verdict === 'conflict') conflicts.push(id);
|
|
158
|
-
else unchanged += 1;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Stable order so two runs over the same input produce byte-identical plans — which is
|
|
162
|
-
// what makes a plan diffable in a log and testable without sorting at every assertion.
|
|
163
|
-
pull.sort();
|
|
164
|
-
push.sort();
|
|
165
|
-
conflicts.sort();
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
pull, push, conflicts, unchanged,
|
|
169
|
-
counts: { pull: pull.length, push: push.length, conflicts: conflicts.length, unchanged },
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/** Nothing to do — the property a correct sync reaches and stays at. */
|
|
174
|
-
export function isSettled(plan) {
|
|
175
|
-
return !!plan && plan.pull.length === 0 && plan.push.length === 0 && plan.conflicts.length === 0;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Resolve a conflict by keeping both: the loser is preserved under a new id rather than
|
|
180
|
-
* overwritten.
|
|
181
|
-
*
|
|
182
|
-
* Silently discarding one side of a conflict is how a sync engine loses the paragraph
|
|
183
|
-
* someone wrote on a plane. The caller supplies `newId` because id minting is a host
|
|
184
|
-
* concern; this only decides WHAT the two resulting records are.
|
|
185
|
-
*/
|
|
186
|
-
export function forkConflict(localRecord, remoteRecord, { newId, label = 'conflicted copy' } = {}) {
|
|
187
|
-
if (typeof newId !== 'function') throw new SyncError('forkConflict needs a newId() function');
|
|
188
|
-
const keep = stampOf(localRecord) >= stampOf(remoteRecord) ? localRecord : remoteRecord;
|
|
189
|
-
const fork = keep === localRecord ? remoteRecord : localRecord;
|
|
190
|
-
const parsedKind = String(fork.id || '').split(':')[0];
|
|
191
|
-
return {
|
|
192
|
-
keep,
|
|
193
|
-
fork: {
|
|
194
|
-
...fork,
|
|
195
|
-
id: `${parsedKind}:${newId()}`,
|
|
196
|
-
title: `${fork.title || 'Untitled'} (${label})`,
|
|
197
|
-
meta: { ...(fork.meta || {}), conflictOf: keep.id },
|
|
198
|
-
},
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Fold applied ids back into a base map, so the NEXT plan can tell a fast-forward from a
|
|
204
|
-
* divergence. Returns a new Map; the input is not mutated.
|
|
205
|
-
*/
|
|
206
|
-
export function advanceBases(bases, applied = []) {
|
|
207
|
-
const next = bases instanceof Map ? new Map(bases) : indexById(bases);
|
|
208
|
-
for (const entry of applied) {
|
|
209
|
-
if (!entry || !entry.id) continue;
|
|
210
|
-
next.set(entry.id, { id: entry.id, updatedAt: num(entry.updatedAt), deletedAt: num(entry.deletedAt) });
|
|
211
|
-
}
|
|
212
|
-
return next;
|
|
213
|
-
}
|
|
1
|
+
class m extends Error{constructor(n){super(n),this.name="SyncError"}}const g=1e3,l=t=>Number.isFinite(Number(t))?Number(t):0;function p(t){return t?Math.max(l(t.updatedAt),l(t.deletedAt)):0}function a(t){const n=new Map;for(const e of t||[])!e||!e.id||n.set(e.id,e);return n}function M(t){if(!t||t.lossy===!0)return 0;const n=t.meta||null;return n&&(n.lossy===!0||n.origin==="warm")?0:1}function A(t,n,{tolerance:e=g,base:o=null}={}){if(!t&&!n)return"none";if(!n)return"push";if(!t)return"pull";const s=p(t),i=p(n);if(!(l(t.deletedAt)>0||l(n.deletedAt)>0)){const r=M(t),u=M(n);if(r!==u)return u>r?"pull":"push"}const c=s-i;if(Math.abs(c)<=e)return"none";if(o){const r=p(o),u=s-r>e,d=i-r>e;if(u&&d)return"conflict"}return c>0?"push":"pull"}function y(t,n,{tolerance:e=g,bases:o=null}={}){const s=a(t),i=a(n),h=o instanceof Map?o:a(o),c=[],r=[],u=[];let d=0;for(const f of new Set([...s.keys(),...i.keys()])){const x=A(s.get(f),i.get(f),{tolerance:e,base:h.get(f)||null});x==="pull"?c.push(f):x==="push"?r.push(f):x==="conflict"?u.push(f):d+=1}return c.sort(),r.sort(),u.sort(),{pull:c,push:r,conflicts:u,unchanged:d,counts:{pull:c.length,push:r.length,conflicts:u.length,unchanged:d}}}function k(t){return!!t&&t.pull.length===0&&t.push.length===0&&t.conflicts.length===0}function S(t,n,{newId:e,label:o="conflicted copy"}={}){if(typeof e!="function")throw new m("forkConflict needs a newId() function");const s=p(t)>=p(n)?t:n,i=s===t?n:t,h=String(i.id||"").split(":")[0];return{keep:s,fork:{...i,id:`${h}:${e()}`,title:`${i.title||"Untitled"} (${o})`,meta:{...i.meta||{},conflictOf:s.id}}}}function w(t,n=[]){const e=t instanceof Map?new Map(t):a(t);for(const o of n)!o||!o.id||e.set(o.id,{id:o.id,updatedAt:l(o.updatedAt),deletedAt:l(o.deletedAt)});return e}export{g as CLOCK_TOLERANCE_MS,m as SyncError,w as advanceBases,A as decide,M as fidelityOf,S as forkConflict,k as isSettled,y as planSync,p as stampOf};
|
package/synthesis.js
CHANGED
|
@@ -1,123 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// already held, with nothing to review. This is the layer the wiki pattern is actually
|
|
5
|
-
// about — "what was decided about Atlas, and when did it change" — and it is exactly the
|
|
6
|
-
// layer the community warned about: agent writing nobody reviewed compounds into confident
|
|
7
|
-
// nonsense that a lint pass cannot tell apart from truth six months on (C2).
|
|
8
|
-
//
|
|
9
|
-
// So two rules do all the defending, and both are enforced here rather than promised:
|
|
10
|
-
//
|
|
11
|
-
// I-K1 EVERY CLAIM CITES A RECORD IT WAS SHOWN. The model sees excerpts labelled with
|
|
12
|
-
// their ids and must attach at least one to each claim. A claim citing nothing, or
|
|
13
|
-
// citing an id that was not in the excerpt set, is REFUSED — not downgraded, not
|
|
14
|
-
// flagged, refused — because a citation a reader cannot open is worse than no claim.
|
|
15
|
-
// I-K3 NOTHING HERE PROMOTES. `claimsFromSynthesis` produces claims in state `proposed`
|
|
16
|
-
// and `promotion.js` is the only path to `promoted`. There is no flag to skip it.
|
|
17
|
-
//
|
|
18
|
-
// The model call itself is not made here (no network, per the package rule). This module
|
|
19
|
-
// owns the SHAPE — one schema, one prompt, one parser — so the extension, the gateway and a
|
|
20
|
-
// desktop client ask the same question and read the answer the same way. It is written as
|
|
21
|
-
// a single appointment on purpose: a team appointment (W7 — drafters that must agree) has
|
|
22
|
-
// the same contract, with `converge()` in promotion.js deciding what to propose.
|
|
23
|
-
|
|
24
|
-
import { defineSchema, describeSchema } from './structured.js';
|
|
25
|
-
|
|
26
|
-
export const MAX_SYNTHESIS_CLAIMS = 8;
|
|
27
|
-
export const MAX_CLAIM_CHARS = 240;
|
|
28
|
-
export const MAX_EXCERPT_CHARS = 1200;
|
|
29
|
-
export const MAX_EXCERPTS = 24;
|
|
30
|
-
|
|
31
|
-
export const SYNTHESIS_SCHEMA = defineSchema({
|
|
32
|
-
name: 'brief_synthesis',
|
|
33
|
-
purpose: 'What the records establish about one subject, as separate cited claims.',
|
|
34
|
-
fields: {
|
|
35
|
-
claims: {
|
|
36
|
-
type: 'object[]', maxItems: MAX_SYNTHESIS_CLAIMS,
|
|
37
|
-
describe: 'the things the records establish about the subject — decisions, roles, status, changes — one per entry, each cited',
|
|
38
|
-
fields: {
|
|
39
|
-
text: { type: 'string', required: true, max: MAX_CLAIM_CHARS, describe: 'one specific claim in plain prose, past tense for events, present for standing facts' },
|
|
40
|
-
refs: { type: 'string[]', required: true, maxItems: 6, describe: 'the record ids (exactly as labelled, e.g. meeting:m_12) that support this claim — at least one, only ones you were shown' },
|
|
41
|
-
when: { type: 'string', max: 10, describe: 'YYYY-MM-DD of the record that establishes it, if one does' },
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
summary: { type: 'string', max: 600, describe: 'two or three sentences of what a colleague should know about this subject right now' },
|
|
45
|
-
},
|
|
46
|
-
// "The records do not establish anything beyond what is already listed" is a legitimate
|
|
47
|
-
// answer and must not be read as a failure — read as one, a caller might fall back to the
|
|
48
|
-
// model's prose without the schema, which is the exact unstructured path this replaces.
|
|
49
|
-
nothing: { claims: [], summary: '' },
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* The prompt. The subject, what the deterministic layer already says (so the model adds to
|
|
54
|
-
* it rather than restating it), and the excerpts it may cite — each labelled with the id it
|
|
55
|
-
* must use. Excerpts are capped per record and in count, because this is a bounded call by
|
|
56
|
-
* design (I-K4): a subject with two hundred records gets its most recent two dozen, and a
|
|
57
|
-
* later pass can take the rest.
|
|
58
|
-
*/
|
|
59
|
-
export function synthesisPrompt({ subject, existing = [], excerpts = [] } = {}) {
|
|
60
|
-
const name = String(subject?.name || 'the subject');
|
|
61
|
-
const kind = String(subject?.kind || 'subject');
|
|
62
|
-
const shown = excerpts.slice(0, MAX_EXCERPTS);
|
|
63
|
-
const lines = [
|
|
64
|
-
`Subject: ${name} (${kind})${subject?.aliases?.length ? ` — also written as ${subject.aliases.join(', ')}` : ''}.`,
|
|
65
|
-
'',
|
|
66
|
-
'Already established (do not repeat these):',
|
|
67
|
-
...(existing.length ? existing.map((c) => `- ${c}`) : ['- nothing yet']),
|
|
68
|
-
'',
|
|
69
|
-
`Records you may cite (${shown.length}). Cite ONLY these ids, exactly as written:`,
|
|
70
|
-
...shown.map((e) => `[${e.id}] ${e.title || ''}${e.date ? ` (${e.date})` : ''}\n${String(e.text || '').slice(0, MAX_EXCERPT_CHARS)}`),
|
|
71
|
-
'',
|
|
72
|
-
'Write what these records ESTABLISH about the subject: decisions, ownership, status, and',
|
|
73
|
-
'anything that changed over time (say what it was before and after). Every claim must cite',
|
|
74
|
-
'at least one of the ids above. If the records establish nothing beyond what is already',
|
|
75
|
-
'listed, return no claims.',
|
|
76
|
-
'',
|
|
77
|
-
describeSchema(SYNTHESIS_SCHEMA),
|
|
78
|
-
];
|
|
79
|
-
return lines.join('\n');
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* The model's answer → claims a brief could carry, with the refusals listed.
|
|
84
|
-
*
|
|
85
|
-
* `knownIds` is the set of ids the model was shown. A claim citing anything outside it is
|
|
86
|
-
* refused whole — the model invented or misremembered a citation, and a claim we cannot
|
|
87
|
-
* trace is a claim we cannot keep (I-K1). `hashOf(id)` gives the record's drift hash so the
|
|
88
|
-
* ref can later say "this record changed since I cited it".
|
|
89
|
-
*
|
|
90
|
-
* Returns claims in state `proposed`, class C. There is no argument that produces
|
|
91
|
-
* `promoted`; that is promotion.js's job and nobody else's (I-K3).
|
|
92
|
-
*/
|
|
93
|
-
export function claimsFromSynthesis(value, { knownIds, hashOf = () => 'unhashed', now = 0, newId = null } = {}) {
|
|
94
|
-
const known = knownIds instanceof Set ? knownIds : new Set(knownIds || []);
|
|
95
|
-
const out = [];
|
|
96
|
-
const refused = [];
|
|
97
|
-
let n = 0;
|
|
98
|
-
const id = () => (newId ? newId() : `s${now}-${(n += 1)}`);
|
|
99
|
-
for (const raw of value?.claims || []) {
|
|
100
|
-
const text = String(raw?.text || '').trim();
|
|
101
|
-
const refs = [...new Set((raw?.refs || []).map((r) => String(r || '').trim()).filter(Boolean))];
|
|
102
|
-
if (!text) { refused.push({ text, why: 'empty' }); continue; }
|
|
103
|
-
if (!refs.length) { refused.push({ text, why: 'no citation' }); continue; }
|
|
104
|
-
const unknown = refs.filter((r) => !known.has(r));
|
|
105
|
-
if (unknown.length) { refused.push({ text, why: `cites a record it was not shown: ${unknown.join(', ')}` }); continue; }
|
|
106
|
-
out.push({
|
|
107
|
-
id: id(),
|
|
108
|
-
kind: 'synthesis',
|
|
109
|
-
text: text.slice(0, MAX_CLAIM_CHARS),
|
|
110
|
-
refs: refs.map((r) => {
|
|
111
|
-
const idx = r.indexOf(':');
|
|
112
|
-
return { kind: r.slice(0, idx), id: r.slice(idx + 1), hash: hashOf(r) };
|
|
113
|
-
}),
|
|
114
|
-
when: /^\d{4}-\d{2}-\d{2}$/.test(String(raw?.when || '')) ? raw.when : '',
|
|
115
|
-
firstSeen: now,
|
|
116
|
-
lastConfirmed: now,
|
|
117
|
-
confidence: 0.6, // a model's read, not a record's word — lower than any class-R claim
|
|
118
|
-
cls: 'C',
|
|
119
|
-
state: 'proposed',
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
return { claims: out, summary: String(value?.summary || '').trim().slice(0, 600), refused };
|
|
123
|
-
}
|
|
1
|
+
import{defineSchema as S,describeSchema as g}from"./structured.js";const b=8,p=240,w=1200,x=24,$=S({name:"brief_synthesis",purpose:"What the records establish about one subject, as separate cited claims.",fields:{claims:{type:"object[]",maxItems:b,describe:"the things the records establish about the subject — decisions, roles, status, changes — one per entry, each cited",fields:{text:{type:"string",required:!0,max:p,describe:"one specific claim in plain prose, past tense for events, present for standing facts"},refs:{type:"string[]",required:!0,maxItems:6,describe:"the record ids (exactly as labelled, e.g. meeting:m_12) that support this claim — at least one, only ones you were shown"},when:{type:"string",max:10,describe:"YYYY-MM-DD of the record that establishes it, if one does"}}},summary:{type:"string",max:600,describe:"two or three sentences of what a colleague should know about this subject right now"}},nothing:{claims:[],summary:""}});function C({subject:s,existing:n=[],excerpts:h=[]}={}){const i=String(s?.name||"the subject"),r=String(s?.kind||"subject"),a=h.slice(0,x);return[`Subject: ${i} (${r})${s?.aliases?.length?` — also written as ${s.aliases.join(", ")}`:""}.`,"","Already established (do not repeat these):",...n.length?n.map(e=>`- ${e}`):["- nothing yet"],"",`Records you may cite (${a.length}). Cite ONLY these ids, exactly as written:`,...a.map(e=>`[${e.id}] ${e.title||""}${e.date?` (${e.date})`:""}
|
|
2
|
+
${String(e.text||"").slice(0,w)}`),"","Write what these records ESTABLISH about the subject: decisions, ownership, status, and","anything that changed over time (say what it was before and after). Every claim must cite","at least one of the ids above. If the records establish nothing beyond what is already","listed, return no claims.","",g($)].join(`
|
|
3
|
+
`)}function _(s,{knownIds:n,hashOf:h=()=>"unhashed",now:i=0,newId:r=null}={}){const a=n instanceof Set?n:new Set(n||[]),l=[],e=[];let f=0;const y=()=>r?r():`s${i}-${f+=1}`;for(const c of s?.claims||[]){const o=String(c?.text||"").trim(),d=[...new Set((c?.refs||[]).map(t=>String(t||"").trim()).filter(Boolean))];if(!o){e.push({text:o,why:"empty"});continue}if(!d.length){e.push({text:o,why:"no citation"});continue}const m=d.filter(t=>!a.has(t));if(m.length){e.push({text:o,why:`cites a record it was not shown: ${m.join(", ")}`});continue}l.push({id:y(),kind:"synthesis",text:o.slice(0,p),refs:d.map(t=>{const u=t.indexOf(":");return{kind:t.slice(0,u),id:t.slice(u+1),hash:h(t)}}),when:/^\d{4}-\d{2}-\d{2}$/.test(String(c?.when||""))?c.when:"",firstSeen:i,lastConfirmed:i,confidence:.6,cls:"C",state:"proposed"})}return{claims:l,summary:String(s?.summary||"").trim().slice(0,600),refused:e}}export{p as MAX_CLAIM_CHARS,x as MAX_EXCERPTS,w as MAX_EXCERPT_CHARS,b as MAX_SYNTHESIS_CLAIMS,$ as SYNTHESIS_SCHEMA,_ as claimsFromSynthesis,C as synthesisPrompt};
|
package/tags.js
CHANGED
|
@@ -1,203 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// A tag typed in the notes editor and a tag typed on the meetings page have to BE the
|
|
4
|
-
// same tag, and `tag:design` has to select it from any list. That is a pure input →
|
|
5
|
-
// output question about normalization and matching, so it lives here: the extension,
|
|
6
|
-
// the gateway's warm index and any future mobile client all have to agree on what "the
|
|
7
|
-
// same tag" is, and three normalizers would mean three answers to that.
|
|
8
|
-
//
|
|
9
|
-
// Design notes:
|
|
10
|
-
// • Normalization is lossy on purpose — case, punctuation and spacing are filing
|
|
11
|
-
// noise. "Design Review", "design-review" and "#DesignReview!" are one tag.
|
|
12
|
-
// • Unicode-aware. Stripping everything outside [a-z0-9-] silently erases a tag
|
|
13
|
-
// written in Japanese, Greek or Hindi — it becomes '' and vanishes. Letters and
|
|
14
|
-
// numbers in ANY script are kept; only separators and punctuation fold to '-'.
|
|
15
|
-
// • Order is the user's, not ours: tags keep insertion order rather than sorting,
|
|
16
|
-
// because the first tag someone adds is usually the one they think in.
|
|
17
|
-
|
|
18
|
-
export const MAX_TAG_LENGTH = 32;
|
|
19
|
-
export const MAX_TAGS = 24;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Fold one user-typed value into its canonical tag, or '' when nothing survives.
|
|
23
|
-
* Idempotent: normalizeTag(normalizeTag(x)) === normalizeTag(x).
|
|
24
|
-
*/
|
|
25
|
-
export function normalizeTag(value) {
|
|
26
|
-
const raw = String(value ?? '').trim().replace(/^#+/, '');
|
|
27
|
-
if (!raw) return '';
|
|
28
|
-
return raw
|
|
29
|
-
.toLowerCase()
|
|
30
|
-
// Any run of things that aren't letters/numbers becomes a single separator.
|
|
31
|
-
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
|
32
|
-
.replace(/^-+|-+$/g, '')
|
|
33
|
-
.slice(0, MAX_TAG_LENGTH)
|
|
34
|
-
// The slice can land mid-separator; don't leave a trailing dash behind.
|
|
35
|
-
.replace(/-+$/, '');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Normalize a list: drop blanks, dedupe (first wins), cap the count. */
|
|
39
|
-
export function normalizeTags(list, { max = MAX_TAGS } = {}) {
|
|
40
|
-
const out = [];
|
|
41
|
-
for (const value of Array.isArray(list) ? list : []) {
|
|
42
|
-
const tag = normalizeTag(value);
|
|
43
|
-
if (tag && !out.includes(tag)) out.push(tag);
|
|
44
|
-
if (out.length >= max) break;
|
|
45
|
-
}
|
|
46
|
-
return out;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function hasTag(tags, value) {
|
|
50
|
-
const tag = normalizeTag(value);
|
|
51
|
-
return !!tag && normalizeTags(tags).includes(tag);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Append a tag (no-op when blank, already present, or at the cap). */
|
|
55
|
-
export function addTag(tags, value, { max = MAX_TAGS } = {}) {
|
|
56
|
-
const current = normalizeTags(tags, { max });
|
|
57
|
-
const tag = normalizeTag(value);
|
|
58
|
-
if (!tag || current.includes(tag) || current.length >= max) return current;
|
|
59
|
-
return [...current, tag];
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function removeTag(tags, value) {
|
|
63
|
-
const tag = normalizeTag(value);
|
|
64
|
-
return normalizeTags(tags).filter((t) => t !== tag);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function toggleTag(tags, value, { max = MAX_TAGS } = {}) {
|
|
68
|
-
return hasTag(tags, value) ? removeTag(tags, value) : addTag(tags, value, { max });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Set equality, order-insensitive — so a save can skip a write that changes nothing. */
|
|
72
|
-
export function sameTags(a, b) {
|
|
73
|
-
const x = normalizeTags(a);
|
|
74
|
-
const y = normalizeTags(b);
|
|
75
|
-
return x.length === y.length && x.every((t) => y.includes(t));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** `#design` — the one display form, so chips read the same on every surface. */
|
|
79
|
-
export function formatTag(tag) {
|
|
80
|
-
const t = normalizeTag(tag);
|
|
81
|
-
return t ? `#${t}` : '';
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// --------------------------------------------------------------------------
|
|
85
|
-
// Query language
|
|
86
|
-
// --------------------------------------------------------------------------
|
|
87
|
-
|
|
88
|
-
// One search box, two jobs: free text and tag selection. Rather than a second input
|
|
89
|
-
// per page, a query may carry tag terms inline —
|
|
90
|
-
// tag:design include
|
|
91
|
-
// #design include (the shorthand people already type)
|
|
92
|
-
// -tag:done / -#done exclude
|
|
93
|
-
// tag:"deep work" quoted, folded to deep-work
|
|
94
|
-
// …and everything left over is the free-text part the ranker sees. A `#` that is part
|
|
95
|
-
// of a word ("C#", "issue #12") is left alone: only a leading, standalone one counts.
|
|
96
|
-
const TERM_RE = /(^|\s)(-)?(?:tag:|#)("([^"]*)"|[^\s"]+)/giu;
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Split a raw query into { include, exclude, text }.
|
|
100
|
-
* `text` is the query with the tag terms removed (whitespace collapsed).
|
|
101
|
-
*/
|
|
102
|
-
export function parseTagQuery(query) {
|
|
103
|
-
const raw = String(query ?? '');
|
|
104
|
-
const include = [];
|
|
105
|
-
const exclude = [];
|
|
106
|
-
let text = raw.replace(TERM_RE, (match, lead, minus, bare, quoted) => {
|
|
107
|
-
const tag = normalizeTag(quoted !== undefined ? quoted : bare);
|
|
108
|
-
if (!tag) return match; // nothing usable — leave it in the free text
|
|
109
|
-
const bucket = minus ? exclude : include;
|
|
110
|
-
if (!bucket.includes(tag)) bucket.push(tag);
|
|
111
|
-
return lead || '';
|
|
112
|
-
});
|
|
113
|
-
text = text.replace(/\s+/g, ' ').trim();
|
|
114
|
-
return { include, exclude, text };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** True when `query` carries at least one tag term. */
|
|
118
|
-
export function hasTagTerms(query) {
|
|
119
|
-
const { include, exclude } = parseTagQuery(query);
|
|
120
|
-
return include.length > 0 || exclude.length > 0;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Render a filter back into query syntax — the inverse of parseTagQuery. */
|
|
124
|
-
export function formatTagQuery({ include = [], exclude = [], text = '' } = {}) {
|
|
125
|
-
return [
|
|
126
|
-
...normalizeTags(include).map((t) => `tag:${t}`),
|
|
127
|
-
...normalizeTags(exclude).map((t) => `-tag:${t}`),
|
|
128
|
-
String(text || '').trim(),
|
|
129
|
-
].filter(Boolean).join(' ');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Does one record's tags satisfy a filter?
|
|
134
|
-
* Include terms are ANDed (narrowing is what filters are for); `mode:'any'` ORs them.
|
|
135
|
-
* Exclusions always win.
|
|
136
|
-
*/
|
|
137
|
-
export function matchesTagFilter(tags, { include = [], exclude = [] } = {}, { mode = 'all' } = {}) {
|
|
138
|
-
const own = normalizeTags(tags);
|
|
139
|
-
const wanted = normalizeTags(include);
|
|
140
|
-
const banned = normalizeTags(exclude);
|
|
141
|
-
if (banned.some((t) => own.includes(t))) return false;
|
|
142
|
-
if (!wanted.length) return true;
|
|
143
|
-
return mode === 'any' ? wanted.some((t) => own.includes(t)) : wanted.every((t) => own.includes(t));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const defaultGetTags = (entry) => entry?.tags;
|
|
147
|
-
|
|
148
|
-
/** Filter a list of records by a parsed filter. Order is preserved. */
|
|
149
|
-
export function filterByTags(entries, filter, getTags = defaultGetTags, opts) {
|
|
150
|
-
const f = filter || {};
|
|
151
|
-
if (!(f.include?.length || f.exclude?.length)) return [...(entries || [])];
|
|
152
|
-
return (entries || []).filter((e) => matchesTagFilter(getTags(e), f, opts));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Tag facets for a filter bar: every tag in the corpus with its count, most-used
|
|
157
|
-
* first then alphabetical (so the bar is stable as counts tie). `selected` tags are
|
|
158
|
-
* always included even at count 0, so a filter that empties the list can be undone.
|
|
159
|
-
*/
|
|
160
|
-
export function tagFacets(entries, getTags = defaultGetTags, { limit = 0, selected = [] } = {}) {
|
|
161
|
-
const counts = new Map();
|
|
162
|
-
for (const entry of entries || []) {
|
|
163
|
-
for (const tag of normalizeTags(getTags(entry))) counts.set(tag, (counts.get(tag) || 0) + 1);
|
|
164
|
-
}
|
|
165
|
-
for (const tag of normalizeTags(selected)) if (!counts.has(tag)) counts.set(tag, 0);
|
|
166
|
-
const facets = [...counts.entries()]
|
|
167
|
-
.map(([tag, count]) => ({ tag, count }))
|
|
168
|
-
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
|
169
|
-
const keep = new Set(normalizeTags(selected));
|
|
170
|
-
if (!limit || facets.length <= limit) return facets;
|
|
171
|
-
// Truncation must never hide an active selection — keep those, then fill by rank.
|
|
172
|
-
const picked = facets.filter((f) => keep.has(f.tag));
|
|
173
|
-
for (const f of facets) {
|
|
174
|
-
if (picked.length >= limit) break;
|
|
175
|
-
if (!keep.has(f.tag)) picked.push(f);
|
|
176
|
-
}
|
|
177
|
-
return picked.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Suggest tags a record doesn't have yet, drawn from what's already in use —
|
|
182
|
-
* `existing` facets ranked by count, minus what's on the record. Reusing a tag the
|
|
183
|
-
* user coined beats inventing a synonym for it.
|
|
184
|
-
*/
|
|
185
|
-
export function suggestExistingTags(facets, tags, { limit = 6 } = {}) {
|
|
186
|
-
const own = new Set(normalizeTags(tags));
|
|
187
|
-
return (facets || [])
|
|
188
|
-
.map((f) => (typeof f === 'string' ? { tag: normalizeTag(f), count: 0 } : f))
|
|
189
|
-
.filter((f) => f.tag && !own.has(f.tag))
|
|
190
|
-
.slice(0, limit)
|
|
191
|
-
.map((f) => f.tag);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* The line a full-text index should carry for tags. Both forms are emitted — `#design`
|
|
196
|
-
* so an exact-text search for what the chip shows hits, and `design` so a plain word
|
|
197
|
-
* query does too.
|
|
198
|
-
*/
|
|
199
|
-
export function tagsSearchText(tags) {
|
|
200
|
-
const list = normalizeTags(tags);
|
|
201
|
-
if (!list.length) return '';
|
|
202
|
-
return [...list.map((t) => `#${t}`), ...list].join(' ');
|
|
203
|
-
}
|
|
1
|
+
const S=32,w=24;function g(t){const n=String(t??"").trim().replace(/^#+/,"");return n?n.toLowerCase().replace(/[^\p{L}\p{N}]+/gu,"-").replace(/^-+|-+$/g,"").slice(0,32).replace(/-+$/,""):""}function a(t,{max:n=24}={}){const e=[];for(const o of Array.isArray(t)?t:[]){const r=g(o);if(r&&!e.includes(r)&&e.push(r),e.length>=n)break}return e}function x(t,n){const e=g(n);return!!e&&a(t).includes(e)}function h(t,n,{max:e=24}={}){const o=a(t,{max:e}),r=g(n);return!r||o.includes(r)||o.length>=e?o:[...o,r]}function d(t,n){const e=g(n);return a(t).filter(o=>o!==e)}function G(t,n,{max:e=24}={}){return x(t,n)?d(t,n):h(t,n,{max:e})}function _(t,n){const e=a(t),o=a(n);return e.length===o.length&&e.every(r=>o.includes(r))}function M(t){const n=g(t);return n?`#${n}`:""}const m=/(^|\s)(-)?(?:tag:|#)("([^"]*)"|[^\s"]+)/giu;function A(t){const n=String(t??""),e=[],o=[];let r=n.replace(m,(u,f,i,c,s)=>{const l=g(s!==void 0?s:c);if(!l)return u;const p=i?o:e;return p.includes(l)||p.push(l),f||""});return r=r.replace(/\s+/g," ").trim(),{include:e,exclude:o,text:r}}function X(t){const{include:n,exclude:e}=A(t);return n.length>0||e.length>0}function $({include:t=[],exclude:n=[],text:e=""}={}){return[...a(t).map(o=>`tag:${o}`),...a(n).map(o=>`-tag:${o}`),String(e||"").trim()].filter(Boolean).join(" ")}function y(t,{include:n=[],exclude:e=[]}={},{mode:o="all"}={}){const r=a(t),u=a(n);return a(e).some(i=>r.includes(i))?!1:u.length?o==="any"?u.some(i=>r.includes(i)):u.every(i=>r.includes(i)):!0}const T=t=>t?.tags;function k(t,n,e=T,o){const r=n||{};return r.include?.length||r.exclude?.length?(t||[]).filter(u=>y(e(u),r,o)):[...t||[]]}function E(t,n=T,{limit:e=0,selected:o=[]}={}){const r=new Map;for(const c of t||[])for(const s of a(n(c)))r.set(s,(r.get(s)||0)+1);for(const c of a(o))r.has(c)||r.set(c,0);const u=[...r.entries()].map(([c,s])=>({tag:c,count:s})).sort((c,s)=>s.count-c.count||c.tag.localeCompare(s.tag)),f=new Set(a(o));if(!e||u.length<=e)return u;const i=u.filter(c=>f.has(c.tag));for(const c of u){if(i.length>=e)break;f.has(c.tag)||i.push(c)}return i.sort((c,s)=>s.count-c.count||c.tag.localeCompare(s.tag))}function L(t,n,{limit:e=6}={}){const o=new Set(a(n));return(t||[]).map(r=>typeof r=="string"?{tag:g(r),count:0}:r).filter(r=>r.tag&&!o.has(r.tag)).slice(0,e).map(r=>r.tag)}function b(t){const n=a(t);return n.length?[...n.map(e=>`#${e}`),...n].join(" "):""}export{w as MAX_TAGS,S as MAX_TAG_LENGTH,h as addTag,k as filterByTags,M as formatTag,$ as formatTagQuery,x as hasTag,X as hasTagTerms,y as matchesTagFilter,g as normalizeTag,a as normalizeTags,A as parseTagQuery,d as removeTag,_ as sameTags,L as suggestExistingTags,E as tagFacets,b as tagsSearchText,G as toggleTag};
|