@crossworks/content-core 0.235.1 → 0.237.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/content-core",
3
- "version": "0.235.1",
3
+ "version": "0.237.1",
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
6
  "./app-nav": "./src/app-nav.ts",
@@ -14,6 +14,7 @@
14
14
  "./formula-seed": "./src/formula-seed.ts",
15
15
  "./formula-signature": "./src/formula-signature.ts",
16
16
  "./formula-spec": "./src/formula-spec.ts",
17
+ "./heading-fold": "./src/heading-fold.ts",
17
18
  "./journal-options": "./src/journal-options.ts",
18
19
  "./markdown": "./src/markdown-to-doc.ts",
19
20
  "./markdown-refs": "./src/markdown-refs.ts",
@@ -32,7 +33,7 @@
32
33
  "./tree": "./src/tree.ts"
33
34
  },
34
35
  "dependencies": {
35
- "@mantle/client-types": "npm:@crossworks/client-types@0.235.1",
36
+ "@mantle/client-types": "npm:@crossworks/client-types@0.237.1",
36
37
  "marked": "^18.0.11",
37
38
  "mathjs": "15.2.0"
38
39
  },
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ AGENT_THINKING_EFFORTS,
4
+ NO_THINKING,
5
+ applyAgentThinking,
6
+ parseAgentThinkingEffort,
7
+ profileThinking,
8
+ resolveAgentThinking,
9
+ thinkingBudgetForEffort,
10
+ } from './profile-projections';
11
+
12
+ const ON_MEDIUM = { streamThoughts: true, thinkingBudget: 4096 };
13
+ const ON_OFF = { streamThoughts: true, thinkingBudget: 0 };
14
+ const SWITCH_OFF = { streamThoughts: false, thinkingBudget: 8000 };
15
+
16
+ describe('resolveAgentThinking: the precedence table', () => {
17
+ // [agent effort, profile, expected]
18
+ const rows: Array<[string | null, typeof ON_MEDIUM | null, number, string | undefined]> = [
19
+ // Inherit = today's behaviour exactly.
20
+ [null, ON_MEDIUM, 4096, 'medium'],
21
+ [null, ON_OFF, 0, undefined],
22
+ [null, SWITCH_OFF, 0, undefined],
23
+ [null, null, 0, undefined],
24
+ // Own 'off' beats any profile.
25
+ ['off', ON_MEDIUM, 0, undefined],
26
+ ['off', SWITCH_OFF, 0, undefined],
27
+ // Own tier beats the profile, the profile's Off, and the display switch.
28
+ ['low', ON_MEDIUM, 1024, 'low'],
29
+ ['high', ON_OFF, 8000, 'high'],
30
+ ['medium', SWITCH_OFF, 4096, 'medium'],
31
+ ['xhigh', null, 16000, 'xhigh'],
32
+ ['max', ON_MEDIUM, 24000, 'max'],
33
+ ];
34
+ for (const [own, prefs, budget, effort] of rows) {
35
+ it(`agent ${own ?? 'inherit'} + profile ${JSON.stringify(prefs)} → ${budget}/${effort}`, () => {
36
+ expect(resolveAgentThinking({ thinkingEffort: own }, prefs)).toEqual({ budget, effort });
37
+ });
38
+ }
39
+
40
+ it('a missing agent row is inherit', () => {
41
+ expect(resolveAgentThinking(null, ON_MEDIUM)).toEqual(profileThinking(ON_MEDIUM));
42
+ expect(resolveAgentThinking(undefined, ON_MEDIUM)).toEqual({ budget: 4096, effort: 'medium' });
43
+ });
44
+
45
+ it('a bad stored value is inherit, never reasoning on', () => {
46
+ for (const bad of ['', 'inherit', 'HIGH', 'none', 'extreme']) {
47
+ expect(resolveAgentThinking({ thinkingEffort: bad }, ON_OFF)).toEqual(NO_THINKING);
48
+ }
49
+ });
50
+ });
51
+
52
+ describe('applyAgentThinking', () => {
53
+ it('passes the inherited value through untouched on inherit', () => {
54
+ const inherited = { budget: 2048, effort: undefined };
55
+ expect(applyAgentThinking({ thinkingEffort: null }, inherited)).toBe(inherited);
56
+ });
57
+ });
58
+
59
+ describe('agent effort vocabulary', () => {
60
+ it("is 'off' plus every provider tier, in order", () => {
61
+ expect(AGENT_THINKING_EFFORTS).toEqual(['off', 'low', 'medium', 'high', 'xhigh', 'max']);
62
+ });
63
+
64
+ it('parses only known values', () => {
65
+ expect(parseAgentThinkingEffort('max')).toBe('max');
66
+ expect(parseAgentThinkingEffort('off')).toBe('off');
67
+ expect(parseAgentThinkingEffort(null)).toBeNull();
68
+ expect(parseAgentThinkingEffort(3)).toBeNull();
69
+ });
70
+
71
+ it('backs the profile tiers with their own ids, the upper rungs with more headroom', () => {
72
+ expect(thinkingBudgetForEffort('low')).toBe(1024);
73
+ expect(thinkingBudgetForEffort('medium')).toBe(4096);
74
+ expect(thinkingBudgetForEffort('high')).toBe(8000);
75
+ expect(thinkingBudgetForEffort('xhigh')).toBe(16000);
76
+ expect(thinkingBudgetForEffort('max')).toBe(24000);
77
+ });
78
+ });
package/src/block-list.ts CHANGED
@@ -144,7 +144,11 @@ function blockMeta(kind: string, node: AnyNode): Record<string, unknown> | null
144
144
  const attrs = node.attrs ?? {};
145
145
  switch (kind) {
146
146
  case 'heading':
147
- return typeof attrs.level === 'number' ? { level: attrs.level } : null;
147
+ if (typeof attrs.level !== 'number') return null;
148
+ // A foldable heading says so (and how it starts), so an agent can see it.
149
+ return attrs.fold === 'open' || attrs.fold === 'closed'
150
+ ? { level: attrs.level, fold: attrs.fold }
151
+ : { level: attrs.level };
148
152
  case 'codeBlock':
149
153
  return typeof attrs.language === 'string' && attrs.language
150
154
  ? { language: attrs.language }
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import { FOLDER_HERE } from './markdown-refs';
30
+ import { foldMarker, normalizeFold } from './heading-fold';
30
31
 
31
32
  type PMMark = { type?: string; attrs?: Record<string, unknown> };
32
33
  type PMNode = {
@@ -220,7 +221,14 @@ function blockToMd(node: PMNode): string {
220
221
  return escapeLeading(inlineNodes(node.content));
221
222
  case 'heading': {
222
223
  const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6);
223
- return `${'#'.repeat(level)} ${inlineNodes(node.content)}`;
224
+ // A heading whose own words END in a fold marker escapes its `{`, so it
225
+ // re-parses as that text and not as a foldable heading.
226
+ const text = inlineNodes(node.content).replace(
227
+ /\{(fold(?:=(?:open|closed))?\}\s*)$/,
228
+ '\\{$1',
229
+ );
230
+ const fold = normalizeFold(node.attrs?.fold);
231
+ return `${'#'.repeat(level)} ${text}${fold ? ` ${foldMarker(fold)}` : ''}`;
224
232
  }
225
233
  case 'horizontalRule':
226
234
  return '---';
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Foldable headings: the markdown marker round-trips losslessly, old docs are
3
+ * untouched, and the shared section rule + reader-choice store behave.
4
+ */
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { markdownToDoc } from './markdown-to-doc';
7
+ import { docToMarkdown } from './doc-to-markdown';
8
+ import { listBlocks } from './block-list';
9
+ import {
10
+ FOLD_CHOICES_MAX,
11
+ FOLD_STORAGE_KEY,
12
+ foldSections,
13
+ isFolded,
14
+ normalizeFold,
15
+ readFoldChoices,
16
+ writeFoldChoice,
17
+ } from './heading-fold';
18
+
19
+ type N = { type?: string; attrs?: Record<string, unknown>; content?: N[]; text?: string };
20
+
21
+ const blocks = (md: string) => (markdownToDoc(md) as { content: N[] }).content;
22
+ const heading = (md: string) => blocks(md).find((b) => b.type === 'heading')!;
23
+ const text = (n: N) => (n.content ?? []).map((c) => c.text ?? '').join('');
24
+
25
+ describe('foldable heading markdown', () => {
26
+ it('`{fold}` makes a foldable heading that starts open', () => {
27
+ const h = heading('## Plans {fold}');
28
+ expect(h.attrs).toMatchObject({ level: 2, fold: 'open' });
29
+ expect(text(h)).toBe('Plans');
30
+ });
31
+
32
+ it('`{fold=closed}` starts folded; `{fold=open}` is the same as `{fold}`', () => {
33
+ expect(heading('# A {fold=closed}').attrs?.fold).toBe('closed');
34
+ expect(heading('# A {fold=open}').attrs?.fold).toBe('open');
35
+ });
36
+
37
+ it('keeps inline marks before the marker', () => {
38
+ const h = heading('### **Bold** and `code` {fold}');
39
+ expect(h.attrs?.fold).toBe('open');
40
+ expect(docToMarkdown({ type: 'doc', content: [h] })).toBe('### **Bold** and `code` {fold}');
41
+ });
42
+
43
+ it('a heading without a marker has NO fold attr (old docs unchanged)', () => {
44
+ const h = heading('## Plans');
45
+ expect(h.attrs).not.toHaveProperty('fold');
46
+ expect(Object.keys(h.attrs ?? {}).sort()).toEqual(['id', 'level']);
47
+ });
48
+
49
+ it('only the trailing marker counts, and only on headings', () => {
50
+ expect(heading('## a {fold} b').attrs).not.toHaveProperty('fold');
51
+ expect(text(heading('## a {fold} b'))).toBe('a {fold} b');
52
+ const p = blocks('text {fold}')[0]!;
53
+ expect(p.type).toBe('paragraph');
54
+ expect(text(p)).toBe('text {fold}');
55
+ expect(heading('## a {folded}').attrs).not.toHaveProperty('fold');
56
+ });
57
+
58
+ it('an escaped marker is plain heading text', () => {
59
+ const h = heading('## Use \\{fold} here \\{fold}');
60
+ expect(h.attrs).not.toHaveProperty('fold');
61
+ expect(text(h)).toBe('Use {fold} here {fold}');
62
+ });
63
+
64
+ it('works inside containers (callout, list item)', () => {
65
+ const callout = blocks(':::info\n## Inner {fold=closed}\nbody\n:::')[0]!;
66
+ expect(callout.content?.[0]?.attrs?.fold).toBe('closed');
67
+ });
68
+ });
69
+
70
+ describe('foldable heading round-trip (markdown -> doc -> markdown)', () => {
71
+ const cases = [
72
+ '## Plans {fold}',
73
+ '# Top {fold=closed}\n\nBody text.\n\n## Child {fold}\n\n- a\n- b',
74
+ '### **Bold** and *em* {fold}',
75
+ '## Plain heading\n\nNo fold here.',
76
+ '## Literal \\{fold}',
77
+ ':::info\n## In a callout {fold}\n\nx\n:::',
78
+ ];
79
+ for (const md of cases) {
80
+ it(`is identical: ${JSON.stringify(md)}`, () => {
81
+ expect(docToMarkdown(markdownToDoc(md))).toBe(md);
82
+ });
83
+ }
84
+
85
+ it('a heading whose WORDS end in a marker survives an editor save', () => {
86
+ // As the editor stores it: plain text, no fold attr.
87
+ const doc = {
88
+ type: 'doc',
89
+ content: [
90
+ { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'x {fold}' }] },
91
+ ],
92
+ };
93
+ const md = docToMarkdown(doc);
94
+ expect(md).toBe('## x \\{fold}');
95
+ const back = heading(md);
96
+ expect(back.attrs).not.toHaveProperty('fold');
97
+ expect(text(back)).toBe('x {fold}');
98
+ });
99
+
100
+ it('`{fold=closed}` as heading WORDS stays words (its `=` is escaped)', () => {
101
+ const md = docToMarkdown(markdownToDoc('## Literal \\{fold=closed}'));
102
+ expect(md).toBe('## Literal {fold\\=closed}');
103
+ expect(heading(md).attrs).not.toHaveProperty('fold');
104
+ expect(text(heading(md))).toBe('Literal {fold=closed}');
105
+ });
106
+
107
+ it('a literal backslash before the words stays text too', () => {
108
+ const doc = {
109
+ type: 'doc',
110
+ content: [
111
+ { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'a\\{fold}' }] },
112
+ ],
113
+ };
114
+ const back = heading(docToMarkdown(doc));
115
+ expect(back.attrs).not.toHaveProperty('fold');
116
+ expect(text(back)).toBe('a\\{fold}');
117
+ });
118
+
119
+ it('an editor-saved non-foldable heading (fold: null) writes plain markdown', () => {
120
+ const doc = {
121
+ type: 'doc',
122
+ content: [
123
+ {
124
+ type: 'heading',
125
+ attrs: { level: 2, fold: null },
126
+ content: [{ type: 'text', text: 'T' }],
127
+ },
128
+ ],
129
+ };
130
+ expect(docToMarkdown(doc)).toBe('## T');
131
+ });
132
+ });
133
+
134
+ describe('page_blocks_list meta', () => {
135
+ it('names the fold of a foldable heading only', () => {
136
+ const list = listBlocks(markdownToDoc('## A {fold=closed}\n\n## B') as Record<string, unknown>);
137
+ expect(list[0]!.meta).toEqual({ level: 2, fold: 'closed' });
138
+ expect(list[1]!.meta).toEqual({ level: 2 });
139
+ });
140
+ });
141
+
142
+ describe('foldSections', () => {
143
+ // h1 p h2 p h3 p h2 p h1 p
144
+ const levels = [1, null, 2, null, 3, null, 2, null, 1, null];
145
+ const lv = (i: number) => levels[i] ?? null;
146
+
147
+ it('a section runs to the next heading of the same or a higher level', () => {
148
+ const all = foldSections(levels.length, lv, () => true);
149
+ expect(all.get(0)).toBe(8); // h1 → next h1
150
+ expect(all.get(2)).toBe(6); // h2 → next h2 (skips the h3)
151
+ expect(all.get(4)).toBe(6); // h3 → the h2
152
+ expect(all.get(6)).toBe(8); // h2 → the h1
153
+ expect(all.get(8)).toBe(10); // last h1 → the end
154
+ });
155
+
156
+ it('only foldable headings get a section', () => {
157
+ const one = foldSections(levels.length, lv, (i) => i === 2);
158
+ expect([...one.keys()]).toEqual([2]);
159
+ });
160
+ });
161
+
162
+ describe('fold choices', () => {
163
+ const store = new Map<string, string>();
164
+ beforeEach(() => {
165
+ store.clear();
166
+ (globalThis as { localStorage?: unknown }).localStorage = {
167
+ getItem: (k: string) => store.get(k) ?? null,
168
+ setItem: (k: string, v: string) => void store.set(k, v),
169
+ removeItem: (k: string) => void store.delete(k),
170
+ };
171
+ });
172
+ afterEach(() => {
173
+ delete (globalThis as { localStorage?: unknown }).localStorage;
174
+ });
175
+
176
+ it('normalizeFold accepts only open/closed', () => {
177
+ expect(normalizeFold('open')).toBe('open');
178
+ expect(normalizeFold('closed')).toBe('closed');
179
+ expect(normalizeFold(null)).toBeNull();
180
+ expect(normalizeFold('x')).toBeNull();
181
+ });
182
+
183
+ it('the reader choice wins over the doc default; no choice = the default', () => {
184
+ expect(isFolded('h1', 'closed', {})).toBe(true);
185
+ expect(isFolded('h1', 'open', {})).toBe(false);
186
+ expect(isFolded('h1', 'closed', { h1: false })).toBe(false);
187
+ expect(isFolded('h1', 'open', { h1: true })).toBe(true);
188
+ expect(isFolded('h1', null, { h1: true })).toBe(false); // not foldable = never folded
189
+ });
190
+
191
+ it('writes, reads back, and keeps only the newest choices', () => {
192
+ writeFoldChoice('a', true);
193
+ writeFoldChoice('b', false);
194
+ expect(readFoldChoices()).toEqual({ a: true, b: false });
195
+ for (let i = 0; i < FOLD_CHOICES_MAX + 5; i++) writeFoldChoice(`k${i}`, true);
196
+ const keys = Object.keys(readFoldChoices());
197
+ expect(keys).toHaveLength(FOLD_CHOICES_MAX);
198
+ expect(keys).not.toContain('a');
199
+ expect(keys[keys.length - 1]).toBe(`k${FOLD_CHOICES_MAX + 4}`);
200
+ });
201
+
202
+ it('survives junk in storage and no storage at all', () => {
203
+ store.set(FOLD_STORAGE_KEY, 'not json');
204
+ expect(readFoldChoices()).toEqual({});
205
+ store.set(FOLD_STORAGE_KEY, '{"a":true,"b":"x"}');
206
+ expect(readFoldChoices()).toEqual({ a: true });
207
+ delete (globalThis as { localStorage?: unknown }).localStorage;
208
+ expect(readFoldChoices()).toEqual({});
209
+ expect(() => writeFoldChoice('a', true)).not.toThrow();
210
+ });
211
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Foldable headings: the shared rules every surface folds by.
3
+ *
4
+ * A heading can be FOLDABLE (Notion's toggle heading). The document stores
5
+ * only that fact, plus an optional default for a reader who never chose:
6
+ *
7
+ * heading attrs.fold null | 'open' | 'closed'
8
+ * markdown `## Title {fold}` foldable, starts open
9
+ * `## Title {fold=closed}` foldable, starts folded
10
+ *
11
+ * Whether a heading is folded RIGHT NOW is the reader's own choice, not part
12
+ * of the document: it is a reading preference, like a scroll position, and
13
+ * one reader folding a section must not hide it from everyone else (nor make
14
+ * a draft "changed"). The choice lives in the reader's localStorage, keyed by
15
+ * the heading's block id, so it follows the heading across the editor, the
16
+ * read view, the member and client views and the share reader of one browser.
17
+ * Block ids are UUIDs, so one map holds every page; it keeps the most recent
18
+ * FOLD_CHOICES_MAX choices.
19
+ *
20
+ * What a fold hides: the blocks after the heading, in the same parent, up to
21
+ * the next heading of the same or a higher level (level <= the heading's), or
22
+ * the end of the parent. A heading inside a callout or a column folds within
23
+ * that container. Print always shows everything (CSS, see share-ui app.css).
24
+ *
25
+ * Pure and browser-safe. The storage helpers no-op where localStorage is
26
+ * missing or refuses (SSR, private mode).
27
+ */
28
+
29
+ export type HeadingFold = 'open' | 'closed';
30
+
31
+ /** The stored attr, normalised: anything else is "not foldable". */
32
+ export function normalizeFold(v: unknown): HeadingFold | null {
33
+ return v === 'open' || v === 'closed' ? v : null;
34
+ }
35
+
36
+ /**
37
+ * The trailing markdown marker, matched against a heading's LAST inline text
38
+ * run (so an escaped `\{fold}` is plain text: `marked` splits the escape into
39
+ * its own token). Group 1 is the optional `=open` / `=closed` value.
40
+ */
41
+ export const FOLD_MARKER_RE = /\s*\{fold(?:=(open|closed))?\}\s*$/;
42
+
43
+ /**
44
+ * A heading's trailing marker, read off its LAST inline `marked` token and
45
+ * removed from it. An escaped `\{fold}` never matches: `marked` lexes the
46
+ * escape as its own token, so the last text run is `fold}`. Shared by
47
+ * markdownToDoc and the client's chat renderer, so both read it the same way.
48
+ */
49
+ export function splitFoldMarker<T extends { type: string; text?: string }>(
50
+ tokens: T[] | undefined,
51
+ ): { tokens: T[] | undefined; fold: HeadingFold | null } {
52
+ const last = tokens?.[tokens.length - 1];
53
+ if (!tokens || !last || last.type !== 'text') return { tokens, fold: null };
54
+ const m = FOLD_MARKER_RE.exec(last.text ?? '');
55
+ if (!m) return { tokens, fold: null };
56
+ const rest = (last.text ?? '').slice(0, m.index).replace(/\s+$/, '');
57
+ const head = tokens.slice(0, -1);
58
+ return {
59
+ tokens: rest ? [...head, { ...last, text: rest }] : head,
60
+ fold: m[1] === 'closed' ? 'closed' : 'open',
61
+ };
62
+ }
63
+
64
+ /** The marker docToMarkdown writes for a stored fold value. */
65
+ export function foldMarker(fold: HeadingFold): string {
66
+ return fold === 'closed' ? '{fold=closed}' : '{fold}';
67
+ }
68
+
69
+ /**
70
+ * For each foldable heading among a run of SIBLING blocks, the index just past
71
+ * its section (exclusive). `level(i)` is the heading level of sibling i, or
72
+ * null when it is not a heading; `foldable(i)` says whether it folds.
73
+ */
74
+ export function foldSections(
75
+ count: number,
76
+ level: (i: number) => number | null,
77
+ foldable: (i: number) => boolean,
78
+ ): Map<number, number> {
79
+ const out = new Map<number, number>();
80
+ for (let i = 0; i < count; i++) {
81
+ const lv = level(i);
82
+ if (lv == null || !foldable(i)) continue;
83
+ let end = i + 1;
84
+ while (end < count) {
85
+ const next = level(end);
86
+ if (next != null && next <= lv) break;
87
+ end++;
88
+ }
89
+ out.set(i, end);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /* ─────────────────────────── the reader's choices ─────────────────────────── */
95
+
96
+ export const FOLD_STORAGE_KEY = 'pages.headingFolds';
97
+ export const FOLD_CHOICES_MAX = 1000;
98
+
99
+ /** heading id → true (folded) / false (open). Absent = the document default. */
100
+ export type FoldChoices = Record<string, boolean>;
101
+
102
+ function storage(): Storage | null {
103
+ try {
104
+ return typeof localStorage === 'undefined' ? null : localStorage;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ export function readFoldChoices(): FoldChoices {
111
+ const s = storage();
112
+ if (!s) return {};
113
+ try {
114
+ const raw = JSON.parse(s.getItem(FOLD_STORAGE_KEY) ?? '{}') as unknown;
115
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
116
+ const out: FoldChoices = {};
117
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
118
+ if (typeof v === 'boolean') out[k] = v;
119
+ }
120
+ return out;
121
+ } catch {
122
+ return {};
123
+ }
124
+ }
125
+
126
+ /** Remember one choice. The newest choices are kept, the oldest dropped. */
127
+ export function writeFoldChoice(id: string, folded: boolean): void {
128
+ const s = storage();
129
+ if (!s || !id) return;
130
+ const choices = readFoldChoices();
131
+ delete choices[id]; // re-insert at the end: insertion order = recency
132
+ choices[id] = folded;
133
+ const keys = Object.keys(choices);
134
+ for (const k of keys.slice(0, Math.max(0, keys.length - FOLD_CHOICES_MAX))) delete choices[k];
135
+ try {
136
+ s.setItem(FOLD_STORAGE_KEY, JSON.stringify(choices));
137
+ } catch {
138
+ // Quota or private mode: the fold still works for this view.
139
+ }
140
+ }
141
+
142
+ /** Is this heading folded for this reader? Their choice, else the doc default. */
143
+ export function isFolded(
144
+ id: string | null | undefined,
145
+ fold: unknown,
146
+ choices: FoldChoices,
147
+ ): boolean {
148
+ const f = normalizeFold(fold);
149
+ if (!f) return false;
150
+ if (id && typeof choices[id] === 'boolean') return choices[id]!;
151
+ return f === 'closed';
152
+ }
@@ -19,6 +19,7 @@
19
19
  * Columns: :::columns … +++ … ::: (2+ parts split by a lone +++)
20
20
  * Highlight: ==text==
21
21
  * Colour: [text]{color=chart-2} / [text]{highlight=chart-3} (chart-1..5)
22
+ * Foldable heading: ## Title {fold} / ## Title {fold=closed} (heading-fold.ts)
22
23
  *
23
24
  * Pure (only `marked`) and DB-free, so it's safe to call from the tool
24
25
  * runtime. Defensive: anything it can't map degrades to a paragraph rather
@@ -37,6 +38,7 @@ import {
37
38
  FOLDER_HREF,
38
39
  FOLDER_HERE,
39
40
  } from './markdown-refs';
41
+ import { splitFoldMarker } from './heading-fold';
40
42
 
41
43
  type PMMark = { type: string; attrs?: Record<string, unknown> };
42
44
  type PMNode = {
@@ -375,13 +377,18 @@ function blocks(tokens: Tok[] | undefined): PMNode[] {
375
377
  case 'space':
376
378
  case 'def':
377
379
  break;
378
- case 'heading':
380
+ case 'heading': {
381
+ const { tokens, fold } = splitFoldMarker(t.tokens);
382
+ const level = Math.min(Math.max(t.depth ?? 1, 1), 3);
379
383
  out.push({
380
384
  type: 'heading',
381
- attrs: { level: Math.min(Math.max(t.depth ?? 1, 1), 3) },
382
- content: inline(t.tokens),
385
+ // `fold` only on a foldable heading, so every other heading's JSON
386
+ // is exactly what it was before foldable headings existed.
387
+ attrs: fold ? { level, fold } : { level },
388
+ content: inline(tokens),
383
389
  });
384
390
  break;
391
+ }
385
392
  case 'paragraph': {
386
393
  const ref = blockRefNode(t.tokens);
387
394
  if (ref) {
@@ -24,7 +24,12 @@ import type {
24
24
  ThoughtTrailMode,
25
25
  } from '@mantle/client-types';
26
26
 
27
- import { thinkingEffortForBudget, type ThinkingEffort } from './thinking-tiers';
27
+ import {
28
+ parseAgentThinkingEffort,
29
+ thinkingBudgetForEffort,
30
+ thinkingEffortForBudget,
31
+ type ThinkingEffort,
32
+ } from './thinking-tiers';
28
33
  import {
29
34
  APP_OPENS_MAX,
30
35
  APP_PINS_MAX,
@@ -347,9 +352,14 @@ export function resolveThinkingBudget(
347
352
  // can use the values without pulling @mantle/db into the browser bundle.
348
353
  // Re-exported here so server-side callers keep importing from one place.
349
354
  export {
355
+ AGENT_THINKING_EFFORTS,
356
+ AGENT_THINKING_EFFORT_LABELS,
350
357
  THINKING_EFFORTS,
351
358
  THINKING_TIERS,
359
+ parseAgentThinkingEffort,
360
+ thinkingBudgetForEffort,
352
361
  thinkingEffortForBudget,
362
+ type AgentThinkingEffort,
353
363
  type ThinkingEffort,
354
364
  } from './thinking-tiers';
355
365
 
@@ -363,6 +373,56 @@ export function resolveThinkingEffort(
363
373
  return thinkingEffortForBudget(resolveThinkingBudget(prefs));
364
374
  }
365
375
 
376
+ /** The thinking a turn asks for: `budget` > 0 turns reasoning on (and sizes
377
+ * the max_tokens headroom), `effort` is the tier the providers honour.
378
+ * `{ budget: 0, effort: undefined }` = no reasoning. */
379
+ export type ResolvedThinking = { budget: number; effort: ThinkingEffort | undefined };
380
+
381
+ export const NO_THINKING: ResolvedThinking = Object.freeze({ budget: 0, effort: undefined });
382
+
383
+ /** Apply an agent's own effort over what it would otherwise inherit. The one
384
+ * precedence rule, shared by every turn path:
385
+ *
386
+ * agent effort set → that effort ('off' = none), whatever was inherited
387
+ * agent effort NULL → `inherited`, unchanged (the behaviour before 0228)
388
+ *
389
+ * An agent's own effort is NOT gated by the person's live-thinking switch:
390
+ * that switch is about what a person likes to watch, while the effort is a
391
+ * property of the agent (an owner who sets High on a research agent wants it
392
+ * to reason, whether or not they watch the trail). The switch still gates
393
+ * the INHERITED profile budget, so an agent on inherit behaves exactly as
394
+ * before, and a profile at Off still sends no reasoning for it. */
395
+ export function applyAgentThinking(
396
+ agent: { thinkingEffort?: string | null } | null | undefined,
397
+ inherited: ResolvedThinking,
398
+ ): ResolvedThinking {
399
+ const own = parseAgentThinkingEffort(agent?.thinkingEffort);
400
+ if (own === null) return inherited;
401
+ if (own === 'off') return NO_THINKING;
402
+ return { budget: thinkingBudgetForEffort(own), effort: own };
403
+ }
404
+
405
+ /** Resolve a top-level turn's thinking: the agent's own effort when set, else
406
+ * the person's profile (gated by the live-thinking switch AND a positive
407
+ * budget, see {@link resolveThinkingEffort}). `prefs: null` means the profile
408
+ * does not apply on this path (a team or client turn, where the profile is the
409
+ * owner's and not the caller's): inherit is then off. */
410
+ export function resolveAgentThinking(
411
+ agent: { thinkingEffort?: string | null } | null | undefined,
412
+ prefs: Pick<ProfilePreferences, 'streamThoughts' | 'thinkingBudget'> | null,
413
+ ): ResolvedThinking {
414
+ return applyAgentThinking(agent, profileThinking(prefs));
415
+ }
416
+
417
+ /** The person's profile thinking alone (what an agent on inherit gets). */
418
+ export function profileThinking(
419
+ prefs: Pick<ProfilePreferences, 'streamThoughts' | 'thinkingBudget'> | null,
420
+ ): ResolvedThinking {
421
+ if (!prefs) return NO_THINKING;
422
+ const budget = resolveThinkingBudget(prefs);
423
+ return budget > 0 ? { budget, effort: resolveThinkingEffort(prefs) } : NO_THINKING;
424
+ }
425
+
366
426
  /** Whitelist projection for {@link OnboardingModelChoices} — same contract as
367
427
  * projectThinkingBudget: read and write MUST share this, or the field gets
368
428
  * silently dropped on read. */
@@ -467,9 +527,24 @@ export function projectAppOpens(raw: unknown): Record<string, AppOpenStat> | und
467
527
  return Object.fromEntries(rows.slice(0, APP_OPENS_MAX));
468
528
  }
469
529
 
530
+ /** The house style a NEW brain starts with (Settings, Profile). Seeded only when
531
+ * a profile row is first created, so an owner who edits or clears it keeps
532
+ * their choice. The dash characters are escapes so the source holds none. */
533
+ export const DEFAULT_HOUSE_STYLE =
534
+ 'Never use em dashes (\u2014). Rewrite instead: a comma, a colon, parentheses, or two ' +
535
+ 'sentences all carry the same break, so use whichever reads best. The character should ' +
536
+ 'not appear in your output at all.\n\n' +
537
+ 'The same goes for an en dash (\u2013) used as a sentence break. En dashes inside numeric ' +
538
+ 'ranges (2020\u20132024, pages 10\u201314) are correct and stay, and hyphens in compound ' +
539
+ 'words (self-hosted, read-only) are unaffected.';
540
+
470
541
  export const DEFAULT_PREFERENCES: ProfilePreferences = {
471
542
  timezone: 'UTC',
472
543
  locale: 'en-GB',
544
+ houseStyle: DEFAULT_HOUSE_STYLE,
545
+ // Live thinking on (streamThoughts defaults on) at Medium: the 4096 tier of
546
+ // THINKING_TIERS. Unset would mean no thinking at all.
547
+ thinkingBudget: 4096,
473
548
  };
474
549
 
475
550
  /** IANA tz validation via Intl.DateTimeFormat — the runtime throws
@@ -54,3 +54,43 @@ export function thinkingEffortForBudget(budget: number | undefined): ThinkingEff
54
54
  );
55
55
  return nearest.effort ?? undefined;
56
56
  }
57
+
58
+ /** The values an AGENT's own thinking effort may hold (`agents.thinking_effort`,
59
+ * migration 0228): 'off' plus every tier. NULL (not in this list) means
60
+ * "inherit the person's profile setting". Wider than THINKING_TIERS on
61
+ * purpose: the profile dropdown stops at High, but an agent may ask for the
62
+ * upper rungs; the provider adapters downgrade a rung a model lacks (see
63
+ * anthropicEffort in @mantle/voice). The migration's CHECK holds this list. */
64
+ export const AGENT_THINKING_EFFORTS = ['off', ...THINKING_EFFORTS] as const;
65
+ export type AgentThinkingEffort = (typeof AGENT_THINKING_EFFORTS)[number];
66
+
67
+ /** Labels for the agent select, in order. */
68
+ export const AGENT_THINKING_EFFORT_LABELS: Record<AgentThinkingEffort, string> = {
69
+ off: 'Off',
70
+ low: 'Low',
71
+ medium: 'Medium',
72
+ high: 'High',
73
+ xhigh: 'Extra high',
74
+ max: 'Max',
75
+ };
76
+
77
+ /** Narrow an unknown value to an agent effort. Anything else (null, '',
78
+ * 'inherit', a typo) is null = inherit, so a bad value can never turn
79
+ * reasoning ON. */
80
+ export function parseAgentThinkingEffort(raw: unknown): AgentThinkingEffort | null {
81
+ return typeof raw === 'string' && (AGENT_THINKING_EFFORTS as readonly string[]).includes(raw)
82
+ ? (raw as AgentThinkingEffort)
83
+ : null;
84
+ }
85
+
86
+ /** The budget (tier id) that backs an effort. The runtime still gates on a
87
+ * positive budget and sizes the max_tokens headroom from it (see
88
+ * clampThinkingBudget / resolveMaxTokens in @mantle/runtime), so an agent's
89
+ * effort needs one. The profile tiers keep their ids; the two upper rungs
90
+ * have no profile tier, so they get a larger headroom of their own (24000 is
91
+ * the profile route's ceiling). */
92
+ export function thinkingBudgetForEffort(effort: ThinkingEffort): number {
93
+ const tier = THINKING_TIERS.find((t) => t.effort === effort);
94
+ if (tier) return tier.budget;
95
+ return effort === 'xhigh' ? 16000 : 24000;
96
+ }