@crossworks/content-core 0.232.356 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/content-core",
3
- "version": "0.232.356",
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.356",
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
- // Sub-page link card (Phase 4a) — addressable so block tools can move/remove it
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, sub-page card', () => {
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
- '![gantry](media:f-1)\n\n[spec.pdf](media:f-2)\n\n[Sub plan](page:p-9)',
114
+ '![gantry](media:f-1)\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
 
@@ -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}$$`;
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * ![alt](media:<file-id>) an uploaded image, by node id
6
6
  * [label](media:<file-id>) a file embed
7
- * [Title](page:<page-id>) a child page
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
  * ![alt](draw:<draw-id>) 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 child page by node id. */
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.
@@ -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 { MENTION_HREF, MEDIA_HREF, PAGE_HREF, DRAW_HREF } from './markdown-refs';
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:…) and [Title](page:…)
283
- * standalone lines into their block nodes (fileEmbed / childPage). */
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 { markdownToDoc } from './markdown-to-doc';
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
- // 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', () => {
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('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');
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('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
- ]);
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
  });
@@ -1,300 +1,24 @@
1
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).
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
- * 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.
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
- 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).
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 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. */
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
- 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'). */
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
- }
@@ -857,6 +857,13 @@ export type TableRow = {
857
857
  rowCount: number;
858
858
  /** Access level (admin > team > client > public); the owner UI's badge. */
859
859
  audience: AccessLevel;
860
+ /** The share it inherits from a folder above it (team or client), or
861
+ * null; it is read at the more open of this and `audience`. Optional:
862
+ * absent from brains before the folder audit fixes. */
863
+ inherited?: 'team' | 'client' | null;
864
+ /** The share it is read at through something that embeds it (migration
865
+ * 0208), or null. Optional: absent from brains before it. */
866
+ embedded?: 'team' | 'client' | null;
860
867
  createdAt: string;
861
868
  updatedAt: string;
862
869
  };
package/src/tree.ts CHANGED
@@ -17,20 +17,26 @@ import type { AccessLevel } from '@mantle/client-types';
17
17
  const LEVEL_RANK: Record<AccessLevel, number> = { public: 0, client: 1, team: 2, admin: 3 };
18
18
 
19
19
  /**
20
- * The level an item is read at: the more open of its own level and the share
21
- * it inherits from a folder (public < client < team < admin). This is the
22
- * level its pill shows, its embeds follow and its indexed page text is
23
- * folded for. Folding for the more open reader is safe for every reader of
24
- * the row: what a public or client reader may see, every reader above may
25
- * too. (Which ROLES read the row is the database's union of the two:
26
- * nodes_viewer_read, migration 0204.)
20
+ * The level an item is read at: the most open of its own level, the share it
21
+ * inherits from a folder, and the share of something that embeds it
22
+ * (public < client < team < admin). This is the level its pill shows and its
23
+ * indexed page text is folded for. Folding for the more open reader is safe
24
+ * for every reader of the row: what a public or client reader may see, every
25
+ * reader above may too. (Which ROLES read the row is the database's union of
26
+ * the three: nodes_viewer_read, migrations 0204 and 0208.)
27
27
  */
28
28
  export function effectiveLevel(
29
29
  own: AccessLevel,
30
30
  inherited: TreeShareLevel | null | undefined,
31
+ /** The share of something that embeds the item (a shared note's image),
32
+ * when the brain sends it. */
33
+ embedded?: TreeShareLevel | null,
31
34
  ): AccessLevel {
32
- if (!inherited) return own;
33
- return LEVEL_RANK[inherited] < LEVEL_RANK[own] ? inherited : own;
35
+ let level = own;
36
+ for (const share of [inherited, embedded]) {
37
+ if (share && LEVEL_RANK[share] < LEVEL_RANK[level]) level = share;
38
+ }
39
+ return level;
34
40
  }
35
41
 
36
42
  function labels(path: string): string[] {
@@ -72,9 +78,9 @@ export function isTreeFolderPathAllowed(path: string): boolean {
72
78
 
73
79
  /**
74
80
  * Cut a folder path that would sit too deep back to the deepest allowed
75
- * level: the extra levels are lifted into the third. Inbound paths use it (an
76
- * agent's `mkdir -p`, the crawler, Accept), so a deep chain lands in the
77
- * deepest folder instead of failing.
81
+ * level: the extra levels are lifted into the third. For clients; the brain's
82
+ * inbound paths clamp with their own helpers (Files: clampFilesFolderPath;
83
+ * Accept: accept-place.ts; member rows: carrySpaceRows).
78
84
  */
79
85
  export function clampTreePath(path: string): string {
80
86
  const parts = labels(path);