@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 +19 -17
- package/src/persona-bank.ts +1 -1
- package/src/profile-projections.ts +422 -0
- package/src/thinking-tiers.ts +1 -1
package/package.json
CHANGED
|
@@ -1,33 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/content-core",
|
|
3
|
-
"version": "0.232.
|
|
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
|
-
"./
|
|
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
|
-
"./
|
|
13
|
-
"./
|
|
14
|
-
"./
|
|
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-
|
|
23
|
-
"./
|
|
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
|
-
"./
|
|
28
|
-
"./
|
|
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
|
},
|
package/src/persona-bank.ts
CHANGED
|
@@ -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/
|
|
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
|
+
}
|
package/src/thinking-tiers.ts
CHANGED
|
@@ -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
|
|
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];
|