@pinepaper.studio/mcp-server 1.6.17 → 1.6.19

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 (125) hide show
  1. package/README.de.md +1 -1
  2. package/README.es.md +1 -1
  3. package/README.fr.md +1 -1
  4. package/README.hi.md +1 -1
  5. package/README.ja.md +1 -1
  6. package/README.ko.md +1 -1
  7. package/README.md +189 -10
  8. package/README.pt-BR.md +1 -1
  9. package/README.zh-CN.md +1 -1
  10. package/dist/agent/export-manager.js +2 -2
  11. package/dist/agent/session-manager.d.ts +8 -0
  12. package/dist/agent/session-manager.d.ts.map +1 -1
  13. package/dist/agent/session-manager.js +17 -2
  14. package/dist/agent/session-manager.js.map +1 -1
  15. package/dist/assets/asset-manager.d.ts +28 -1
  16. package/dist/assets/asset-manager.d.ts.map +1 -1
  17. package/dist/assets/asset-manager.js +65 -25
  18. package/dist/assets/asset-manager.js.map +1 -1
  19. package/dist/assets/index.d.ts +1 -3
  20. package/dist/assets/index.d.ts.map +1 -1
  21. package/dist/assets/index.js +1 -3
  22. package/dist/assets/index.js.map +1 -1
  23. package/dist/assets/repositories/iconify.js +1 -1
  24. package/dist/assets/repositories/iconify.js.map +1 -1
  25. package/dist/assets/repositories/openclipart.js +1 -1
  26. package/dist/assets/repositories/openclipart.js.map +1 -1
  27. package/dist/assets/repositories/svgrepo.js +2 -2
  28. package/dist/assets/repositories/svgrepo.js.map +1 -1
  29. package/dist/assets/types.d.ts +2 -2
  30. package/dist/assets/types.d.ts.map +1 -1
  31. package/dist/browser/puppeteer-controller.d.ts +86 -1
  32. package/dist/browser/puppeteer-controller.d.ts.map +1 -1
  33. package/dist/browser/puppeteer-controller.js +274 -6
  34. package/dist/browser/puppeteer-controller.js.map +1 -1
  35. package/dist/build-info.json +2 -2
  36. package/dist/cli.js +4 -2
  37. package/dist/cli.js.map +1 -1
  38. package/dist/design/design-systems.d.ts +2 -0
  39. package/dist/design/design-systems.d.ts.map +1 -1
  40. package/dist/design/design-systems.js +2 -0
  41. package/dist/design/design-systems.js.map +1 -1
  42. package/dist/execution/canvas-state.d.ts.map +1 -1
  43. package/dist/execution/canvas-state.js +19 -43
  44. package/dist/execution/canvas-state.js.map +1 -1
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +94 -16
  48. package/dist/index.js.map +1 -1
  49. package/dist/ontology/design-graph.js +2 -2
  50. package/dist/ontology/design-graph.js.map +1 -1
  51. package/dist/ontology/ontology-validator.d.ts.map +1 -1
  52. package/dist/ontology/ontology-validator.js +9 -5
  53. package/dist/ontology/ontology-validator.js.map +1 -1
  54. package/dist/ontology/upstream-vocabulary.d.ts +2 -2
  55. package/dist/ontology/upstream-vocabulary.js +2 -2
  56. package/dist/ontology/validator.js +1 -1
  57. package/dist/ontology/validator.js.map +1 -1
  58. package/dist/tools/compact-descriptions.d.ts.map +1 -1
  59. package/dist/tools/compact-descriptions.js +15 -14
  60. package/dist/tools/compact-descriptions.js.map +1 -1
  61. package/dist/tools/definitions.d.ts +1 -1
  62. package/dist/tools/definitions.d.ts.map +1 -1
  63. package/dist/tools/definitions.js +556 -157
  64. package/dist/tools/definitions.js.map +1 -1
  65. package/dist/tools/engine-lists.d.ts +35 -0
  66. package/dist/tools/engine-lists.d.ts.map +1 -0
  67. package/dist/tools/engine-lists.js +186 -0
  68. package/dist/tools/engine-lists.js.map +1 -0
  69. package/dist/tools/engine-methods.d.ts.map +1 -1
  70. package/dist/tools/engine-methods.js +18 -1
  71. package/dist/tools/engine-methods.js.map +1 -1
  72. package/dist/tools/engine-surface.d.ts +1 -1
  73. package/dist/tools/engine-surface.d.ts.map +1 -1
  74. package/dist/tools/engine-surface.js +66 -9
  75. package/dist/tools/engine-surface.js.map +1 -1
  76. package/dist/tools/handlers/brand-intake.d.ts +95 -0
  77. package/dist/tools/handlers/brand-intake.d.ts.map +1 -0
  78. package/dist/tools/handlers/brand-intake.js +242 -0
  79. package/dist/tools/handlers/brand-intake.js.map +1 -0
  80. package/dist/tools/handlers/camera.js +8 -8
  81. package/dist/tools/handlers/camera.js.map +1 -1
  82. package/dist/tools/handlers/export.d.ts.map +1 -1
  83. package/dist/tools/handlers/export.js +77 -5
  84. package/dist/tools/handlers/export.js.map +1 -1
  85. package/dist/tools/handlers/font.js +10 -10
  86. package/dist/tools/handlers/font.js.map +1 -1
  87. package/dist/tools/handlers/generate.d.ts +12 -12
  88. package/dist/tools/handlers/maps.d.ts.map +1 -1
  89. package/dist/tools/handlers/maps.js +23 -3
  90. package/dist/tools/handlers/maps.js.map +1 -1
  91. package/dist/tools/handlers.d.ts +44 -11
  92. package/dist/tools/handlers.d.ts.map +1 -1
  93. package/dist/tools/handlers.js +462 -43
  94. package/dist/tools/handlers.js.map +1 -1
  95. package/dist/tools/minimal-descriptions.d.ts.map +1 -1
  96. package/dist/tools/minimal-descriptions.js +36 -29
  97. package/dist/tools/minimal-descriptions.js.map +1 -1
  98. package/dist/tools/shape-params.d.ts +2 -2
  99. package/dist/tools/shape-params.d.ts.map +1 -1
  100. package/dist/tools/shape-params.js +5 -2
  101. package/dist/tools/shape-params.js.map +1 -1
  102. package/dist/tools/stick-vocabulary.d.ts +1 -1
  103. package/dist/tools/stick-vocabulary.js +1 -1
  104. package/dist/tools/toolkits.d.ts.map +1 -1
  105. package/dist/tools/toolkits.js +5 -0
  106. package/dist/tools/toolkits.js.map +1 -1
  107. package/dist/types/code-generator.d.ts +96 -4
  108. package/dist/types/code-generator.d.ts.map +1 -1
  109. package/dist/types/code-generator.js +1265 -277
  110. package/dist/types/code-generator.js.map +1 -1
  111. package/dist/types/schemas.d.ts +4181 -1757
  112. package/dist/types/schemas.d.ts.map +1 -1
  113. package/dist/types/schemas.js +378 -84
  114. package/dist/types/schemas.js.map +1 -1
  115. package/dist/utils/mcp-log.d.ts +16 -0
  116. package/dist/utils/mcp-log.d.ts.map +1 -0
  117. package/dist/utils/mcp-log.js +10 -0
  118. package/dist/utils/mcp-log.js.map +1 -0
  119. package/dist/utils/progress.d.ts +28 -0
  120. package/dist/utils/progress.d.ts.map +1 -0
  121. package/dist/utils/progress.js +24 -0
  122. package/dist/utils/progress.js.map +1 -0
  123. package/dist/vendor/design/design-style.js +1 -1
  124. package/manifest.json +22 -2
  125. package/package.json +7 -4
@@ -5,8 +5,8 @@
5
5
  * Supports i18n for localized error and success messages.
6
6
  * Supports browser execution via Puppeteer.
7
7
  */
8
- import { codeGenerator, generateQueryMutationsCode } from '../types/code-generator.js';
9
- import { CreateItemInputSchema, ModifyItemInputSchema, DeleteItemInputSchema, AddRelationInputSchema, RemoveRelationInputSchema, QueryRelationsInputSchema, AnimateItemInputSchema, ImportMotionCaptureInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, GetItemsInputSchema, PlayTimelineInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, ImportSVGInputSchema, ImportMermaidInputSchema, AddFilterInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, GeometryInputSchema, EquationPathInputSchema, EventInputSchema, ConstructionSequenceInputSchema, ValidateSceneInputSchema, CaptureFramesInputSchema, InstantiateOntologyInputSchema, LintSceneInputSchema, MediaInputSchema, TextStyleInputSchema, TextEffectInputSchema, DesignMediumInputSchema, ShatterImageInputSchema, ImportLayeredCharacterInputSchema, CharacterInputSchema, GameInputSchema, World3DInputSchema, BrandKitInputSchema, ComponentInputSchema, ArtboardInputSchema, CommentInputSchema, ProvenanceInputSchema, SceneDiffInputSchema, AudioBeatsInputSchema, TemplateParamsInputSchema, RenderBatchInputSchema, AccessibilityCheckInputSchema, QueryMutationsInputSchema, ComposeInputSchema, CropImageInputSchema, PathOpInputSchema, MotionInputSchema, SoundInputSchema, InterchangeInputSchema, StickInputSchema, DesignSystemInputSchema, StoryInputSchema, ChromaKeyInputSchema, RiggingInputSchema, GroupInputSchema, CameraDirectorInputSchema, DetectObjectsInputSchema, ExtractObjectInputSchema, ArrangeInputSchema, CreateSceneInputSchema, GetPerformanceMetricsInputSchema, DiagnosticReportInputSchema, SearchAssetsInputSchema, ImportAssetInputSchema, P5DrawInputSchema,
8
+ import { codeGenerator, generateQueryMutationsCode, SHEET_RUBRIC } from '../types/code-generator.js';
9
+ import { CreateItemInputSchema, ModifyItemInputSchema, DeleteItemInputSchema, AddRelationInputSchema, RemoveRelationInputSchema, QueryRelationsInputSchema, AnimateItemInputSchema, ImportMotionCaptureInputSchema, KeyframeAnimateInputSchema, ExecuteGeneratorInputSchema, ApplyEffectInputSchema, GetItemsInputSchema, PlayTimelineInputSchema, SetBackgroundColorInputSchema, SetCanvasSizeInputSchema, ImportSVGInputSchema, ImportMermaidInputSchema, AddFilterInputSchema, CreateGlossySphereInputSchema, CreateDiagonalStripesInputSchema, BatchCreateInputSchema, BatchModifyInputSchema, CreateGridInputSchema, GeometryInputSchema, EquationPathInputSchema, EventInputSchema, ConstructionSequenceInputSchema, ValidateSceneInputSchema, CaptureFramesInputSchema, InstantiateOntologyInputSchema, LintSceneInputSchema, MediaInputSchema, TextStyleInputSchema, TextEffectInputSchema, DesignMediumInputSchema, RelightInputSchema, ShaderGraphInputSchema, ShatterImageInputSchema, ImportLayeredCharacterInputSchema, CharacterInputSchema, GameInputSchema, World3DInputSchema, BrandKitInputSchema, ComponentInputSchema, ArtboardInputSchema, CommentInputSchema, ProvenanceInputSchema, SceneDiffInputSchema, AudioBeatsInputSchema, TemplateParamsInputSchema, RenderBatchInputSchema, AccessibilityCheckInputSchema, QueryMutationsInputSchema, ComposeInputSchema, CropImageInputSchema, PathOpInputSchema, MotionInputSchema, SoundInputSchema, InterchangeInputSchema, StickInputSchema, DesignSystemInputSchema, StoryInputSchema, ChromaKeyInputSchema, RiggingInputSchema, GroupInputSchema, CameraDirectorInputSchema, DetectObjectsInputSchema, ExtractObjectInputSchema, ArrangeInputSchema, CreateSceneInputSchema, GetPerformanceMetricsInputSchema, DiagnosticReportInputSchema, SearchAssetsInputSchema, ImportAssetInputSchema, P5DrawInputSchema,
10
10
  // Diagram schemas
11
11
  CreateDiagramShapeInputSchema, ConnectInputSchema, ConnectPortsInputSchema, AddPortsInputSchema, AutoLayoutInputSchema, GetDiagramShapesInputSchema, UpdateConnectorInputSchema, RemoveConnectorInputSchema, DiagramModeInputSchema,
12
12
  // Agent flow mode schemas
@@ -27,7 +27,7 @@ ImportImageInputSchema,
27
27
  // Scene management schemas
28
28
  ManageScenesInputSchema, ScenePlaybackInputSchema, SceneGraphInputSchema, SequenceInputSchema, StaggerInputSchema, FlipInputSchema,
29
29
  // New consolidated tool schemas
30
- SelectionInputSchema, TransformInputSchema, HistoryInputSchema, ImageFilterInputSchema, LassoInputSchema, CutoutStyleInputSchema, PrecompInputSchema, BackgroundInputSchema, QueryInputSchema, QueryCapabilitiesInputSchema, DeformInputSchema, SpriteSheetInputSchema, InteractionInputSchema, ExportWidgetInputSchema, ExportWidgetHtmlInputSchema, CreateChartInputSchema, MagicInputSchema, PhysicsInputSchema, MeasurementInputSchema, ErrorCodes, } from '../types/schemas.js';
30
+ SelectionInputSchema, TransformInputSchema, HistoryInputSchema, ImageFilterInputSchema, LassoInputSchema, CutoutStyleInputSchema, PrecompInputSchema, BackgroundInputSchema, QueryInputSchema, QueryCapabilitiesInputSchema, DeformInputSchema, SpriteSheetInputSchema, InteractionInputSchema, ExportWidgetInputSchema, ExportWidgetHtmlInputSchema, CreateChartInputSchema, MagicInputSchema, PhysicsInputSchema, MeasurementInputSchema, PlaceOnSurfaceInputSchema, StyledSceneInputSchema, ErrorCodes, } from '../types/schemas.js';
31
31
  import { ZodError, z } from 'zod';
32
32
  import { writeFile, mkdir, appendFile, unlink, readFile, stat } from 'node:fs/promises';
33
33
  import { tmpdir } from 'node:os';
@@ -37,6 +37,7 @@ import { getBrowserController, resetBrowserController, } from '../browser/puppet
37
37
  import { sameStudioTarget, validateStudioUrl } from '../browser/url-target.js';
38
38
  import { getPerformanceTracker } from '../metrics/index.js';
39
39
  import { formatErrorContext, captureCanvasState } from '../execution/index.js';
40
+ import { FILTER_PARAM_RANGES } from './engine-lists.js';
40
41
  import { getSessionManager } from '../agent/session-manager.js';
41
42
  import { vocabularyHintForPath, validateBatchVocabulary, detectBatchPropertyTypos } from '../ontology/hints.js';
42
43
  import { cameraHandlers } from './handlers/camera.js';
@@ -85,13 +86,19 @@ export function getScreenshotMode() {
85
86
  // =============================================================================
86
87
  // EXPORT FILE SAVE HELPERS
87
88
  // =============================================================================
88
- function getExportDir() {
89
+ export function getExportDir() {
89
90
  return process.env.PINEPAPER_EXPORT_DIR || join(tmpdir(), 'pinepaper-exports');
90
91
  }
91
92
  // wav joins them: a minute of 48kHz 16-bit is ~5.8 MB of base64 and the ten
92
93
  // minutes the schema allows is ~77 MB. Deliverable across the bridge, useless
93
94
  // pasted into a response.
94
- export const ALWAYS_SAVE_FORMATS = new Set(['mp4', 'webm', 'gif', 'apng', 'pdf', 'wav', 'srt', 'vtt', 'scc', 'html5-ad', 'playable']);
95
+ // Stills join them too. The threshold below is in CHARACTERS of base64, and a
96
+ // 59 KB 1920x1080 PNG is ~79k of them: under the threshold, so it came back
97
+ // inline, and over the token limit of the client reading it (a Halloween
98
+ // look-frame session, 2026-09-29), while bigger PNGs saved to a file. Same
99
+ // format, two shapes, decided by file size. An image is never useful as
100
+ // base64 in a text result, so png/jpg/webp always save and name the path.
101
+ export const ALWAYS_SAVE_FORMATS = new Set(['mp4', 'webm', 'gif', 'apng', 'pdf', 'wav', 'srt', 'vtt', 'scc', 'html5-ad', 'playable', 'png', 'jpg', 'webp']);
95
102
  // 500_000 was chosen against the bridge's limits, not the CALLER's. A pilot
96
103
  // session hit a 263K-character end_job result — comfortably under this, so it
97
104
  // was returned inline, and over the tool-result limit of the client reading
@@ -554,10 +561,37 @@ export function getExecutionMode() {
554
561
  // Default to 'puppeteer' for live browser execution
555
562
  return 'puppeteer';
556
563
  }
564
+ /** Where a generated script travels for a host to record, beside the agent-visible text. */
565
+ /** Relation types registered with register_custom_relation in this server session (gate D2). */
566
+ export const CUSTOM_RELATION_NAMES = new Set();
567
+ /**
568
+ * Tools whose browser work legitimately outlasts the governor's 10 s default.
569
+ * The first detection call downloads and initialises a model, which on a cold
570
+ * page took longer than 10 s and died as a timeout (gate C10, 1.6.19).
571
+ */
572
+ const LONG_GOVERNOR_BUDGET_MS = {
573
+ pinepaper_detect_objects: 120_000,
574
+ pinepaper_extract_object: 120_000,
575
+ };
576
+ export const CODE_META_KEY = 'pinepaper.studio/code';
557
577
  // =============================================================================
558
578
  // RESULT HELPERS
559
579
  // =============================================================================
560
- function successResult(code, description) {
580
+ function successResult(code, description, deferred = false) {
581
+ if (deferred) {
582
+ return {
583
+ content: [{
584
+ type: 'text',
585
+ text: JSON.stringify({
586
+ success: true,
587
+ status: 'recorded',
588
+ step: description || 'canvas step',
589
+ note: 'Recorded on the scene. It runs in the studio when the scene renders or exports, not now; keep building, then render.',
590
+ }, null, 2),
591
+ }],
592
+ _meta: { [CODE_META_KEY]: code },
593
+ };
594
+ }
561
595
  const content = [
562
596
  {
563
597
  type: 'text',
@@ -570,7 +604,7 @@ function successResult(code, description) {
570
604
  text: `\n${description}`,
571
605
  });
572
606
  }
573
- return { content };
607
+ return { content, _meta: { [CODE_META_KEY]: code } };
574
608
  }
575
609
  /**
576
610
  * A plain data answer.
@@ -583,6 +617,45 @@ function successResult(code, description) {
583
617
  function dataResult(value) {
584
618
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
585
619
  }
620
+ /**
621
+ * Where a call's time went (O1, per-request trace). In the result's _meta, the
622
+ * spec's slot for metadata, under one namespaced key, so it adds nothing to
623
+ * the text a model reads. browserMs includes the screenshot when one was taken.
624
+ * index.ts adds toolMs, the whole call, for every tool.
625
+ */
626
+ export const TIMING_META_KEY = 'pinepaper.studio/timing';
627
+ export function withTiming(out, t) {
628
+ const meta = (out._meta ?? {});
629
+ const prev = (meta[TIMING_META_KEY] ?? {});
630
+ return { ...out, _meta: { ...meta, [TIMING_META_KEY]: { ...prev, ...t } } };
631
+ }
632
+ /**
633
+ * A call that only succeeded after the browser session was recovered says so,
634
+ * in the result: the controller has reported `recovered` / `canvasReset` for
635
+ * a while, but nothing read them, so an agent kept using item ids from a
636
+ * scene that was gone (M6).
637
+ */
638
+ function withSessionNote(out, r) {
639
+ if (!r.recovered)
640
+ return out;
641
+ const note = r.relaunched
642
+ ? `The browser session had been lost; the browser was relaunched and this call ran on an EMPTY canvas${r.canvasSizeRestored ? ` (size restored to ${r.canvasSizeRestored.width}x${r.canvasSizeRestored.height})` : ' at the default size'}. Item ids from before are gone: rebuild the scene.`
643
+ : r.canvasReset
644
+ ? 'The studio page had reloaded or navigated away, and the canvas came back EMPTY. Item ids from before are gone: rebuild the scene.'
645
+ : 'The page connection was re-established; the scene survived.';
646
+ const first = out.content[0];
647
+ if (first && first.type === 'text') {
648
+ try {
649
+ const obj = JSON.parse(first.text);
650
+ obj.session = { recovered: true, ...(r.relaunched ? { relaunched: true } : {}), ...(r.canvasReset !== undefined ? { canvasReset: r.canvasReset } : {}), note };
651
+ return { ...out, content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }, ...out.content.slice(1)] };
652
+ }
653
+ catch {
654
+ return { ...out, content: [{ type: 'text', text: `${note}\n\n${first.text}` }, ...out.content.slice(1)] };
655
+ }
656
+ }
657
+ return out;
658
+ }
586
659
  function executedResult(code, result, screenshot, description, report) {
587
660
  const resultObj = { success: true, result };
588
661
  if (description)
@@ -740,6 +813,204 @@ function handleValidationError(error, i18n) {
740
813
  *
741
814
  * Default is 'on_request' for optimal performance per best practices.
742
815
  */
816
+ /** Page code: remember the id of every item on the canvas. */
817
+ const MARK_ITEMS_BEFORE_RUN = `(function () {
818
+ if (typeof paper === 'undefined' || !paper.project) return { ok: false };
819
+ const ids = new Set();
820
+ paper.project.getItems({}).forEach(function (i) { ids.add(i.id); });
821
+ window.__ppPreRunIds = ids;
822
+ return { ok: true };
823
+ })();`;
824
+ /** Page code: remove every item that was not there at the mark, outermost first. */
825
+ const REMOVE_ITEMS_CREATED_SINCE_MARK = `(function () {
826
+ const pre = window.__ppPreRunIds;
827
+ window.__ppPreRunIds = null;
828
+ if (!pre || typeof paper === 'undefined' || !paper.project) return { ok: false };
829
+ const fresh = paper.project.getItems({}).filter(function (i) { return !pre.has(i.id); });
830
+ const freshIds = new Set(fresh.map(function (i) { return i.id; }));
831
+ const tops = fresh.filter(function (i) { return !(i.parent && freshIds.has(i.parent.id)); });
832
+ let removed = 0;
833
+ tops.forEach(function (it) {
834
+ try { if (typeof app !== 'undefined' && typeof app.deleteItem === 'function' && app.deleteItem(it)) { removed++; return; } } catch (e) {}
835
+ try { if (it.parent) { it.remove(); removed++; } } catch (e) {}
836
+ });
837
+ const left = paper.project.getItems({}).filter(function (i) { return !pre.has(i.id); }).length;
838
+ try { if (paper.view) paper.view.update(); } catch (e) {}
839
+ return { ok: left === 0, removed: removed, left: left };
840
+ })();`;
841
+ /**
842
+ * Record which canvas items exist before agent-written code runs, so a failed
843
+ * run's leftovers can be told apart from the scene it started with. Returns
844
+ * false when the page could not be marked (the rollback is then skipped and
845
+ * the failure note says nothing about the scene it cannot vouch for).
846
+ */
847
+ async function markItemsBeforeRun(controller) {
848
+ try {
849
+ const r = await controller.executeCode(MARK_ITEMS_BEFORE_RUN, false, { bypassGovernor: true });
850
+ return !!(r.success && r.result?.ok);
851
+ }
852
+ catch {
853
+ return false;
854
+ }
855
+ }
856
+ /**
857
+ * Remove what agent-written code created before it failed, and say so.
858
+ *
859
+ * A run stopped by the loop deadline after drawing 42 strokes left them on
860
+ * the canvas: a half-painted scene. Undo cannot fix that. The governor's one
861
+ * history entry only covers mutations that announce themselves, and raw
862
+ * `new paper.Path(...)` does not (measured: report.mutated false, 30 paths
863
+ * left behind). So the rollback is by identity instead: every item that was
864
+ * not on the canvas before the call goes, through app.deleteItem where the
865
+ * engine knows the item (registry, trackers) and item.remove() otherwise.
866
+ * Edits to items that already existed are NOT reverted; the note says so.
867
+ */
868
+ export async function customCodeRollback(controller, error) {
869
+ let rolledBack = false;
870
+ let removed = 0;
871
+ let left = -1;
872
+ try {
873
+ const r = await controller.executeCode(REMOVE_ITEMS_CREATED_SINCE_MARK, false, { bypassGovernor: true });
874
+ const v = (r.result || {});
875
+ rolledBack = !!(r.success && v.ok);
876
+ removed = v.removed ?? 0;
877
+ left = typeof v.left === 'number' ? v.left : -1;
878
+ }
879
+ catch {
880
+ rolledBack = false;
881
+ }
882
+ const scene = rolledBack
883
+ ? (removed > 0
884
+ ? `The ${removed} item(s) this call created before it stopped were removed. Changes it made to items that already existed are not reverted.`
885
+ : 'This call created no items before it stopped. Changes it made to items that already existed are not reverted.')
886
+ : `Items this call created could not all be removed${left > 0 ? ` (${left} remain)` : ''}: check the canvas before continuing.`;
887
+ const how = /Synchronous execution exceeded|Loop budget exceeded/i.test(error || '')
888
+ ? ' This is execute_custom_code\'s per-call guard, not a limit on the scene: its loops may run about 4 s (and 2,000,000 iterations per loop) so the studio tab stays responsive. Split the work across several calls, each of which gets a fresh budget. For example, paint 10 strokes per call instead of 40 in one.'
889
+ : '';
890
+ return { rolledBack, note: scene + how };
891
+ }
892
+ /**
893
+ * pinepaper_brand_kit from_url: the product's real brand, read from its own
894
+ * site (see handlers/brand-intake.ts). Runs in the MCP server's browser, in an
895
+ * isolated context, so code mode cannot do it and says so.
896
+ */
897
+ async function brandFromUrl(url, shots, options) {
898
+ const effectiveExecMode = options.executionMode ?? getExecutionMode();
899
+ if (effectiveExecMode === 'code' || !options.executeInBrowser) {
900
+ return errorResult(ErrorCodes.EXECUTION_ERROR, 'brand_kit from_url reads the site in the MCP server\'s browser, so it needs browser mode; it cannot run as generated code.', {}, { toolName: 'pinepaper_brand_kit' });
901
+ }
902
+ const controller = options.browserController || getBrowserController();
903
+ if (!controller.connected) {
904
+ try {
905
+ await controller.connect();
906
+ }
907
+ catch (e) {
908
+ return errorResult(ErrorCodes.BROWSER_NOT_CONNECTED, `could not start the browser: ${e instanceof Error ? e.message : String(e)}`, {}, { toolName: 'pinepaper_brand_kit' });
909
+ }
910
+ }
911
+ const host = (() => { try {
912
+ return new URL(url).hostname.replace(/[^a-z0-9.-]/gi, '_');
913
+ }
914
+ catch {
915
+ return 'site';
916
+ } })();
917
+ const outDir = join(getExportDir(), `brand-${host}-${Date.now()}`);
918
+ try {
919
+ const { intakeBrand } = await import('./handlers/brand-intake.js');
920
+ const r = await controller
921
+ .withIsolatedPage((page) => intakeBrand(page, url, outDir, shots));
922
+ const text = [
923
+ `Brand read from ${url}. Files in ${outDir}:`,
924
+ ...r.screenshots.map((f) => `- screenshot: ${f}`),
925
+ r.logo ? `- logo: ${r.logo.file} (from ${r.logo.source})` : '- logo: none found in the header, icons or og:image',
926
+ '',
927
+ 'Review the proposed kit against the screenshots, then apply it with pinepaper_brand_kit { action: "apply", kit } (or "plan" first). Import the logo or a screenshot with pinepaper_import_image { url: <file> }.',
928
+ '',
929
+ JSON.stringify({ success: true, ...r }, null, 2),
930
+ ].join('\n');
931
+ return { content: [{ type: 'text', text }] };
932
+ }
933
+ catch (e) {
934
+ return errorResult(ErrorCodes.EXECUTION_ERROR, `could not read ${url}: ${e instanceof Error ? e.message : String(e)}`, {}, { toolName: 'pinepaper_brand_kit' });
935
+ }
936
+ }
937
+ /**
938
+ * If an executed result carries `data` (a data URL, or JSON/text), write it to
939
+ * a file in the export dir and replace it with filePath + bytes.
940
+ */
941
+ async function writeResultDataToFile(out, ext) {
942
+ const first = out.content?.[0];
943
+ if (out.isError || !first || first.type !== 'text')
944
+ return out;
945
+ let obj;
946
+ try {
947
+ obj = JSON.parse(first.text);
948
+ }
949
+ catch {
950
+ return out;
951
+ }
952
+ const r = obj.result;
953
+ if (!r || r.data === undefined || r.data === null)
954
+ return out;
955
+ let bytes;
956
+ if (typeof r.data === 'string' && r.data.startsWith('data:'))
957
+ bytes = Buffer.from(r.data.slice(r.data.indexOf(',') + 1), 'base64');
958
+ else if (typeof r.data === 'string')
959
+ bytes = Buffer.from(r.data, 'utf-8');
960
+ else
961
+ bytes = Buffer.from(JSON.stringify(r.data), 'utf-8');
962
+ const dir = getExportDir();
963
+ await mkdir(dir, { recursive: true });
964
+ const filePath = join(dir, `pinepaper_${String(r.format ?? 'export').replace(/[^a-z0-9-]/gi, '')}_${Date.now()}.${ext}`);
965
+ await writeFile(filePath, bytes);
966
+ const { data: _d, ...rest } = r;
967
+ void _d;
968
+ obj.result = { ...rest, filePath, bytes: bytes.length };
969
+ return { ...out, content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }, ...out.content.slice(1)] };
970
+ }
971
+ /**
972
+ * pinepaper_capture_frames with `sheet`: one tiled image of the film, saved
973
+ * to a file (an image is never useful inline, see ALWAYS_SAVE_FORMATS), with
974
+ * the rubric the agent scores it against. Code mode returns the code as usual.
975
+ */
976
+ async function captureSheet(input, options) {
977
+ const code = codeGenerator.generateCaptureSheet(input);
978
+ const description = 'Contact sheet';
979
+ const effectiveExecMode = options.executionMode ?? getExecutionMode();
980
+ if (effectiveExecMode === 'code' || !options.executeInBrowser) {
981
+ return executeOrGenerate(code, description, options, 'pinepaper_capture_frames');
982
+ }
983
+ const controller = options.browserController || getBrowserController();
984
+ if (!controller.connected) {
985
+ try {
986
+ await controller.connect();
987
+ }
988
+ catch {
989
+ return executeOrGenerate(code, description, options, 'pinepaper_capture_frames');
990
+ }
991
+ }
992
+ // Frames wait for video seeks one at a time, so 48 of them can outlast the
993
+ // governor's 10 s default.
994
+ const run = await controller.executeCode(code, false, { governorTimeoutMs: 120_000 });
995
+ const value = (run.result ?? {});
996
+ if (!run.success || value.success === false || typeof value.dataUrl !== 'string') {
997
+ return errorResult(ErrorCodes.EXECUTION_ERROR, run.error || value.error || 'the contact sheet could not be made', { code, governorReport: run.report }, { toolName: 'pinepaper_capture_frames' });
998
+ }
999
+ const format = input.sheet?.format === 'png' ? 'png' : 'jpg';
1000
+ const { filePath, fileSize } = await saveExportToFile(value.dataUrl, format, 'sheet');
1001
+ const { dataUrl: _d, success: _s, ...facts } = value;
1002
+ void _d;
1003
+ void _s;
1004
+ const result = { success: true, filePath, fileSize, format, ...facts, review: SHEET_RUBRIC };
1005
+ return {
1006
+ content: [{
1007
+ type: 'text',
1008
+ text: `Contact sheet saved: ${filePath} (${(fileSize / 1024).toFixed(1)} KB, ${String(facts.tiles)} tiles).\n`
1009
+ + 'Open the image and score it against review.criteria (1-10 each), then check review.checklist.\n\n'
1010
+ + JSON.stringify(result, null, 2),
1011
+ }],
1012
+ };
1013
+ }
743
1014
  export async function executeOrGenerate(code, description, options, toolName,
744
1015
  /**
745
1016
  * Values too large to live inside the generated code. Written to
@@ -801,7 +1072,7 @@ The code is ready to use. Each subsequent tool call will also generate code you
801
1072
  timestamp: Date.now(),
802
1073
  success: true,
803
1074
  });
804
- return successResult(code, description);
1075
+ return successResult(code, description, options.deferred === true);
805
1076
  }
806
1077
  const controller = browserController || getBrowserController();
807
1078
  // Auto-connect in agent mode if not connected
@@ -880,18 +1151,33 @@ ${code}
880
1151
  if (shouldTakeScreenshot) {
881
1152
  tracker.startTimer(`${timerId}_screenshot`);
882
1153
  }
883
- const result = await controller.executeCode(code, shouldTakeScreenshot, stage ? { stage } : {});
1154
+ // Agent-written code is rolled back when it fails part-way; a tool's own
1155
+ // emitter is not (its verdict already describes what it left behind).
1156
+ const rollbackOnFailure = toolName === 'pinepaper_execute_custom_code'
1157
+ && await markItemsBeforeRun(controller);
1158
+ const governorTimeoutMs = LONG_GOVERNOR_BUDGET_MS[toolName];
1159
+ const result = await controller.executeCode(code, shouldTakeScreenshot, {
1160
+ ...(stage ? { stage } : {}),
1161
+ ...(governorTimeoutMs ? { governorTimeoutMs } : {}),
1162
+ });
884
1163
  const browserDuration = tracker.endTimer(`${timerId}_browser_execution`);
1164
+ // THE METRIC RECORDS THE CALL'S VERDICT, NOT THE TRANSPORT'S (gate H2,
1165
+ // 1.6.19). result.success only says the code RAN; an emitter that ran and
1166
+ // returned { success: false } is a failed call, and get_performance_metrics
1167
+ // reported "Success Rate: 100.0%" over exactly such a failure.
1168
+ const innerVerdict = result.success ? innerFailure(result.result) : null;
885
1169
  tracker.recordMetric({
886
1170
  toolName,
887
1171
  phase: 'browser_execution',
888
1172
  duration: browserDuration,
889
1173
  timestamp: Date.now(),
890
- success: result.success,
891
- error: result.error,
1174
+ success: result.success && !innerVerdict,
1175
+ error: result.error || innerVerdict || undefined,
892
1176
  });
1177
+ let screenshotMs;
893
1178
  if (shouldTakeScreenshot) {
894
1179
  const screenshotDuration = tracker.endTimer(`${timerId}_screenshot`);
1180
+ screenshotMs = screenshotDuration;
895
1181
  tracker.recordMetric({
896
1182
  toolName,
897
1183
  phase: 'screenshot',
@@ -907,19 +1193,23 @@ ${code}
907
1193
  phase: 'total',
908
1194
  duration: totalDuration,
909
1195
  timestamp: Date.now(),
910
- success: result.success,
911
- error: result.error,
1196
+ success: result.success && !innerVerdict,
1197
+ error: result.error || innerVerdict || undefined,
912
1198
  });
1199
+ const timing = { browserMs: Math.round(browserDuration), ...(screenshotMs !== undefined ? { screenshotMs: Math.round(screenshotMs) } : {}) };
913
1200
  if (!result.success) {
914
1201
  // Capture canvas state for error context
915
1202
  const canvasState = await captureCanvasState(controller);
916
- return errorResult(ErrorCodes.EXECUTION_ERROR, result.error || 'Failed to execute code in browser',
1203
+ return withTiming(errorResult(ErrorCodes.EXECUTION_ERROR, result.error || 'Failed to execute code in browser',
917
1204
  // errorCode surfaces the governor's structured code (PP_ITEM_BUDGET,
918
1205
  // PP_LOOP_BUDGET, PP_TIMEOUT) so the model can correct on the next turn.
919
- { code, errorCode: result.errorCode, governorReport: result.report }, {
1206
+ {
1207
+ code, errorCode: result.errorCode, governorReport: result.report,
1208
+ ...(rollbackOnFailure ? await customCodeRollback(controller, result.error) : {}),
1209
+ }, {
920
1210
  toolName,
921
1211
  canvasState: canvasState || undefined,
922
- });
1212
+ }), timing);
923
1213
  }
924
1214
  // TWO VERDICTS. `result.success` says the CODE RAN. The emitted code has its
925
1215
  // own verdict, and almost every emitter in this repo returns
@@ -933,12 +1223,24 @@ ${code}
933
1223
  // outer success and the agent kept building on a rig that did not exist.
934
1224
  // pinepaper_agent_export got this check in 1.6.9; it belongs HERE, where
935
1225
  // every tool inherits it.
936
- const inner = innerFailure(result.result);
1226
+ const inner = innerVerdict;
937
1227
  if (inner) {
938
1228
  const canvasState = await captureCanvasState(controller);
939
- return errorResult(ErrorCodes.EXECUTION_ERROR, inner, { code, result: result.result, governorReport: result.report }, { toolName, canvasState: canvasState || undefined });
1229
+ return withTiming(errorResult(ErrorCodes.EXECUTION_ERROR, inner, {
1230
+ code, result: result.result, governorReport: result.report,
1231
+ ...(rollbackOnFailure ? await customCodeRollback(controller, inner) : {}),
1232
+ }, { toolName, canvasState: canvasState || undefined }), timing);
1233
+ }
1234
+ // Session item counts come from the governor's own count of what this call
1235
+ // created (gate H3: the session stats read 0 with items on the canvas).
1236
+ const created = result.report?.items?.created;
1237
+ if (typeof created === 'number' && created > 0) {
1238
+ try {
1239
+ getSessionManager().noteItemsCreated(created);
1240
+ }
1241
+ catch { /* no session */ }
940
1242
  }
941
- return executedResult(code, result.result, result.screenshot, description, result.report);
1243
+ return withTiming(withSessionNote(executedResult(code, result.result, result.screenshot, description, result.report), result), timing);
942
1244
  }
943
1245
  /**
944
1246
  * The emitted code's own verdict, when it reported one and it was a failure.
@@ -1496,14 +1798,38 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
1496
1798
  }
1497
1799
  case 'pinepaper_validate_scene': {
1498
1800
  const input = ValidateSceneInputSchema.parse(args);
1801
+ // The validator reads { from, to, relation } on an addRelation op; the
1802
+ // add_relation tool's own names (source, target, relationType) are what
1803
+ // an agent reaches for, and the tool's example used them, so every
1804
+ // example op failed SOURCE_NOT_FOUND "undefined" (gate D8, 1.6.19).
1805
+ if (Array.isArray(input.ops)) {
1806
+ input.ops = input.ops.map((op) => {
1807
+ const o = { ...op };
1808
+ if (o.from === undefined && o.source !== undefined) {
1809
+ o.from = o.source;
1810
+ delete o.source;
1811
+ }
1812
+ if (o.to === undefined && o.target !== undefined) {
1813
+ o.to = o.target;
1814
+ delete o.target;
1815
+ }
1816
+ if (o.relation === undefined && o.relationType !== undefined) {
1817
+ o.relation = o.relationType;
1818
+ delete o.relationType;
1819
+ }
1820
+ return o;
1821
+ });
1822
+ }
1499
1823
  const code = codeGenerator.generateValidateScene(input);
1500
1824
  const description = input.ops ? `Pre-validate ${input.ops.length} proposed op(s)` : 'Audit the live scene';
1501
1825
  return executeOrGenerate(code, description, options, 'pinepaper_validate_scene');
1502
1826
  }
1503
1827
  case 'pinepaper_capture_frames': {
1504
1828
  const input = CaptureFramesInputSchema.parse(args);
1829
+ if (input.sheet)
1830
+ return captureSheet(input, options);
1505
1831
  const code = codeGenerator.generateCaptureFrames(input);
1506
- const description = `Deterministic capture of ${input.times.length} frame(s)`;
1832
+ const description = `Deterministic capture of ${(input.times ?? []).length} frame(s)`;
1507
1833
  return executeOrGenerate(code, description, options, 'pinepaper_capture_frames');
1508
1834
  }
1509
1835
  case 'pinepaper_instantiate_ontology': {
@@ -1561,8 +1887,27 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
1561
1887
  return executeOrGenerate(code, `Text effect: ${input.action}`, options, 'pinepaper_text_effect');
1562
1888
  }
1563
1889
  // ─── 1.6.4: agent surface for the Tier-2 engine features ───
1890
+ case 'pinepaper_relight': {
1891
+ const input = RelightInputSchema.parse(args);
1892
+ return executeOrGenerate(codeGenerator.generateRelight(input), `Relight: ${input.action}`, options, 'pinepaper_relight');
1893
+ }
1894
+ case 'pinepaper_shader_graph': {
1895
+ const input = ShaderGraphInputSchema.parse(args);
1896
+ return executeOrGenerate(codeGenerator.generateShaderGraph(input), `Shader graph: ${input.action}`, options, 'pinepaper_shader_graph');
1897
+ }
1898
+ case 'pinepaper_styled_scene': {
1899
+ const input = StyledSceneInputSchema.parse(args);
1900
+ return executeOrGenerate(codeGenerator.generateStyledScene(input), `Styled scene${input.style ? `: ${input.style}` : ''}`, options, 'pinepaper_styled_scene');
1901
+ }
1902
+ case 'pinepaper_place_on_surface': {
1903
+ const input = PlaceOnSurfaceInputSchema.parse(args);
1904
+ const code = codeGenerator.generatePlaceOnSurface(input);
1905
+ return executeOrGenerate(code, 'Place a UI onto a screen in a photo', options, 'pinepaper_place_on_surface');
1906
+ }
1564
1907
  case 'pinepaper_brand_kit': {
1565
1908
  const input = BrandKitInputSchema.parse(args);
1909
+ if (input.action === 'from_url')
1910
+ return brandFromUrl(input.url, input.screenshots ?? 3, options);
1566
1911
  const code = codeGenerator.generateBrandKit(input);
1567
1912
  return executeOrGenerate(code, `Brand kit: ${input.action}`, options, 'pinepaper_brand_kit');
1568
1913
  }
@@ -1820,8 +2165,17 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
1820
2165
  // RELATION TOOLS
1821
2166
  // -----------------------------------------------------------------------
1822
2167
  case 'pinepaper_add_relation': {
1823
- const input = AddRelationInputSchema.parse(args);
1824
- const code = codeGenerator.generateAddRelation(input);
2168
+ // A relation registered with register_custom_relation this session is
2169
+ // a valid type here (gate D2, 1.6.19: the enum refused the name just
2170
+ // registered). Only those names; an unknown one is still refused.
2171
+ const rt = args.relationType;
2172
+ // The schema's refinements check presence, not which type, so a custom
2173
+ // name is validated with a built-in stand-in and then put back.
2174
+ const isCustom = typeof rt === 'string' && CUSTOM_RELATION_NAMES.has(rt);
2175
+ const input = AddRelationInputSchema.parse(isCustom ? { ...args, relationType: 'orbits' } : args);
2176
+ if (isCustom)
2177
+ input.relationType = rt;
2178
+ const code = codeGenerator.generateAddRelation(input, isCustom);
1825
2179
  const description = getLocalizedSuccessMessage(i18n, 'relationAdded', {
1826
2180
  relationType: input.relationType ?? `preset:${input.presetId}`,
1827
2181
  sourceId: input.sourceId,
@@ -1845,6 +2199,7 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
1845
2199
  }
1846
2200
  case 'pinepaper_register_custom_relation': {
1847
2201
  const input = RegisterCustomRelationInputSchema.parse(args);
2202
+ CUSTOM_RELATION_NAMES.add(input.name);
1848
2203
  const code = codeGenerator.generateRegisterCustomRelation(input);
1849
2204
  return executeOrGenerate(code, `Registers custom relation type: ${input.name}`, options, 'pinepaper_register_custom_relation');
1850
2205
  }
@@ -1986,10 +2341,9 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
1986
2341
  case 'pinepaper_set_canvas_size': {
1987
2342
  const input = SetCanvasSizeInputSchema.parse(args);
1988
2343
  const code = codeGenerator.generateSetCanvasSize(input);
1989
- const description = getLocalizedSuccessMessage(i18n, 'canvasSizeSet', {
1990
- width: input.width,
1991
- height: input.height,
1992
- });
2344
+ const description = input.preset && input.width === undefined
2345
+ ? `Set canvas size to preset ${input.preset}`
2346
+ : getLocalizedSuccessMessage(i18n, 'canvasSizeSet', { width: input.width ?? '', height: input.height ?? '' });
1993
2347
  return executeOrGenerate(code, description, options, 'pinepaper_set_canvas_size');
1994
2348
  }
1995
2349
  case 'pinepaper_get_canvas_size': {
@@ -2007,8 +2361,13 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
2007
2361
  return errorResult(ErrorCodes.BROWSER_NOT_CONNECTED, 'Not connected to PinePaper Studio. Call pinepaper_browser_connect first.');
2008
2362
  }
2009
2363
  try {
2010
- // Refresh the page
2011
- await controller.refreshPage();
2364
+ // Refresh the page (recovers a dead session first; M6)
2365
+ const refreshed = await controller.refreshPage();
2366
+ const sizeNote = refreshed.relaunched
2367
+ ? (refreshed.canvasSizeRestored
2368
+ ? `\n\nThe browser session had been lost, so the browser was relaunched. The canvas size was restored to ${refreshed.canvasSizeRestored.width}x${refreshed.canvasSizeRestored.height}.`
2369
+ : '\n\nThe browser session had been lost, so the browser was relaunched at the default canvas size: set the size again if you need another.')
2370
+ : '';
2012
2371
  // Wait a moment for the page to stabilize
2013
2372
  await new Promise(resolve => setTimeout(resolve, 2000));
2014
2373
  // Take a screenshot to show the result
@@ -2020,7 +2379,7 @@ async function handleToolCallInner(toolName, args, options, tracker, baseTimerId
2020
2379
 
2021
2380
  The canvas is now completely empty. The welcome template will not appear (it only shows for first-time visitors).
2022
2381
 
2023
- You can now start creating new items on a clean canvas.`,
2382
+ You can now start creating new items on a clean canvas.${sizeNote}`,
2024
2383
  },
2025
2384
  ];
2026
2385
  if (screenshot) {
@@ -2090,6 +2449,15 @@ You can now start creating new items on a clean canvas.`,
2090
2449
  // -----------------------------------------------------------------------
2091
2450
  case 'pinepaper_add_filter': {
2092
2451
  const input = AddFilterInputSchema.parse(args);
2452
+ // Out-of-range parameters are refused with the engine's own range
2453
+ // (gate A, 1.6.19: dither levels 99 was accepted and quietly clamped).
2454
+ const ranges = FILTER_PARAM_RANGES[input.filterType] ?? {};
2455
+ for (const [k, v] of Object.entries((input.params ?? {}))) {
2456
+ const r = ranges[k];
2457
+ if (r && typeof v === 'number' && (v < r[0] || v > r[1])) {
2458
+ return errorResult(ErrorCodes.VALIDATION_ERROR, `${input.filterType} ${k} must be ${r[0]}..${r[1]} (got ${v}).`, { filterType: input.filterType, param: k, range: r });
2459
+ }
2460
+ }
2093
2461
  const code = codeGenerator.generateAddFilter(input.filterType, input.params);
2094
2462
  const description = `Adds ${input.filterType} filter to the canvas`;
2095
2463
  return executeOrGenerate(code, description, options, 'pinepaper_add_filter');
@@ -2300,7 +2668,19 @@ You can now start creating new items on a clean canvas.`,
2300
2668
  const perfTracker = getPerformanceTracker();
2301
2669
  const recentMetrics = perfTracker.exportMetrics('json', { limit: metricsLimit });
2302
2670
  const stats = perfTracker.getStats();
2303
- metricsSection = { entries: recentMetrics, stats };
2671
+ // exportMetrics('json') returns a JSON STRING; embedded as-is it reached
2672
+ // the report as one escaped string, not an array (gate H3).
2673
+ let entries = recentMetrics;
2674
+ if (typeof recentMetrics === 'string') {
2675
+ try {
2676
+ entries = JSON.parse(recentMetrics);
2677
+ }
2678
+ catch { /* keep the string */ }
2679
+ }
2680
+ if (entries && typeof entries === 'object' && !Array.isArray(entries) && Array.isArray(entries.metrics)) {
2681
+ entries = entries.metrics;
2682
+ }
2683
+ metricsSection = { entries, stats };
2304
2684
  }
2305
2685
  catch { /* metrics not available */ }
2306
2686
  }
@@ -2355,17 +2735,25 @@ You can now start creating new items on a clean canvas.`,
2355
2735
  },
2356
2736
  recommendations: [],
2357
2737
  };
2358
- // Add actionable recommendations
2738
+ // RECOMMEND ONLY WHAT SAVES (gate H4, 1.6.19). This said "switch to
2739
+ // agent" on full, which saves under 1% (agent omits three small
2740
+ // tools). Each alternative is measured against the active set and
2741
+ // offered only when it saves at least 10%, with the saving stated.
2359
2742
  const recs = toolDefsSection.recommendations;
2360
- if (toolkit === 'full') {
2361
- recs.push(`Switch to PINEPAPER_TOOLKIT=agent to reduce to ~${agentCompactTokens.toLocaleString()} tokens/turn (agent+compact)`);
2362
- }
2363
- if (verbosity === 'verbose') {
2364
- recs.push(`Set PINEPAPER_VERBOSITY=compact to reduce tool definitions overhead`);
2365
- }
2366
- if (verbosity !== 'minimal') {
2367
- recs.push(`Set PINEPAPER_VERBOSITY=minimal for maximum savings (~${agentMinimalTokens.toLocaleString()} tokens with agent toolkit) — use pinepaper_tool_guide for on-demand docs`);
2743
+ const alternatives = [];
2744
+ for (const v of ['compact', 'minimal']) {
2745
+ for (const k of ['full', 'agent', 'minimal']) {
2746
+ if (k === toolkit && v === verbosity)
2747
+ continue;
2748
+ const t = Math.ceil(Buffer.byteLength(JSON.stringify(getToolsForToolkit(getToolsForVerbosity(v), k)), 'utf-8') / 4);
2749
+ alternatives.push({ label: `${k}+${v}`, how: `PINEPAPER_TOOLKIT=${k} PINEPAPER_VERBOSITY=${v}`, tokens: t });
2750
+ }
2368
2751
  }
2752
+ alternatives
2753
+ .filter((alt) => alt.tokens <= activeTokens * 0.9)
2754
+ .sort((x, y) => x.tokens - y.tokens)
2755
+ .slice(0, 3)
2756
+ .forEach((alt) => recs.push(`${alt.how}: ~${alt.tokens.toLocaleString()} tokens/turn (${Math.round((1 - alt.tokens / activeTokens) * 100)}% less)${alt.label.startsWith('minimal+') ? ' — a small core set; switch profiles at runtime with pinepaper_set_toolkit' : ''}`));
2369
2757
  if (activeTokens > 20000) {
2370
2758
  recs.push(`Tool definitions alone consume ~${activeTokens.toLocaleString()} tokens per API turn — this is a major context budget item`);
2371
2759
  }
@@ -2484,11 +2872,19 @@ You can now start creating new items on a clean canvas.`,
2484
2872
  // ASSET TOOLS
2485
2873
  // -----------------------------------------------------------------------
2486
2874
  case 'pinepaper_search_assets': {
2875
+ // Asked for by name, a removed repository says why rather than failing
2876
+ // the enum check with a bare "invalid option".
2877
+ {
2878
+ const { REMOVED_REPOSITORIES, REMOVED_REPOSITORY_REASON } = await import('../assets/index.js');
2879
+ if (typeof args.repository === 'string' && REMOVED_REPOSITORIES.includes(args.repository)) {
2880
+ return errorResult(ErrorCodes.INVALID_PARAMS, `${REMOVED_REPOSITORY_REASON} Search repository 'all', 'iconify' or 'fontawesome'.`, { repository: args.repository }, { toolName: 'pinepaper_search_assets' });
2881
+ }
2882
+ }
2487
2883
  const input = SearchAssetsInputSchema.parse(args);
2488
2884
  const { getAssetManager } = await import('../assets/index.js');
2489
2885
  const assetManager = getAssetManager();
2490
2886
  try {
2491
- const results = await assetManager.search(input.query, input.repository || 'all', input.limit || 10);
2887
+ const { results, sources } = await assetManager.searchWithStatus(input.query, input.repository || 'all', input.limit || 10);
2492
2888
  // If includeSvgContent is true, fetch SVG content for each result
2493
2889
  let resultsWithContent = [];
2494
2890
  if (input.includeSvgContent) {
@@ -2560,6 +2956,7 @@ You can now start creating new items on a clean canvas.`,
2560
2956
  query: input.query,
2561
2957
  repository: input.repository || 'all',
2562
2958
  count: resultsWithContent.length,
2959
+ sources,
2563
2960
  includeSvgContent: input.includeSvgContent || false,
2564
2961
  attributionRequired: requiresAttribution.length > 0,
2565
2962
  attributionNotice,
@@ -2620,6 +3017,15 @@ You can now start creating new items on a clean canvas.`,
2620
3017
  svg = body;
2621
3018
  metadata = { source: 'url', url: input.url };
2622
3019
  }
3020
+ // `color` was accepted and never applied, so a recoloured icon
3021
+ // rendered black (gate D15, 1.6.19). A monochrome icon draws in
3022
+ // currentColor (stroke sets) or in the default fill (fill sets):
3023
+ // both take the colour.
3024
+ if (input.color) {
3025
+ const c = input.color.replace(/"/g, '');
3026
+ svg = svg.replace(/currentColor/g, c);
3027
+ svg = svg.replace(/<svg\b([^>]*)>/i, (m, attrs) => (/\sfill\s*=/.test(attrs) ? m : `<svg${attrs} fill="${c}">`));
3028
+ }
2623
3029
  // Import the SVG onto canvas using existing import_svg tool
2624
3030
  const code = codeGenerator.generateImportSVG(svg, undefined, input.position, input.scale);
2625
3031
  // A URL import has no catalogue metadata (no title, no licence): this
@@ -3150,10 +3556,18 @@ You can now start creating new items on a clean canvas.`,
3150
3556
  `Call action 'list_styles' and use one marked composable.`, { style: input.style }, { toolName: 'pinepaper_design_system' });
3151
3557
  }
3152
3558
  const ops = designSystems.sceneToOps(scene);
3559
+ // Some vendored styles lay out at their OWN fixed size and ignore
3560
+ // width/height (swiss_typographic is always 1280x720), so a caller
3561
+ // asking for 1080x1080 got a resized canvas (gate run 3). Say so.
3562
+ const sizeIgnored = (input.width !== undefined && input.width !== scene.width)
3563
+ || (input.height !== undefined && input.height !== scene.height);
3564
+ const sizeWarning = sizeIgnored
3565
+ ? `"${scene.style}" lays out at its own size, ${scene.width}x${scene.height}; the requested ${input.width ?? '?'}x${input.height ?? '?'} was not used`
3566
+ : undefined;
3153
3567
  if (input.draw === false) {
3154
- return dataResult({ scene, ops, drawn: false });
3568
+ return dataResult({ scene, ops, drawn: false, ...(sizeWarning ? { warning: sizeWarning } : {}) });
3155
3569
  }
3156
- const code = codeGenerator.generateDesignCompose(scene, ops, scene.style);
3570
+ const code = codeGenerator.generateDesignCompose(scene, ops, scene.style, sizeWarning);
3157
3571
  return executeOrGenerate(code, `Composed a ${scene.style} scene (${ops.length} items)`, options, 'pinepaper_design_system');
3158
3572
  }
3159
3573
  }
@@ -3171,7 +3585,12 @@ You can now start creating new items on a clean canvas.`,
3171
3585
  case 'pinepaper_interchange': {
3172
3586
  const input = InterchangeInputSchema.parse(args);
3173
3587
  const code = codeGenerator.generateInterchange(input);
3174
- return executeOrGenerate(code, `Interchange: ${input.action}`, options, 'pinepaper_interchange');
3588
+ const out = await executeOrGenerate(code, `Interchange: ${input.action}`, options, 'pinepaper_interchange');
3589
+ // AN EXPORT IS A FILE (gate D7, 1.6.19): .lottie, GLB and the PNG-sequence
3590
+ // zip came back as byte-index objects or inline data URLs, and a Lottie
3591
+ // JSON can be large. Written out; the result names the path.
3592
+ const ext = { export_lottie: 'json', export_dotlottie: 'lottie', export_glb: 'glb', export_png_sequence: 'zip', export_bvh: 'bvh' };
3593
+ return ext[input.action] ? await writeResultDataToFile(out, ext[input.action]) : out;
3175
3594
  }
3176
3595
  case 'pinepaper_sound': {
3177
3596
  const input = SoundInputSchema.parse(args);