matryoshka-rlm 0.2.30 → 0.2.32

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 (48) hide show
  1. package/README.md +97 -0
  2. package/dist/adapters/nucleus.d.ts.map +1 -1
  3. package/dist/adapters/nucleus.js +52 -55
  4. package/dist/adapters/nucleus.js.map +1 -1
  5. package/dist/engine/handle-session.d.ts +19 -0
  6. package/dist/engine/handle-session.d.ts.map +1 -1
  7. package/dist/engine/handle-session.js +2 -0
  8. package/dist/engine/handle-session.js.map +1 -1
  9. package/dist/engine/nucleus-engine.d.ts +24 -0
  10. package/dist/engine/nucleus-engine.d.ts.map +1 -1
  11. package/dist/engine/nucleus-engine.js +15 -2
  12. package/dist/engine/nucleus-engine.js.map +1 -1
  13. package/dist/fsm/rlm-states.d.ts +31 -0
  14. package/dist/fsm/rlm-states.d.ts.map +1 -1
  15. package/dist/fsm/rlm-states.js +246 -7
  16. package/dist/fsm/rlm-states.js.map +1 -1
  17. package/dist/lattice-mcp-server.d.ts +7 -1
  18. package/dist/lattice-mcp-server.d.ts.map +1 -1
  19. package/dist/lattice-mcp-server.js +123 -2
  20. package/dist/lattice-mcp-server.js.map +1 -1
  21. package/dist/logic/lc-interpreter.d.ts.map +1 -1
  22. package/dist/logic/lc-interpreter.js +6 -1
  23. package/dist/logic/lc-interpreter.js.map +1 -1
  24. package/dist/logic/lc-parser.d.ts.map +1 -1
  25. package/dist/logic/lc-parser.js +129 -1
  26. package/dist/logic/lc-parser.js.map +1 -1
  27. package/dist/logic/lc-solver.d.ts +66 -0
  28. package/dist/logic/lc-solver.d.ts.map +1 -1
  29. package/dist/logic/lc-solver.js +352 -4
  30. package/dist/logic/lc-solver.js.map +1 -1
  31. package/dist/logic/type-inference.d.ts.map +1 -1
  32. package/dist/logic/type-inference.js +8 -0
  33. package/dist/logic/type-inference.js.map +1 -1
  34. package/dist/logic/types.d.ts +118 -2
  35. package/dist/logic/types.d.ts.map +1 -1
  36. package/dist/persistence/handle-registry.d.ts +4 -1
  37. package/dist/persistence/handle-registry.d.ts.map +1 -1
  38. package/dist/persistence/handle-registry.js +17 -8
  39. package/dist/persistence/handle-registry.js.map +1 -1
  40. package/dist/persistence/session-db.d.ts +13 -0
  41. package/dist/persistence/session-db.d.ts.map +1 -1
  42. package/dist/persistence/session-db.js +36 -4
  43. package/dist/persistence/session-db.js.map +1 -1
  44. package/dist/rlm.d.ts +51 -1
  45. package/dist/rlm.d.ts.map +1 -1
  46. package/dist/rlm.js +209 -6
  47. package/dist/rlm.js.map +1 -1
  48. package/package.json +1 -1
@@ -19,6 +19,60 @@ import { synthesizeExtractor, compileToFunction, prettyPrint } from "../synthesi
19
19
  import { synthesizeFromExamples } from "./relational-solver.js";
20
20
  import { SynthesisIntegrator } from "./synthesis-integrator.js";
21
21
  const moduleQStore = new QValueStore();
22
+ /**
23
+ * Walk the pattern with a paren stack to detect any group that has
24
+ * an internal quantifier AND a trailing quantifier — the canonical
25
+ * shape of catastrophic backtracking, e.g. (a+)+, (a*)+, (.*(.*).*)+,
26
+ * ((a|b)+)+, (?:a+)+. A regex-only check can't see through nested
27
+ * parens; the scanner skips escapes and character classes so it
28
+ * doesn't false-positive on literal `(`/`)`/quantifier chars.
29
+ */
30
+ function hasNestedQuantifier(pattern) {
31
+ const stack = [];
32
+ const isQuant = (c) => c === "+" || c === "*" || c === "{";
33
+ let i = 0;
34
+ while (i < pattern.length) {
35
+ const c = pattern[i];
36
+ if (c === "\\") {
37
+ i += 2;
38
+ continue;
39
+ }
40
+ if (c === "[") {
41
+ // Skip character class — its contents aren't group structure.
42
+ i++;
43
+ while (i < pattern.length && pattern[i] !== "]") {
44
+ if (pattern[i] === "\\")
45
+ i++;
46
+ i++;
47
+ }
48
+ i++;
49
+ continue;
50
+ }
51
+ if (c === "(") {
52
+ stack.push({ hasQuantInside: false });
53
+ i++;
54
+ continue;
55
+ }
56
+ if (c === ")") {
57
+ const top = stack.pop();
58
+ i++;
59
+ if (i < pattern.length && isQuant(pattern[i])) {
60
+ if (top?.hasQuantInside)
61
+ return true;
62
+ // Trailing quantifier on this group counts as a quantifier
63
+ // in the parent group's body for the next iteration.
64
+ if (stack.length > 0)
65
+ stack[stack.length - 1].hasQuantInside = true;
66
+ }
67
+ continue;
68
+ }
69
+ if (isQuant(c) && stack.length > 0) {
70
+ stack[stack.length - 1].hasQuantInside = true;
71
+ }
72
+ i++;
73
+ }
74
+ return false;
75
+ }
22
76
  /**
23
77
  * Validate a regex pattern for safety (ReDoS protection)
24
78
  */
@@ -31,9 +85,8 @@ export function validateRegex(pattern) {
31
85
  if (pattern.length > 500) {
32
86
  return { valid: false, error: `Regex pattern too long (${pattern.length} chars, max 500)` };
33
87
  }
34
- // Reject nested quantifiers that cause catastrophic backtracking
35
- // Patterns like (a+)+, (a*)+, (a+)*, (a{1,})+, etc.
36
- if (/(\((?:[^()]*[+*{])[^()]*\))[+*{]|\(\?[^)]*[+*{][^)]*\)[+*{]/.test(pattern)) {
88
+ // Reject nested quantifiers that cause catastrophic backtracking.
89
+ if (hasNestedQuantifier(pattern)) {
37
90
  return { valid: false, error: "Regex contains nested quantifiers which may cause catastrophic backtracking" };
38
91
  }
39
92
  // Verify the pattern is a valid regex
@@ -76,6 +129,80 @@ function canonicalizeOneOf(response, oneOf) {
76
129
  }
77
130
  return null;
78
131
  }
132
+ /**
133
+ * Convert a resolved `(context EXPR)` value into a clean line-oriented
134
+ * document for a child Nucleus session.
135
+ *
136
+ * Strings pass through untouched. Arrays join their stringified items
137
+ * by newlines so the child's `^anchor` regexes work — the primary
138
+ * differentiator from the existing llm_query path, which JSON-
139
+ * stringifies the binding and pollutes line starts with `[`/`{`/quotes.
140
+ *
141
+ * Per-item stringification: grep-result-like objects (anything with a
142
+ * string `.line` field) yield that field's value, since that's the
143
+ * "one item per line" representation users expect when piping grep
144
+ * results into a child. Other objects fall back to compact JSON.
145
+ * Strings, numbers, booleans become their natural string form;
146
+ * null/undefined become "".
147
+ *
148
+ * Output is capped at MAX_CONTEXT_DOC_CHARS to bound child memory
149
+ * footprint; longer values are silently truncated. The cap is
150
+ * intentionally larger than llm_query's 500k interpolation cap
151
+ * because rlm_query passes the value as a child DOCUMENT (where the
152
+ * matryoshka 50MB doc limit applies), not as prompt text.
153
+ */
154
+ const MAX_CONTEXT_DOC_CHARS = 5_000_000;
155
+ function stringifyItem(item) {
156
+ if (item === null || item === undefined)
157
+ return "";
158
+ if (typeof item === "string")
159
+ return item;
160
+ if (typeof item === "number" || typeof item === "boolean")
161
+ return String(item);
162
+ if (typeof item === "object") {
163
+ const obj = item;
164
+ if (typeof obj.line === "string")
165
+ return obj.line;
166
+ try {
167
+ return JSON.stringify(item);
168
+ }
169
+ catch {
170
+ return String(item);
171
+ }
172
+ }
173
+ return String(item);
174
+ }
175
+ /**
176
+ * Top-level dispatch for `(rlm_query …)` context materialization.
177
+ * Returns a string; an empty string ("") is a valid output (e.g., the
178
+ * user passed `(context [])` — empty array). Callers MUST distinguish
179
+ * this from `null` (no `(context …)` form supplied) when deciding
180
+ * whether to fall back to using the prompt as the child's document.
181
+ */
182
+ function materializeContext(value) {
183
+ let out;
184
+ if (value === null || value === undefined) {
185
+ out = "";
186
+ }
187
+ else if (typeof value === "string") {
188
+ out = value;
189
+ }
190
+ else if (Array.isArray(value)) {
191
+ out = value.map(stringifyItem).join("\n");
192
+ }
193
+ else if (typeof value === "number" || typeof value === "boolean") {
194
+ out = String(value);
195
+ }
196
+ else {
197
+ // Object that isn't an array — use the same line-extraction
198
+ // heuristic as for array items.
199
+ out = stringifyItem(value);
200
+ }
201
+ if (out.length > MAX_CONTEXT_DOC_CHARS) {
202
+ out = out.slice(0, MAX_CONTEXT_DOC_CHARS);
203
+ }
204
+ return out;
205
+ }
79
206
  /**
80
207
  * Solve an LC term using miniKanren as the logic engine
81
208
  * @param term The LC term to evaluate
@@ -176,6 +303,68 @@ async function evaluate(term, tools, bindings, log, depth = 0) {
176
303
  if (!regexValidation.valid) {
177
304
  throw new Error(`Invalid regex pattern: ${regexValidation.error}`);
178
305
  }
306
+ // Phase 3 — optional haystack arg. The haystack MUST evaluate
307
+ // to a string. Numbers/booleans get rendered cleanly via
308
+ // String(); arrays/objects/null/undefined are rejected with a
309
+ // specific error so a typo (e.g. `(grep "X" RESULTS)` where
310
+ // RESULTS is the latest grep result array) doesn't silently
311
+ // coerce to "[object Object]" and produce garbage matches.
312
+ // Per project rule: correctness > performance — surface user
313
+ // mistakes loudly.
314
+ if (term.haystack) {
315
+ const hayValue = await evaluate(term.haystack, tools, bindings, log, depth + 1);
316
+ let haystack;
317
+ if (typeof hayValue === "string") {
318
+ haystack = hayValue;
319
+ }
320
+ else if (typeof hayValue === "number" || typeof hayValue === "boolean") {
321
+ haystack = String(hayValue);
322
+ }
323
+ else {
324
+ const shape = Array.isArray(hayValue)
325
+ ? `array(${hayValue.length})`
326
+ : hayValue === null
327
+ ? "null"
328
+ : hayValue === undefined
329
+ ? "undefined"
330
+ : typeof hayValue;
331
+ throw new Error(`(grep "${term.pattern}" HAYSTACK): haystack must evaluate to a string, got ${shape}. ` +
332
+ `If you have an array, materialize it first (e.g., join lines) or grep an individual element.`);
333
+ }
334
+ log(`[Solver] Executing grep("${pattern}") over haystack (${haystack.length} chars)`);
335
+ if (tools.grepIn) {
336
+ const results = tools.grepIn(pattern, haystack);
337
+ log(`[Solver] Found ${results.length} matches in haystack`);
338
+ return results;
339
+ }
340
+ // Fallback in-line search — same shape as the production
341
+ // grep callback. Used by test stubs that don't provide
342
+ // grepIn AND legacy SolverTools instances. ReDoS validated
343
+ // upstream by the same validateRegex call we already ran.
344
+ const flags = "gmi";
345
+ const regex = new RegExp(pattern, flags);
346
+ const lines = haystack.split("\n");
347
+ const results = [];
348
+ const MAX_HAYSTACK_MATCHES = 10_000;
349
+ let m;
350
+ while ((m = regex.exec(haystack)) !== null) {
351
+ const beforeMatch = haystack.slice(0, m.index);
352
+ const lineNum = (beforeMatch.match(/\n/g) || []).length + 1;
353
+ results.push({
354
+ match: m[0],
355
+ line: lines[lineNum - 1] ?? "",
356
+ lineNum,
357
+ index: m.index,
358
+ groups: m.slice(1).filter((g) => g !== undefined),
359
+ });
360
+ if (m[0].length === 0)
361
+ regex.lastIndex++;
362
+ if (results.length >= MAX_HAYSTACK_MATCHES)
363
+ break;
364
+ }
365
+ log(`[Solver] Found ${results.length} matches in haystack (in-line fallback)`);
366
+ return results;
367
+ }
179
368
  log(`[Solver] Executing grep("${pattern}")`);
180
369
  const results = tools.grep(pattern);
181
370
  log(`[Solver] Found ${results.length} matches`);
@@ -187,6 +376,71 @@ async function evaluate(term, tools, bindings, log, depth = 0) {
187
376
  }
188
377
  return results;
189
378
  }
379
+ case "show_vars": {
380
+ // Phase 4 — binding introspection. Returns a string summary
381
+ // that can be inlined into a sub-LLM prompt, used in a
382
+ // FINAL_VAR substitution, or just passed back to the user as
383
+ // the answer. Format mirrors `lattice_bindings` in spirit but
384
+ // is reachable from inside a query.
385
+ //
386
+ // Internal bindings start with `_` followed by a non-digit
387
+ // character (e.g., `_sessionDB`, `_compaction_trace`). These
388
+ // are private plumbing the user shouldn't see — surfacing
389
+ // them would expose internal state and confuse the LLM about
390
+ // what it can FINAL_VAR-reference. User-visible bindings:
391
+ // RESULTS, plain names, and `_<digits>` (per-turn results).
392
+ const isInternal = (name) => name.startsWith("_") && name.length > 1 && !/^_\d/.test(name);
393
+ const visibleEntries = [...bindings].filter(([n]) => !isInternal(n));
394
+ if (visibleEntries.length === 0) {
395
+ return "No bindings yet — run a query to populate RESULTS or a binding name.";
396
+ }
397
+ const lines = [];
398
+ for (const [name, value] of visibleEntries) {
399
+ let shape;
400
+ if (Array.isArray(value)) {
401
+ shape = `Array(${value.length})`;
402
+ }
403
+ else if (typeof value === "string") {
404
+ shape = `String(${value.length})`;
405
+ }
406
+ else if (typeof value === "number") {
407
+ shape = `Number(${value})`;
408
+ }
409
+ else if (typeof value === "boolean") {
410
+ shape = `Boolean(${value})`;
411
+ }
412
+ else if (value === null) {
413
+ shape = "null";
414
+ }
415
+ else if (value === undefined) {
416
+ shape = "undefined";
417
+ }
418
+ else if (typeof value === "object") {
419
+ shape = "object";
420
+ }
421
+ else {
422
+ shape = typeof value;
423
+ }
424
+ lines.push(` ${name}: ${shape}`);
425
+ }
426
+ return `Bindings (${visibleEntries.length}):\n${lines.join("\n")}`;
427
+ }
428
+ case "context": {
429
+ // Phase 3 — `(context N)` selector. Returns the Nth loaded
430
+ // context's content. Falls back to `tools.context` when
431
+ // `contexts` is unset and the index is 0 — back-compat for
432
+ // single-doc workflows that haven't wired the new field.
433
+ const idx = term.index;
434
+ if (tools.contexts) {
435
+ if (idx < 0 || idx >= tools.contexts.length) {
436
+ throw new Error(`(context ${idx}): index out of range — ${tools.contexts.length} context(s) loaded`);
437
+ }
438
+ return tools.contexts[idx];
439
+ }
440
+ if (idx === 0)
441
+ return tools.context;
442
+ throw new Error(`(context ${idx}): only 1 context loaded — use runRLMFromContent with an array to enable multi-context`);
443
+ }
190
444
  case "fuzzy_search": {
191
445
  const fuzzyLimit = Math.min(Math.max(1, term.limit ?? 10), 1000);
192
446
  log(`[Solver] Executing fuzzy_search("${term.query}", ${fuzzyLimit})`);
@@ -615,6 +869,9 @@ async function evaluate(term, tools, bindings, log, depth = 0) {
615
869
  if (typeof str !== "string") {
616
870
  throw new Error(`replace: expected string, got ${typeof str}`);
617
871
  }
872
+ if (typeof term.to !== "string") {
873
+ throw new Error(`replace: expected string for 'to', got ${typeof term.to}`);
874
+ }
618
875
  const replaceValidation = validateRegex(term.from);
619
876
  if (!replaceValidation.valid) {
620
877
  throw new Error(`replace: ${replaceValidation.error}`);
@@ -637,7 +894,12 @@ async function evaluate(term, tools, bindings, log, depth = 0) {
637
894
  if (!term.delim || term.delim.length === 0 || term.delim.length > 1000)
638
895
  return null;
639
896
  const MAX_SPLIT_PARTS = 10_000;
640
- const parts = str.split(term.delim);
897
+ // Bound the split itself, not just the post-allocation check —
898
+ // an unbounded `str.split(delim)` on a multi-MB string with a
899
+ // common delimiter materializes the full part array (potentially
900
+ // millions of strings) before we ever look at .length. Asking
901
+ // for one extra part is enough to detect overflow.
902
+ const parts = str.split(term.delim, MAX_SPLIT_PARTS + 1);
641
903
  if (parts.length > MAX_SPLIT_PARTS)
642
904
  return null;
643
905
  return parts[term.index] ?? null;
@@ -1184,6 +1446,89 @@ async function evaluate(term, tools, bindings, log, depth = 0) {
1184
1446
  }
1185
1447
  return response;
1186
1448
  }
1449
+ case "rlm_query": {
1450
+ // Phase 1 recursive primitive — spawn a child Nucleus FSM with
1451
+ // an explicit query and (optional) clean line-oriented document.
1452
+ // The actual child-spawning logic lives behind the `rlmQuery`
1453
+ // callback the parent's RLM loop wires up; this case is just
1454
+ // the materialization-and-dispatch layer.
1455
+ if (!tools.rlmQuery) {
1456
+ throw new Error("rlm_query is not available in this execution context. " +
1457
+ "The RLM loop provides it once the parent threads an " +
1458
+ "rlmQuery callback through SolverTools. Standalone " +
1459
+ "NucleusEngine / HandleSession instances must pass an " +
1460
+ "rlmQuery option to enable it.");
1461
+ }
1462
+ // Resolve (context EXPR) when present. The materialized form is
1463
+ // line-oriented: arrays join their stringified items by \n;
1464
+ // strings pass through unchanged; null/undefined become null.
1465
+ // This is the structural difference from llm_query — the child
1466
+ // gets a clean document its grep/lines/chunk primitives can
1467
+ // operate on, not a JSON-stringified blob.
1468
+ let contextDoc = null;
1469
+ if (term.context) {
1470
+ const resolved = await evaluate(term.context, tools, bindings, log, depth + 1);
1471
+ contextDoc = materializeContext(resolved);
1472
+ }
1473
+ log(`[Solver] rlm_query prompt length: ${term.prompt.length} chars, ` +
1474
+ `context: ${contextDoc === null ? "null" : `${contextDoc.length} chars`}`);
1475
+ const response = await tools.rlmQuery(term.prompt, contextDoc);
1476
+ log(`[Solver] rlm_query response length: ${response.length} chars`);
1477
+ return response;
1478
+ }
1479
+ case "rlm_batch": {
1480
+ // Phase 2 — concurrent variant of `(map COLL (lambda x (rlm_query …)))`.
1481
+ // Builds N (prompt, contextDoc) pairs from the inner rlm_query
1482
+ // template and dispatches them through tools.rlmBatch in a
1483
+ // single call. The implementation fans them out concurrently
1484
+ // (typically Promise.all over child sessions); the solver
1485
+ // itself is just the materialization-and-dispatch layer.
1486
+ if (!tools.rlmBatch) {
1487
+ throw new Error("rlm_batch is not available in this execution context. " +
1488
+ "The RLM loop provides it once the parent threads an " +
1489
+ "rlmBatch callback through SolverTools. Standalone " +
1490
+ "NucleusEngine / HandleSession instances must pass an " +
1491
+ "rlmBatch option to enable it.");
1492
+ }
1493
+ const collection = await evaluate(term.collection, tools, bindings, log, depth + 1);
1494
+ if (!Array.isArray(collection)) {
1495
+ throw new Error(`rlm_batch: expected array, got ${typeof collection}`);
1496
+ }
1497
+ // Empty collection short-circuit — match llm_batch's behavior
1498
+ // so callers don't pay a dispatch round-trip for zero work.
1499
+ if (collection.length === 0) {
1500
+ log(`[Solver] rlm_batch: empty collection, skipping dispatch`);
1501
+ return [];
1502
+ }
1503
+ const items = [];
1504
+ for (const item of collection) {
1505
+ // Bind the lambda param to the RAW item (no toItemString
1506
+ // coercion). rlm_batch funnels each item through
1507
+ // materializeContext, which knows how to render arrays as
1508
+ // line-oriented documents and objects with a `.line` field
1509
+ // as their line text. Stringifying the item up-front would
1510
+ // collapse all that structure into a flat string and defeat
1511
+ // the handle-as-document property we built for rlm_query.
1512
+ const scopedBindings = new Map(bindings);
1513
+ scopedBindings.set(term.param, item);
1514
+ let contextDoc = null;
1515
+ if (term.context) {
1516
+ const resolved = await evaluate(term.context, tools, scopedBindings, log, depth + 1);
1517
+ contextDoc = materializeContext(resolved);
1518
+ }
1519
+ items.push({ prompt: term.prompt, contextDoc });
1520
+ }
1521
+ log(`[Solver] rlm_batch dispatching ${items.length} child sessions in a single call`);
1522
+ const responses = await tools.rlmBatch(items);
1523
+ if (!Array.isArray(responses)) {
1524
+ throw new Error(`rlm_batch: tools.rlmBatch must return an array, got ${typeof responses}`);
1525
+ }
1526
+ if (responses.length !== items.length) {
1527
+ throw new Error(`rlm_batch: expected ${items.length} responses, got ${responses.length}`);
1528
+ }
1529
+ log(`[Solver] rlm_batch received ${responses.length} responses`);
1530
+ return responses;
1531
+ }
1187
1532
  case "llm_batch": {
1188
1533
  // Batched sibling of `llm_query`. Drop-in replacement for
1189
1534
  // (map COLL (lambda x (llm_query "…" …bindings)))
@@ -1388,6 +1733,9 @@ async function evaluateWithBinding(body, param, value, tools, bindings, log, dep
1388
1733
  const str = body.str.tag === "var" && body.str.name === param
1389
1734
  ? String(value)
1390
1735
  : String(await evaluateWithBinding(body.str, param, value, tools, bindings, log, depth + 1));
1736
+ if (typeof body.to !== "string") {
1737
+ throw new Error(`replace: expected string for 'to', got ${typeof body.to}`);
1738
+ }
1391
1739
  const replaceVal = validateRegex(body.from);
1392
1740
  if (!replaceVal.valid) {
1393
1741
  throw new Error(`replace: ${replaceVal.error}`);