@crossworks/content-core 0.232.56 → 0.232.58
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 -1
- package/src/recall-compile.test.ts +185 -0
- package/src/recall-compile.ts +325 -0
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/content-core",
|
|
3
|
-
"version": "0.232.
|
|
3
|
+
"version": "0.232.58",
|
|
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
|
"./markdown": "./src/markdown-to-doc.ts",
|
|
7
7
|
"./markdown-refs": "./src/markdown-refs.ts",
|
|
8
|
+
"./recall-compile": "./src/recall-compile.ts",
|
|
8
9
|
"./doc-to-markdown": "./src/doc-to-markdown.ts",
|
|
9
10
|
"./block-ids": "./src/block-ids.ts",
|
|
10
11
|
"./block-list": "./src/block-list.ts",
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { markdownToDoc } from './markdown-to-doc';
|
|
4
|
+
import {
|
|
5
|
+
RECALL_BODY_CHAR_BUDGET,
|
|
6
|
+
assignRecallSlugs,
|
|
7
|
+
parseRecallDoc,
|
|
8
|
+
recallOptionsMarkdown,
|
|
9
|
+
recallSlug,
|
|
10
|
+
} from './recall-compile';
|
|
11
|
+
|
|
12
|
+
// Docs are built through the real markdown→doc pipeline, so the parser is
|
|
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', () => {
|
|
115
|
+
it('kebab-cases titles', () => {
|
|
116
|
+
expect(recallSlug('Fleet, access & the MCP brains')).toBe('fleet-access-the-mcp-brains');
|
|
117
|
+
expect(recallSlug(' ')).toBe('node');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('dedupes collisions stably in order', () => {
|
|
121
|
+
const slugs = assignRecallSlugs([
|
|
122
|
+
{ id: '1', title: 'Setup' },
|
|
123
|
+
{ id: '2', title: 'Setup' },
|
|
124
|
+
{ id: '3', title: 'Setup' },
|
|
125
|
+
]);
|
|
126
|
+
expect(slugs.get('1')).toBe('setup');
|
|
127
|
+
expect(slugs.get('2')).toBe('setup-2');
|
|
128
|
+
expect(slugs.get('3')).toBe('setup-3');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('audit regressions', () => {
|
|
133
|
+
it('slug dedupe never collides with a literal numbered title', () => {
|
|
134
|
+
const slugs = assignRecallSlugs([
|
|
135
|
+
{ id: '1', title: 'Setup' },
|
|
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
|
+
]);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recall — the pure compile core (S1 of the Recall plan; design page
|
|
3
|
+
* "Recall — architecture plan v1" on the dev brain, roadmap task 97cf7850).
|
|
4
|
+
*
|
|
5
|
+
* Recall serves two kinds of content to agents: MAPS (curated knowledge
|
|
6
|
+
* walked by structure — an index node whose Options block says where to go
|
|
7
|
+
* next and when) and PROMPTS (the actual prompt text, found by similarity).
|
|
8
|
+
* Pages are the authoring layer; at commit they are COMPILED into small
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { PAGE_HREF } from './markdown-refs';
|
|
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).
|
|
38
|
+
* Character-based on purpose: it matches `EMBED_TEXT_*`'s convention and
|
|
39
|
+
* keeps this package dependency-free. */
|
|
40
|
+
export const RECALL_BODY_CHAR_BUDGET = 6000;
|
|
41
|
+
|
|
42
|
+
/** Hard cap on members per map. The compiler recompiles the WHOLE map on any
|
|
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. */
|
|
46
|
+
export const RECALL_MAX_MAP_NODES = 100;
|
|
47
|
+
|
|
48
|
+
export type RecallOption = {
|
|
49
|
+
label: string;
|
|
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'). */
|
|
298
|
+
export function recallSlug(title: string): string {
|
|
299
|
+
const slug = title
|
|
300
|
+
.toLowerCase()
|
|
301
|
+
.normalize('NFKD')
|
|
302
|
+
.replace(/[̀-ͯ]/g, '')
|
|
303
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
304
|
+
.replace(/^-+|-+$/g, '')
|
|
305
|
+
.slice(0, 60)
|
|
306
|
+
.replace(/-+$/g, '');
|
|
307
|
+
return slug || 'node';
|
|
308
|
+
}
|
|
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
|
+
}
|