@hraness/slopcamera 3.8.0 → 3.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 (35) hide show
  1. package/NOTICE.md +14 -0
  2. package/README.md +294 -298
  3. package/apps/desktop/application/html-overlay-integrity.ts +27 -4
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/application/operations/spatial-render.ts +1 -1
  6. package/apps/desktop/cli/capability-manifest.ts +15 -2
  7. package/apps/desktop/cli/commands.ts +1 -1
  8. package/apps/desktop/cli/help.ts +23 -4
  9. package/apps/desktop/cli/portable-surface.ts +1 -0
  10. package/apps/desktop/cli/verbs.ts +4 -0
  11. package/apps/desktop/dist/cli/main.js +306 -284
  12. package/dist/cli.js +24 -4
  13. package/dist/{index-842t02pp.js → index-m6g0ca9b.js} +1 -1
  14. package/dist/{index-y8spxs77.js → index-mgychzh3.js} +6 -3
  15. package/dist/{index-k9bjw80z.js → index-rxx27q8b.js} +4 -4
  16. package/dist/index.js +1 -1
  17. package/dist/operations.js +1 -1
  18. package/dist/workflow.js +1 -1
  19. package/docs/README.md +4 -1
  20. package/package.json +5 -3
  21. package/skills/slopcamera/SKILL.md +2 -0
  22. package/skills/slopcamera/references/customization.md +8 -1
  23. package/skills/slopcamera/references/icon-place.md +76 -0
  24. package/skills/slopcamera/references/install.md +2 -2
  25. package/skills/slopcamera/references/music-video.md +10 -2
  26. package/skills/slopcamera/references/soundtracks.md +58 -0
  27. package/src/bounded-file.ts +135 -0
  28. package/src/cli.ts +191 -0
  29. package/src/icon-scene.ts +695 -0
  30. package/src/index.ts +2 -0
  31. package/src/mcp/tools.ts +311 -0
  32. package/src/mcp/types.ts +4 -0
  33. package/src/operations.ts +306 -0
  34. package/src/soundtrack.ts +489 -0
  35. package/src/version.ts +1 -1
package/src/mcp/tools.ts CHANGED
@@ -22,6 +22,26 @@ import {
22
22
  type RenderSlopcameraOperationInput,
23
23
  type VectorizeSlopcameraOperationInput,
24
24
  } from "../operations.js"
25
+ import {
26
+ composeSlopcameraIcon,
27
+ renderSlopcameraIcon,
28
+ slopcameraIconLanguages,
29
+ slopcameraIconPalettes,
30
+ slopcameraIconSceneLimits,
31
+ SlopcameraIconError,
32
+ type SlopcameraIconComposeInput,
33
+ type SlopcameraIconRenderInput,
34
+ } from "../icon-scene.js"
35
+ import {
36
+ composeSlopcameraSoundtrack,
37
+ deriveSlopcameraSoundtrackGrid,
38
+ slopcameraSoundtrackFormats,
39
+ slopcameraSoundtrackLimits,
40
+ SlopcameraSoundtrackError,
41
+ type SlopcameraSoundtrackComposeInput,
42
+ type SlopcameraSoundtrackGridInput,
43
+ } from "../soundtrack.js"
44
+ import { BoundedFileError } from "../bounded-file.js"
25
45
  import {
26
46
  createDefaultHostResourceCoordinator,
27
47
  HostResourceError,
@@ -158,6 +178,28 @@ function deepFreeze<T>(value: T): T {
158
178
  return Object.freeze(value)
159
179
  }
160
180
 
181
+ const iconReceiptOutputSchema = {
182
+ type: "object",
183
+ additionalProperties: false,
184
+ required: ["ok", "operation", "receipt"],
185
+ properties: {
186
+ ok: { const: true },
187
+ operation: { type: "string", enum: ["slopcamera.icon.compose", "slopcamera.icon.render"] },
188
+ receipt: { type: "object" },
189
+ },
190
+ } as const
191
+
192
+ const soundtrackReceiptOutputSchema = {
193
+ type: "object",
194
+ additionalProperties: false,
195
+ required: ["ok", "operation", "receipt"],
196
+ properties: {
197
+ ok: { const: true },
198
+ operation: { type: "string", enum: ["slopcamera.soundtrack.compose", "slopcamera.soundtrack.grid"] },
199
+ receipt: { type: "object" },
200
+ },
201
+ } as const
202
+
161
203
  /**
162
204
  * The complete MCP tool registry. Public copy cites its size, its scene-tool
163
205
  * count, and its names; `scripts/check-copy.ts` fails when they disagree.
@@ -999,6 +1041,131 @@ export const slopcameraMcpTools: readonly McpToolDefinition[] = deepFreeze([
999
1041
  openWorldHint: false,
1000
1042
  },
1001
1043
  },
1044
+ {
1045
+ name: "compose_icon",
1046
+ title: "Compose icon scene",
1047
+ description:
1048
+ "Parse and solve one root-relative vector icon scene, collection, construction program, or recipe, and report its digests, geometry, and diagnostics. Optionally writes the canonical solved document. Local and deterministic; no model or network.",
1049
+ inputSchema: {
1050
+ type: "object",
1051
+ additionalProperties: false,
1052
+ required: ["path"],
1053
+ properties: {
1054
+ path: {
1055
+ type: "string",
1056
+ description: `Root-relative path to an icon source JSON (${String(slopcameraIconSceneLimits.sourceBytes / 1024 / 1024)} MiB maximum).`,
1057
+ },
1058
+ output: {
1059
+ type: "string",
1060
+ description: "Optional root-relative .json path for the canonical solved document.",
1061
+ },
1062
+ },
1063
+ },
1064
+ outputSchema: iconReceiptOutputSchema,
1065
+ annotations: {
1066
+ title: "Compose icon scene",
1067
+ readOnlyHint: false,
1068
+ destructiveHint: true,
1069
+ idempotentHint: true,
1070
+ openWorldHint: false,
1071
+ },
1072
+ },
1073
+ {
1074
+ name: "render_icon",
1075
+ title: "Render icon scene",
1076
+ description:
1077
+ "Draw one root-relative vector icon scene or construction program to an inert SVG, or replay a recipe and require its recorded digests. Optionally writes a replayable recipe. Local and deterministic; no model or network.",
1078
+ inputSchema: {
1079
+ type: "object",
1080
+ additionalProperties: false,
1081
+ required: ["path", "output"],
1082
+ properties: {
1083
+ path: {
1084
+ type: "string",
1085
+ description: `Root-relative path to an icon source JSON (${String(slopcameraIconSceneLimits.sourceBytes / 1024 / 1024)} MiB maximum).`,
1086
+ },
1087
+ output: { type: "string", description: "Root-relative .svg output path." },
1088
+ recipe: { type: "string", description: "Optional root-relative .json path for a replayable recipe." },
1089
+ size: {
1090
+ type: "integer",
1091
+ minimum: slopcameraIconSceneLimits.sizeMin,
1092
+ maximum: slopcameraIconSceneLimits.sizeMax,
1093
+ description: "Output edge in pixels. Recipes fix their own size.",
1094
+ },
1095
+ background: { type: "boolean", description: "Paint the paper background." },
1096
+ palette: { type: "string", enum: slopcameraIconPalettes, description: "Scene palette." },
1097
+ language: { type: "string", enum: slopcameraIconLanguages, description: "Construction-program drawing language." },
1098
+ },
1099
+ },
1100
+ outputSchema: iconReceiptOutputSchema,
1101
+ annotations: {
1102
+ title: "Render icon scene",
1103
+ readOnlyHint: false,
1104
+ destructiveHint: true,
1105
+ idempotentHint: true,
1106
+ openWorldHint: false,
1107
+ },
1108
+ },
1109
+ {
1110
+ name: "compose_soundtrack",
1111
+ title: "Compose soundtrack score",
1112
+ description:
1113
+ "Parse and verify one root-relative loop or song from compose text, song text, score JSON, or a Standard MIDI file, and report its canonical digest, tempo, meter, and length. Optionally writes the score document. Renders no audio; no network.",
1114
+ inputSchema: {
1115
+ type: "object",
1116
+ additionalProperties: false,
1117
+ required: ["path"],
1118
+ properties: {
1119
+ path: {
1120
+ type: "string",
1121
+ description: `Root-relative path to a score or MIDI file (${String(slopcameraSoundtrackLimits.sourceBytes / 1024 / 1024)} MiB maximum).`,
1122
+ },
1123
+ output: { type: "string", description: "Optional root-relative .json path for the score document." },
1124
+ format: { type: "string", enum: slopcameraSoundtrackFormats, description: "Source format. Detected when omitted." },
1125
+ },
1126
+ },
1127
+ outputSchema: soundtrackReceiptOutputSchema,
1128
+ annotations: {
1129
+ title: "Compose soundtrack score",
1130
+ readOnlyHint: false,
1131
+ destructiveHint: true,
1132
+ idempotentHint: true,
1133
+ openWorldHint: false,
1134
+ },
1135
+ },
1136
+ {
1137
+ name: "derive_soundtrack_grid",
1138
+ title: "Derive soundtrack beat grid",
1139
+ description:
1140
+ "Derive bpm, beatOffsetUs, beatsPerBar, and section cue times from one root-relative loop, song, or Standard MIDI file, in the music timing HTML scene requests accept. Optionally writes the grid. Renders no audio; no network.",
1141
+ inputSchema: {
1142
+ type: "object",
1143
+ additionalProperties: false,
1144
+ required: ["path"],
1145
+ properties: {
1146
+ path: {
1147
+ type: "string",
1148
+ description: `Root-relative path to a score or MIDI file (${String(slopcameraSoundtrackLimits.sourceBytes / 1024 / 1024)} MiB maximum).`,
1149
+ },
1150
+ output: { type: "string", description: "Optional root-relative .json path for the grid." },
1151
+ format: { type: "string", enum: slopcameraSoundtrackFormats, description: "Source format. Detected when omitted." },
1152
+ start_us: {
1153
+ type: "integer",
1154
+ minimum: 0,
1155
+ maximum: slopcameraSoundtrackLimits.maxTimeUs,
1156
+ description: "Timeline microsecond where the soundtrack's first beat lands. Defaults to 0.",
1157
+ },
1158
+ },
1159
+ },
1160
+ outputSchema: soundtrackReceiptOutputSchema,
1161
+ annotations: {
1162
+ title: "Derive soundtrack beat grid",
1163
+ readOnlyHint: false,
1164
+ destructiveHint: true,
1165
+ idempotentHint: true,
1166
+ openWorldHint: false,
1167
+ },
1168
+ },
1002
1169
  ])
1003
1170
 
1004
1171
  class ToolFailure extends Error {
@@ -1402,6 +1569,65 @@ function parseSceneBehaviorAuditArguments(
1402
1569
  }
1403
1570
  }
1404
1571
 
1572
+ type LocalMediaOperation =
1573
+ | { readonly operation: "slopcamera.icon.compose"; readonly input: SlopcameraIconComposeInput }
1574
+ | { readonly operation: "slopcamera.icon.render"; readonly input: SlopcameraIconRenderInput }
1575
+ | { readonly operation: "slopcamera.soundtrack.compose"; readonly input: SlopcameraSoundtrackComposeInput }
1576
+ | { readonly operation: "slopcamera.soundtrack.grid"; readonly input: SlopcameraSoundtrackGridInput }
1577
+
1578
+ const localMediaToolOperations = Object.freeze({
1579
+ compose_icon: "slopcamera.icon.compose",
1580
+ render_icon: "slopcamera.icon.render",
1581
+ compose_soundtrack: "slopcamera.soundtrack.compose",
1582
+ derive_soundtrack_grid: "slopcamera.soundtrack.grid",
1583
+ } as const)
1584
+
1585
+ type LocalMediaToolName = keyof typeof localMediaToolOperations
1586
+
1587
+ function isLocalMediaTool(name: string): name is LocalMediaToolName {
1588
+ return Object.hasOwn(localMediaToolOperations, name)
1589
+ }
1590
+
1591
+ function isLocalMediaOperation(
1592
+ operation: SlopcameraOperationCode,
1593
+ ): operation is LocalMediaOperation["operation"] {
1594
+ return (Object.values(localMediaToolOperations) as readonly string[]).includes(operation)
1595
+ }
1596
+
1597
+ /**
1598
+ * Map a dedicated tool's snake_case arguments onto the operation's input and
1599
+ * validate them with the same registry parser `execute_slopcamera` uses.
1600
+ */
1601
+ function parseLocalMediaToolArguments(
1602
+ name: LocalMediaToolName,
1603
+ value: unknown,
1604
+ ): LocalMediaOperation {
1605
+ if (!isRecord(value)) {
1606
+ throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
1607
+ }
1608
+ const allowed: Record<LocalMediaToolName, readonly string[]> = {
1609
+ compose_icon: ["path", "output"],
1610
+ render_icon: ["path", "output", "recipe", "size", "background", "palette", "language"],
1611
+ compose_soundtrack: ["path", "output", "format"],
1612
+ derive_soundtrack_grid: ["path", "output", "format", "start_us"],
1613
+ }
1614
+ rejectUnknownKeys(value, new Set(allowed[name]))
1615
+ const renames: Readonly<Record<string, string>> = {
1616
+ path: "sourcePath",
1617
+ output: "outputPath",
1618
+ recipe: "recipePath",
1619
+ start_us: "startUs",
1620
+ }
1621
+ const input = Object.fromEntries(
1622
+ Object.entries(value).map(([key, entry]) => [renames[key] ?? key, entry]),
1623
+ )
1624
+ const operation = localMediaToolOperations[name]
1625
+ return {
1626
+ operation,
1627
+ input: parseSlopcameraOperationInput(operation, input),
1628
+ } as LocalMediaOperation
1629
+ }
1630
+
1405
1631
  function parseSceneTemporalArguments(value: unknown): ParsedSceneTemporalArguments {
1406
1632
  if (!isRecord(value)) {
1407
1633
  throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
@@ -1568,6 +1794,13 @@ function failureResult(error: unknown): McpToolResult {
1568
1794
  error.message.replace(/^\[[A-Z_]+\]\s*/u, ""),
1569
1795
  320,
1570
1796
  )
1797
+ } else if (
1798
+ error instanceof SlopcameraIconError ||
1799
+ error instanceof SlopcameraSoundtrackError ||
1800
+ error instanceof BoundedFileError
1801
+ ) {
1802
+ code = error.code
1803
+ message = safeFragment(error.message, 320)
1571
1804
  } else if (error instanceof VectorizeError) {
1572
1805
  code = `VECTORIZE_${error.code.toUpperCase()}`
1573
1806
  message = "Local vectorization failed safely."
@@ -1872,6 +2105,9 @@ export class SlopcameraMcpToolRuntime {
1872
2105
  async () => await this.auditSceneTemporal(options),
1873
2106
  )
1874
2107
  }
2108
+ if (isLocalMediaTool(name)) {
2109
+ return await this.runLocalMedia(parseLocalMediaToolArguments(name, argumentsValue))
2110
+ }
1875
2111
  throw new ToolFailure("UNKNOWN_TOOL", "Requested tool is not available.")
1876
2112
  } catch (error) {
1877
2113
  return failureResult(error)
@@ -1896,6 +2132,18 @@ export class SlopcameraMcpToolRuntime {
1896
2132
  private async execute(
1897
2133
  options: ParsedExecuteArguments,
1898
2134
  ): Promise<McpToolResult> {
2135
+ if (isLocalMediaOperation(options.operation)) {
2136
+ const result = await this.runLocalMedia({
2137
+ operation: options.operation,
2138
+ input: parseSlopcameraOperationInput(options.operation, options.input),
2139
+ } as LocalMediaOperation)
2140
+ if (result.isError === true) return result
2141
+ const structured = result.structuredContent as { readonly receipt: unknown }
2142
+ return {
2143
+ content: result.content,
2144
+ structuredContent: { ok: true, operation: options.operation, result: structured.receipt },
2145
+ }
2146
+ }
1899
2147
  if (options.operation === "slopcamera.diagram.check") {
1900
2148
  const input = parseSlopcameraOperationInput(
1901
2149
  options.operation,
@@ -2021,6 +2269,69 @@ export class SlopcameraMcpToolRuntime {
2021
2269
  })
2022
2270
  }
2023
2271
 
2272
+ /**
2273
+ * Icon and soundtrack operations: confine every path to the workspace root,
2274
+ * cap the source before reading, run in process under the registry's host
2275
+ * claim, and report root-relative paths in the receipt.
2276
+ */
2277
+ private async runLocalMedia(request: LocalMediaOperation): Promise<McpToolResult> {
2278
+ // Queued with diagram renders so writes inside the root stay one at a time.
2279
+ return await this.enqueueRender(async () => await this.withHostAdmission(request.operation, async () => {
2280
+ const sourceLimit = request.operation.startsWith("slopcamera.icon.")
2281
+ ? slopcameraIconSceneLimits.sourceBytes
2282
+ : slopcameraSoundtrackLimits.sourceBytes
2283
+ const source = await this.boundary.resolveInputFile(request.input.sourcePath, sourceLimit)
2284
+ const relative = new Map<string, string>([[source.absolutePath, source.relativePath]])
2285
+ const output = async (value: string | undefined): Promise<string | undefined> => {
2286
+ if (value === undefined) return undefined
2287
+ const prepared = await this.boundary.prepareOutputFile(value)
2288
+ relative.set(prepared.absolutePath, prepared.relativePath)
2289
+ return prepared.absolutePath
2290
+ }
2291
+ const outputPath = await output(request.input.outputPath)
2292
+ const paths = {
2293
+ sourcePath: source.absolutePath,
2294
+ ...(outputPath === undefined ? {} : { outputPath }),
2295
+ }
2296
+ let receipt: { readonly operation: string }
2297
+ switch (request.operation) {
2298
+ case "slopcamera.icon.compose":
2299
+ receipt = await composeSlopcameraIcon({ ...request.input, ...paths })
2300
+ break
2301
+ case "slopcamera.icon.render": {
2302
+ if (paths.outputPath === undefined) {
2303
+ throw new ToolFailure("INVALID_ARGUMENTS", "output is required.")
2304
+ }
2305
+ const recipePath = await output(request.input.recipePath)
2306
+ receipt = await renderSlopcameraIcon({
2307
+ ...request.input,
2308
+ ...paths,
2309
+ outputPath: paths.outputPath,
2310
+ ...(recipePath === undefined ? {} : { recipePath }),
2311
+ })
2312
+ break
2313
+ }
2314
+ case "slopcamera.soundtrack.compose":
2315
+ receipt = await composeSlopcameraSoundtrack({ ...request.input, ...paths })
2316
+ break
2317
+ case "slopcamera.soundtrack.grid":
2318
+ receipt = await deriveSlopcameraSoundtrackGrid({ ...request.input, ...paths })
2319
+ break
2320
+ }
2321
+ // Receipts carry absolute paths; the tool reports only root-relative ones.
2322
+ const portable = JSON.parse(
2323
+ JSON.stringify(receipt, (key, value: unknown) =>
2324
+ key === "path" && typeof value === "string" ? (relative.get(value) ?? "") : value,
2325
+ ),
2326
+ ) as Record<string, unknown>
2327
+ const written = [...relative.values()].filter((path) => path !== source.relativePath)
2328
+ return successResult(
2329
+ `Executed ${request.operation} on ${source.relativePath}${written.length === 0 ? "" : `: wrote ${written.join(", ")}`}.`,
2330
+ { ok: true, operation: request.operation, receipt: portable },
2331
+ )
2332
+ }))
2333
+ }
2334
+
2024
2335
  private async check(options: ParsedCheckArguments): Promise<McpToolResult> {
2025
2336
  const { source, spec } = await loadDiagram(this.boundary, options.path)
2026
2337
  const allFindings = lintDiagram(spec)
package/src/mcp/types.ts CHANGED
@@ -51,6 +51,10 @@ export interface McpToolDefinition {
51
51
  | "check_scene_behavior"
52
52
  | "audit_scene_behavior"
53
53
  | "audit_scene_temporal"
54
+ | "compose_icon"
55
+ | "render_icon"
56
+ | "compose_soundtrack"
57
+ | "derive_soundtrack_grid"
54
58
  readonly title: string
55
59
  readonly description: string
56
60
  readonly inputSchema: Readonly<Record<string, unknown>>