@crossworks/content-core 0.232.55 → 0.232.57
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/journal-options.test.ts +46 -15
- package/src/journal-options.ts +61 -47
- package/src/recall-compile.test.ts +154 -0
- package/src/recall-compile.ts +306 -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.57",
|
|
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",
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
AGENT_KIND_KEYS,
|
|
4
|
+
KIND_KEYS,
|
|
5
|
+
USER_KIND_KEYS,
|
|
6
|
+
kindLabel,
|
|
7
|
+
kindLane,
|
|
8
|
+
legacyCategoryToKind,
|
|
9
|
+
normalizeEntryDate,
|
|
10
|
+
} from './journal-options';
|
|
3
11
|
|
|
4
12
|
describe('normalizeEntryDate', () => {
|
|
5
13
|
it('passes through a full ISO timestamp (canonicalised)', () => {
|
|
@@ -32,26 +40,49 @@ describe('normalizeEntryDate', () => {
|
|
|
32
40
|
});
|
|
33
41
|
});
|
|
34
42
|
|
|
35
|
-
describe('
|
|
36
|
-
it('
|
|
37
|
-
expect(
|
|
43
|
+
describe('kind vocabulary', () => {
|
|
44
|
+
it('splits cleanly into the two lanes', () => {
|
|
45
|
+
expect(USER_KIND_KEYS).toEqual(['identity', 'context', 'preference', 'goal']);
|
|
46
|
+
expect(AGENT_KIND_KEYS).toEqual(['lesson', 'expectation', 'gap']);
|
|
47
|
+
expect(KIND_KEYS).toEqual([...USER_KIND_KEYS, ...AGENT_KIND_KEYS]);
|
|
38
48
|
});
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('kindLabel', () => {
|
|
52
|
+
it('maps a known kind key to its label', () => {
|
|
53
|
+
expect(kindLabel('expectation')).toBe('Expectation');
|
|
54
|
+
expect(kindLabel('gap')).toBe('Open question');
|
|
55
|
+
});
|
|
56
|
+
it('title-cases an unknown/free-text kind', () => {
|
|
57
|
+
expect(kindLabel('hobbies')).toBe('Hobbies');
|
|
41
58
|
});
|
|
42
|
-
it('returns null for no
|
|
43
|
-
expect(
|
|
59
|
+
it('returns null for no kind', () => {
|
|
60
|
+
expect(kindLabel(null)).toBeNull();
|
|
44
61
|
});
|
|
45
62
|
});
|
|
46
63
|
|
|
47
|
-
describe('
|
|
48
|
-
it('
|
|
49
|
-
expect(
|
|
64
|
+
describe('kindLane', () => {
|
|
65
|
+
it('routes agent kinds to the agent lane', () => {
|
|
66
|
+
expect(kindLane('lesson')).toBe('agent');
|
|
67
|
+
expect(kindLane('expectation')).toBe('agent');
|
|
68
|
+
expect(kindLane('gap')).toBe('agent');
|
|
50
69
|
});
|
|
51
|
-
it('
|
|
52
|
-
expect(
|
|
70
|
+
it('routes user kinds — and anything unknown — to the user lane', () => {
|
|
71
|
+
expect(kindLane('identity')).toBe('user');
|
|
72
|
+
expect(kindLane('made-up')).toBe('user');
|
|
73
|
+
expect(kindLane(null)).toBe('user');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('legacyCategoryToKind', () => {
|
|
78
|
+
it('carries identity and goal over', () => {
|
|
79
|
+
expect(legacyCategoryToKind('identity')).toBe('identity');
|
|
80
|
+
expect(legacyCategoryToKind('goal')).toBe('goal');
|
|
53
81
|
});
|
|
54
|
-
it('
|
|
55
|
-
expect(
|
|
82
|
+
it('maps every other legacy life area (and none) to context', () => {
|
|
83
|
+
expect(legacyCategoryToKind('work')).toBe('context');
|
|
84
|
+
expect(legacyCategoryToKind('faith')).toBe('context');
|
|
85
|
+
expect(legacyCategoryToKind('emotion')).toBe('context');
|
|
86
|
+
expect(legacyCategoryToKind(null)).toBe('context');
|
|
56
87
|
});
|
|
57
88
|
});
|
package/src/journal-options.ts
CHANGED
|
@@ -1,65 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser-safe leaf for Journal option lists (
|
|
2
|
+
* Browser-safe leaf for Journal option lists (kinds + gap statuses).
|
|
3
3
|
*
|
|
4
4
|
* These constants are needed both server-side (CRUD, extractor framing, the
|
|
5
|
-
* identity-
|
|
6
|
-
* They live in their own module — with NO `@mantle/db` import — so a
|
|
7
|
-
* component can pull them in without dragging `postgres` into the
|
|
8
|
-
* bundle. Same pattern as `contacts-format.ts`. `journal.ts`
|
|
5
|
+
* identity/working-notes distillers) and client-side (the /journal editor +
|
|
6
|
+
* filters). They live in their own module — with NO `@mantle/db` import — so a
|
|
7
|
+
* client component can pull them in without dragging `postgres` into the
|
|
8
|
+
* browser bundle. Same pattern as `contacts-format.ts`. `journal.ts`
|
|
9
|
+
* re-exports these.
|
|
10
|
+
*
|
|
11
|
+
* Journal v2 (2026-08): the mood palette and life-area categories are GONE.
|
|
12
|
+
* A journal entry is now one of a small set of KINDS across two lanes:
|
|
13
|
+
* - user lane: durable self-knowledge that grounds every agent turn
|
|
14
|
+
* - agent lane: what an agent has learned about doing its job well
|
|
15
|
+
* Legacy rows keep their old `mood`/`category` jsonb values; nothing reads
|
|
16
|
+
* `mood` anymore, and `category` maps to a kind at read time (see
|
|
17
|
+
* `legacyCategoryToKind`). No migration, no backfill.
|
|
9
18
|
*/
|
|
10
19
|
|
|
11
|
-
/**
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
export const MOODS = [
|
|
15
|
-
{ key: 'happy', label: 'Happy', emoji: '😀' },
|
|
16
|
-
{ key: 'grateful', label: 'Grateful', emoji: '🙏' },
|
|
17
|
-
{ key: 'calm', label: 'Calm', emoji: '😌' },
|
|
18
|
-
{ key: 'excited', label: 'Excited', emoji: '🤩' },
|
|
19
|
-
{ key: 'hopeful', label: 'Hopeful', emoji: '🌱' },
|
|
20
|
-
{ key: 'reflective', label: 'Reflective', emoji: '🤔' },
|
|
21
|
-
{ key: 'tired', label: 'Tired', emoji: '😮💨' },
|
|
22
|
-
{ key: 'anxious', label: 'Anxious', emoji: '😟' },
|
|
23
|
-
{ key: 'sad', label: 'Sad', emoji: '😔' },
|
|
24
|
-
{ key: 'angry', label: 'Angry', emoji: '😠' },
|
|
25
|
-
] as const;
|
|
26
|
-
|
|
27
|
-
export type MoodKey = (typeof MOODS)[number]['key'];
|
|
28
|
-
export const MOOD_KEYS: readonly string[] = MOODS.map((m) => m.key);
|
|
20
|
+
/** The lane a kind belongs to. User-lane entries feed the "# About the user"
|
|
21
|
+
* block; agent-lane entries feed the per-turn "# Working notes" block. */
|
|
22
|
+
export type JournalLane = 'user' | 'agent';
|
|
29
23
|
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
export const
|
|
33
|
-
|
|
34
|
-
{ key: '
|
|
35
|
-
{ key: '
|
|
36
|
-
{ key: '
|
|
37
|
-
{ key: '
|
|
38
|
-
|
|
39
|
-
{ key: '
|
|
40
|
-
{ key: '
|
|
41
|
-
{ key: '
|
|
24
|
+
/** The full kind vocabulary. Stored as the bare key string in `data.kind`;
|
|
25
|
+
* free text is tolerated on read, but pickers/tools offer these. */
|
|
26
|
+
export const KINDS = [
|
|
27
|
+
// ── user lane: who the user/org is and what they want ──────────────────
|
|
28
|
+
{ key: 'identity', lane: 'user', label: 'Identity' },
|
|
29
|
+
{ key: 'context', lane: 'user', label: 'Context' },
|
|
30
|
+
{ key: 'preference', lane: 'user', label: 'Preference' },
|
|
31
|
+
{ key: 'goal', lane: 'user', label: 'Goal' },
|
|
32
|
+
// ── agent lane: what an agent has learned about doing its job ──────────
|
|
33
|
+
{ key: 'lesson', lane: 'agent', label: 'Lesson' },
|
|
34
|
+
{ key: 'expectation', lane: 'agent', label: 'Expectation' },
|
|
35
|
+
{ key: 'gap', lane: 'agent', label: 'Open question' },
|
|
42
36
|
] as const;
|
|
43
37
|
|
|
44
|
-
export type
|
|
45
|
-
export const
|
|
38
|
+
export type KindKey = (typeof KINDS)[number]['key'];
|
|
39
|
+
export const KIND_KEYS: readonly string[] = KINDS.map((k) => k.key);
|
|
40
|
+
export const USER_KIND_KEYS: readonly string[] = KINDS.filter((k) => k.lane === 'user').map(
|
|
41
|
+
(k) => k.key,
|
|
42
|
+
);
|
|
43
|
+
export const AGENT_KIND_KEYS: readonly string[] = KINDS.filter((k) => k.lane === 'agent').map(
|
|
44
|
+
(k) => k.key,
|
|
45
|
+
);
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
export
|
|
49
|
-
|
|
50
|
-
const found = MOODS.find((m) => m.key === key);
|
|
51
|
-
if (found) return { emoji: found.emoji, label: found.label };
|
|
52
|
-
return { emoji: '', label: key };
|
|
53
|
-
}
|
|
47
|
+
/** Gap-entry lifecycle. Only entries with kind='gap' carry a status. */
|
|
48
|
+
export const GAP_STATUSES = ['open', 'resolved'] as const;
|
|
49
|
+
export type GapStatus = (typeof GAP_STATUSES)[number];
|
|
54
50
|
|
|
55
|
-
/**
|
|
56
|
-
export function
|
|
51
|
+
/** Kind key → human label, tolerant of free-text/unknown values. */
|
|
52
|
+
export function kindLabel(key: string | null): string | null {
|
|
57
53
|
if (!key) return null;
|
|
58
|
-
const found =
|
|
54
|
+
const found = KINDS.find((k) => k.key === key);
|
|
59
55
|
if (found) return found.label;
|
|
60
56
|
return key.charAt(0).toUpperCase() + key.slice(1);
|
|
61
57
|
}
|
|
62
58
|
|
|
59
|
+
/** Which lane a kind belongs to. Unknown/free-text kinds read as user lane —
|
|
60
|
+
* the safe default (they render as user self-knowledge, never as agent
|
|
61
|
+
* working notes). */
|
|
62
|
+
export function kindLane(key: string | null): JournalLane {
|
|
63
|
+
const found = KINDS.find((k) => k.key === key);
|
|
64
|
+
return found?.lane ?? 'user';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Map a legacy pre-v2 `category` value to a kind, for rows written before
|
|
68
|
+
* the kind vocabulary existed. `identity`/`goal` carry over; every other
|
|
69
|
+
* life area (work, family, faith, health, emotion, …) reads as `context`.
|
|
70
|
+
* Only consulted when a row has no `kind`. */
|
|
71
|
+
export function legacyCategoryToKind(category: string | null): KindKey {
|
|
72
|
+
if (category === 'identity') return 'identity';
|
|
73
|
+
if (category === 'goal') return 'goal';
|
|
74
|
+
return 'context';
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
/**
|
|
64
78
|
* Normalise a user/agent-supplied entry date to a canonical ISO-8601 string,
|
|
65
79
|
* or return null if it isn't a real date. Stored `entry_date` is later cast to
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
recallSlug,
|
|
9
|
+
} from './recall-compile';
|
|
10
|
+
|
|
11
|
+
// Docs are built through the real markdown→doc pipeline, so the parser is
|
|
12
|
+
// tested against exactly the node shapes the editor and MCP tools produce.
|
|
13
|
+
|
|
14
|
+
const INDEX_MD = `Use when: working on the Mantle fleet or its brains.
|
|
15
|
+
|
|
16
|
+
The registry index. Read the one you need.
|
|
17
|
+
|
|
18
|
+
## Options
|
|
19
|
+
|
|
20
|
+
- [Fleet access](page:aaa-fleet) — use when logging into a box
|
|
21
|
+
- [Architecture](mention:node:bbb-arch) — use when asking why a design is as it is
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
describe('parseRecallDoc', () => {
|
|
25
|
+
it('parses body, use-when, and both link forms of options', () => {
|
|
26
|
+
const doc = markdownToDoc(INDEX_MD);
|
|
27
|
+
const parsed = parseRecallDoc(doc);
|
|
28
|
+
|
|
29
|
+
expect(parsed.issues).toEqual([]);
|
|
30
|
+
expect(parsed.useWhen).toBe('working on the Mantle fleet or its brains.');
|
|
31
|
+
expect(parsed.bodyMarkdown).toContain('The registry index');
|
|
32
|
+
expect(parsed.bodyMarkdown).not.toContain('Options');
|
|
33
|
+
expect(parsed.options).toEqual([
|
|
34
|
+
{ label: 'Fleet access', targetPageId: 'aaa-fleet', useWhen: 'use when logging into a box' },
|
|
35
|
+
{
|
|
36
|
+
label: 'Architecture',
|
|
37
|
+
targetPageId: 'bbb-arch',
|
|
38
|
+
useWhen: 'use when asking why a design is as it is',
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('flags an option without a use-when line', () => {
|
|
44
|
+
const doc = markdownToDoc('Body.\n\n## Options\n\n- [Bare link](page:ccc)\n');
|
|
45
|
+
const parsed = parseRecallDoc(doc);
|
|
46
|
+
expect(parsed.options).toEqual([]);
|
|
47
|
+
expect(parsed.issues).toEqual([
|
|
48
|
+
expect.objectContaining({ severity: 'error', code: 'option-no-use-when' }),
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('flags an option without any page target', () => {
|
|
53
|
+
const doc = markdownToDoc('Body.\n\n## Options\n\n- just prose, no link anywhere\n');
|
|
54
|
+
const parsed = parseRecallDoc(doc);
|
|
55
|
+
expect(parsed.issues).toEqual([
|
|
56
|
+
expect.objectContaining({ severity: 'error', code: 'option-no-target' }),
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('flags a malformed Options section (content after the list)', () => {
|
|
61
|
+
const doc = markdownToDoc(
|
|
62
|
+
'Body.\n\n## Options\n\n- [A](page:aaa) — use when x\n\nTrailing prose.\n',
|
|
63
|
+
);
|
|
64
|
+
const parsed = parseRecallDoc(doc);
|
|
65
|
+
expect(parsed.issues).toEqual([
|
|
66
|
+
expect.objectContaining({ severity: 'error', code: 'options-shape' }),
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('treats a doc with no Options heading as option-less, not broken', () => {
|
|
71
|
+
const parsed = parseRecallDoc(markdownToDoc('Just knowledge, no options.'));
|
|
72
|
+
expect(parsed.options).toBeNull();
|
|
73
|
+
expect(parsed.issues).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('the LAST Options heading opens the section; earlier ones stay body', () => {
|
|
77
|
+
const doc = markdownToDoc(
|
|
78
|
+
'## Options\n\nProse about options in general.\n\n## Options\n\n- [A](page:aaa) — use when x\n',
|
|
79
|
+
);
|
|
80
|
+
const parsed = parseRecallDoc(doc);
|
|
81
|
+
expect(parsed.issues).toEqual([]);
|
|
82
|
+
expect(parsed.bodyMarkdown).toContain('Prose about options in general.');
|
|
83
|
+
expect(parsed.options).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('requires a prompt to declare its use-when', () => {
|
|
87
|
+
const parsed = parseRecallDoc(markdownToDoc('A prompt body with no declaration.'), {
|
|
88
|
+
isPrompt: true,
|
|
89
|
+
});
|
|
90
|
+
expect(parsed.issues).toEqual([
|
|
91
|
+
expect.objectContaining({ severity: 'error', code: 'prompt-no-use-when' }),
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('accepts a prompt that opens with Use when', () => {
|
|
96
|
+
const parsed = parseRecallDoc(
|
|
97
|
+
markdownToDoc('Use when: drafting a jackdaw dialog.\n\nThe prompt body.'),
|
|
98
|
+
{ isPrompt: true },
|
|
99
|
+
);
|
|
100
|
+
expect(parsed.issues).toEqual([]);
|
|
101
|
+
expect(parsed.useWhen).toBe('drafting a jackdaw dialog.');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('enforces the character budget on the rendered body', () => {
|
|
105
|
+
const parsed = parseRecallDoc(markdownToDoc('x'.repeat(200)), { bodyCharBudget: 100 });
|
|
106
|
+
expect(parsed.issues).toEqual([
|
|
107
|
+
expect.objectContaining({ severity: 'error', code: 'body-over-budget' }),
|
|
108
|
+
]);
|
|
109
|
+
expect(RECALL_BODY_CHAR_BUDGET).toBeGreaterThan(1000);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('slugs', () => {
|
|
114
|
+
it('kebab-cases titles', () => {
|
|
115
|
+
expect(recallSlug('Fleet, access & the MCP brains')).toBe('fleet-access-the-mcp-brains');
|
|
116
|
+
expect(recallSlug(' ')).toBe('node');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('dedupes collisions stably in order', () => {
|
|
120
|
+
const slugs = assignRecallSlugs([
|
|
121
|
+
{ id: '1', title: 'Setup' },
|
|
122
|
+
{ id: '2', title: 'Setup' },
|
|
123
|
+
{ id: '3', title: 'Setup' },
|
|
124
|
+
]);
|
|
125
|
+
expect(slugs.get('1')).toBe('setup');
|
|
126
|
+
expect(slugs.get('2')).toBe('setup-2');
|
|
127
|
+
expect(slugs.get('3')).toBe('setup-3');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('audit regressions', () => {
|
|
132
|
+
it('slug dedupe never collides with a literal numbered title', () => {
|
|
133
|
+
const slugs = assignRecallSlugs([
|
|
134
|
+
{ id: '1', title: 'Setup' },
|
|
135
|
+
{ id: '2', title: 'Setup 2' },
|
|
136
|
+
{ id: '3', title: 'Setup' },
|
|
137
|
+
]);
|
|
138
|
+
expect(new Set(slugs.values()).size).toBe(3);
|
|
139
|
+
expect(slugs.get('1')).toBe('setup');
|
|
140
|
+
expect(slugs.get('2')).toBe('setup-2');
|
|
141
|
+
expect(slugs.get('3')).toBe('setup-3');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('use-when survives the label text appearing before the link', () => {
|
|
145
|
+
const doc = markdownToDoc(
|
|
146
|
+
'Body.\n\n## Options\n\n- Deploy notes: [Deploy](page:aaa) — use when shipping\n',
|
|
147
|
+
);
|
|
148
|
+
const parsed = parseRecallDoc(doc);
|
|
149
|
+
expect(parsed.issues).toEqual([]);
|
|
150
|
+
expect(parsed.options).toEqual([
|
|
151
|
+
{ label: 'Deploy', targetPageId: 'aaa', useWhen: 'use when shipping' },
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
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
|
+
/** Kebab-case a title into a stable slug ('Fleet, access & MCP' → 'fleet-access-mcp'). */
|
|
279
|
+
export function recallSlug(title: string): string {
|
|
280
|
+
const slug = title
|
|
281
|
+
.toLowerCase()
|
|
282
|
+
.normalize('NFKD')
|
|
283
|
+
.replace(/[̀-ͯ]/g, '')
|
|
284
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
285
|
+
.replace(/^-+|-+$/g, '')
|
|
286
|
+
.slice(0, 60)
|
|
287
|
+
.replace(/-+$/g, '');
|
|
288
|
+
return slug || 'node';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Assign unique slugs in tree order: first keeps the bare slug, collisions
|
|
292
|
+
* count up until FREE — checked against every emitted slug, so a literal
|
|
293
|
+
* title like "Setup 2" can never collide with a generated "setup-2". Stable
|
|
294
|
+
* as long as titles and order are stable. */
|
|
295
|
+
export function assignRecallSlugs(titles: { id: string; title: string }[]): Map<string, string> {
|
|
296
|
+
const used = new Set<string>();
|
|
297
|
+
const out = new Map<string, string>();
|
|
298
|
+
for (const { id, title } of titles) {
|
|
299
|
+
const base = recallSlug(title);
|
|
300
|
+
let slug = base;
|
|
301
|
+
for (let n = 2; used.has(slug); n++) slug = `${base}-${n}`;
|
|
302
|
+
used.add(slug);
|
|
303
|
+
out.set(id, slug);
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|