@chatpanel/gateway 0.6.60 → 0.6.61
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/package.json +2 -2
- package/src/config.js +11 -6
- package/src/history-store.js +2 -1
- package/src/knowledge.js +215 -0
- package/src/mcp.js +52 -3
- package/src/models.js +51 -6
- package/src/ner-engine.js +6 -3
- package/src/server.js +12 -3
- package/src/sqlite-store.js +6 -3
- package/src/stt-models.js +20 -6
- package/src/subject-kinds.js +6 -0
- package/src/subject-name.js +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.61",
|
|
4
4
|
"description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"node": ">=18"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@chatpanel/pii": "^0.
|
|
30
|
+
"@chatpanel/pii": "^0.7.0",
|
|
31
31
|
"@huggingface/transformers": "^4.2.0",
|
|
32
32
|
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c",
|
|
33
33
|
"phonemizer": "^1.2.1"
|
package/src/config.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import os from 'node:os';
|
|
11
|
+
import { DEFAULT_MODEL as DEFAULT_NER_MODEL } from './models.js';
|
|
12
|
+
import { DEFAULT_STT_MODEL } from './stt-models.js';
|
|
11
13
|
|
|
12
14
|
// Exported so tests can assert that every section here survives persistConfig's
|
|
13
15
|
// allowlist — a new section that is not persisted reverts on restart, and that
|
|
@@ -90,19 +92,22 @@ export const DEFAULTS = {
|
|
|
90
92
|
// Fails open: if the model can't load, the gateway runs deterministic-only.
|
|
91
93
|
ner: {
|
|
92
94
|
autostart: true,
|
|
93
|
-
|
|
95
|
+
// One definition, in models.js. It lived here, in models.js and in ner-engine.js at
|
|
96
|
+
// once — three constants for one default, which is three chances to move two of them.
|
|
97
|
+
model: DEFAULT_NER_MODEL,
|
|
94
98
|
allowDownload: true,
|
|
95
99
|
// Auto-bump redaction.tier to 'full' once the detector is ready (names/orgs).
|
|
96
100
|
enableFullTier: true,
|
|
97
101
|
},
|
|
98
102
|
|
|
99
|
-
// Local speech-to-text (dictation) —
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
+
// Local speech-to-text (dictation) — the in-process ONNX engine and model dir shared
|
|
104
|
+
// with NER (stt-engine.js). No autostart on purpose: the model downloads on FIRST
|
|
105
|
+
// dictation, never on gateway boot (first-run load time). The default is Parakeet, which
|
|
106
|
+
// is multilingual and several times faster than Whisper — see stt-models.js for what
|
|
107
|
+
// that costs on a first run.
|
|
103
108
|
stt: {
|
|
104
109
|
enabled: true,
|
|
105
|
-
model:
|
|
110
|
+
model: DEFAULT_STT_MODEL,
|
|
106
111
|
allowDownload: true,
|
|
107
112
|
// Speaker diarization ("who said what") is an OPT-IN per-session stage (its
|
|
108
113
|
// model loads only when a session asks). Set false to disable it gateway-wide.
|
package/src/history-store.js
CHANGED
|
@@ -216,8 +216,9 @@ export class HistoryStore {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
// Metadata list for an external UI, newest first, paginated. No bodies.
|
|
219
|
-
list({ limit = 50, offset = 0 } = {}) {
|
|
219
|
+
list({ limit = 50, offset = 0, type = null } = {}) {
|
|
220
220
|
const all = [...this.records.values()]
|
|
221
|
+
.filter((r) => !type || r.type === type)
|
|
221
222
|
.map((r) => ({ id: r.id, title: r.title, type: r.type, date: r.date, chars: r.text.length }))
|
|
222
223
|
.sort((a, b) => (b.date || 0) - (a.date || 0));
|
|
223
224
|
return { total: all.length, items: all.slice(offset, offset + limit) };
|
package/src/knowledge.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/knowledge.js — edit there, then copy over.
|
|
2
|
+
// BRIEFS — the derived layer. A statement about a SUBJECT that accumulates across records.
|
|
3
|
+
//
|
|
4
|
+
// Everything else ChatPanel stores is a record of an event: a chat happened, a call
|
|
5
|
+
// happened, a human wrote a note. Nothing is a synthesis, so every answer is re-derived
|
|
6
|
+
// from scratch, every session, forever — and a multi-agent run's findings die with the run.
|
|
7
|
+
// A brief is the thing that compounds.
|
|
8
|
+
//
|
|
9
|
+
// FOUR INVARIANTS, and they are the whole defence (see docs/knowledge-compounding.md §5.1):
|
|
10
|
+
//
|
|
11
|
+
// I-K1 Every claim cites raw. A claim with no ref is a BUG, not a weak claim — it is
|
|
12
|
+
// refused on write. Derived text can then always be checked against, or rebuilt
|
|
13
|
+
// from, the immutable records under it.
|
|
14
|
+
// I-K2 A brief is REBUILDABLE. Delete every brief and this module reconstructs them from
|
|
15
|
+
// the record store. That makes a brief a projection — the same guarantee replay()
|
|
16
|
+
// gives the event log — and it makes "rebuild all" a cache clear, not data loss.
|
|
17
|
+
// I-K3 Nothing self-promotes. `draft` is free; `promoted` needs a gate. Class-R
|
|
18
|
+
// derivations may auto-promote (a backlink is not an opinion); model-written prose
|
|
19
|
+
// may not. Unreviewed agent writing compounding into confident nonsense is the
|
|
20
|
+
// failure mode that is undetectable six months later.
|
|
21
|
+
// I-K4 Bounded, or it is a second corpus. A brief has a size ceiling and the SET of
|
|
22
|
+
// briefs has a count ceiling driven by evidence — memory.js already made this
|
|
23
|
+
// argument for memory, and the same sentence applies here.
|
|
24
|
+
//
|
|
25
|
+
// This phase (W1) is entirely class R: no model, no network, no clock of its own. Every
|
|
26
|
+
// claim is something the corpus already states — who was present, when, what co-occurs,
|
|
27
|
+
// what the user themselves told us. Prose synthesis arrives in W3, behind the gate, and
|
|
28
|
+
// lands as `proposed` beside these rather than replacing them.
|
|
29
|
+
//
|
|
30
|
+
// WHY THIS FILE IS THE MODEL AND `knowledge-derive.js` IS THE PASS. Reading a brief and
|
|
31
|
+
// BUILDING one have very different costs: reading needs the shape and the renderer, while
|
|
32
|
+
// building walks the whole corpus and needs entity resolution and the maintenance passes.
|
|
33
|
+
// The MV3 service worker only ever reads — it syncs stored briefs to the gateway — so
|
|
34
|
+
// putting both halves in one module would have put 60 KB of derivation on its cold start
|
|
35
|
+
// for code it never runs. The split is what keeps that honest rather than remembered.
|
|
36
|
+
|
|
37
|
+
// From subject-name.js, not entity.js: this module is on the MV3 service worker's graph and
|
|
38
|
+
// needs exactly one string function, where entity.js also carries alias resolution and merge
|
|
39
|
+
// suggestion. Same argument as the knowledge/knowledge-derive split, one level down.
|
|
40
|
+
import { normalizeSubject } from './subject-name.js';
|
|
41
|
+
|
|
42
|
+
/** draft → proposed → promoted → archived. `promotion.js` (W3) owns the transitions. */
|
|
43
|
+
export const BRIEF_STATES = Object.freeze(['draft', 'proposed', 'promoted', 'archived']);
|
|
44
|
+
|
|
45
|
+
/** What a claim is derived FROM. Class R throughout this phase. */
|
|
46
|
+
export const CLAIM_KINDS = Object.freeze(['presence', 'timeline', 'together', 'wanted', 'stated']);
|
|
47
|
+
|
|
48
|
+
// I-K4, made concrete. A brief that grows without bound is a document, and a document
|
|
49
|
+
// needs its own summary, and then nothing has been gained.
|
|
50
|
+
export const MAX_CLAIMS = 12;
|
|
51
|
+
export const MAX_CLAIM_REFS = 8;
|
|
52
|
+
export const MAX_BRIEF_RECORDS = 200;
|
|
53
|
+
export const MAX_BRIEF_CHARS = 4000;
|
|
54
|
+
|
|
55
|
+
// How many co-occurring subjects a `together` claim names. Past a handful it stops being a
|
|
56
|
+
// statement and becomes a tag cloud.
|
|
57
|
+
const TOGETHER_LIMIT = 5;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A stable, non-cryptographic content hash, computed SYNCHRONOUSLY.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately not SHA-256, which `store.js` uses and which is async: derivation walks
|
|
63
|
+
* every record in the corpus and runs in an MV3 service worker, so a hash per record has to
|
|
64
|
+
* be synchronous or the whole pass becomes a promise storm. The job here is DRIFT
|
|
65
|
+
* DETECTION — "has the record this claim cites changed since the claim was made" — not
|
|
66
|
+
* tamper resistance, and FNV-1a answers that exactly as well while staying pure.
|
|
67
|
+
*/
|
|
68
|
+
export function contentHash(text) {
|
|
69
|
+
const s = String(text ?? '');
|
|
70
|
+
let h1 = 0x811c9dc5, h2 = 0x01000193;
|
|
71
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
72
|
+
const c = s.charCodeAt(i);
|
|
73
|
+
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
|
|
74
|
+
h2 = Math.imul(h2 ^ (c + i), 0x85ebca6b) >>> 0;
|
|
75
|
+
}
|
|
76
|
+
return `f${h1.toString(16).padStart(8, '0')}${h2.toString(16).padStart(8, '0')}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `person:alex rivera` → `brief:person-alex-rivera`. Safe as a storage key and a URL hash. */
|
|
80
|
+
export function briefId(subjectKey) {
|
|
81
|
+
const [kind, ...rest] = String(subjectKey || '').split(':');
|
|
82
|
+
const slug = normalizeSubject(rest.join(':')).replace(/\s+/g, '-').replace(/-+/g, '-');
|
|
83
|
+
if (!kind || !slug) return '';
|
|
84
|
+
// The slug is TRUNCATED, so two long subjects can share one, and it is lossy, so two
|
|
85
|
+
// kinds can too. A hash of the CANONICAL key rides along to separate them. Canonical, not
|
|
86
|
+
// raw: "Alex Rivera" and "alex rivera" are one subject and must land on one id, or a
|
|
87
|
+
// rebuild would fork the page in two — the exact failure I-K2 exists to make impossible.
|
|
88
|
+
const canonical = `${kind}:${normalizeSubject(rest.join(':'))}`;
|
|
89
|
+
return `brief:${kind}-${slug.slice(0, 48)}-${contentHash(canonical).slice(1, 7)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Records are addressed `chat:x` / `meeting:y` / `note:z` — the ref kind is the prefix. */
|
|
93
|
+
function refForRecord(rec) {
|
|
94
|
+
const [kind, ...rest] = String(rec.id).split(':');
|
|
95
|
+
const id = rest.join(':') || rec.id;
|
|
96
|
+
const known = kind === 'chat' || kind === 'meeting' || kind === 'note' || kind === 'page';
|
|
97
|
+
return makeRef({ kind: known ? kind : 'result', id: known ? id : rec.id, hash: contentHash(rec.text) });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function claim({ id, kind, text, refs, at = 0, confidence = 1 }) {
|
|
101
|
+
return {
|
|
102
|
+
id,
|
|
103
|
+
kind,
|
|
104
|
+
text,
|
|
105
|
+
// I-K1 lives here: a claim is CONSTRUCTED with its refs, and `checkKnowledgeInvariants`
|
|
106
|
+
// refuses one that arrives without them. There is no path that writes a claim first and
|
|
107
|
+
// attaches provenance later, because that path is how provenance goes missing.
|
|
108
|
+
refs: refs.slice(0, MAX_CLAIM_REFS),
|
|
109
|
+
firstSeen: at,
|
|
110
|
+
lastConfirmed: at,
|
|
111
|
+
confidence,
|
|
112
|
+
cls: 'R', // class R — derived, not written. W3's prose claims carry 'C'.
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
117
|
+
const isoDay = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : '');
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The searchable body. A brief is a SOURCE, ranked by the same engine as everything else
|
|
121
|
+
* (design §7.6 — no second retrieval stack), so it has to render to text like one.
|
|
122
|
+
*/
|
|
123
|
+
export function briefToText(brief) {
|
|
124
|
+
if (!brief) return '';
|
|
125
|
+
const L = [`BRIEF: ${brief.subject.name}`];
|
|
126
|
+
if (brief.subject.aliases?.length) L.push(`Also known as: ${brief.subject.aliases.join(', ')}`);
|
|
127
|
+
L.push(`Kind: ${brief.kind}`);
|
|
128
|
+
L.push('');
|
|
129
|
+
for (const c of brief.claims) {
|
|
130
|
+
L.push(`- ${c.text}`);
|
|
131
|
+
if (c.refs.length) L.push(` (${c.refs.map((r) => `${r.kind}:${r.id}`).join(', ')})`);
|
|
132
|
+
}
|
|
133
|
+
if (brief.records.length) {
|
|
134
|
+
L.push('', 'RECORDS:');
|
|
135
|
+
for (const r of brief.records.slice(-40).reverse()) L.push(`- ${r.type}: ${r.title || 'untitled'}`);
|
|
136
|
+
}
|
|
137
|
+
return L.join('\n').slice(0, MAX_BRIEF_CHARS);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The inverse of `briefToText` — a brief's claims and refs read back out of the text form.
|
|
142
|
+
*
|
|
143
|
+
* Exists because the warm store holds RECORDS: `{ id, title, type, date, text }`, nothing
|
|
144
|
+
* else. Briefs cross to the gateway as that shape, so an agent asking `get_brief` over MCP
|
|
145
|
+
* can only be handed structure if the text form is stable enough to parse. It is: this
|
|
146
|
+
* module writes both ends, and the claim line (`- text`) followed by its refs
|
|
147
|
+
* (` (kind:id, kind:id)`) is a grammar, not a rendering. Round-trips in the tests.
|
|
148
|
+
*
|
|
149
|
+
* Returns `null` for text that is not a brief, so a caller can tell "not a brief" from
|
|
150
|
+
* "a brief with no claims".
|
|
151
|
+
*/
|
|
152
|
+
export function parseBriefText(text) {
|
|
153
|
+
const lines = String(text ?? '').split('\n');
|
|
154
|
+
if (!/^BRIEF: /.test(lines[0] || '')) return null;
|
|
155
|
+
const out = { name: lines[0].slice('BRIEF: '.length).trim(), aliases: [], kind: '', claims: [], records: [] };
|
|
156
|
+
let section = 'head';
|
|
157
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
158
|
+
const line = lines[i];
|
|
159
|
+
if (section === 'head') {
|
|
160
|
+
if (line.startsWith('Also known as: ')) out.aliases = line.slice(15).split(',').map((a) => a.trim()).filter(Boolean);
|
|
161
|
+
else if (line.startsWith('Kind: ')) out.kind = line.slice(6).trim();
|
|
162
|
+
else if (line === '') section = 'claims';
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (line === 'RECORDS:') { section = 'records'; continue; }
|
|
166
|
+
if (section === 'claims' && line.startsWith('- ')) {
|
|
167
|
+
const claim = { text: line.slice(2), refs: [] };
|
|
168
|
+
const next = lines[i + 1] || '';
|
|
169
|
+
const m = /^ \((.*)\)$/.exec(next);
|
|
170
|
+
if (m) {
|
|
171
|
+
claim.refs = m[1].split(', ').map((r) => {
|
|
172
|
+
const idx = r.indexOf(':');
|
|
173
|
+
return idx > 0 ? { kind: r.slice(0, idx), id: r.slice(idx + 1) } : null;
|
|
174
|
+
}).filter(Boolean);
|
|
175
|
+
i += 1;
|
|
176
|
+
}
|
|
177
|
+
out.claims.push(claim);
|
|
178
|
+
} else if (section === 'records' && line.startsWith('- ')) {
|
|
179
|
+
const idx = line.indexOf(': ');
|
|
180
|
+
out.records.push(idx > 0 ? { type: line.slice(2, idx), title: line.slice(idx + 2) } : { type: '', title: line.slice(2) });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Terms the graph and the search index rank a brief by — its subject and its neighbours. */
|
|
187
|
+
export function briefTerms(brief) {
|
|
188
|
+
if (!brief) return [];
|
|
189
|
+
const together = brief.claims.find((c) => c.kind === 'together');
|
|
190
|
+
const names = together ? together.text.replace(/^Usually alongside /, '').replace(/\.$/, '').split(', ') : [];
|
|
191
|
+
return [...new Set([brief.subject.name, ...(brief.subject.aliases || []), ...names])].filter(Boolean);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The invariants, as a check rather than a promise. Returns the failures; empty means clean.
|
|
196
|
+
* Same shape as `invariants.js checkInvariants()`, for the same reason: an invariant nobody
|
|
197
|
+
* can run is a comment.
|
|
198
|
+
*/
|
|
199
|
+
export function checkKnowledgeInvariants(brief) {
|
|
200
|
+
const fail = [];
|
|
201
|
+
if (!brief || typeof brief !== 'object') return [{ invariant: 'I-K1', detail: 'not a brief' }];
|
|
202
|
+
if (!BRIEF_STATES.includes(brief.state)) fail.push({ invariant: 'I-K3', detail: `unknown state ${brief.state}` });
|
|
203
|
+
if (brief.state === 'promoted' && brief.cls === 'C') {
|
|
204
|
+
fail.push({ invariant: 'I-K3', detail: 'model-written prose cannot be promoted without the gate' });
|
|
205
|
+
}
|
|
206
|
+
for (const c of brief.claims || []) {
|
|
207
|
+
if (!c.refs?.length) fail.push({ invariant: 'I-K1', detail: `claim ${c.id} cites nothing` });
|
|
208
|
+
if (!CLAIM_KINDS.includes(c.kind) && c.cls !== 'C') {
|
|
209
|
+
fail.push({ invariant: 'I-K1', detail: `claim ${c.id} has unknown kind ${c.kind}` });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if ((brief.claims || []).length > MAX_CLAIMS) fail.push({ invariant: 'I-K4', detail: `${brief.claims.length} claims exceeds ${MAX_CLAIMS}` });
|
|
213
|
+
if (briefToText(brief).length >= MAX_BRIEF_CHARS) fail.push({ invariant: 'I-K4', detail: 'brief text is at the ceiling' });
|
|
214
|
+
return fail;
|
|
215
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -18,6 +18,7 @@ import { loadConfig } from './config.js';
|
|
|
18
18
|
import { readBridgeToken } from './bridge.js';
|
|
19
19
|
import { ensureGatewayToken } from './gateway-token.js';
|
|
20
20
|
import { MEMORY_KINDS, MEMORY_KIND_NAMES } from './memory.js';
|
|
21
|
+
import { parseBriefText } from './knowledge.js';
|
|
21
22
|
|
|
22
23
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
23
24
|
const SERVER = { name: 'chatpanel-history', version: '1.0.0' };
|
|
@@ -118,13 +119,13 @@ async function bridgeJson(path) {
|
|
|
118
119
|
const TOOLS = [
|
|
119
120
|
{
|
|
120
121
|
name: 'smart_search',
|
|
121
|
-
description: 'BEST first choice for a question about the user\'s ChatPanel history (meetings, notes, past chats). Ask it a natural-language QUESTION and it expands that into several complementary keyword queries, runs them all, and fuses the rankings — which finds things a single query misses, in one round trip instead of several probes. You know the domain, so pass 2-4 of your own phrasings in `queries` too (e.g. for "what did we decide in the Ben demo": ["Ben demo decisions", "tooling demo action items", "demo outcome next steps"]). Supports the same filters as search_history (type, since, before) and returns snippets with each result\'s id; follow up with get_record for the full text or find_related to expand around a hit.',
|
|
122
|
+
description: 'BEST first choice for a question about the user\'s ChatPanel history (meetings, notes, past chats). Ask it a natural-language QUESTION and it expands that into several complementary keyword queries, runs them all, and fuses the rankings — which finds things a single query misses, in one round trip instead of several probes. You know the domain, so pass 2-4 of your own phrasings in `queries` too (e.g. for "what did we decide in the Ben demo": ["Ben demo decisions", "tooling demo action items", "demo outcome next steps"]). Supports the same filters as search_history (type, since, before) and returns snippets with each result\'s id; follow up with get_record for the full text or find_related to expand around a hit. BRIEFS come first when they match: a brief is a maintained page about one person, project or topic, derived from ALL the records that mention it, with every claim citing its record — so for "what do we know about X" read the brief, then open only the records it cites.',
|
|
122
123
|
inputSchema: {
|
|
123
124
|
type: 'object',
|
|
124
125
|
properties: {
|
|
125
126
|
question: { type: 'string', description: 'The user\'s question, in natural language.' },
|
|
126
127
|
queries: { type: 'array', items: { type: 'string' }, description: 'Your own 2-4 keyword formulations of it — these lead the search.' },
|
|
127
|
-
type: { type: 'string', enum: ['chat', 'meeting', 'note'], description: 'Only this kind of record.' },
|
|
128
|
+
type: { type: 'string', enum: ['brief', 'chat', 'meeting', 'note'], description: 'Only this kind of record. `brief` = a maintained page about one person/project/topic, with every claim cited — start there for "what do we know about X".' },
|
|
128
129
|
since: { type: 'string', description: 'Earliest date: 2026-08-01, or a window like "7d"/"yesterday".' },
|
|
129
130
|
before: { type: 'string', description: 'Latest date: a date or window like `since`.' },
|
|
130
131
|
limit: { type: 'number', description: 'Max fused results (default 10).' },
|
|
@@ -139,7 +140,7 @@ const TOOLS = [
|
|
|
139
140
|
type: 'object',
|
|
140
141
|
properties: {
|
|
141
142
|
query: { type: 'string', description: 'Content keywords (topics, names, decisions) — not the meeting title.' },
|
|
142
|
-
type: { type: 'string', enum: ['chat', 'meeting', 'note'], description: 'Only this kind of record.' },
|
|
143
|
+
type: { type: 'string', enum: ['brief', 'chat', 'meeting', 'note'], description: 'Only this kind of record. `brief` = a maintained page about one person/project/topic, with every claim cited — start there for "what do we know about X".' },
|
|
143
144
|
since: { type: 'string', description: 'Earliest date: 2026-08-01, or a relative window like "7d", "2 weeks", "yesterday".' },
|
|
144
145
|
before: { type: 'string', description: 'Latest date: a date or relative window like `since`.' },
|
|
145
146
|
limit: { type: 'number', description: 'Max results (default 10).' },
|
|
@@ -184,6 +185,28 @@ const TOOLS = [
|
|
|
184
185
|
},
|
|
185
186
|
},
|
|
186
187
|
},
|
|
188
|
+
{
|
|
189
|
+
name: 'list_briefs',
|
|
190
|
+
description: 'The BRIEFS ChatPanel maintains — one page per person, project or topic that appears across enough records, each claim citing the record it came from. This is the index of what the user\'s corpus KNOWS, as opposed to what it merely contains: read it before asking "what do we know about X", then get_brief for the one you need. Briefs are rebuilt from the records, so they are as current as the last rebuild (dates shown).',
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
properties: {
|
|
194
|
+
limit: { type: 'number', description: 'Max briefs (default 50).' },
|
|
195
|
+
offset: { type: 'number', description: 'Skip N for paging (default 0).' },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'get_brief',
|
|
201
|
+
description: 'One brief as STRUCTURE: the subject, its other spellings, and each claim with the record ids it cites — so you can cite the record rather than the synthesis, and open only what you need with get_record. Prefer this over get_record for a brief:… id; get_record returns the same text as prose.',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
id: { type: 'string', description: 'A brief id (brief:…) from list_briefs or search results.' },
|
|
206
|
+
},
|
|
207
|
+
required: ['id'],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
187
210
|
{
|
|
188
211
|
name: 'recall',
|
|
189
212
|
description: "What ChatPanel already knows about the USER — their name, how they want answers written, their ongoing work and environment. Short and cheap; call it at the START of a session and whenever the user states a preference, then just FOLLOW what it returns without announcing that you checked. Pass the current task in `text` and it also returns the facts relevant to that task, not only the always-on ones. This is memory, not history: for what was said in a meeting or a past chat use smart_search instead.",
|
|
@@ -367,6 +390,32 @@ async function callTool(name, args = {}) {
|
|
|
367
390
|
const newest = items[0]?.date || null;
|
|
368
391
|
return [horizonLine(newest, data.total), '', `${items.length} of ${data.total} records:`, ...items.map((it) => `[${it.id}] ${it.title || '(untitled)'} · ${it.type}${it.date ? ' · ' + new Date(it.date).toISOString().slice(0, 10) : ''} · ${it.chars} chars`)].join('\n');
|
|
369
392
|
}
|
|
393
|
+
if (name === 'list_briefs') {
|
|
394
|
+
const q = new URLSearchParams({ limit: String(Number(args.limit) || 50), offset: String(Number(args.offset) || 0), type: 'brief' });
|
|
395
|
+
const data = await gatewayJson(`/v1/history/list?${q}`);
|
|
396
|
+
const items = data.items || [];
|
|
397
|
+
if (!items.length) return 'No briefs yet. ChatPanel builds them from the user\'s records on the Briefs page; if that has run, the user may have "share briefs with local agents" switched off.';
|
|
398
|
+
return [
|
|
399
|
+
`${items.length} of ${data.total} briefs (each is a page about one subject, with every claim cited):`,
|
|
400
|
+
...items.map((it) => `[${it.id}] ${it.title || '(untitled)'}${it.date ? ' · rebuilt ' + new Date(it.date).toISOString().slice(0, 10) : ''}`),
|
|
401
|
+
].join('\n') + '\n\nget_brief <id> for its claims and the records they cite.';
|
|
402
|
+
}
|
|
403
|
+
if (name === 'get_brief') {
|
|
404
|
+
const id = String(args.id || '');
|
|
405
|
+
const data = await gatewayJson(`/v1/history/get?${new URLSearchParams({ id })}`);
|
|
406
|
+
const r = data.record;
|
|
407
|
+
const brief = parseBriefText(r?.text);
|
|
408
|
+
if (!brief) return `${id} is not a brief (type ${r?.type || 'unknown'}). Use get_record for it, or list_briefs for the brief ids.`;
|
|
409
|
+
const lines = [`BRIEF ${id} — ${brief.name} (${brief.kind})${brief.aliases.length ? ` · also known as ${brief.aliases.join(', ')}` : ''}`, ''];
|
|
410
|
+
lines.push(`${brief.claims.length} claim(s), each with the records it cites:`);
|
|
411
|
+
for (const c of brief.claims) {
|
|
412
|
+
lines.push(`• ${c.text}`);
|
|
413
|
+
if (c.refs.length) lines.push(` cites: ${c.refs.map((x) => `${x.kind}:${x.id}`).join(', ')}`);
|
|
414
|
+
}
|
|
415
|
+
if (brief.records.length) lines.push('', `Built from ${brief.records.length} record(s) (most recent first): ${brief.records.slice(0, 12).map((x) => x.title).join(' · ')}${brief.records.length > 12 ? ' …' : ''}`);
|
|
416
|
+
lines.push('', 'get_record <kind:id> opens a cited record; find_related <id> follows its connections.');
|
|
417
|
+
return lines.join('\n');
|
|
418
|
+
}
|
|
370
419
|
if (name === 'recall') {
|
|
371
420
|
const data = await gatewayJson('/v1/memory/recall', {
|
|
372
421
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
package/src/models.js
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
// Catalog of NER models the gateway can run, surfaced in the extension's Gateway
|
|
2
|
-
// settings so users can install a larger or multilingual detector.
|
|
3
|
-
//
|
|
4
|
-
// labels the redaction engine consumes. Sizes are the on-disk q8 footprint, approx.
|
|
2
|
+
// settings so users can install a larger or multilingual detector. Sizes are the
|
|
3
|
+
// on-disk q8 footprint, approx.
|
|
5
4
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// TWO THINGS TO CHECK WHEN ADDING A MODEL, and getting either wrong fails SILENTLY.
|
|
6
|
+
//
|
|
7
|
+
// 1. ITS LABELS MUST BE MAPPED. `@chatpanel/pii` `normalizeEntities` turns a model's
|
|
8
|
+
// labels into our placeholder types, and an unmapped label is dropped rather than
|
|
9
|
+
// passed through — so a model whose vocabulary it does not know detects entities
|
|
10
|
+
// perfectly and redacts none of them, while the UI still reads as on. Run one
|
|
11
|
+
// sentence through the model, look at the labels it returns, and map every one.
|
|
12
|
+
// This is not hypothetical: multilang-pii-ner emits the ai4privacy vocabulary
|
|
13
|
+
// (GIVENNAME, SURNAME, TELEPHONENUM…) and person redaction was silently off for
|
|
14
|
+
// anyone who selected it, until pii 0.6.0.
|
|
15
|
+
//
|
|
16
|
+
// 2. `mirrored` MUST BE TRUE ONLY IF IT REALLY IS. A catalogued model is fetched from
|
|
17
|
+
// ChatPanel's CDN so a clean install depends only on chatpanel.net; a model that is
|
|
18
|
+
// not there 404s at first use. `mirrored: false` says "catalogue it, but fetch it
|
|
19
|
+
// from Hugging Face" — which is what lets a model be a first-class, one-click
|
|
20
|
+
// choice before the mirror upload happens, instead of forcing users to paste a
|
|
21
|
+
// custom id and lose the label, the size and the note.
|
|
9
22
|
|
|
10
23
|
export const DEFAULT_MODEL = 'Xenova/bert-base-NER';
|
|
11
24
|
|
|
@@ -31,8 +44,40 @@ export const MODEL_CATALOG = [
|
|
|
31
44
|
approxMB: 180,
|
|
32
45
|
note: 'Higher multilingual accuracy; larger download.',
|
|
33
46
|
},
|
|
47
|
+
{
|
|
48
|
+
// PURPOSE-BUILT FOR THIS JOB, where the three above are general newswire NER. It
|
|
49
|
+
// finds what redaction actually cares about — given and family names separately,
|
|
50
|
+
// street/building/postcode, phone, national-ID and account numbers, usernames and
|
|
51
|
+
// passwords — none of which a PER/ORG/LOC model emits at all. That makes it the
|
|
52
|
+
// better detector for a privacy product, not merely a bigger one.
|
|
53
|
+
//
|
|
54
|
+
// NOT the default yet, and the reason is written down rather than remembered: it is
|
|
55
|
+
// not on the CDN (see `mirrored`), so defaulting to it would make a clean install
|
|
56
|
+
// depend on Hugging Face, and it needs @chatpanel/pii >= 0.6.0 for its labels to be
|
|
57
|
+
// understood at all. Both are release chores, not code.
|
|
58
|
+
id: 'onnx-community/multilang-pii-ner-ONNX',
|
|
59
|
+
label: 'PII-specialised — multilingual',
|
|
60
|
+
lang: 'Multilingual',
|
|
61
|
+
approxMB: 282,
|
|
62
|
+
mirrored: false,
|
|
63
|
+
recommended: true,
|
|
64
|
+
note: 'Most thorough. Trained for PII rather than general entities: also finds addresses, postcodes, account and ID numbers, usernames and passwords. Larger download, fetched from Hugging Face.',
|
|
65
|
+
},
|
|
34
66
|
];
|
|
35
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Is this model on ChatPanel's CDN, or does it have to come from Hugging Face?
|
|
70
|
+
*
|
|
71
|
+
* Default TRUE for a catalogued model — the mirror is the norm and the point of the
|
|
72
|
+
* catalogue. An entry says `mirrored: false` when it is a first-class choice that has not
|
|
73
|
+
* been uploaded yet. A model that is not in the catalogue at all is a user's own id and is
|
|
74
|
+
* never mirrored.
|
|
75
|
+
*/
|
|
76
|
+
export function isMirroredModel(id) {
|
|
77
|
+
const m = MODEL_CATALOG.find((x) => x.id === id);
|
|
78
|
+
return !!m && m.mirrored !== false;
|
|
79
|
+
}
|
|
80
|
+
|
|
36
81
|
export function isKnownModel(id) {
|
|
37
82
|
return MODEL_CATALOG.some((m) => m.id === id);
|
|
38
83
|
}
|
package/src/ner-engine.js
CHANGED
|
@@ -17,11 +17,10 @@
|
|
|
17
17
|
import os from 'node:os';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
20
|
-
import {
|
|
20
|
+
import { DEFAULT_MODEL, isMirroredModel } from './models.js';
|
|
21
21
|
import { isLoopbackHost, isPrivateHost, isMetadataHost } from '@chatpanel/pii';
|
|
22
22
|
import { verifyModelWeights } from './model-integrity.js';
|
|
23
23
|
|
|
24
|
-
const DEFAULT_MODEL = 'Xenova/bert-base-NER';
|
|
25
24
|
const DEFAULT_MODEL_HOST = 'https://dl.chatpanel.net/models/';
|
|
26
25
|
|
|
27
26
|
// Where model weights are fetched from — ChatPanel's own edge-cached CDN by default,
|
|
@@ -230,7 +229,11 @@ async function loadModel(modelId, { log = () => {}, allowDownload = true } = {})
|
|
|
230
229
|
// (ensureLib set that as remoteHost). A user's BYO id isn't mirrored, so fetch
|
|
231
230
|
// it from Hugging Face directly — only for this load, then restore.
|
|
232
231
|
const prevHost = lib.env.remoteHost;
|
|
233
|
-
|
|
232
|
+
// Fetch from Hugging Face when the mirror cannot serve it — a user's own id, or a
|
|
233
|
+
// catalogued model that has not been uploaded to the CDN yet (models.js `mirrored`).
|
|
234
|
+
// Asking "is it in the catalogue" was the same question only while every catalogued
|
|
235
|
+
// model happened to be mirrored, and a catalogued-but-unmirrored one would have 404'd.
|
|
236
|
+
const isCustom = !isMirroredModel(modelId);
|
|
234
237
|
if (!haveLocal && isCustom) { try { lib.env.remoteHost = 'https://huggingface.co/'; } catch { /* optional */ } }
|
|
235
238
|
|
|
236
239
|
_state = haveLocal ? 'loading' : 'downloading';
|
package/src/server.js
CHANGED
|
@@ -55,7 +55,7 @@ import * as openai from './openai.js';
|
|
|
55
55
|
import * as responses from './responses.js';
|
|
56
56
|
import * as anthropic from './anthropic.js';
|
|
57
57
|
|
|
58
|
-
export const VERSION = '0.6.
|
|
58
|
+
export const VERSION = '0.6.61';
|
|
59
59
|
|
|
60
60
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
61
61
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -780,11 +780,19 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
780
780
|
before: body.before != null ? Number(body.before) : null,
|
|
781
781
|
};
|
|
782
782
|
const perQuery = Math.min(30, Math.max(5, Number(body.limit) || 10) * 2);
|
|
783
|
-
const
|
|
783
|
+
const fused = await multiSearch(
|
|
784
784
|
queries,
|
|
785
785
|
(q) => historyStore.search(q, { limit: perQuery, ...filters }),
|
|
786
786
|
{ limit: Math.min(50, Math.max(1, Number(body.limit) || 10)) },
|
|
787
787
|
);
|
|
788
|
+
// BRIEFS LEAD. A brief is the compaction: when one matches, it answers with what a
|
|
789
|
+
// dozen records say, with a citation to each, and reading it saves an agent opening
|
|
790
|
+
// the dozen. Stable-partitioned to the front rather than re-scored, so rank among
|
|
791
|
+
// briefs and rank among records are both untouched — and only when the caller has
|
|
792
|
+
// not asked for one type, which is a question about records, not about briefs.
|
|
793
|
+
const results = filters.type
|
|
794
|
+
? fused
|
|
795
|
+
: [...fused.filter((r) => r.type === 'brief'), ...fused.filter((r) => r.type !== 'brief')];
|
|
788
796
|
return sendJson(res, 200, {
|
|
789
797
|
ok: true, size: historyStore.size, newest: historyStore.newest, queries, results,
|
|
790
798
|
});
|
|
@@ -825,7 +833,8 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
825
833
|
if (pathname === '/v1/history/list' && req.method === 'GET') {
|
|
826
834
|
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit')) || 50));
|
|
827
835
|
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
|
|
828
|
-
|
|
836
|
+
const type = url.searchParams.get('type') || null;
|
|
837
|
+
return sendJson(res, 200, { ok: true, ...historyStore.list({ limit, offset, type }) });
|
|
829
838
|
}
|
|
830
839
|
if (pathname === '/v1/history/get' && req.method === 'GET') {
|
|
831
840
|
const maxChars = url.searchParams.get('maxChars') != null ? Math.max(1, Number(url.searchParams.get('maxChars')) || 0) : null;
|
package/src/sqlite-store.js
CHANGED
|
@@ -172,9 +172,12 @@ export class SqliteHistoryStore {
|
|
|
172
172
|
return rows.map((r) => ({ id: r.id, score: -r.b, title: r.title, type: r.type, date: r.date }));
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
175
|
+
// `type` is additive: list_briefs is "list, of type brief", not a second index.
|
|
176
|
+
list({ limit = 50, offset = 0, type = null } = {}) {
|
|
177
|
+
const where = type ? ' WHERE type = ?' : '';
|
|
178
|
+
const params = type ? [String(type)] : [];
|
|
179
|
+
const total = this.db.get(`SELECT COUNT(*) c FROM records${where}`, params)?.c || 0;
|
|
180
|
+
const items = this.db.all(`SELECT id, title, type, date, chars FROM records${where} ORDER BY date DESC LIMIT ? OFFSET ?`, [...params, limit, offset]);
|
|
178
181
|
return { total, items };
|
|
179
182
|
}
|
|
180
183
|
|
package/src/stt-models.js
CHANGED
|
@@ -14,7 +14,19 @@
|
|
|
14
14
|
// runtime can't load block-quantized (q8/int8) or fp16 exports (see stt-engine
|
|
15
15
|
// runtimeDtype). `.en` models are English-only and reject language/task options.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// THE SHIP DEFAULT. Parakeet rather than Whisper: several times faster at similar or
|
|
18
|
+
// better accuracy, and multilingual, so dictation is good out of the box instead of good
|
|
19
|
+
// after someone finds the model picker.
|
|
20
|
+
//
|
|
21
|
+
// Two costs, stated because they are real. It is a ~690 MB one-time download against
|
|
22
|
+
// whisper-base's ~300 MB, so first dictation takes longer to become available. And
|
|
23
|
+
// parakeet-engine.js fetches from Hugging Face directly rather than ChatPanel's CDN, so a
|
|
24
|
+
// clean install's first dictation depends on huggingface.co — mirroring it on
|
|
25
|
+
// dl.chatpanel.net removes that dependency and is a release chore, not a code change.
|
|
26
|
+
//
|
|
27
|
+
// `whisper-base` remains the small, mirrored fallback for anyone who wants dictation
|
|
28
|
+
// working in seconds on a slow link.
|
|
29
|
+
export const DEFAULT_STT_MODEL = 'istupakov/parakeet-tdt-0.6b-v3-onnx';
|
|
18
30
|
|
|
19
31
|
export const STT_MODEL_CATALOG = [
|
|
20
32
|
{
|
|
@@ -33,7 +45,7 @@ export const STT_MODEL_CATALOG = [
|
|
|
33
45
|
tier: 'balanced',
|
|
34
46
|
approxMB: 300, // fp32 on WASM; ~80 on native q8
|
|
35
47
|
ramMB: 700,
|
|
36
|
-
note: '
|
|
48
|
+
note: 'Small and quick to install. Detects the spoken language automatically; good accuracy at real-time speed.',
|
|
37
49
|
},
|
|
38
50
|
{
|
|
39
51
|
id: 'onnx-community/whisper-small',
|
|
@@ -64,16 +76,18 @@ export const STT_MODEL_CATALOG = [
|
|
|
64
76
|
lang: '25 European languages (auto-detected)',
|
|
65
77
|
tier: 'accurate',
|
|
66
78
|
engine: 'parakeet-tdt',
|
|
67
|
-
recommended: true, //
|
|
79
|
+
recommended: true, // and the DEFAULT — faster + more accurate than Whisper.
|
|
68
80
|
approxMB: 690, // int8: encoder 652 + decoder_joint 18 + preprocessor
|
|
69
81
|
ramMB: 1600,
|
|
70
82
|
note: 'Recommended — NVIDIA Parakeet transducer. Several× faster than Whisper at similar or better accuracy, English + 24 EU languages. One-time download; best on the native (npm) gateway.',
|
|
71
83
|
},
|
|
72
84
|
];
|
|
73
85
|
|
|
74
|
-
// The model we steer users to
|
|
75
|
-
//
|
|
76
|
-
//
|
|
86
|
+
// The model we steer users to. It is now also DEFAULT_STT_MODEL — the two were split
|
|
87
|
+
// while the default stayed small for a fast first run, and shipping the best model by
|
|
88
|
+
// default is the call that closed that gap. Kept as its own export because the settings UI
|
|
89
|
+
// marks a recommendation, and the two could diverge again (a heavier model that is worth
|
|
90
|
+
// recommending but too big to default to).
|
|
77
91
|
export const RECOMMENDED_STT_MODEL = 'istupakov/parakeet-tdt-0.6b-v3-onnx';
|
|
78
92
|
|
|
79
93
|
// STT engine backing a model. Default 'whisper' = the transformers.js ASR pipeline;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/subject-kinds.js — edit there, then copy over.
|
|
2
|
+
// The subject vocabulary, alone in a file so both halves of subject identity can name it
|
|
3
|
+
// without either importing the other.
|
|
4
|
+
|
|
5
|
+
/** What a subject can be. `title` is a record title someone linked to with [[…]]. */
|
|
6
|
+
export const SUBJECT_KINDS = Object.freeze(['person', 'topic', 'tag', 'title']);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// VENDORED from @chatpanel/events/subject-name.js — edit there, then copy over.
|
|
2
|
+
// Naming a subject, and when one is big enough to deserve a page.
|
|
3
|
+
//
|
|
4
|
+
// The small, dependency-free half of subject identity: fold a name to its canonical form,
|
|
5
|
+
// strip the decoration a directory hangs off it, and hold the evidence thresholds. Nothing
|
|
6
|
+
// here resolves aliases, proposes merges or reaches for a Levenshtein — that is `entity.js`,
|
|
7
|
+
// which imports this.
|
|
8
|
+
//
|
|
9
|
+
// The split is a load-time one, and it is the third time the lesson has come up here (see
|
|
10
|
+
// `distance.js` and `redaction-tokens.js`). `knowledge.js` needs exactly `normalizeSubject`
|
|
11
|
+
// to build a brief id, and the extension's brief store needs exactly `DEFAULT_THRESHOLD` —
|
|
12
|
+
// and both are on the MV3 service worker's graph. Reaching them through `entity.js` put
|
|
13
|
+
// entity resolution and merge suggestion on a worker that will never run either.
|
|
14
|
+
|
|
15
|
+
import { SUBJECT_KINDS } from './subject-kinds.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A subject earns a brief with EVIDENCE, not on first sight (I-K4).
|
|
19
|
+
*
|
|
20
|
+
* PROVISIONAL. These numbers are the W0 measurement's whole point: `surveyCorpus()` reports
|
|
21
|
+
* how many subjects clear them so they can be set from a real corpus instead of taste. Do
|
|
22
|
+
* not treat them as decided until that report has been run.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_THRESHOLD = Object.freeze({ records: 3, mentions: 5 });
|
|
25
|
+
|
|
26
|
+
/** Ceiling on the set of briefs, for the same reason memory.js caps memories. Provisional. */
|
|
27
|
+
export const MAX_SUBJECTS = 500;
|
|
28
|
+
/** Longest name we will treat as a subject — past this it is a sentence, not a subject. */
|
|
29
|
+
export const MAX_SUBJECT_CHARS = 60;
|
|
30
|
+
/**
|
|
31
|
+
* Labels a meeting platform uses for the person holding the microphone.
|
|
32
|
+
*
|
|
33
|
+
* Zoom, Meet and Teams all write the local participant as "You" — so the user appears in
|
|
34
|
+
* their own corpus under a name that is not a name, alongside however their colleagues'
|
|
35
|
+
* clients spelled them. Resolving these needs one fact only the host has: who "you" IS.
|
|
36
|
+
* `resolveSubjects` takes it rather than guessing, and with no `self` supplied these stay
|
|
37
|
+
* unresolved instead of collapsing every meeting's local speaker into one fictional person.
|
|
38
|
+
*/
|
|
39
|
+
export const SELF_LABELS = Object.freeze(['you', 'me', 'myself', 'yourself', 'i']);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is this the platform's label for the local participant?
|
|
43
|
+
*
|
|
44
|
+
* Exported because two layers need the SAME exception and getting the order wrong is subtle:
|
|
45
|
+
* a self-label fails `isSubjectCandidate` (it is a pronoun), so any pass that filters
|
|
46
|
+
* candidacy BEFORE `resolveSubjects` can fold it has already thrown the user away. `curate.js
|
|
47
|
+
* mentionsFrom` keeps them for exactly this reason and lets resolution decide.
|
|
48
|
+
*/
|
|
49
|
+
export function isSelfLabel(name) {
|
|
50
|
+
return SELF_LABELS.includes(normalizeSubject(name));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Strip the decoration a directory or a conference client hangs off a person's name.
|
|
55
|
+
*
|
|
56
|
+
* The same human arrives as "Alex Rivera", "Alex Rivera (ACME)", "Alex Rivera - Host" and
|
|
57
|
+
* "Alex Rivera (he/him)" depending on which client wrote the label. The part in parentheses
|
|
58
|
+
* or after a dash is an org, a role or a pronoun set — decoration, never identity — so it is
|
|
59
|
+
* removed before folding.
|
|
60
|
+
*
|
|
61
|
+
* NOT removed for non-person subjects: "Migration (Phase 2)" is a different topic from
|
|
62
|
+
* "Migration", where "Alex Rivera (ACME)" is not a different person from "Alex Rivera".
|
|
63
|
+
*/
|
|
64
|
+
export function stripQualifiers(name) {
|
|
65
|
+
return String(name ?? '')
|
|
66
|
+
.replace(/\s*[([{][^)\]}]*[)\]}]\s*/g, ' ') // (ACME), [external], {guest}
|
|
67
|
+
.replace(/\s+[-–—|·,]\s+.*$/, '') // - Host, — Guest, | ACME
|
|
68
|
+
.replace(/\s+/g, ' ')
|
|
69
|
+
.trim();
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Fold a name to its canonical form: lowercase, Unicode-aware, separators collapsed.
|
|
73
|
+
*
|
|
74
|
+
* Spaces survive as spaces (unlike normalizeTag, which folds them to '-') because a person's
|
|
75
|
+
* name is read back to the user and "alex rivera" has to be recognisable as one.
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeSubject(name) {
|
|
78
|
+
const raw = String(name ?? '').normalize('NFKC').trim().replace(/^[#@]+/, '');
|
|
79
|
+
if (!raw) return '';
|
|
80
|
+
return raw
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
83
|
+
.trim()
|
|
84
|
+
.slice(0, MAX_SUBJECT_CHARS)
|
|
85
|
+
.trim();
|
|
86
|
+
}
|
|
87
|
+
/** `person:alex rivera` — the identity a brief is filed under. '' when nothing survives. */
|
|
88
|
+
export function subjectKey(kind, name) {
|
|
89
|
+
const norm = normalizeSubject(name);
|
|
90
|
+
if (!norm || !SUBJECT_KINDS.includes(kind)) return '';
|
|
91
|
+
return `${kind}:${norm}`;
|
|
92
|
+
}
|
|
93
|
+
/** Tokens of a canonical name. */
|
|
94
|
+
export function subjectTokens(name) {
|
|
95
|
+
const norm = normalizeSubject(name);
|
|
96
|
+
return norm ? norm.split(' ').filter(Boolean) : [];
|
|
97
|
+
}
|