modelmix 5.0.1 → 5.0.3

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.
Files changed (40) hide show
  1. package/README.md +111 -8
  2. package/RLM_PLUGIN_SPEC.md +465 -0
  3. package/demo/gemini.js +3 -4
  4. package/demo/grok.js +2 -2
  5. package/demo/images.js +2 -2
  6. package/demo/short.js +3 -3
  7. package/effort.js +3 -0
  8. package/index.d.ts +62 -1
  9. package/index.js +355 -49
  10. package/package.json +7 -4
  11. package/plugins/rlm/index.d.ts +194 -0
  12. package/plugins/rlm/index.js +25 -0
  13. package/plugins/rlm/lib/budget.js +153 -0
  14. package/plugins/rlm/lib/isolated-vm-sandbox.js +90 -0
  15. package/plugins/rlm/lib/markdown.js +156 -0
  16. package/plugins/rlm/lib/planner-prompt.js +137 -0
  17. package/plugins/rlm/lib/plugin.js +203 -0
  18. package/plugins/rlm/lib/runtime.js +146 -0
  19. package/plugins/rlm/lib/variable-descriptors.js +228 -0
  20. package/plugins/rlm/lib/worker-catalog.js +70 -0
  21. package/plugins/rlm/package.json +32 -0
  22. package/plugins/rlm/prompts/partials/processing-rules.md +8 -0
  23. package/plugins/rlm/prompts/planner.md +53 -0
  24. package/plugins/rlm/test/budget.test.js +86 -0
  25. package/plugins/rlm/test/fixtures/book.md +24 -0
  26. package/plugins/rlm/test/isolated-vm-sandbox.test.js +114 -0
  27. package/plugins/rlm/test/markdown.test.js +64 -0
  28. package/plugins/rlm/test/planner-template.test.js +140 -0
  29. package/plugins/rlm/test/plugin-contract.test.js +182 -0
  30. package/plugins/rlm/test/rlm-e2e.test.js +338 -0
  31. package/plugins/rlm/test/variable-descriptors.test.js +170 -0
  32. package/plugins/rlm/test/worker-catalog.test.js +104 -0
  33. package/pnpm-workspace.yaml +6 -0
  34. package/skills/modelmix/SKILL.md +23 -4
  35. package/test/effort.test.js +14 -1
  36. package/test/grok.test.js +74 -0
  37. package/test/live.mcp.js +8 -8
  38. package/test/live.test.js +9 -9
  39. package/test/plugins.test.js +356 -0
  40. package/test/tokens.test.js +37 -5
@@ -0,0 +1,228 @@
1
+ function isPlainObject(value) {
2
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
3
+ const prototype = Object.getPrototypeOf(value);
4
+ return prototype === Object.prototype || prototype === null;
5
+ }
6
+
7
+ function roundAverage(total, count) {
8
+ return count === 0 ? 0 : Number((total / count).toFixed(2));
9
+ }
10
+
11
+ function numericStats(values) {
12
+ if (values.length === 0) {
13
+ return { min: 0, max: 0, average: 0, total: 0 };
14
+ }
15
+ let min = values[0];
16
+ let max = values[0];
17
+ let total = 0;
18
+ for (const value of values) {
19
+ min = Math.min(min, value);
20
+ max = Math.max(max, value);
21
+ total += value;
22
+ }
23
+ return {
24
+ min,
25
+ max,
26
+ average: roundAverage(total, values.length),
27
+ total
28
+ };
29
+ }
30
+
31
+ function valueType(value) {
32
+ if (value === null) return 'null';
33
+ if (Array.isArray(value)) return 'array';
34
+ return typeof value;
35
+ }
36
+
37
+ function primitiveSerializedBytes(value, path) {
38
+ let serialized;
39
+ try {
40
+ serialized = JSON.stringify(value);
41
+ } catch (error) {
42
+ throw new TypeError(`External variable ${path} is not JSON-serializable: ${error.message}`);
43
+ }
44
+ if (serialized === undefined) {
45
+ throw new TypeError(`External variable ${path} is not JSON-serializable.`);
46
+ }
47
+ return Buffer.byteLength(serialized, 'utf8');
48
+ }
49
+
50
+ function objectSerializedBytes(value, childDescriptors) {
51
+ const keys = Object.keys(value);
52
+ let bytes = 2;
53
+ for (let index = 0; index < keys.length; index += 1) {
54
+ const key = keys[index];
55
+ bytes += primitiveSerializedBytes(key, key) + 1 + childDescriptors[key].estimatedBytes;
56
+ if (index > 0) bytes += 1;
57
+ }
58
+ return bytes;
59
+ }
60
+
61
+ function arraySerializedBytes(itemBytes) {
62
+ if (itemBytes.length === 0) return 2;
63
+ return 2
64
+ + itemBytes.reduce((sum, bytes) => sum + bytes, 0)
65
+ + itemBytes.length - 1;
66
+ }
67
+
68
+ function countLines(value) {
69
+ return value.length === 0 ? 0 : value.split(/\r\n|\n|\r/).length;
70
+ }
71
+
72
+ function countParagraphs(value) {
73
+ const trimmed = value.trim();
74
+ return trimmed.length === 0
75
+ ? 0
76
+ : trimmed.split(/(?:\r\n|\n|\r)[\t ]*(?:\r\n|\n|\r)+/).length;
77
+ }
78
+
79
+ function stringMetrics(value) {
80
+ return {
81
+ characters: Array.from(value).length,
82
+ utf16CodeUnits: value.length,
83
+ utf8Bytes: Buffer.byteLength(value, 'utf8'),
84
+ lines: countLines(value),
85
+ paragraphs: countParagraphs(value)
86
+ };
87
+ }
88
+
89
+ function incrementType(types, value) {
90
+ const type = valueType(value);
91
+ types[type] = (types[type] || 0) + 1;
92
+ }
93
+
94
+ function summarizeStringValues(values) {
95
+ const metrics = values.map(stringMetrics);
96
+ return {
97
+ characters: numericStats(metrics.map(metric => metric.characters)),
98
+ utf8Bytes: numericStats(metrics.map(metric => metric.utf8Bytes)),
99
+ lines: numericStats(metrics.map(metric => metric.lines)),
100
+ paragraphs: numericStats(metrics.map(metric => metric.paragraphs))
101
+ };
102
+ }
103
+
104
+ function summarizeObjectArray(items) {
105
+ const keys = [...new Set(items.flatMap(item => Object.keys(item)))].sort();
106
+ const properties = {};
107
+
108
+ for (const key of keys) {
109
+ const values = items
110
+ .filter(item => Object.prototype.hasOwnProperty.call(item, key))
111
+ .map(item => item[key]);
112
+ const types = {};
113
+ for (const value of values) incrementType(types, value);
114
+ const property = {
115
+ present: values.length,
116
+ missing: items.length - values.length,
117
+ types
118
+ };
119
+ if (values.length > 0 && values.every(value => typeof value === 'string')) {
120
+ property.stringSize = summarizeStringValues(values);
121
+ }
122
+ properties[key] = property;
123
+ }
124
+
125
+ return {
126
+ type: 'object',
127
+ properties
128
+ };
129
+ }
130
+
131
+ function summarizeArrayItems(items) {
132
+ const types = {};
133
+ for (const item of items) incrementType(types, item);
134
+ const summary = { types };
135
+
136
+ if (items.length > 0 && items.every(item => typeof item === 'string')) {
137
+ summary.type = 'string';
138
+ summary.stringSize = summarizeStringValues(items);
139
+ } else if (items.length > 0 && items.every(isPlainObject)) {
140
+ Object.assign(summary, summarizeObjectArray(items));
141
+ }
142
+
143
+ return summary;
144
+ }
145
+
146
+ function describeValue(value, path, ancestors) {
147
+ const type = valueType(value);
148
+ if (type === 'undefined' || type === 'function' || type === 'symbol' || type === 'bigint') {
149
+ throw new TypeError(`External variable ${path} has unsupported type ${type}.`);
150
+ }
151
+ if (type === 'number' && !Number.isFinite(value)) {
152
+ throw new TypeError(`External variable ${path} must contain only finite numbers.`);
153
+ }
154
+ if (type === 'object' && value !== null && !isPlainObject(value)) {
155
+ throw new TypeError(`External variable ${path} must contain only plain objects.`);
156
+ }
157
+ if (value && typeof value === 'object') {
158
+ if (ancestors.has(value)) {
159
+ throw new TypeError(`External variable ${path} contains a circular reference.`);
160
+ }
161
+ ancestors.add(value);
162
+ }
163
+
164
+ let descriptor;
165
+ if (type === 'string') {
166
+ descriptor = {
167
+ path,
168
+ type,
169
+ estimatedBytes: primitiveSerializedBytes(value, path),
170
+ ...stringMetrics(value)
171
+ };
172
+ } else if (type === 'array') {
173
+ const itemBytes = [];
174
+ for (let index = 0; index < value.length; index += 1) {
175
+ const itemDescriptor = describeValue(value[index], `${path}[${index}]`, ancestors);
176
+ itemBytes.push(itemDescriptor.estimatedBytes);
177
+ }
178
+ descriptor = {
179
+ path,
180
+ type,
181
+ items: value.length,
182
+ estimatedBytes: arraySerializedBytes(itemBytes),
183
+ itemSize: numericStats(itemBytes),
184
+ itemShape: summarizeArrayItems(value)
185
+ };
186
+ } else if (type === 'object') {
187
+ const properties = {};
188
+ for (const key of Object.keys(value).sort()) {
189
+ properties[key] = describeValue(value[key], `${path}.${key}`, ancestors);
190
+ }
191
+ descriptor = {
192
+ path,
193
+ type,
194
+ properties: Object.keys(properties).length,
195
+ estimatedBytes: objectSerializedBytes(value, properties),
196
+ children: properties
197
+ };
198
+ } else {
199
+ descriptor = {
200
+ path,
201
+ type,
202
+ estimatedBytes: primitiveSerializedBytes(value, path)
203
+ };
204
+ }
205
+
206
+ if (value && typeof value === 'object') ancestors.delete(value);
207
+ return descriptor;
208
+ }
209
+
210
+ function describeVariables(variables) {
211
+ if (!isPlainObject(variables)) {
212
+ throw new TypeError('External variables must be a plain object.');
213
+ }
214
+ const descriptors = {};
215
+ for (const name of Object.keys(variables).sort()) {
216
+ descriptors[name] = describeValue(variables[name], name, new WeakSet());
217
+ }
218
+ return {
219
+ sizeBasis: 'serialized-json-utf8',
220
+ variables: Object.keys(descriptors).length,
221
+ estimatedBytes: objectSerializedBytes(variables, descriptors),
222
+ descriptors
223
+ };
224
+ }
225
+
226
+ module.exports = {
227
+ describeVariables
228
+ };
@@ -0,0 +1,70 @@
1
+ function isPlainObject(value) {
2
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
3
+ const prototype = Object.getPrototypeOf(value);
4
+ return prototype === Object.prototype || prototype === null;
5
+ }
6
+
7
+ function finiteRating(value, path) {
8
+ if (!Number.isFinite(value) || value < 0) {
9
+ throw new TypeError(`${path} must be a non-negative finite number.`);
10
+ }
11
+ return value;
12
+ }
13
+
14
+ function validateModel(value, path) {
15
+ if (!value || typeof value !== 'object' || !Array.isArray(value.models)) {
16
+ throw new TypeError(`${path} must be a ModelMix instance.`);
17
+ }
18
+ return value;
19
+ }
20
+
21
+ function createWorkerCatalog(workers) {
22
+ if (!isPlainObject(workers) || Object.keys(workers).length === 0) {
23
+ throw new TypeError('workers must be a non-empty plain object.');
24
+ }
25
+
26
+ const models = new Map();
27
+ const manifest = {};
28
+ for (const name of Object.keys(workers).sort()) {
29
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) {
30
+ throw new TypeError(`Worker name "${name}" must use letters, numbers, underscores, or hyphens.`);
31
+ }
32
+ const worker = workers[name];
33
+ if (!isPlainObject(worker)) {
34
+ throw new TypeError(`Worker "${name}" must be a plain object.`);
35
+ }
36
+ if (typeof worker.description !== 'string' || worker.description.trim().length === 0) {
37
+ throw new TypeError(`Worker "${name}" description must be a non-empty string.`);
38
+ }
39
+ const usesParent = worker.useParent === true;
40
+ if (usesParent === (worker.model !== undefined)) {
41
+ throw new TypeError(
42
+ `Worker "${name}" must define exactly one of model or useParent: true.`
43
+ );
44
+ }
45
+ models.set(
46
+ name,
47
+ usesParent ? undefined : validateModel(worker.model, `Worker "${name}" model`)
48
+ );
49
+ manifest[name] = {
50
+ intelligence: finiteRating(worker.intelligence, `Worker "${name}" intelligence`),
51
+ cost: finiteRating(worker.cost, `Worker "${name}" cost`),
52
+ speed: finiteRating(worker.speed, `Worker "${name}" speed`),
53
+ description: worker.description
54
+ };
55
+ }
56
+
57
+ return {
58
+ get(name) {
59
+ if (typeof name !== 'string' || !models.has(name)) {
60
+ throw new Error(`Unknown RLM worker "${name}".`);
61
+ }
62
+ return models.get(name);
63
+ },
64
+ manifest
65
+ };
66
+ }
67
+
68
+ module.exports = {
69
+ createWorkerCatalog
70
+ };
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@modelmix/rlm",
3
+ "version": "0.0.0",
4
+ "description": "Recursive Language Model plugin for ModelMix.",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./index.d.ts",
10
+ "require": "./index.js",
11
+ "default": "./index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "index.d.ts",
17
+ "lib/",
18
+ "prompts/"
19
+ ],
20
+ "license": "MIT",
21
+ "type": "commonjs",
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "dependencies": {
29
+ "isolated-vm": "6.0.2",
30
+ "mdast-util-from-markdown": "2.0.3"
31
+ }
32
+ }
@@ -0,0 +1,8 @@
1
+ ## Required processing rules
2
+
3
+ - Never send more than <%- maxQueryBytes %> UTF-8 bytes in one `query()` payload.
4
+ - Split oversized strings and string fields at paragraph or other semantic boundaries.
5
+ - Batch compatible array items when each item fits within the payload limit.
6
+ - Process independent batches concurrently with at most <%- maxConcurrentQueries %> active queries.
7
+ - Preserve source order and complete coverage when reassembling the final output.
8
+ - Inspect external variables in the sandbox; never ask the planner to reproduce hidden content.
@@ -0,0 +1,53 @@
1
+ # Recursive Language Model Planner
2
+
3
+ Generate an executable JavaScript orchestration program for the user's task. The source data remains in external sandbox variables and is not present in this prompt.
4
+
5
+ ## External variable manifest
6
+
7
+ The following JSON contains metadata only. Variable values must be inspected inside the sandbox.
8
+
9
+ ```json
10
+ <%- variableManifest %>
11
+ ```
12
+
13
+ ## Processing limits
14
+
15
+ ```json
16
+ <%- processingLimits %>
17
+ ```
18
+
19
+ ## Planning hints
20
+
21
+ These hints are derived from serialized variable sizes and query limits. Apply them to the actual task rather than treating them as mandatory task-specific operations.
22
+
23
+ ```json
24
+ <%- planningHints %>
25
+ ```
26
+
27
+ ## Worker catalog
28
+
29
+ Choose a worker by its registered name for every `query()` call. The catalog contains developer-supplied decision metadata only; model objects, provider configuration, and credentials are not exposed.
30
+
31
+ ```json
32
+ <%- workerManifest %>
33
+ ```
34
+
35
+ ## Sandbox API
36
+
37
+ ```js
38
+ const result = await query({
39
+ worker: 'registered-worker-name',
40
+ system: 'Focused instructions for this subtask.',
41
+ message: externalVariableOrFragment
42
+ });
43
+ ```
44
+
45
+ <%- include('partials/processing-rules.md') %>
46
+
47
+ ## Output requirements
48
+
49
+ ```json
50
+ <%- outputRequirements %>
51
+ ```
52
+
53
+ Return only an async JavaScript IIFE. The returned value must be the final result requested by the user. Do not return Markdown fences, explanations, or source data samples.
@@ -0,0 +1,86 @@
1
+ const { expect } = require('chai');
2
+ const { RlmLimitError } = require('..');
3
+ const { createRuntimeBudget } = require('../lib/budget');
4
+
5
+ function limits(overrides = {}) {
6
+ return {
7
+ maxQueryBytes: 100,
8
+ sandboxMemoryBytes: 1024 * 1024,
9
+ maxConcurrentQueries: 2,
10
+ maxCalls: 4,
11
+ maxOutputBytes: 100,
12
+ maxGeneratedTokens: 20,
13
+ maxWallTimeMs: 1000,
14
+ ...overrides
15
+ };
16
+ }
17
+
18
+ describe('RLM runtime budget', () => {
19
+ it('limits active queries while allowing queued work to finish', async () => {
20
+ const budget = createRuntimeBudget(limits());
21
+ let active = 0;
22
+ let peak = 0;
23
+ const operation = async value => budget.runQuery({ payloadBytes: 10 }, async () => {
24
+ active += 1;
25
+ peak = Math.max(peak, active);
26
+ await new Promise(resolve => setTimeout(resolve, 10));
27
+ active -= 1;
28
+ return { message: value, tokens: { output: 1 } };
29
+ });
30
+
31
+ const results = await Promise.all([
32
+ operation('a'),
33
+ operation('b'),
34
+ operation('c'),
35
+ operation('d')
36
+ ]);
37
+
38
+ expect(results.map(result => result.message)).to.deep.equal(['a', 'b', 'c', 'd']);
39
+ expect(peak).to.equal(2);
40
+ expect(budget.snapshot()).to.include({
41
+ calls: 4,
42
+ active: 0,
43
+ peakConcurrency: 2,
44
+ outputBytes: 4,
45
+ generatedTokens: 4
46
+ });
47
+ });
48
+
49
+ it('fails clearly on payload, call, output, and token limits', async () => {
50
+ const payloadBudget = createRuntimeBudget(limits());
51
+ expect(() => payloadBudget.assertQueryPayload(101))
52
+ .to.throw(RlmLimitError).with.property('limit', 'maxQueryBytes');
53
+
54
+ const callBudget = createRuntimeBudget(limits({ maxCalls: 1 }));
55
+ await callBudget.runPlanner(async () => ({ message: 'planner' }));
56
+ let callFailure;
57
+ try {
58
+ await callBudget.runPlanner(async () => ({ message: 'again' }));
59
+ } catch (error) {
60
+ callFailure = error;
61
+ }
62
+ expect(callFailure).to.be.instanceOf(RlmLimitError);
63
+ expect(callFailure.limit).to.equal('maxCalls');
64
+
65
+ const outputBudget = createRuntimeBudget(limits({ maxOutputBytes: 2 }));
66
+ let outputFailure;
67
+ try {
68
+ await outputBudget.runPlanner(async () => ({ message: 'long' }));
69
+ } catch (error) {
70
+ outputFailure = error;
71
+ }
72
+ expect(outputFailure.limit).to.equal('maxOutputBytes');
73
+
74
+ const tokenBudget = createRuntimeBudget(limits({ maxGeneratedTokens: 1 }));
75
+ let tokenFailure;
76
+ try {
77
+ await tokenBudget.runPlanner(async () => ({
78
+ message: '',
79
+ tokens: { output: 2 }
80
+ }));
81
+ } catch (error) {
82
+ tokenFailure = error;
83
+ }
84
+ expect(tokenFailure.limit).to.equal('maxGeneratedTokens');
85
+ });
86
+ });
@@ -0,0 +1,24 @@
1
+ Introductory note with a [reference](https://example.com).
2
+
3
+ - Preface item one
4
+ - Preface item two
5
+
6
+ # Chapter One
7
+
8
+ Opening paragraph.
9
+
10
+ - First point
11
+ - Second point
12
+ - Nested point
13
+
14
+ ## Scene One
15
+
16
+ ```text
17
+ # This is code, not a chapter
18
+ ```
19
+
20
+ # Chapter One
21
+
22
+ | Character | Role |
23
+ | --- | --- |
24
+ | Ada | Engineer |
@@ -0,0 +1,114 @@
1
+ const { expect } = require('chai');
2
+ const {
3
+ RlmLimitError,
4
+ createIsolatedVmSandbox
5
+ } = require('..');
6
+
7
+ function limits(sandboxMemoryBytes = 16 * 1024 * 1024) {
8
+ return { sandboxMemoryBytes };
9
+ }
10
+
11
+ describe('RLM isolated-vm sandbox', () => {
12
+ it('exposes only copied variables and the validated query callback', async () => {
13
+ const sandbox = createIsolatedVmSandbox();
14
+ const calls = [];
15
+ const value = await sandbox.execute({
16
+ code: `(async () => ({
17
+ item: variables.items[0],
18
+ response: await query({ worker: 'fast', system: 'Classify.', message: 'Ada' }),
19
+ globals: {
20
+ process: typeof process,
21
+ require: typeof require,
22
+ fetch: typeof fetch,
23
+ Buffer: typeof Buffer,
24
+ query: typeof query
25
+ }
26
+ }))()`,
27
+ variables: { items: ['external'] },
28
+ query: async input => {
29
+ calls.push(input);
30
+ return 'person';
31
+ },
32
+ limits: limits(),
33
+ timeoutMs: 1000
34
+ });
35
+
36
+ expect(value).to.deep.equal({
37
+ item: 'external',
38
+ response: 'person',
39
+ globals: {
40
+ process: 'undefined',
41
+ require: 'undefined',
42
+ fetch: 'undefined',
43
+ Buffer: 'undefined',
44
+ query: 'function'
45
+ }
46
+ });
47
+ expect(calls).to.deep.equal([{
48
+ worker: 'fast',
49
+ system: 'Classify.',
50
+ message: 'Ada'
51
+ }]);
52
+ });
53
+
54
+ it('terminates programs that exceed their wall-time limit', async () => {
55
+ let failure;
56
+ try {
57
+ await createIsolatedVmSandbox().execute({
58
+ code: '(async () => { while (true) {} })()',
59
+ variables: {},
60
+ query: async () => '',
61
+ limits: limits(),
62
+ timeoutMs: 20
63
+ });
64
+ } catch (error) {
65
+ failure = error;
66
+ }
67
+
68
+ expect(failure).to.be.instanceOf(RlmLimitError);
69
+ expect(failure.limit).to.equal('maxWallTimeMs');
70
+ });
71
+
72
+ it('rejects memory limits below the isolated-vm minimum', async () => {
73
+ let failure;
74
+ try {
75
+ await createIsolatedVmSandbox().execute({
76
+ code: '(async () => "ok")()',
77
+ variables: {},
78
+ query: async () => '',
79
+ limits: limits(1024 * 1024),
80
+ timeoutMs: 1000
81
+ });
82
+ } catch (error) {
83
+ failure = error;
84
+ }
85
+
86
+ expect(failure).to.be.instanceOf(TypeError);
87
+ expect(failure.message).to.include('at least 8388608');
88
+ });
89
+
90
+ it('terminates programs that exceed the configured isolate heap', async () => {
91
+ let failure;
92
+ try {
93
+ await createIsolatedVmSandbox().execute({
94
+ code: `(() => {
95
+ const values = [];
96
+ while (true) {
97
+ const item = new Uint8Array(1024 * 1024);
98
+ for (let index = 0; index < item.length; index += 4096) item[index] = 1;
99
+ values.push(item);
100
+ }
101
+ })()`,
102
+ variables: {},
103
+ query: async () => '',
104
+ limits: limits(8 * 1024 * 1024),
105
+ timeoutMs: 2000
106
+ });
107
+ } catch (error) {
108
+ failure = error;
109
+ }
110
+
111
+ expect(failure).to.be.instanceOf(RlmLimitError);
112
+ expect(failure.limit).to.equal('sandboxMemoryBytes');
113
+ });
114
+ });
@@ -0,0 +1,64 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { expect } = require('chai');
4
+ const {
5
+ parseMarkdownDocument,
6
+ reconstructMarkdownDocument
7
+ } = require('..');
8
+
9
+ const FIXTURE_PATH = path.resolve(__dirname, 'fixtures/book.md');
10
+
11
+ describe('RLM Markdown document mapping', () => {
12
+ it('creates a stable semantic tree and preserves exact source order', async () => {
13
+ const source = fs.readFileSync(FIXTURE_PATH, 'utf8');
14
+ const document = await parseMarkdownDocument(source);
15
+
16
+ expect(document.format).to.equal('markdown');
17
+ expect(document.stats).to.include({
18
+ utf8Bytes: Buffer.byteLength(source, 'utf8'),
19
+ sectionCount: 3
20
+ });
21
+ expect(document.preamble.source).to.include('Introductory note');
22
+ expect(document.preamble.lists[0].items.map(item => item.text)).to.deep.equal([
23
+ 'Preface item one',
24
+ 'Preface item two'
25
+ ]);
26
+ expect(document.sections).to.have.length(2);
27
+ expect(document.sections[0]).to.include({
28
+ id: 'chapter-one',
29
+ title: 'Chapter One',
30
+ depth: 1,
31
+ order: 0
32
+ });
33
+ expect(document.sections[0].path).to.deep.equal(['chapter-one']);
34
+ expect(document.sections[0].children[0]).to.include({
35
+ id: 'scene-one',
36
+ title: 'Scene One',
37
+ depth: 2,
38
+ order: 1
39
+ });
40
+ expect(document.sections[0].children[0].path).to.deep.equal([
41
+ 'chapter-one',
42
+ 'scene-one'
43
+ ]);
44
+ expect(document.sections[1].id).to.equal('chapter-one-2');
45
+ expect(document.sections[0].body).to.include('Opening paragraph.');
46
+ expect(document.sections[0].lists[0].items[1].lists[0].items[0].text)
47
+ .to.equal('Nested point');
48
+ expect(document.sections[0].children[0].body)
49
+ .to.include('# This is code, not a chapter');
50
+ expect(document.sections[1].body).to.include('| Character | Role |');
51
+ expect(reconstructMarkdownDocument(document)).to.equal(source);
52
+ });
53
+
54
+ it('maps heading-free Markdown to introductory content', async () => {
55
+ const source = 'Paragraph one.\n\n1. Alpha\n2. Beta\n';
56
+ const document = await parseMarkdownDocument(source);
57
+
58
+ expect(document.sections).to.deep.equal([]);
59
+ expect(document.preamble.source).to.equal(source);
60
+ expect(document.preamble.lists[0].items.map(item => item.text))
61
+ .to.deep.equal(['Alpha', 'Beta']);
62
+ expect(reconstructMarkdownDocument(document)).to.equal(source);
63
+ });
64
+ });