@crossworks/content-core 0.232.362 → 0.232.365
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/block-ids.ts +4 -1
- package/src/doc-to-markdown.test.ts +3 -2
- package/src/doc-to-markdown.ts +7 -0
- package/src/markdown-refs.ts +17 -4
- package/src/markdown-to-doc.test.ts +9 -0
- package/src/markdown-to-doc.ts +18 -3
- package/src/recall-compile.test.ts +11 -172
- package/src/recall-compile.ts +11 -304
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/content-core",
|
|
3
|
-
"version": "0.232.
|
|
3
|
+
"version": "0.232.365",
|
|
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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"./tree": "./src/tree.ts"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@mantle/client-types": "npm:@crossworks/client-types@0.232.
|
|
34
|
+
"@mantle/client-types": "npm:@crossworks/client-types@0.232.365",
|
|
35
35
|
"marked": "^18.0.11",
|
|
36
36
|
"mathjs": "15.2.0"
|
|
37
37
|
},
|
package/src/block-ids.ts
CHANGED
|
@@ -66,8 +66,11 @@ const BLOCK_TYPES = new Set([
|
|
|
66
66
|
'fileEmbed',
|
|
67
67
|
'blockMath',
|
|
68
68
|
'diagram',
|
|
69
|
-
//
|
|
69
|
+
// Page link card (Phase 4a; a link, never a parent since folder phase 7),
|
|
70
|
+
// addressable so block tools can move/remove it
|
|
70
71
|
'childPage',
|
|
72
|
+
// Folder index (folder phase 7): a live list of a folder's pages
|
|
73
|
+
'folderIndex',
|
|
71
74
|
]);
|
|
72
75
|
|
|
73
76
|
type AnyNode = {
|
|
@@ -108,10 +108,11 @@ describe('docToMarkdown — round-trip stability', () => {
|
|
|
108
108
|
roundTrips('Inline $E=mc^2$ here.\n\n$$\n\\int_0^1 x\\,dx\n$$');
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
-
it('reference links: mention, uploaded image, file embed,
|
|
111
|
+
it('reference links: mention, uploaded image, file embed, page link card, folder index', () => {
|
|
112
112
|
roundTrips(
|
|
113
113
|
'ping [Sarah](mention:entity:n-1) about [the plan](mention:node:p-2)\n\n' +
|
|
114
|
-
'\n\n[spec.pdf](media:f-2)\n\n[Sub plan](page:p-9)'
|
|
114
|
+
'\n\n[spec.pdf](media:f-2)\n\n[Sub plan](page:p-9)\n\n' +
|
|
115
|
+
'[Folder index](folder:f-3)\n\n[Folder index](folder:here)',
|
|
115
116
|
);
|
|
116
117
|
});
|
|
117
118
|
|
package/src/doc-to-markdown.ts
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
* Pure + DB-free, like markdownToDoc, so it's safe to call from the tool runtime.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import { FOLDER_HERE } from './markdown-refs';
|
|
30
|
+
|
|
29
31
|
type PMMark = { type?: string; attrs?: Record<string, unknown> };
|
|
30
32
|
type PMNode = {
|
|
31
33
|
type?: string;
|
|
@@ -278,6 +280,11 @@ function blockToMd(node: PMNode): string {
|
|
|
278
280
|
const title = (s(node.attrs?.title) || 'Untitled page').replace(/[[\]]/g, '\\$&');
|
|
279
281
|
return `[${title}](page:${s(node.attrs?.pageId)})`;
|
|
280
282
|
}
|
|
283
|
+
case 'folderIndex': {
|
|
284
|
+
// Round-trip syntax: a standalone-line [Folder index](folder:<id>) link;
|
|
285
|
+
// `folder:here` is the page's own folder (folderId null).
|
|
286
|
+
return `[Folder index](folder:${s(node.attrs?.folderId) || FOLDER_HERE})`;
|
|
287
|
+
}
|
|
281
288
|
case 'blockMath': {
|
|
282
289
|
const latex = s(node.attrs?.latex);
|
|
283
290
|
return latex.includes('\n') ? `$$\n${latex}\n$$` : `$$${latex}$$`;
|
package/src/markdown-refs.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
*  an uploaded image, by node id
|
|
6
6
|
* [label](media:<file-id>) a file embed
|
|
7
|
-
* [Title](page:<page-id>) a
|
|
7
|
+
* [Title](page:<page-id>) a page link card
|
|
8
|
+
* [Folder index](folder:<id>) a live list of a folder's pages (`folder:here`
|
|
9
|
+
* for the page's own folder)
|
|
8
10
|
*  an embedded drawing
|
|
9
11
|
* [Label](mention:<ref>:<id>) a mention chip
|
|
10
12
|
*
|
|
@@ -28,8 +30,14 @@
|
|
|
28
30
|
export const MENTION_HREF = /^mention:(?:(node|entity):)?([^\s]+)$/;
|
|
29
31
|
/** `media:<file-id>`, an uploaded file by node id. */
|
|
30
32
|
export const MEDIA_HREF = /^media:([^\s]+)$/;
|
|
31
|
-
/** `page:<page-id>`, a
|
|
33
|
+
/** `page:<page-id>`, a page link card by node id (pages do not nest; the
|
|
34
|
+
* card is a link, folder phase 7). */
|
|
32
35
|
export const PAGE_HREF = /^page:([^\s]+)$/;
|
|
36
|
+
/** `folder:<folder-id>` or `folder:here`, the Folder index block: a live,
|
|
37
|
+
* title-only list of a pages folder's pages, as the reader sees it. `here`
|
|
38
|
+
* is the folder the page itself sits in. */
|
|
39
|
+
export const FOLDER_HREF = /^folder:([^\s]+)$/;
|
|
40
|
+
export const FOLDER_HERE = 'here';
|
|
33
41
|
/** `draw:<draw-id>`, a drawing embedded as a picture. Deliberately a LIVE
|
|
34
42
|
* reference, not a copy: the page renders the drawing's current committed
|
|
35
43
|
* snapshot, so editing the drawing updates every page that embeds it. */
|
|
@@ -87,9 +95,9 @@ export function inlineMediaImageIds(source: string | undefined | null): Set<stri
|
|
|
87
95
|
* the id it points at, and the node type that scheme requires (unset ⇒ any
|
|
88
96
|
* node). `nodeType` is what makes a wrong-type reference reportable. */
|
|
89
97
|
export type MarkdownRef = {
|
|
90
|
-
scheme: 'media' | 'page' | 'mention' | 'draw';
|
|
98
|
+
scheme: 'media' | 'page' | 'mention' | 'draw' | 'folder';
|
|
91
99
|
id: string;
|
|
92
|
-
nodeType?: 'file' | 'page' | 'draw';
|
|
100
|
+
nodeType?: 'file' | 'page' | 'draw' | 'branch';
|
|
93
101
|
};
|
|
94
102
|
|
|
95
103
|
/** Any markdown link or image, capturing the href. */
|
|
@@ -119,6 +127,11 @@ export function markdownRefs(source: string | undefined | null): MarkdownRef[] {
|
|
|
119
127
|
if (page?.[1]) ref = { scheme: 'page', id: page[1], nodeType: 'page' };
|
|
120
128
|
const draw = DRAW_HREF.exec(href);
|
|
121
129
|
if (draw?.[1]) ref = { scheme: 'draw', id: draw[1], nodeType: 'draw' };
|
|
130
|
+
// A Folder index names a folder row; `folder:here` names none.
|
|
131
|
+
const folder = FOLDER_HREF.exec(href);
|
|
132
|
+
if (folder?.[1] && folder[1] !== FOLDER_HERE) {
|
|
133
|
+
ref = { scheme: 'folder', id: folder[1], nodeType: 'branch' };
|
|
134
|
+
}
|
|
122
135
|
const mention = MENTION_HREF.exec(href);
|
|
123
136
|
// Bare `mention:<id>` defaults to an entity — skip; only `mention:node:` is
|
|
124
137
|
// a node reference.
|
|
@@ -160,6 +160,15 @@ describe('markdownToDoc', () => {
|
|
|
160
160
|
pageId: 'p-9',
|
|
161
161
|
title: 'Sub plan',
|
|
162
162
|
});
|
|
163
|
+
// The Folder index block (folder phase 7): an id, or `here` for the
|
|
164
|
+
// page's own folder.
|
|
165
|
+
expect(find('[Folder index](folder:f-3)', 'folderIndex')?.attrs).toMatchObject({
|
|
166
|
+
folderId: 'f-3',
|
|
167
|
+
});
|
|
168
|
+
expect(find('[Folder index](folder:here)', 'folderIndex')?.attrs).toMatchObject({
|
|
169
|
+
folderId: null,
|
|
170
|
+
});
|
|
171
|
+
expect(find('see [x](folder:f-3) here', 'folderIndex')).toBeUndefined();
|
|
163
172
|
// Inline (mixed with prose) media:/page: links stay plain links.
|
|
164
173
|
expect(find('see [spec.pdf](media:f-2) here', 'fileEmbed')).toBeUndefined();
|
|
165
174
|
// Ordinary links are untouched.
|
package/src/markdown-to-doc.ts
CHANGED
|
@@ -29,7 +29,14 @@ import { ensureBlockIds } from './block-ids';
|
|
|
29
29
|
// The reference-link schemes live in their own leaf so the client converter
|
|
30
30
|
// (client/web/lib/rich-markdown.ts) reads the SAME definitions. See
|
|
31
31
|
// markdown-refs.ts for why, and rich-markdown.drift.test.ts for the guard.
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
MENTION_HREF,
|
|
34
|
+
MEDIA_HREF,
|
|
35
|
+
PAGE_HREF,
|
|
36
|
+
DRAW_HREF,
|
|
37
|
+
FOLDER_HREF,
|
|
38
|
+
FOLDER_HERE,
|
|
39
|
+
} from './markdown-refs';
|
|
33
40
|
|
|
34
41
|
type PMMark = { type: string; attrs?: Record<string, unknown> };
|
|
35
42
|
type PMNode = {
|
|
@@ -279,8 +286,9 @@ function paragraphAndImages(tokens: Tok[] | undefined): PMNode[] {
|
|
|
279
286
|
}
|
|
280
287
|
|
|
281
288
|
/** A paragraph consisting solely of one link (whitespace allowed around it)
|
|
282
|
-
* returns that link token; used to lift [file](media:…)
|
|
283
|
-
* standalone lines into their block nodes
|
|
289
|
+
* returns that link token; used to lift [file](media:…), [Title](page:…)
|
|
290
|
+
* and [Folder index](folder:…) standalone lines into their block nodes
|
|
291
|
+
* (fileEmbed / childPage / folderIndex). */
|
|
284
292
|
function soleLink(tokens: Tok[] | undefined): Tok | null {
|
|
285
293
|
let link: Tok | null = null;
|
|
286
294
|
for (const t of tokens ?? []) {
|
|
@@ -314,6 +322,13 @@ function blockRefNode(tokens: Tok[] | undefined): PMNode | null {
|
|
|
314
322
|
attrs: { pageId: page[1]!, title: link.text || 'Untitled page', icon: null },
|
|
315
323
|
};
|
|
316
324
|
}
|
|
325
|
+
const folder = FOLDER_HREF.exec(link.href ?? '');
|
|
326
|
+
if (folder) {
|
|
327
|
+
return {
|
|
328
|
+
type: 'folderIndex',
|
|
329
|
+
attrs: { folderId: folder[1] === FOLDER_HERE ? null : folder[1]! },
|
|
330
|
+
};
|
|
331
|
+
}
|
|
317
332
|
return null;
|
|
318
333
|
}
|
|
319
334
|
|
|
@@ -1,185 +1,24 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
RECALL_BODY_CHAR_BUDGET,
|
|
6
|
-
assignRecallSlugs,
|
|
7
|
-
parseRecallDoc,
|
|
8
|
-
recallOptionsMarkdown,
|
|
9
|
-
recallSlug,
|
|
10
|
-
} from './recall-compile';
|
|
3
|
+
import { RECALL_BODY_CHAR_BUDGET, RECALL_MAX_MAP_NODES, recallSlug } from './recall-compile';
|
|
11
4
|
|
|
12
|
-
|
|
13
|
-
// tested against exactly the node shapes the editor and MCP tools produce.
|
|
14
|
-
|
|
15
|
-
const INDEX_MD = `Use when: working on the Mantle fleet or its brains.
|
|
16
|
-
|
|
17
|
-
The registry index. Read the one you need.
|
|
18
|
-
|
|
19
|
-
## Options
|
|
20
|
-
|
|
21
|
-
- [Fleet access](page:aaa-fleet) — use when logging into a box
|
|
22
|
-
- [Architecture](mention:node:bbb-arch) — use when asking why a design is as it is
|
|
23
|
-
`;
|
|
24
|
-
|
|
25
|
-
describe('parseRecallDoc', () => {
|
|
26
|
-
it('parses body, use-when, and both link forms of options', () => {
|
|
27
|
-
const doc = markdownToDoc(INDEX_MD);
|
|
28
|
-
const parsed = parseRecallDoc(doc);
|
|
29
|
-
|
|
30
|
-
expect(parsed.issues).toEqual([]);
|
|
31
|
-
expect(parsed.useWhen).toBe('working on the Mantle fleet or its brains.');
|
|
32
|
-
expect(parsed.bodyMarkdown).toContain('The registry index');
|
|
33
|
-
expect(parsed.bodyMarkdown).not.toContain('Options');
|
|
34
|
-
expect(parsed.options).toEqual([
|
|
35
|
-
{ label: 'Fleet access', targetPageId: 'aaa-fleet', useWhen: 'use when logging into a box' },
|
|
36
|
-
{
|
|
37
|
-
label: 'Architecture',
|
|
38
|
-
targetPageId: 'bbb-arch',
|
|
39
|
-
useWhen: 'use when asking why a design is as it is',
|
|
40
|
-
},
|
|
41
|
-
]);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('flags an option without a use-when line', () => {
|
|
45
|
-
const doc = markdownToDoc('Body.\n\n## Options\n\n- [Bare link](page:ccc)\n');
|
|
46
|
-
const parsed = parseRecallDoc(doc);
|
|
47
|
-
expect(parsed.options).toEqual([]);
|
|
48
|
-
expect(parsed.issues).toEqual([
|
|
49
|
-
expect.objectContaining({ severity: 'error', code: 'option-no-use-when' }),
|
|
50
|
-
]);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('flags an option without any page target', () => {
|
|
54
|
-
const doc = markdownToDoc('Body.\n\n## Options\n\n- just prose, no link anywhere\n');
|
|
55
|
-
const parsed = parseRecallDoc(doc);
|
|
56
|
-
expect(parsed.issues).toEqual([
|
|
57
|
-
expect.objectContaining({ severity: 'error', code: 'option-no-target' }),
|
|
58
|
-
]);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('flags a malformed Options section (content after the list)', () => {
|
|
62
|
-
const doc = markdownToDoc(
|
|
63
|
-
'Body.\n\n## Options\n\n- [A](page:aaa) — use when x\n\nTrailing prose.\n',
|
|
64
|
-
);
|
|
65
|
-
const parsed = parseRecallDoc(doc);
|
|
66
|
-
expect(parsed.issues).toEqual([
|
|
67
|
-
expect.objectContaining({ severity: 'error', code: 'options-shape' }),
|
|
68
|
-
]);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('treats a doc with no Options heading as option-less, not broken', () => {
|
|
72
|
-
const parsed = parseRecallDoc(markdownToDoc('Just knowledge, no options.'));
|
|
73
|
-
expect(parsed.options).toBeNull();
|
|
74
|
-
expect(parsed.issues).toEqual([]);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('the LAST Options heading opens the section; earlier ones stay body', () => {
|
|
78
|
-
const doc = markdownToDoc(
|
|
79
|
-
'## Options\n\nProse about options in general.\n\n## Options\n\n- [A](page:aaa) — use when x\n',
|
|
80
|
-
);
|
|
81
|
-
const parsed = parseRecallDoc(doc);
|
|
82
|
-
expect(parsed.issues).toEqual([]);
|
|
83
|
-
expect(parsed.bodyMarkdown).toContain('Prose about options in general.');
|
|
84
|
-
expect(parsed.options).toHaveLength(1);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('requires a prompt to declare its use-when', () => {
|
|
88
|
-
const parsed = parseRecallDoc(markdownToDoc('A prompt body with no declaration.'), {
|
|
89
|
-
isPrompt: true,
|
|
90
|
-
});
|
|
91
|
-
expect(parsed.issues).toEqual([
|
|
92
|
-
expect.objectContaining({ severity: 'error', code: 'prompt-no-use-when' }),
|
|
93
|
-
]);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('accepts a prompt that opens with Use when', () => {
|
|
97
|
-
const parsed = parseRecallDoc(
|
|
98
|
-
markdownToDoc('Use when: drafting a jackdaw dialog.\n\nThe prompt body.'),
|
|
99
|
-
{ isPrompt: true },
|
|
100
|
-
);
|
|
101
|
-
expect(parsed.issues).toEqual([]);
|
|
102
|
-
expect(parsed.useWhen).toBe('drafting a jackdaw dialog.');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('enforces the character budget on the rendered body', () => {
|
|
106
|
-
const parsed = parseRecallDoc(markdownToDoc('x'.repeat(200)), { bodyCharBudget: 100 });
|
|
107
|
-
expect(parsed.issues).toEqual([
|
|
108
|
-
expect.objectContaining({ severity: 'error', code: 'body-over-budget' }),
|
|
109
|
-
]);
|
|
110
|
-
expect(RECALL_BODY_CHAR_BUDGET).toBeGreaterThan(1000);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe('slugs', () => {
|
|
5
|
+
describe('recallSlug', () => {
|
|
115
6
|
it('kebab-cases titles', () => {
|
|
116
7
|
expect(recallSlug('Fleet, access & the MCP brains')).toBe('fleet-access-the-mcp-brains');
|
|
117
8
|
expect(recallSlug(' ')).toBe('node');
|
|
118
9
|
});
|
|
119
10
|
|
|
120
|
-
it('
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
]);
|
|
126
|
-
expect(slugs.get('1')).toBe('setup');
|
|
127
|
-
expect(slugs.get('2')).toBe('setup-2');
|
|
128
|
-
expect(slugs.get('3')).toBe('setup-3');
|
|
11
|
+
it('strips accents and cuts at 60 characters with no trailing dash', () => {
|
|
12
|
+
expect(recallSlug('Café Übersicht')).toBe('cafe-ubersicht');
|
|
13
|
+
const long = recallSlug('word '.repeat(30));
|
|
14
|
+
expect(long.length).toBeLessThanOrEqual(60);
|
|
15
|
+
expect(long.endsWith('-')).toBe(false);
|
|
129
16
|
});
|
|
130
17
|
});
|
|
131
18
|
|
|
132
|
-
describe('
|
|
133
|
-
it('
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
{ id: '2', title: 'Setup 2' },
|
|
137
|
-
{ id: '3', title: 'Setup' },
|
|
138
|
-
]);
|
|
139
|
-
expect(new Set(slugs.values()).size).toBe(3);
|
|
140
|
-
expect(slugs.get('1')).toBe('setup');
|
|
141
|
-
expect(slugs.get('2')).toBe('setup-2');
|
|
142
|
-
expect(slugs.get('3')).toBe('setup-3');
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('use-when survives the label text appearing before the link', () => {
|
|
146
|
-
const doc = markdownToDoc(
|
|
147
|
-
'Body.\n\n## Options\n\n- Deploy notes: [Deploy](page:aaa) — use when shipping\n',
|
|
148
|
-
);
|
|
149
|
-
const parsed = parseRecallDoc(doc);
|
|
150
|
-
expect(parsed.issues).toEqual([]);
|
|
151
|
-
expect(parsed.options).toEqual([
|
|
152
|
-
{ label: 'Deploy', targetPageId: 'aaa', useWhen: 'use when shipping' },
|
|
153
|
-
]);
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
describe('recallOptionsMarkdown', () => {
|
|
158
|
-
const OPTIONS = [
|
|
159
|
-
{ label: 'Fleet access', targetPageId: 'aaa-fleet', useWhen: 'use when logging into a box' },
|
|
160
|
-
{ label: 'Architecture', targetPageId: 'bbb-arch', useWhen: 'use when asking why' },
|
|
161
|
-
];
|
|
162
|
-
|
|
163
|
-
it('round-trips through markdownToDoc + parseRecallDoc unchanged', () => {
|
|
164
|
-
const md = `Body text.\n\n${recallOptionsMarkdown(OPTIONS)}`;
|
|
165
|
-
const parsed = parseRecallDoc(markdownToDoc(md));
|
|
166
|
-
expect(parsed.issues).toEqual([]);
|
|
167
|
-
expect(parsed.options).toEqual(OPTIONS);
|
|
168
|
-
expect(parsed.bodyMarkdown).toBe('Body text.');
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('emits nothing for an empty list (a node with no options has no section)', () => {
|
|
172
|
-
expect(recallOptionsMarkdown([])).toBe('');
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('normalizes whitespace and strips brackets that would break the link syntax', () => {
|
|
176
|
-
const md = `Body.\n\n${recallOptionsMarkdown([
|
|
177
|
-
{ label: ' Fleet\n[access] ', targetPageId: ' aaa ', useWhen: 'use when\nlogging in' },
|
|
178
|
-
])}`;
|
|
179
|
-
const parsed = parseRecallDoc(markdownToDoc(md));
|
|
180
|
-
expect(parsed.issues).toEqual([]);
|
|
181
|
-
expect(parsed.options).toEqual([
|
|
182
|
-
{ label: 'Fleet access', targetPageId: 'aaa', useWhen: 'use when logging in' },
|
|
183
|
-
]);
|
|
19
|
+
describe('the caps clients share', () => {
|
|
20
|
+
it('holds the body budget and the card cap the editor counts against', () => {
|
|
21
|
+
expect(RECALL_BODY_CHAR_BUDGET).toBe(6000);
|
|
22
|
+
expect(RECALL_MAX_MAP_NODES).toBe(100);
|
|
184
23
|
});
|
|
185
24
|
});
|
package/src/recall-compile.ts
CHANGED
|
@@ -1,300 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Recall — the
|
|
3
|
-
*
|
|
2
|
+
* Recall — the shared constants and the slug rule, read by the brain's native
|
|
3
|
+
* write path (packages/content/src/recall-native.ts) and by clients, so the
|
|
4
|
+
* editor's counter and the brain's write check always agree.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* serving rows so the hot path is a single indexed read — this module is the
|
|
10
|
-
* parse-and-lint half of that compiler, DB-free so it can be unit-tested and
|
|
11
|
-
* shared.
|
|
12
|
-
*
|
|
13
|
-
* The authoring convention (settled with Jason 2026-08-23):
|
|
14
|
-
* - a map is a page tree whose ROOT page carries the `recall` tag;
|
|
15
|
-
* - a node's next steps live in a trailing `## Options` section — a bullet
|
|
16
|
-
* list of `[label](page:<id>) — use when …` (a mention chip or child-page
|
|
17
|
-
* card work identically as the target);
|
|
18
|
-
* - a prompt is a page tagged `prompt`, and declares its matcher line in a
|
|
19
|
-
* leading "Use when: …" paragraph;
|
|
20
|
-
* - bodies are budgeted in CHARACTERS (the repo's size-budget convention —
|
|
21
|
-
* there is deliberately no tokenizer dependency here).
|
|
22
|
-
*
|
|
23
|
-
* Lint severity contract: `error` blocks the COMPILE (the page still commits
|
|
24
|
-
* and the map keeps serving its last good rev); `warning` never blocks.
|
|
6
|
+
* The file name is historical: it held the v1 page compiler (parseRecallDoc,
|
|
7
|
+
* assignRecallSlugs and the lint types) until R5 retired page-built maps.
|
|
8
|
+
* The subpath `@mantle/content-core/recall-compile` stays, because clients
|
|
9
|
+
* import these constants from it.
|
|
25
10
|
*/
|
|
26
11
|
|
|
27
|
-
|
|
28
|
-
import { docToMarkdown } from './doc-to-markdown';
|
|
29
|
-
|
|
30
|
-
/** Tag on a tree's ROOT page that makes the whole tree a Recall map. */
|
|
31
|
-
export const RECALL_TAG = 'recall';
|
|
32
|
-
|
|
33
|
-
/** Tag on a page (inside a map, or standalone-tagged `recall`) marking it a
|
|
34
|
-
* prompt — embedded for `recall_match`, and required to carry a use-when. */
|
|
35
|
-
export const RECALL_PROMPT_TAG = 'prompt';
|
|
36
|
-
|
|
37
|
-
/** Body budget per node, in characters of rendered markdown (~1.5k tokens).
|
|
12
|
+
/** Body budget per card, in characters of markdown (~1.5k tokens).
|
|
38
13
|
* Character-based on purpose: it matches `EMBED_TEXT_*`'s convention and
|
|
39
14
|
* keeps this package dependency-free. */
|
|
40
15
|
export const RECALL_BODY_CHAR_BUDGET = 6000;
|
|
41
16
|
|
|
42
|
-
/** Hard cap on
|
|
43
|
-
* member commit, so an unbounded tree turns every save into a bulk job —
|
|
44
|
-
* past this, the lint refuses (last good rev keeps serving). A map this big
|
|
45
|
-
* has stopped being a map anyway. */
|
|
17
|
+
/** Hard cap on cards per map. A map this big has stopped being a map. */
|
|
46
18
|
export const RECALL_MAX_MAP_NODES = 100;
|
|
47
19
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
/** The target page's node id, from `page:`/`mention:node:` refs or a
|
|
51
|
-
* child-page card. Resolved to a slug by the DB half of the compiler. */
|
|
52
|
-
targetPageId: string;
|
|
53
|
-
useWhen: string;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
export type RecallLintIssue = {
|
|
57
|
-
severity: 'error' | 'warning';
|
|
58
|
-
code:
|
|
59
|
-
| 'options-shape'
|
|
60
|
-
| 'option-no-target'
|
|
61
|
-
| 'option-no-use-when'
|
|
62
|
-
| 'body-over-budget'
|
|
63
|
-
| 'prompt-no-use-when'
|
|
64
|
-
| 'index-no-options'
|
|
65
|
-
| 'target-outside-map'
|
|
66
|
-
| 'map-too-big'
|
|
67
|
-
| 'orphan-node';
|
|
68
|
-
message: string;
|
|
69
|
-
/** Which page the issue is about (filled by the map-level compiler when it
|
|
70
|
-
* aggregates; the doc-level parser leaves it for the caller). */
|
|
71
|
-
pageId?: string;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
export type ParsedRecallNode = {
|
|
75
|
-
/** Rendered markdown of the body — everything BEFORE the Options section. */
|
|
76
|
-
bodyMarkdown: string;
|
|
77
|
-
/** The "Use when: …" declaration from the leading paragraph, if present. */
|
|
78
|
-
useWhen: string | null;
|
|
79
|
-
/** Parsed options, or null when the doc has no Options section at all. */
|
|
80
|
-
options: RecallOption[] | null;
|
|
81
|
-
issues: RecallLintIssue[];
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
type PMNode = {
|
|
85
|
-
type?: string;
|
|
86
|
-
attrs?: Record<string, unknown>;
|
|
87
|
-
content?: PMNode[];
|
|
88
|
-
marks?: { type?: string; attrs?: Record<string, unknown> }[];
|
|
89
|
-
text?: string;
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const s = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
93
|
-
|
|
94
|
-
/** Plain text of a node's inline content (text runs + mention labels). */
|
|
95
|
-
function inlineText(node: PMNode): string {
|
|
96
|
-
if (node.type === 'text') return s(node.text);
|
|
97
|
-
if (node.type === 'mention') return s(node.attrs?.label);
|
|
98
|
-
if (node.type === 'hardBreak') return ' ';
|
|
99
|
-
return (node.content ?? []).map(inlineText).join('');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** The heading that opens the Options section: any level, text "options". */
|
|
103
|
-
function isOptionsHeading(node: PMNode): boolean {
|
|
104
|
-
return node.type === 'heading' && inlineText(node).trim().toLowerCase() === 'options';
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const USE_WHEN_RE = /^use when\b[:\s—–-]*/i;
|
|
108
|
-
|
|
109
|
-
/** A leading "Use when: …" paragraph anywhere in the first three blocks —
|
|
110
|
-
* title-adjacent, so authors can open with an intro line first. */
|
|
111
|
-
function extractUseWhen(body: PMNode[]): string | null {
|
|
112
|
-
for (const node of body.slice(0, 3)) {
|
|
113
|
-
if (node.type !== 'paragraph') continue;
|
|
114
|
-
const text = inlineText(node).trim();
|
|
115
|
-
if (USE_WHEN_RE.test(text)) {
|
|
116
|
-
const rest = text.replace(USE_WHEN_RE, '').trim();
|
|
117
|
-
if (rest) return rest;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Flatten a list item's leaf nodes in document order — the basis for the
|
|
124
|
-
* positional label/use-when split (a string search would misfire when the
|
|
125
|
-
* label's text also appears elsewhere in the item). */
|
|
126
|
-
function flattenLeaves(node: PMNode, out: PMNode[] = []): PMNode[] {
|
|
127
|
-
if (node.type === 'text' || node.type === 'mention' || node.type === 'childPage') {
|
|
128
|
-
out.push(node);
|
|
129
|
-
return out;
|
|
130
|
-
}
|
|
131
|
-
for (const child of node.content ?? []) flattenLeaves(child, out);
|
|
132
|
-
return out;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function pageLinkHref(node: PMNode): string | null {
|
|
136
|
-
if (node.type !== 'text') return null;
|
|
137
|
-
for (const mark of node.marks ?? []) {
|
|
138
|
-
if (mark.type === 'link' && PAGE_HREF.test(s(mark.attrs?.href))) return s(mark.attrs?.href);
|
|
139
|
-
}
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Leading separators between the link and its use-when text: "— use when…". */
|
|
144
|
-
const SEPARATOR_RE = /^[\s—–:-]+/;
|
|
145
|
-
|
|
146
|
-
function parseOptionItem(
|
|
147
|
-
item: PMNode,
|
|
148
|
-
ordinal: number,
|
|
149
|
-
): { option?: RecallOption; issue?: RecallLintIssue } {
|
|
150
|
-
const leaves = flattenLeaves(item);
|
|
151
|
-
|
|
152
|
-
// Find the target span: contiguous text runs sharing one page-link href,
|
|
153
|
-
// or a single mention chip / child-page card.
|
|
154
|
-
let target: { id: string; label: string } | null = null;
|
|
155
|
-
let afterAt = leaves.length;
|
|
156
|
-
for (let i = 0; i < leaves.length; i++) {
|
|
157
|
-
const leaf = leaves[i]!;
|
|
158
|
-
const href = pageLinkHref(leaf);
|
|
159
|
-
if (href) {
|
|
160
|
-
const id = PAGE_HREF.exec(href)![1]!;
|
|
161
|
-
let end = i;
|
|
162
|
-
while (end + 1 < leaves.length && pageLinkHref(leaves[end + 1]!) === href) end++;
|
|
163
|
-
target = {
|
|
164
|
-
id,
|
|
165
|
-
label: leaves
|
|
166
|
-
.slice(i, end + 1)
|
|
167
|
-
.map(inlineText)
|
|
168
|
-
.join('')
|
|
169
|
-
.trim(),
|
|
170
|
-
};
|
|
171
|
-
afterAt = end + 1;
|
|
172
|
-
break;
|
|
173
|
-
}
|
|
174
|
-
if (leaf.type === 'mention' && s(leaf.attrs?.ref) === 'node') {
|
|
175
|
-
target = { id: s(leaf.attrs?.id), label: s(leaf.attrs?.label).trim() };
|
|
176
|
-
afterAt = i + 1;
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
179
|
-
if (leaf.type === 'childPage') {
|
|
180
|
-
target = { id: s(leaf.attrs?.pageId), label: s(leaf.attrs?.title).trim() };
|
|
181
|
-
afterAt = i + 1;
|
|
182
|
-
break;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (!target || !target.id) {
|
|
187
|
-
return {
|
|
188
|
-
issue: {
|
|
189
|
-
severity: 'error',
|
|
190
|
-
code: 'option-no-target',
|
|
191
|
-
message: `Option ${ordinal}: no page link — each option needs a [label](page:<id>) target.`,
|
|
192
|
-
},
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
const useWhen = leaves.slice(afterAt).map(inlineText).join('').replace(SEPARATOR_RE, '').trim();
|
|
196
|
-
if (!useWhen) {
|
|
197
|
-
return {
|
|
198
|
-
issue: {
|
|
199
|
-
severity: 'error',
|
|
200
|
-
code: 'option-no-use-when',
|
|
201
|
-
message: `Option ${ordinal} (“${target.label || target.id}”): missing the “use when …” line that tells an agent when to follow it.`,
|
|
202
|
-
},
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
return { option: { label: target.label || 'Untitled', targetPageId: target.id, useWhen } };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Parse one committed page doc into its Recall shape. Pure — no DB, no
|
|
210
|
-
* throwing; problems come back as lint issues. `isPrompt` adds the
|
|
211
|
-
* prompt-specific checks (a prompt must declare its use-when).
|
|
212
|
-
*/
|
|
213
|
-
export function parseRecallDoc(
|
|
214
|
-
doc: unknown,
|
|
215
|
-
opts: { isPrompt?: boolean; bodyCharBudget?: number } = {},
|
|
216
|
-
): ParsedRecallNode {
|
|
217
|
-
const budget = opts.bodyCharBudget ?? RECALL_BODY_CHAR_BUDGET;
|
|
218
|
-
const issues: RecallLintIssue[] = [];
|
|
219
|
-
const content: PMNode[] = Array.isArray((doc as PMNode)?.content)
|
|
220
|
-
? ((doc as PMNode).content as PMNode[])
|
|
221
|
-
: [];
|
|
222
|
-
|
|
223
|
-
// The LAST "Options" heading opens the section; an author writing about
|
|
224
|
-
// options earlier in the body keeps that text in the body.
|
|
225
|
-
let headingAt = -1;
|
|
226
|
-
for (let i = 0; i < content.length; i++) {
|
|
227
|
-
if (isOptionsHeading(content[i]!)) headingAt = i;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const body = headingAt === -1 ? content : content.slice(0, headingAt);
|
|
231
|
-
let options: RecallOption[] | null = null;
|
|
232
|
-
|
|
233
|
-
if (headingAt !== -1) {
|
|
234
|
-
const tail = content
|
|
235
|
-
.slice(headingAt + 1)
|
|
236
|
-
.filter((n) => !(n.type === 'paragraph' && inlineText(n).trim() === ''));
|
|
237
|
-
const [list, ...extra] = tail;
|
|
238
|
-
if (!list || list.type !== 'bulletList' || extra.length > 0) {
|
|
239
|
-
issues.push({
|
|
240
|
-
severity: 'error',
|
|
241
|
-
code: 'options-shape',
|
|
242
|
-
message:
|
|
243
|
-
'The Options section must be exactly one bullet list of “[label](page:<id>) — use when …” items, with nothing after it.',
|
|
244
|
-
});
|
|
245
|
-
options = [];
|
|
246
|
-
} else {
|
|
247
|
-
options = [];
|
|
248
|
-
(list.content ?? []).forEach((item, i) => {
|
|
249
|
-
const parsed = parseOptionItem(item, i + 1);
|
|
250
|
-
if (parsed.issue) issues.push(parsed.issue);
|
|
251
|
-
if (parsed.option) options!.push(parsed.option);
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const bodyMarkdown = docToMarkdown({ type: 'doc', content: body }).trim();
|
|
257
|
-
if (bodyMarkdown.length > budget) {
|
|
258
|
-
issues.push({
|
|
259
|
-
severity: 'error',
|
|
260
|
-
code: 'body-over-budget',
|
|
261
|
-
message: `Body is ${bodyMarkdown.length} characters; the budget is ${budget}. Recall nodes stay small — split the page or trim it.`,
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const useWhen = extractUseWhen(body);
|
|
266
|
-
if (opts.isPrompt && !useWhen) {
|
|
267
|
-
issues.push({
|
|
268
|
-
severity: 'error',
|
|
269
|
-
code: 'prompt-no-use-when',
|
|
270
|
-
message:
|
|
271
|
-
'A prompt page must open with a “Use when: …” paragraph — it is the line recall_match shows to callers.',
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return { bodyMarkdown, useWhen, options, issues };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* The ONE writer for a node's `## Options` section. Every author path — the
|
|
280
|
-
* owner UI's routing editor and the agent-side authoring tools — must emit
|
|
281
|
-
* options through this, so human-authored and agent-authored options are
|
|
282
|
-
* byte-identical and always round-trip through `parseRecallDoc`. Compose a
|
|
283
|
-
* full body as `bodyMarkdown + '\n\n' + recallOptionsMarkdown(options)`.
|
|
284
|
-
*/
|
|
285
|
-
export function recallOptionsMarkdown(
|
|
286
|
-
options: { label: string; targetPageId: string; useWhen: string }[],
|
|
287
|
-
): string {
|
|
288
|
-
if (options.length === 0) return '';
|
|
289
|
-
const line = (v: string) => v.replace(/\s+/g, ' ').trim();
|
|
290
|
-
const items = options.map((o) => {
|
|
291
|
-
const label = line(o.label).replace(/[[\]]/g, '') || 'Untitled';
|
|
292
|
-
return `- [${label}](page:${o.targetPageId.trim()}) — ${line(o.useWhen)}`;
|
|
293
|
-
});
|
|
294
|
-
return `## Options\n\n${items.join('\n')}\n`;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** Kebab-case a title into a stable slug ('Fleet, access & MCP' → 'fleet-access-mcp'). */
|
|
20
|
+
/** Kebab-case a title into a stable slug ('Fleet, access & MCP' → 'fleet-access-mcp').
|
|
21
|
+
* Cut at 60 characters; `recallNativeSlug` backs that cut up to a word. */
|
|
298
22
|
export function recallSlug(title: string): string {
|
|
299
23
|
const slug = title
|
|
300
24
|
.toLowerCase()
|
|
@@ -306,20 +30,3 @@ export function recallSlug(title: string): string {
|
|
|
306
30
|
.replace(/-+$/g, '');
|
|
307
31
|
return slug || 'node';
|
|
308
32
|
}
|
|
309
|
-
|
|
310
|
-
/** Assign unique slugs in tree order: first keeps the bare slug, collisions
|
|
311
|
-
* count up until FREE — checked against every emitted slug, so a literal
|
|
312
|
-
* title like "Setup 2" can never collide with a generated "setup-2". Stable
|
|
313
|
-
* as long as titles and order are stable. */
|
|
314
|
-
export function assignRecallSlugs(titles: { id: string; title: string }[]): Map<string, string> {
|
|
315
|
-
const used = new Set<string>();
|
|
316
|
-
const out = new Map<string, string>();
|
|
317
|
-
for (const { id, title } of titles) {
|
|
318
|
-
const base = recallSlug(title);
|
|
319
|
-
let slug = base;
|
|
320
|
-
for (let n = 2; used.has(slug); n++) slug = `${base}-${n}`;
|
|
321
|
-
used.add(slug);
|
|
322
|
-
out.set(id, slug);
|
|
323
|
-
}
|
|
324
|
-
return out;
|
|
325
|
-
}
|