@nxuss/lemma 1.8.0 → 1.9.0

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 (76) hide show
  1. package/README.md +126 -176
  2. package/dist/cjs/mcp/prompts.d.ts +10 -1
  3. package/dist/cjs/mcp/prompts.d.ts.map +1 -1
  4. package/dist/cjs/mcp/prompts.js +25 -73
  5. package/dist/cjs/mcp/prompts.js.map +1 -1
  6. package/dist/cjs/mcp/tool-profiles.d.ts +10 -1
  7. package/dist/cjs/mcp/tool-profiles.d.ts.map +1 -1
  8. package/dist/cjs/mcp/tool-profiles.js +12 -3
  9. package/dist/cjs/mcp/tool-profiles.js.map +1 -1
  10. package/dist/cjs/mcp/tools.d.ts.map +1 -1
  11. package/dist/cjs/mcp/tools.js +122 -28
  12. package/dist/cjs/mcp/tools.js.map +1 -1
  13. package/dist/cjs/mcp/utils.d.ts.map +1 -1
  14. package/dist/cjs/mcp/utils.js +19 -6
  15. package/dist/cjs/mcp/utils.js.map +1 -1
  16. package/dist/cjs/security/SemanticScrubber.d.ts +2 -0
  17. package/dist/cjs/security/SemanticScrubber.d.ts.map +1 -1
  18. package/dist/cjs/security/SemanticScrubber.js +53 -13
  19. package/dist/cjs/security/SemanticScrubber.js.map +1 -1
  20. package/dist/cjs/utils/ContextSqueezer.d.ts.map +1 -1
  21. package/dist/cjs/utils/ContextSqueezer.js +55 -22
  22. package/dist/cjs/utils/ContextSqueezer.js.map +1 -1
  23. package/dist/cjs/utils/FileOutline.d.ts +22 -0
  24. package/dist/cjs/utils/FileOutline.d.ts.map +1 -0
  25. package/dist/cjs/utils/FileOutline.js +55 -0
  26. package/dist/cjs/utils/FileOutline.js.map +1 -0
  27. package/dist/cjs/utils/LineNumberMap.d.ts +29 -0
  28. package/dist/cjs/utils/LineNumberMap.d.ts.map +1 -0
  29. package/dist/cjs/utils/LineNumberMap.js +98 -0
  30. package/dist/cjs/utils/LineNumberMap.js.map +1 -0
  31. package/dist/cjs/utils/PatchMatcher.d.ts +2 -0
  32. package/dist/cjs/utils/PatchMatcher.d.ts.map +1 -1
  33. package/dist/cjs/utils/PatchMatcher.js +9 -1
  34. package/dist/cjs/utils/PatchMatcher.js.map +1 -1
  35. package/dist/cjs/utils/ReadWorkspaceCache.d.ts +16 -3
  36. package/dist/cjs/utils/ReadWorkspaceCache.d.ts.map +1 -1
  37. package/dist/cjs/utils/ReadWorkspaceCache.js +20 -8
  38. package/dist/cjs/utils/ReadWorkspaceCache.js.map +1 -1
  39. package/dist/esm/mcp/prompts.d.ts +10 -1
  40. package/dist/esm/mcp/prompts.d.ts.map +1 -1
  41. package/dist/esm/mcp/prompts.js +25 -73
  42. package/dist/esm/mcp/prompts.js.map +1 -1
  43. package/dist/esm/mcp/tool-profiles.d.ts +10 -1
  44. package/dist/esm/mcp/tool-profiles.d.ts.map +1 -1
  45. package/dist/esm/mcp/tool-profiles.js +12 -3
  46. package/dist/esm/mcp/tool-profiles.js.map +1 -1
  47. package/dist/esm/mcp/tools.d.ts.map +1 -1
  48. package/dist/esm/mcp/tools.js +122 -28
  49. package/dist/esm/mcp/tools.js.map +1 -1
  50. package/dist/esm/mcp/utils.d.ts.map +1 -1
  51. package/dist/esm/mcp/utils.js +19 -6
  52. package/dist/esm/mcp/utils.js.map +1 -1
  53. package/dist/esm/security/SemanticScrubber.d.ts +2 -0
  54. package/dist/esm/security/SemanticScrubber.d.ts.map +1 -1
  55. package/dist/esm/security/SemanticScrubber.js +53 -13
  56. package/dist/esm/security/SemanticScrubber.js.map +1 -1
  57. package/dist/esm/utils/ContextSqueezer.d.ts.map +1 -1
  58. package/dist/esm/utils/ContextSqueezer.js +55 -22
  59. package/dist/esm/utils/ContextSqueezer.js.map +1 -1
  60. package/dist/esm/utils/FileOutline.d.ts +22 -0
  61. package/dist/esm/utils/FileOutline.d.ts.map +1 -0
  62. package/dist/esm/utils/FileOutline.js +51 -0
  63. package/dist/esm/utils/FileOutline.js.map +1 -0
  64. package/dist/esm/utils/LineNumberMap.d.ts +29 -0
  65. package/dist/esm/utils/LineNumberMap.d.ts.map +1 -0
  66. package/dist/esm/utils/LineNumberMap.js +93 -0
  67. package/dist/esm/utils/LineNumberMap.js.map +1 -0
  68. package/dist/esm/utils/PatchMatcher.d.ts +2 -0
  69. package/dist/esm/utils/PatchMatcher.d.ts.map +1 -1
  70. package/dist/esm/utils/PatchMatcher.js +9 -1
  71. package/dist/esm/utils/PatchMatcher.js.map +1 -1
  72. package/dist/esm/utils/ReadWorkspaceCache.d.ts +16 -3
  73. package/dist/esm/utils/ReadWorkspaceCache.d.ts.map +1 -1
  74. package/dist/esm/utils/ReadWorkspaceCache.js +19 -8
  75. package/dist/esm/utils/ReadWorkspaceCache.js.map +1 -1
  76. package/package.json +1 -1
@@ -38,6 +38,8 @@ import { distillCommandOutput, buildDistillFooter, readRegion } from "../utils/C
38
38
  import { findMatch, reindentReplacement } from "../utils/PatchMatcher.js";
39
39
  import { searchWorkspace, groupSearchResults, parseExtensionFilter } from "../utils/WorkspaceSearch.js";
40
40
  import { checkAlreadySent, recordSent } from "../utils/ReadWorkspaceCache.js";
41
+ import { mapSqueezedToOriginal, withLineNumbers, sequentialNumbers } from "../utils/LineNumberMap.js";
42
+ import { fileOutline, formatOutline } from "../utils/FileOutline.js";
41
43
  import { getCachedSearch, setCachedSearch } from "../utils/SearchCache.js";
42
44
  // ── Pro License Gate ──────────────────────────────────────────────────────────
43
45
  const LICENSE_FILE = path.join(os.homedir(), '.lemma-cache', 'license.json');
@@ -163,13 +165,16 @@ const toolDefinitions = [
163
165
  },
164
166
  {
165
167
  name: "read_workspace_file",
166
- description: "Read a file inside the workspace. Automatically compresses comments/whitespace and scrubs API keys. If this exact file (same compact mode) was already read this session and hasn't changed on disk, returns a short UNCHANGED marker instead of resending the full content — reuse your existing copy. Pass force:true to always get the full content.",
168
+ description: "Read a file inside the workspace. Compresses comments/whitespace and scrubs API keys. Every line carries its REAL line number in the original file (compression removes lines, it never renumbers them), so you can cite file:line and patch precisely without re-reading. The header lists which imports were compressed away. Use offset/limit to read a line range of a large file. If this exact view was already read this session and hasn't changed on disk, returns an UNCHANGED marker plus a symbol outline — reuse your copy, or pass force:true if it's no longer in your context.",
167
169
  inputSchema: {
168
170
  type: "object",
169
171
  properties: {
170
172
  filePath: { type: "string", description: "Path relative to the project root" },
171
173
  compact: { type: "boolean", description: "Whether to compress comments and whitespace", default: true },
172
174
  force: { type: "boolean", description: "Bypass the unchanged-file short-circuit and always return full content", default: false },
175
+ lineNumbers: { type: "boolean", description: "Prefix each line with its real line number in the original file", default: true },
176
+ offset: { type: "number", description: "First line to read (1-indexed). Omit to start at the beginning." },
177
+ limit: { type: "number", description: "How many lines to read from offset. Omit to read to the end." },
173
178
  },
174
179
  required: ["filePath"],
175
180
  },
@@ -213,7 +218,7 @@ const toolDefinitions = [
213
218
  },
214
219
  {
215
220
  name: "run_workspace_command",
216
- description: "Execute a bash command in the workspace root (15s timeout). Captures both stdout and stderr. Long test/build/lint output is distilled deterministically — for jest, vitest and tsc it returns the failing tests or diagnostics with their locations and reasons, dropping code frames and node_modules stack frames; anything else falls back to head/tail plus error-matching lines. The complete output is always stored first and the reply carries a handle: nothing is lost, use output_region to retrieve any part verbatim. Pass raw:true to skip distillation.",
221
+ description: "Execute a bash command in the workspace root. Default timeout 120s — raise it with timeoutMs for a full test or build run (max 600s). Captures both stdout and stderr, and returns whatever was produced even if the command times out. Long test/build/lint output is distilled deterministically — for jest, vitest and tsc it returns the failing tests or diagnostics with their locations and reasons, dropping code frames and node_modules stack frames; anything else falls back to head/tail plus error-matching lines. The complete output is always stored first and the reply carries a handle: nothing is lost, use output_region to retrieve any part verbatim. Pass raw:true to skip distillation.",
217
222
  inputSchema: {
218
223
  type: "object",
219
224
  properties: {
@@ -223,6 +228,11 @@ const toolDefinitions = [
223
228
  description: "Return the complete untouched output instead of the distilled view",
224
229
  default: false,
225
230
  },
231
+ timeoutMs: {
232
+ type: "number",
233
+ description: "Milliseconds before the command is killed (default 120000, max 600000)",
234
+ default: 120000,
235
+ },
226
236
  },
227
237
  required: ["command"],
228
238
  },
@@ -1044,7 +1054,15 @@ const allToolDefinitions = [
1044
1054
  ];
1045
1055
  // Narrow what gets advertised to what this project can actually use. Hidden tools stay
1046
1056
  // registered in `toolHandlers` and callable — both directly and through `lemma_toolbox`.
1047
- const _toolSurface = resolveToolSurface(allToolDefinitions, process.cwd());
1057
+ //
1058
+ // The entitlement filter matters as much as the project filter: without it a free session
1059
+ // advertised the full core surface — read, search, patch, run, AST — and refused fourteen of
1060
+ // those twenty tools at call time. That is the worst of both, paying the per-turn schema cost
1061
+ // for capabilities the session cannot use. They remain listed in `lemma_toolbox`, so the
1062
+ // capability is still discoverable and the upgrade path is still visible.
1063
+ const _toolSurface = resolveToolSurface(allToolDefinitions, process.cwd(), {
1064
+ isEntitled: (name) => isProUser() || FREE_TOOLS.has(name),
1065
+ });
1048
1066
  const toolDefinitionsArray = _toolSurface.advertised;
1049
1067
  const hiddenToolDefinitions = _toolSurface.hidden;
1050
1068
  /** Serialized size of every tool schema sent to the client, for cost accounting. */
@@ -1437,14 +1455,28 @@ async function handleReadWorkspaceFile(args) {
1437
1455
  const filePath = args?.filePath;
1438
1456
  const compact = args?.compact !== false;
1439
1457
  const force = !!args?.force;
1458
+ const lineNumbers = args?.lineNumbers !== false;
1459
+ const offset = typeof args?.offset === "number" && args.offset > 0 ? Math.floor(args.offset) : undefined;
1460
+ const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.floor(args.limit) : undefined;
1440
1461
  if (!filePath)
1441
1462
  throw new Error("filePath is required");
1442
1463
  const workspaceRoot = process.cwd();
1443
1464
  try {
1444
1465
  const { resolved } = safeResolvePath(workspaceRoot, filePath);
1445
- let content = fs.readFileSync(resolved, "utf8");
1466
+ const fullContent = fs.readFileSync(resolved, "utf8");
1467
+ const view = { compact, lineNumbers, offset, limit };
1468
+ // The range is applied to the original file, before any compression: `offset` means the
1469
+ // line the user would see in an editor, not a position in the squeezed output.
1470
+ const allLines = fullContent.split("\n");
1471
+ const startIndex = offset ? Math.min(offset - 1, allLines.length) : 0;
1472
+ const endIndex = limit ? Math.min(startIndex + limit, allLines.length) : allLines.length;
1473
+ const ranged = offset || limit ? allLines.slice(startIndex, endIndex).join("\n") : fullContent;
1474
+ const rangeHeader = offset || limit
1475
+ ? `[lines ${startIndex + 1}-${endIndex} of ${allLines.length} in ${filePath}]\n`
1476
+ : "";
1477
+ let content = ranged;
1446
1478
  const originalSize = content.length;
1447
- const dedup = checkAlreadySent(resolved, compact, content);
1479
+ const dedup = checkAlreadySent(resolved, view, content);
1448
1480
  if (!force && dedup.unchanged) {
1449
1481
  reportSavings({
1450
1482
  source: "contextSqueeze",
@@ -1453,17 +1485,32 @@ async function handleReadWorkspaceFile(args) {
1453
1485
  toolName: "read_workspace_file",
1454
1486
  filePath,
1455
1487
  });
1488
+ // A hash alone is not actionable: the model's context may have been compacted since,
1489
+ // in which case it no longer holds the copy this marker tells it to reuse. The outline
1490
+ // costs ~50 tokens and is enough to either locate what it needs or decide to re-fetch.
1491
+ const outline = formatOutline(fileOutline(content), allLines.length);
1456
1492
  return {
1457
1493
  content: [
1458
1494
  {
1459
1495
  type: "text",
1460
- text: `UNCHANGED: ${filePath} is identical to what you already read this session (hash ${dedup.hash.slice(0, 12)}) — reuse your existing copy. Pass force:true to re-fetch anyway.`,
1496
+ text: [
1497
+ `UNCHANGED: ${filePath} is identical to what you already read this session (hash ${dedup.hash.slice(0, 12)}) — reuse your existing copy.`,
1498
+ `If it is no longer in your context, re-fetch with force:true. Structure:`,
1499
+ outline,
1500
+ ].join("\n"),
1461
1501
  },
1462
1502
  ],
1463
1503
  };
1464
1504
  }
1505
+ // Line numbers are computed against the pre-squeeze text and carried through, so a
1506
+ // compressed read still reports the position the code actually occupies on disk.
1507
+ let numbers = null;
1465
1508
  if (compact) {
1466
- content = squeezeCode(content);
1509
+ const squeezed = squeezeCode(content);
1510
+ if (lineNumbers) {
1511
+ numbers = mapSqueezedToOriginal(content, squeezed).map((n) => (n === null ? null : n + startIndex));
1512
+ }
1513
+ content = squeezed;
1467
1514
  if (content.length < originalSize) {
1468
1515
  reportSavings({
1469
1516
  source: "contextSqueeze",
@@ -1474,16 +1521,26 @@ async function handleReadWorkspaceFile(args) {
1474
1521
  });
1475
1522
  }
1476
1523
  }
1524
+ else if (lineNumbers) {
1525
+ numbers = sequentialNumbers(content, startIndex + 1);
1526
+ }
1477
1527
  try {
1478
1528
  const scrubber = new SemanticScrubber();
1479
1529
  const { maskedPrompt } = scrubber.mask(content);
1530
+ // Masking must not change the line count, or every number after a masked secret would
1531
+ // be off. If it ever does, drop the gutter rather than print numbers that lie.
1532
+ if (numbers && maskedPrompt.split("\n").length !== content.split("\n").length) {
1533
+ numbers = null;
1534
+ }
1480
1535
  content = maskedPrompt;
1481
1536
  }
1482
1537
  catch (err) {
1483
1538
  logWarn("read_workspace_file", "SemanticScrubber failed, returning raw content");
1484
1539
  }
1485
- recordSent(resolved, compact, dedup.hash);
1486
- return { content: [{ type: "text", text: content }] };
1540
+ if (numbers)
1541
+ content = withLineNumbers(content, numbers);
1542
+ recordSent(resolved, view, dedup.hash);
1543
+ return { content: [{ type: "text", text: rangeHeader + content }] };
1487
1544
  }
1488
1545
  catch (err) {
1489
1546
  logError("read_workspace_file", err);
@@ -1535,13 +1592,35 @@ async function handleCreateWorkspaceFile(args) {
1535
1592
  return { content: [{ type: "text", text: `Error creating file: ${err.message}` }] };
1536
1593
  }
1537
1594
  }
1595
+ /**
1596
+ * `read_workspace_file` now returns a "123| code" gutter, which makes it easy to paste a block
1597
+ * back into a patch with the numbers still attached. That block matches nothing on disk, so the
1598
+ * patch fails for a reason that has nothing to do with the edit. Strip the gutter when *every*
1599
+ * non-empty line carries one — a uniform prefix is a gutter; a lone "12| " inside real code is
1600
+ * not, and must be left alone.
1601
+ */
1602
+ function stripLineNumberGutter(text) {
1603
+ const lines = text.split("\n");
1604
+ const nonEmpty = lines.filter((l) => l.trim().length > 0);
1605
+ if (nonEmpty.length === 0)
1606
+ return text;
1607
+ const GUTTER = /^\s*\d+\|\s?/;
1608
+ if (!nonEmpty.every((l) => GUTTER.test(l)))
1609
+ return text;
1610
+ return lines.map((l) => l.replace(GUTTER, "")).join("\n");
1611
+ }
1538
1612
  async function handleApplyWorkspacePatch(args) {
1539
1613
  const filePath = args?.filePath;
1540
- const searchContent = args?.searchContent;
1541
- const replaceContent = args?.replaceContent;
1542
- if (!filePath || !searchContent || replaceContent === undefined) {
1614
+ const rawSearch = args?.searchContent;
1615
+ const rawReplace = args?.replaceContent;
1616
+ if (!filePath || !rawSearch || rawReplace === undefined) {
1543
1617
  throw new Error("filePath, searchContent, and replaceContent are required");
1544
1618
  }
1619
+ const searchContent = stripLineNumberGutter(rawSearch);
1620
+ // Only strip the replacement's gutter when the search block had one too: that pairing means
1621
+ // the model was working from a numbered read. Stripping unconditionally would mangle a
1622
+ // replacement that legitimately contains "1| " (a table, a doc block).
1623
+ const replaceContent = searchContent !== rawSearch ? stripLineNumberGutter(rawReplace) : rawReplace;
1545
1624
  const workspaceRoot = process.cwd();
1546
1625
  try {
1547
1626
  const { resolved } = safeResolvePath(workspaceRoot, filePath);
@@ -1581,10 +1660,19 @@ async function handleApplyWorkspacePatch(args) {
1581
1660
  };
1582
1661
  }
1583
1662
  const match = matchResult[0];
1584
- const contentLines = originalContent.split("\n");
1585
- const finalReplace = match.strategy === "exact" ? replaceContent : reindentReplacement(replaceContent, searchContent, contentLines[match.startLine]);
1586
- const updatedLines = [...contentLines.slice(0, match.startLine), ...finalReplace.split("\n"), ...contentLines.slice(match.endLine + 1)];
1587
- fs.writeFileSync(resolved, updatedLines.join("\n"), "utf8");
1663
+ let updatedContent;
1664
+ if (match.strategy === "exact" && match.charStart !== undefined && match.charEnd !== undefined) {
1665
+ // Splice by character offset the search block may be a mid-line fragment, and
1666
+ // a whole-line splice would discard everything else sharing that line.
1667
+ updatedContent = originalContent.slice(0, match.charStart) + replaceContent + originalContent.slice(match.charEnd);
1668
+ }
1669
+ else {
1670
+ const contentLines = originalContent.split("\n");
1671
+ const finalReplace = reindentReplacement(replaceContent, searchContent, contentLines[match.startLine]);
1672
+ const updatedLines = [...contentLines.slice(0, match.startLine), ...finalReplace.split("\n"), ...contentLines.slice(match.endLine + 1)];
1673
+ updatedContent = updatedLines.join("\n");
1674
+ }
1675
+ fs.writeFileSync(resolved, updatedContent, "utf8");
1588
1676
  const note = match.strategy === "exact" ? "" : ` (matched via ${match.strategy}, score ${match.score.toFixed(2)})`;
1589
1677
  return { content: [{ type: "text", text: `Success: Patch successfully applied to ${filePath}${note}` }] };
1590
1678
  }
@@ -1626,32 +1714,38 @@ async function handleRunWorkspaceCommand(args) {
1626
1714
  // stdio is overridden, which silently dropped the output of every tool that reports on
1627
1715
  // stderr (jest and tsc both do) on a successful run. It also gives us stdout, stderr and
1628
1716
  // the exit code through one code path instead of a throw-on-failure split.
1717
+ // 15s was below the runtime of the commands this tool exists for: a real `npm test` or
1718
+ // `tsc --noEmit` always tripped it, so the distillation pipeline never ran on the output
1719
+ // it was built for and the call was pure overhead before falling back to a plain shell.
1720
+ const DEFAULT_TIMEOUT_MS = 120000;
1721
+ const MAX_TIMEOUT_MS = 600000;
1722
+ const requested = typeof args?.timeoutMs === "number" && args.timeoutMs > 0 ? args.timeoutMs : DEFAULT_TIMEOUT_MS;
1723
+ const timeoutMs = Math.min(Math.floor(requested), MAX_TIMEOUT_MS);
1629
1724
  const result = spawnSync(command, {
1630
1725
  cwd: workspaceRoot,
1631
1726
  shell: true,
1632
1727
  encoding: "utf8",
1633
- timeout: 15000,
1728
+ timeout: timeoutMs,
1634
1729
  maxBuffer: 20 * 1024 * 1024,
1635
1730
  env: { ...process.env },
1636
1731
  });
1637
- if (result.error) {
1638
- const timedOut = result.error.code === "ETIMEDOUT";
1732
+ const timedOut = !!result.error && result.error.code === "ETIMEDOUT";
1733
+ if (result.error && !timedOut) {
1639
1734
  return {
1640
- content: [
1641
- {
1642
- type: "text",
1643
- text: timedOut
1644
- ? `Command timed out after 15s: ${command}`
1645
- : `Command could not be executed: ${result.error.message}`,
1646
- },
1647
- ],
1735
+ content: [{ type: "text", text: `Command could not be executed: ${result.error.message}` }],
1648
1736
  };
1649
1737
  }
1650
1738
  const stdout = result.stdout || "";
1651
1739
  const stderr = result.stderr || "";
1652
1740
  const combined = [stdout, stderr].filter((s) => s.trim().length > 0).join("\n");
1653
1741
  const status = result.status ?? 0;
1654
- const header = status === 0 ? "Command completed successfully." : `Command failed with exit code ${status}.`;
1742
+ // A timed-out run still produced output, and that partial output is normally where the
1743
+ // failure is. Discarding it forced the whole command to be re-run somewhere else.
1744
+ const header = timedOut
1745
+ ? `Command timed out after ${Math.round(timeoutMs / 1000)}s (killed): ${command}\nOutput produced before the kill is below — it may be incomplete. Re-run with a higher timeoutMs if it was still making progress.`
1746
+ : status === 0
1747
+ ? "Command completed successfully."
1748
+ : `Command failed with exit code ${status}.`;
1655
1749
  if (!combined.trim()) {
1656
1750
  return { content: [{ type: "text", text: `${header}\n\n(no output)` }] };
1657
1751
  }