@crossworks/content-core 0.235.1 → 0.237.2
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 +3 -2
- package/src/agent-thinking.test.ts +78 -0
- package/src/block-list.ts +5 -1
- package/src/doc-to-markdown.ts +9 -1
- package/src/heading-fold.test.ts +211 -0
- package/src/heading-fold.ts +152 -0
- package/src/markdown-to-doc.ts +10 -3
- package/src/profile-projections.ts +76 -1
- package/src/thinking-tiers.ts +40 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/content-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.237.2",
|
|
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.
|
|
36
|
+
"@mantle/client-types": "npm:@crossworks/client-types@0.237.2",
|
|
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
|
-
|
|
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 }
|
package/src/doc-to-markdown.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/markdown-to-doc.ts
CHANGED
|
@@ -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
|
-
|
|
382
|
-
|
|
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 {
|
|
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
|
package/src/thinking-tiers.ts
CHANGED
|
@@ -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
|
+
}
|