@crossworks/content-core 0.232.133 → 0.232.142

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 CHANGED
@@ -1,33 +1,35 @@
1
1
  {
2
2
  "name": "@crossworks/content-core",
3
- "version": "0.232.133",
3
+ "version": "0.232.142",
4
4
  "description": "Browser-safe content logic — markdown, blocks, pages, tables, formulas — shared by the server and any client. Zero server deps by design: nothing here may reach @mantle/db, node-only APIs, or the network (the jackdaw-repo-split P0 boundary).",
5
5
  "exports": {
6
- "./markdown": "./src/markdown-to-doc.ts",
7
- "./markdown-refs": "./src/markdown-refs.ts",
8
- "./recall-compile": "./src/recall-compile.ts",
9
- "./doc-to-markdown": "./src/doc-to-markdown.ts",
6
+ "./block-diff": "./src/block-diff.ts",
10
7
  "./block-ids": "./src/block-ids.ts",
11
8
  "./block-list": "./src/block-list.ts",
12
- "./block-diff": "./src/block-diff.ts",
13
- "./page-toc": "./src/page-toc.ts",
14
- "./page-split": "./src/page-split.ts",
15
- "./page-diff": "./src/page-diff.ts",
16
- "./table-model": "./src/table-model.ts",
17
- "./table-formula": "./src/table-formula.ts",
18
- "./table-formula-mathjs": "./src/table-formula-mathjs.ts",
19
- "./formula-spec": "./src/formula-spec.ts",
9
+ "./contacts-format": "./src/contacts-format.ts",
10
+ "./doc-to-markdown": "./src/doc-to-markdown.ts",
11
+ "./formula-dimensions": "./src/formula-dimensions.ts",
20
12
  "./formula-eval": "./src/formula-eval.ts",
21
13
  "./formula-seed": "./src/formula-seed.ts",
22
- "./formula-dimensions": "./src/formula-dimensions.ts",
23
- "./contacts-format": "./src/contacts-format.ts",
14
+ "./formula-signature": "./src/formula-signature.ts",
15
+ "./formula-spec": "./src/formula-spec.ts",
24
16
  "./journal-options": "./src/journal-options.ts",
17
+ "./markdown": "./src/markdown-to-doc.ts",
18
+ "./markdown-refs": "./src/markdown-refs.ts",
25
19
  "./onboarding-questions": "./src/onboarding-questions.ts",
20
+ "./page-diff": "./src/page-diff.ts",
21
+ "./page-split": "./src/page-split.ts",
22
+ "./page-toc": "./src/page-toc.ts",
26
23
  "./persona-bank": "./src/persona-bank.ts",
27
- "./thinking-tiers": "./src/thinking-tiers.ts",
28
- "./formula-signature": "./src/formula-signature.ts"
24
+ "./profile-projections": "./src/profile-projections.ts",
25
+ "./recall-compile": "./src/recall-compile.ts",
26
+ "./table-formula": "./src/table-formula.ts",
27
+ "./table-formula-mathjs": "./src/table-formula-mathjs.ts",
28
+ "./table-model": "./src/table-model.ts",
29
+ "./thinking-tiers": "./src/thinking-tiers.ts"
29
30
  },
30
31
  "dependencies": {
32
+ "@mantle/client-types": "npm:@crossworks/client-types@0.232.142",
31
33
  "marked": "^18.0.7",
32
34
  "mathjs": "15.2.0"
33
35
  },
@@ -207,7 +207,7 @@ const BUILDERS: Record<PersonaPresetKey, (opts: BuildOpts) => string> = {
207
207
  /**
208
208
  * The token a built prompt carries in place of the assistant's name.
209
209
  *
210
- * MIRRORS `AGENT_NAME_TOKEN` in `@mantle/agent-runtime/skills`, which is what
210
+ * MIRRORS `AGENT_NAME_TOKEN` in `@mantle/runtime/agent/skills`, which is what
211
211
  * resolves it on every turn. Duplicated rather than imported because this file
212
212
  * is a browser-safe leaf and agent-runtime depends on THIS package — importing
213
213
  * back would be a cycle. `persona-bank-token.test.ts` in agent-runtime is the
@@ -0,0 +1,422 @@
1
+ /**
2
+ * @mantle/content-core · profile projections
3
+ *
4
+ * The PURE half of profile preferences: whitelist projections for every jsonb
5
+ * field, the size caps, the thinking-budget/effort resolvers and the Intl-based
6
+ * validators. No database, no I/O — read and write MUST share these, or a field
7
+ * gets silently dropped on read (BRAIN_PREFERENCE_KEYS exists because two real
8
+ * bugs came from the wrong frame; see brain-preferences.test.ts).
9
+ *
10
+ * Moved out of @mantle/content on 2026-09-02 (audit, tier 3) so the settings UI
11
+ * can project a value without pulling @mantle/db — and `postgres` — into the
12
+ * browser bundle. content-core keeps its ZERO-runtime-dependency rule: the only
13
+ * import here is `import type` from @mantle/client-types, which the compiler
14
+ * erases entirely, and the one UUID regex is inlined rather than reaching for
15
+ * @mantle/std (runtime code, and this file needs five characters of it).
16
+ *
17
+ * @mantle/content re-exports every name below, so nothing downstream moved.
18
+ */
19
+
20
+ import type {
21
+ OnboardingModelChoices,
22
+ ProfilePreferences,
23
+ ReminderChannel,
24
+ ThoughtTrailMode,
25
+ } from '@mantle/client-types';
26
+
27
+ import { thinkingEffortForBudget, type ThinkingEffort } from './thinking-tiers';
28
+
29
+ export type { OnboardingModelChoices, ProfilePreferences, ReminderChannel, ThoughtTrailMode };
30
+
31
+ /** Inlined from @mantle/std: content-core takes no runtime dependencies. */
32
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
33
+
34
+ /** Resolve the live-streaming preference to a definite boolean — ON unless the
35
+ * user explicitly turned it off. Use this everywhere instead of reading the
36
+ * optional field directly, so "unset" reliably means on. */
37
+ export function isStreamThoughtsEnabled(
38
+ prefs: Pick<ProfilePreferences, 'streamThoughts'>,
39
+ ): boolean {
40
+ return prefs.streamThoughts !== false;
41
+ }
42
+
43
+ /** Resolve the trail display mode to a definite value — 'list' unless explicitly
44
+ * set to 'replace'. */
45
+ export function resolveThoughtTrailMode(
46
+ prefs: Pick<ProfilePreferences, 'thoughtTrailMode'>,
47
+ ): ThoughtTrailMode {
48
+ return prefs.thoughtTrailMode === 'replace' ? 'replace' : 'list';
49
+ }
50
+
51
+ /** Whether the thought trail is persisted onto the finished message — ON unless
52
+ * the user explicitly turned it off. */
53
+ export function isPersistThoughtsEnabled(
54
+ prefs: Pick<ProfilePreferences, 'persistThoughts'>,
55
+ ): boolean {
56
+ return prefs.persistThoughts !== false;
57
+ }
58
+
59
+ /** Builtin read tools that reach the owner's PRIVATE corpus (email + journal).
60
+ * The Team Chat responder holds these via the `team-read` group, but they only
61
+ * actually reach the model when the owner has opted in (`teamPrivateReads`).
62
+ * Stripped from a team turn's tool set otherwise — see run-team-turn.ts. */
63
+ export const TEAM_PRIVATE_READ_SLUGS: readonly string[] = [
64
+ 'email_list',
65
+ 'email_get',
66
+ 'journal_list',
67
+ 'journal_get',
68
+ ];
69
+
70
+ /** Whether the external Team Chat responder may read the owner's private corpus
71
+ * (email + journal) for a team member. **Defaults OFF** — an explicit opt-in,
72
+ * since it exposes the owner's personal correspondence and journal to an
73
+ * external member. Non-private brain-knowledge reads are always allowed. */
74
+ export function isTeamPrivateReadsEnabled(
75
+ prefs: Pick<ProfilePreferences, 'teamPrivateReads'>,
76
+ ): boolean {
77
+ return prefs.teamPrivateReads === true;
78
+ }
79
+
80
+ /** Project a stored `thinkingBudget` jsonb value to the typed field — a positive
81
+ * integer, or undefined for unset/garbage/non-positive. Shared by BOTH the read
82
+ * (`loadProfilePreferences`) and return (`updateProfilePreferences`) projections
83
+ * so the two can't drift — that drift is exactly what originally dropped the
84
+ * field on read and left the feature silently dead. */
85
+ export function projectThinkingBudget(raw: unknown): number | undefined {
86
+ return typeof raw === 'number' && raw > 0 ? Math.floor(raw) : undefined;
87
+ }
88
+
89
+ /** Cap on a stored site name — generous for a wordmark; the header truncates
90
+ * visually anyway, this just keeps garbage-length strings out of the row. */
91
+ export const SITE_NAME_MAX = 40;
92
+
93
+ /** Project a stored `siteName` jsonb value — trimmed, non-empty, capped at
94
+ * {@link SITE_NAME_MAX} chars, or undefined for unset/blank/garbage (⇒ the UI
95
+ * falls back to the Mantle wordmark). Shared by BOTH the read and write
96
+ * projections so they can't drift (the projectThinkingBudget lesson). */
97
+ export function projectSiteName(raw: unknown): string | undefined {
98
+ if (typeof raw !== 'string') return undefined;
99
+ const trimmed = raw.trim().slice(0, SITE_NAME_MAX);
100
+ return trimmed.length > 0 ? trimmed : undefined;
101
+ }
102
+
103
+ export const PEER_NAME_MAX = 40;
104
+
105
+ /** Project a stored `peerName` — the header-centre federation label. Same
106
+ * contract as projectSiteName: trimmed, capped, empty ⇒ undefined (unset). */
107
+ export function projectPeerName(raw: unknown): string | undefined {
108
+ if (typeof raw !== 'string') return undefined;
109
+ const trimmed = raw.trim().slice(0, PEER_NAME_MAX);
110
+ return trimmed.length > 0 ? trimmed : undefined;
111
+ }
112
+
113
+ export const HOUSE_STYLE_MAX = 2000;
114
+
115
+ /** Project a stored `houseStyle` — the owner's writing conventions, injected
116
+ * into every composed system prompt. Trimmed, capped at {@link
117
+ * HOUSE_STYLE_MAX} chars, empty ⇒ undefined (no block emitted).
118
+ *
119
+ * The cap is a prompt-budget guard, not a validation: this text rides in the
120
+ * cached prefix of EVERY turn on every agent, so an accidental paste of a
121
+ * whole style guide would tax each one. 2000 chars is ~500 tokens, which is
122
+ * room for a dozen real rules. Same read+write sharing contract as the other
123
+ * projectors. */
124
+ export function projectHouseStyle(raw: unknown): string | undefined {
125
+ if (typeof raw !== 'string') return undefined;
126
+ const trimmed = raw.trim().slice(0, HOUSE_STYLE_MAX);
127
+ return trimmed.length > 0 ? trimmed : undefined;
128
+ }
129
+
130
+ /** Project a stored `logoKey` — must be the exact content-addressed shape
131
+ * @mantle/storage's contentKey emits, so a hand-edited row can never point
132
+ * the public logo route at an arbitrary object. Same read+write sharing
133
+ * contract as the other projectors. */
134
+ export function projectLogoKey(raw: unknown): string | undefined {
135
+ if (typeof raw !== 'string') return undefined;
136
+ return /^attachments\/[0-9a-f]{2}\/[0-9a-f]{2}\/[0-9a-f]{64}$/.test(raw) ? raw : undefined;
137
+ }
138
+
139
+ /** The image types the logo upload accepts — svg for crisp brand marks, the
140
+ * three raster staples for everyone else. The serve route replays ONLY a
141
+ * projected value, so an unlisted type can never reach a Content-Type. */
142
+ export const LOGO_TYPES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/webp'] as const;
143
+
144
+ export function projectLogoType(raw: unknown): string | undefined {
145
+ return typeof raw === 'string' && (LOGO_TYPES as readonly string[]).includes(raw)
146
+ ? raw
147
+ : undefined;
148
+ }
149
+
150
+ /** Cache-busting logo version for clients — the first 8 hex of the sha in
151
+ * the content-addressed key. null when no logo is set. */
152
+ export function logoVersion(logoKey: string | undefined): string | null {
153
+ const projected = projectLogoKey(logoKey);
154
+ return projected ? projected.slice(-64).slice(0, 8) : null;
155
+ }
156
+
157
+ /** The raster types the profile PHOTO accepts. A photo is never an SVG —
158
+ * that alone removes the logo route's whole active-content problem class.
159
+ * Same replay contract as LOGO_TYPES: the serve route emits only a
160
+ * projected value, so an unlisted type can never reach a Content-Type. */
161
+ export const AVATAR_PHOTO_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const;
162
+
163
+ export function projectAvatarPhotoType(raw: unknown): string | undefined {
164
+ return typeof raw === 'string' && (AVATAR_PHOTO_TYPES as readonly string[]).includes(raw)
165
+ ? raw
166
+ : undefined;
167
+ }
168
+
169
+ /** Project a stored `colorTheme` jsonb value — a slug-shaped theme id, or
170
+ * undefined for unset/garbage (⇒ the default theme). The theme LIST lives in
171
+ * the web app (server/web/lib/themes.ts); the server stores any well-formed id
172
+ * and the client falls back to the default for ids it doesn't know, so a
173
+ * theme added or removed in the UI never strands the stored preference. */
174
+ export function projectColorTheme(raw: unknown): string | undefined {
175
+ if (typeof raw !== 'string') return undefined;
176
+ const t = raw.trim().toLowerCase();
177
+ return /^[a-z0-9][a-z0-9-]{0,63}$/.test(t) ? t : undefined;
178
+ }
179
+
180
+ /** Project a stored font key (`fontLogo` / `fontTitle`) — a slug-shaped display
181
+ * font id, or undefined for unset/garbage. Same lenient contract as
182
+ * projectColorTheme: the font LIST lives in the web app, so the server only
183
+ * shape-checks and the client resolves unknown keys to the default. */
184
+ export function projectFontKey(raw: unknown): string | undefined {
185
+ if (typeof raw !== 'string') return undefined;
186
+ const t = raw.trim().toLowerCase();
187
+ return /^[a-z0-9][a-z0-9-]{0,63}$/.test(t) ? t : undefined;
188
+ }
189
+
190
+ /** Project a stored `avatarStyle` — a slug-shaped avatar style id, or undefined
191
+ * for unset/garbage. Same lenient contract as projectColorTheme: the style
192
+ * REGISTRY lives in the web layer (@mantle/web-ui/avatar), so the server only
193
+ * shape-checks. That is deliberate — it also lets the legacy boring-avatars
194
+ * ids ('beam', 'marble', …) survive storage untouched and be translated to a
195
+ * shipped style on read, so no stored avatar had to be migrated. */
196
+ export function projectAvatarStyle(raw: unknown): string | undefined {
197
+ if (typeof raw !== 'string') return undefined;
198
+ const t = raw.trim().toLowerCase();
199
+ return /^[a-z0-9][a-z0-9-]{0,63}$/.test(t) ? t : undefined;
200
+ }
201
+
202
+ /** Project stored avatar-builder choices: component → pinned variant | null
203
+ * ("hide"). Shape-checked only, same contract as projectAvatarStyle — which
204
+ * components a style actually has is web-layer knowledge, and stale entries
205
+ * are dropped at RENDER time so a style switch never invalidates a row.
206
+ * Empty/garbage → undefined (= seed only). Names follow DiceBear's camelCase
207
+ * identifier pattern; the entry cap is a storage guard. */
208
+ const AVATAR_PART_NAME = /^[a-z][a-zA-Z0-9]{0,63}$/;
209
+ export function projectAvatarParts(raw: unknown): Record<string, string | null> | undefined {
210
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
211
+ const out: Record<string, string | null> = {};
212
+ for (const [k, v] of Object.entries(raw)) {
213
+ if (!AVATAR_PART_NAME.test(k)) continue;
214
+ if (v === null) out[k] = null;
215
+ else if (typeof v === 'string' && AVATAR_PART_NAME.test(v)) out[k] = v;
216
+ if (Object.keys(out).length >= 64) break;
217
+ }
218
+ return Object.keys(out).length ? out : undefined;
219
+ }
220
+
221
+ /**
222
+ * Project a stored `backgrounds` map, `area=style` pairs, comma separated.
223
+ *
224
+ * Shape-checked only, exactly like projectAvatarStyle and for the same reason:
225
+ * the AREA and STYLE registries both live in the web layer
226
+ * (@mantle/web-ui/backgrounds), and duplicating either here would create two
227
+ * lists to keep in step. Unknown areas and unknown styles are dropped on READ
228
+ * by `decodeBackgrounds`, so a value that survives storage can still never
229
+ * reach the document unvalidated.
230
+ *
231
+ * The cap is a storage guard, not a semantic one: a handful of areas exist, and
232
+ * an unbounded string on a preferences row is somebody else's outage.
233
+ */
234
+ export const BACKGROUNDS_MAX = 200;
235
+
236
+ export function projectBackgrounds(raw: unknown): string | undefined {
237
+ if (typeof raw !== 'string') return undefined;
238
+ const t = raw.trim().toLowerCase();
239
+ if (!t) return undefined;
240
+ if (t.length > BACKGROUNDS_MAX) return undefined;
241
+ return /^[a-z0-9-]+=[a-z0-9-]+(,[a-z0-9-]+=[a-z0-9-]+)*$/.test(t) ? t : undefined;
242
+ }
243
+
244
+ /**
245
+ * Project a stored `neatBackground` — the whole-surface animated gradient's
246
+ * spec, compact JSON `{v:1, seed, tone, speed}`.
247
+ *
248
+ * Shape-checked only, the projectBackgrounds contract: colours and the full
249
+ * shader parameter derivation live in the web layer
250
+ * (@mantle/web-ui/neat-background), and the client decodes defensively again
251
+ * on read, so a value that survives storage still never reaches WebGL
252
+ * unvalidated. The cap is a storage guard — a canonical spec is ~60 chars.
253
+ */
254
+ export const NEAT_BACKGROUND_MAX = 200;
255
+
256
+ export function projectNeatBackground(raw: unknown): string | undefined {
257
+ if (typeof raw !== 'string') return undefined;
258
+ const t = raw.trim();
259
+ if (!t || t.length > NEAT_BACKGROUND_MAX) return undefined;
260
+ try {
261
+ const o = JSON.parse(t) as Record<string, unknown>;
262
+ if (!o || typeof o !== 'object' || o.v !== 1) return undefined;
263
+ if (typeof o.seed !== 'number' || !Number.isInteger(o.seed) || o.seed < 0) return undefined;
264
+ if (o.tone !== 'auto' && o.tone !== 'darker' && o.tone !== 'lighter') return undefined;
265
+ if (typeof o.speed !== 'number' || !Number.isFinite(o.speed) || o.speed < 0) return undefined;
266
+ return t;
267
+ } catch {
268
+ return undefined;
269
+ }
270
+ }
271
+
272
+ /** Project a stored `defaultMode` — the brain's default light/dark mode for
273
+ * surfaces without a visitor choice (the public /s share reader). A closed
274
+ * set like avatarTint, validated by value: there is no registry to fall back
275
+ * through, and an unknown value would flip a public page's entire palette.
276
+ * Anything else ⇒ unset ⇒ 'light' (the share surface's historical look). */
277
+ export function projectDefaultMode(raw: unknown): 'light' | 'dark' | 'system' | undefined {
278
+ if (typeof raw !== 'string') return undefined;
279
+ const t = raw.trim().toLowerCase();
280
+ return t === 'light' || t === 'dark' || t === 'system' ? t : undefined;
281
+ }
282
+
283
+ /** Project a stored font size (the interface scale and the three local ones).
284
+ * A closed set like avatarTint, validated by value: an unknown size would
285
+ * rescale the entire interface and there is no registry to fall back through.
286
+ * Anything else ⇒ unset ⇒ 'medium'. */
287
+ export function projectFontSize(raw: unknown): string | undefined {
288
+ if (typeof raw !== 'string') return undefined;
289
+ const t = raw.trim().toLowerCase();
290
+ return t === 'xsmall' || t === 'small' || t === 'medium' || t === 'large' ? t : undefined;
291
+ }
292
+
293
+ /** Project a stored `avatarTint`. Unlike the style this IS a closed set, so it
294
+ * is validated by value: an unknown tint would change how every avatar in the
295
+ * brain looks, and there is no registry in the web layer to fall back through.
296
+ * Anything else stores as unset ⇒ the default ('mixed'). */
297
+ export function projectAvatarTint(raw: unknown): string | undefined {
298
+ if (typeof raw !== 'string') return undefined;
299
+ const t = raw.trim().toLowerCase();
300
+ return t === 'native' || t === 'mixed' || t === 'theme' ? t : undefined;
301
+ }
302
+
303
+ /** Effective per-turn thinking budget in tokens — gated by BOTH the live-thinking
304
+ * switch (`streamThoughts`) AND a positive `thinkingBudget`. Returns 0 when
305
+ * either is missing, so real reasoning is requested only when the user has
306
+ * explicitly opted into both. This is the gate that replaced the per-box
307
+ * `MANTLE_THINKING_BUDGET` env var. NOTE: the magnitude is further clamped at
308
+ * turn time against the agent's `max_tokens` (see tool-loop.ts) so a budget
309
+ * ≥ max_tokens can't 400 the reasoning providers. */
310
+ export function resolveThinkingBudget(
311
+ prefs: Pick<ProfilePreferences, 'streamThoughts' | 'thinkingBudget'>,
312
+ ): number {
313
+ if (!isStreamThoughtsEnabled(prefs)) return 0;
314
+ return projectThinkingBudget(prefs.thinkingBudget) ?? 0;
315
+ }
316
+
317
+ // The tier vocabulary lives in a leaf module with no imports so the settings UI
318
+ // can use the values without pulling @mantle/db into the browser bundle.
319
+ // Re-exported here so server-side callers keep importing from one place.
320
+ export {
321
+ THINKING_EFFORTS,
322
+ THINKING_TIERS,
323
+ thinkingEffortForBudget,
324
+ type ThinkingEffort,
325
+ } from './thinking-tiers';
326
+
327
+ /** Effective per-turn thinking EFFORT — the control that actually reaches the
328
+ * providers today. Same double gate as {@link resolveThinkingBudget} (the
329
+ * live-thinking switch AND a positive budget); undefined means "don't ask for
330
+ * reasoning", which adapters render by omitting the field entirely. */
331
+ export function resolveThinkingEffort(
332
+ prefs: Pick<ProfilePreferences, 'streamThoughts' | 'thinkingBudget'>,
333
+ ): ThinkingEffort | undefined {
334
+ return thinkingEffortForBudget(resolveThinkingBudget(prefs));
335
+ }
336
+
337
+ /** Whitelist projection for {@link OnboardingModelChoices} — same contract as
338
+ * projectThinkingBudget: read and write MUST share this, or the field gets
339
+ * silently dropped on read. */
340
+ export function projectOnboardingModels(raw: unknown): OnboardingModelChoices | undefined {
341
+ if (!raw || typeof raw !== 'object') return undefined;
342
+ const o = raw as Record<string, unknown>;
343
+ const str = (v: unknown) => (typeof v === 'string' && v.length > 0 ? v : undefined);
344
+ const out: OnboardingModelChoices = {
345
+ assistantModel: str(o.assistantModel),
346
+ workerModel: str(o.workerModel),
347
+ route: o.route === 'azure' ? 'azure' : o.route === 'openrouter' ? 'openrouter' : undefined,
348
+ azureBaseUrl: str(o.azureBaseUrl),
349
+ };
350
+ return out.assistantModel || out.workerModel || out.route ? out : undefined;
351
+ }
352
+
353
+ /** Project a stored `teamHubAppId` jsonb value — a canonical UUID string, or
354
+ * undefined for unset/garbage (⇒ built-in hub). Shared by BOTH the read and
355
+ * write projections so they can't drift (the projectThinkingBudget lesson). */
356
+ export function projectTeamHubAppId(raw: unknown): string | undefined {
357
+ if (typeof raw !== 'string') return undefined;
358
+ const trimmed = raw.trim().toLowerCase();
359
+ return UUID_RE.test(trimmed) ? trimmed : undefined;
360
+ }
361
+
362
+ /** Cap on curated Dashboard tag sections — enough for a rich overview, small
363
+ * enough that the member Dashboard stays a dashboard and the section fan-out
364
+ * stays a handful of cheap indexed queries. */
365
+ export const TEAM_HUB_TAGS_MAX = 8;
366
+
367
+ /** Per-tag length cap — matches the /api/pages tag schema (max 40 chars) so a
368
+ * stored curation tag can always have been a real node tag. */
369
+ export const TEAM_HUB_TAG_MAX_LEN = 40;
370
+
371
+ /** Project a stored `teamHubTags` jsonb value — an ordered list of trimmed,
372
+ * lowercased, deduped, non-empty tag strings capped at
373
+ * {@link TEAM_HUB_TAGS_MAX} entries, or undefined for unset/empty/garbage
374
+ * (⇒ no curated sections). Lowercased because node tags are matched with
375
+ * `= ANY(nodes.tags)` — pages dedupe tags case-insensitively on save, so the
376
+ * lowercase form is the canonical one. Shared by BOTH the read and write
377
+ * projections so they can't drift (the projectThinkingBudget lesson). */
378
+ export function projectTeamHubTags(raw: unknown): string[] | undefined {
379
+ if (!Array.isArray(raw)) return undefined;
380
+ const out: string[] = [];
381
+ for (const v of raw) {
382
+ if (typeof v !== 'string') continue;
383
+ const t = v.trim().toLowerCase().slice(0, TEAM_HUB_TAG_MAX_LEN);
384
+ if (t.length === 0 || out.includes(t)) continue;
385
+ out.push(t);
386
+ if (out.length >= TEAM_HUB_TAGS_MAX) break;
387
+ }
388
+ return out.length > 0 ? out : undefined;
389
+ }
390
+
391
+ export const DEFAULT_PREFERENCES: ProfilePreferences = {
392
+ timezone: 'UTC',
393
+ locale: 'en-GB',
394
+ };
395
+
396
+ /** IANA tz validation via Intl.DateTimeFormat — the runtime throws
397
+ * on unknown ids, so we use that as a 600KB-tz-database-free check. */
398
+ export function isValidTimezone(tz: string): boolean {
399
+ if (!tz || tz.length === 0) return false;
400
+ try {
401
+ new Intl.DateTimeFormat('en', { timeZone: tz });
402
+ return true;
403
+ } catch {
404
+ return false;
405
+ }
406
+ }
407
+
408
+ /** BCP-47 locale validation via Intl.Locale. */
409
+ export function isValidLocale(loc: string): boolean {
410
+ if (!loc || loc.length === 0) return false;
411
+ try {
412
+ new Intl.Locale(loc);
413
+ return true;
414
+ } catch {
415
+ return false;
416
+ }
417
+ }
418
+
419
+ /** Narrow an unknown value to a deliverable ReminderChannel. */
420
+ export function isReminderChannel(v: unknown): v is ReminderChannel {
421
+ return v === 'telegram' || v === 'mobile';
422
+ }
@@ -16,7 +16,7 @@
16
16
  * No `none`: "off" is expressed by omitting the field entirely, because models
17
17
  * flagged `reasoning.mandatory` in OpenRouter's GET /models reject an explicit
18
18
  * none. Mirrored as `ThinkingEffort` in `@mantle/voice` (which must not depend
19
- * on this package); a compile-time assertion in `@mantle/assistant-runtime`
19
+ * on this package); a compile-time assertion in `@mantle/runtime/assistant`
20
20
  * pins the two lists together. */
21
21
  export const THINKING_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
22
22
  export type ThinkingEffort = (typeof THINKING_EFFORTS)[number];