@unoverse-platform/studio 0.1.53 → 0.1.55
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.
- package/bin/studio.mjs +56 -0
- package/local/sample-prompts/blocks/formatting/sample-concise-answers.md +22 -0
- package/local/sample-prompts/skills/sample-complaint-handling/SKILL.md +52 -0
- package/local/sample-styles/README.md +23 -0
- package/local/sample-styles/base/color.yaml +18 -0
- package/local/scaffold.mjs +78 -30
- package/package.json +1 -1
- package/src/NodesView.tsx +6 -1
package/bin/studio.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { dirname, resolve, join, basename } from "path";
|
|
|
21
21
|
import { fileURLToPath } from "url";
|
|
22
22
|
import { existsSync, readdirSync } from "fs";
|
|
23
23
|
import { createInterface } from "readline/promises";
|
|
24
|
+
import { execSync } from "child_process";
|
|
24
25
|
import { designHome, hasDesignHome } from "../local/design-home.mjs";
|
|
25
26
|
import { createFirstProject, checkProjectName } from "../local/scaffold.mjs";
|
|
26
27
|
|
|
@@ -161,8 +162,63 @@ await scaffoldIfEmpty();
|
|
|
161
162
|
*/
|
|
162
163
|
const projectRoot = findProjectRootFromCwd();
|
|
163
164
|
process.env.UNOVERSE_HOME = projectRoot;
|
|
165
|
+
// Samples this workspace has never seen arrive on launch (new ones ship with Studio
|
|
166
|
+
// releases); anything it ever had — edited or deleted — is never touched. scaffold.mjs
|
|
167
|
+
// topUpSamples explains the record that makes that distinction safe.
|
|
168
|
+
{
|
|
169
|
+
const { topUpSamples } = await import("../local/scaffold.mjs");
|
|
170
|
+
try { topUpSamples(projectRoot); } catch { /* a read-only folder must not block launch */ }
|
|
171
|
+
}
|
|
164
172
|
console.log(`\n Studio reading: ${designHome(projectRoot)}\n`);
|
|
165
173
|
|
|
174
|
+
/**
|
|
175
|
+
* LAUNCHING STUDIO IS THE DECISION; A STALE STUDIO YIELDS THE PORT.
|
|
176
|
+
*
|
|
177
|
+
* Studio is always 4108 with strictPort, so the address in your browser's history is
|
|
178
|
+
* always the right one. The price of one fixed port used to be a raw vite "Port 4108
|
|
179
|
+
* is already in use" whenever a previous Studio was still running — the tab from the
|
|
180
|
+
* last session, forgotten, holding the port for a project you have moved on from.
|
|
181
|
+
* Typing `unoverse studio` again IS the answer to "which one do you want": the old
|
|
182
|
+
* instance is stopped and this one takes the port.
|
|
183
|
+
*
|
|
184
|
+
* ONLY a recognisable Studio is stopped. Anything else on 4108 is somebody's own
|
|
185
|
+
* process, and killing it on sight would be worse than the error, so that case names
|
|
186
|
+
* the process and stops.
|
|
187
|
+
*/
|
|
188
|
+
function freePortOfStaleStudio(port) {
|
|
189
|
+
let pids = [];
|
|
190
|
+
try {
|
|
191
|
+
pids = execSync(`lsof -nP -ti tcp:${port} -s tcp:LISTEN`, { stdio: ["ignore", "pipe", "ignore"] })
|
|
192
|
+
.toString().trim().split("\n").filter(Boolean);
|
|
193
|
+
} catch {
|
|
194
|
+
return; // nothing listening, or no lsof: let vite find out
|
|
195
|
+
}
|
|
196
|
+
for (const pid of pids) {
|
|
197
|
+
if (pid === String(process.pid)) continue;
|
|
198
|
+
let command = "";
|
|
199
|
+
try {
|
|
200
|
+
command = execSync(`ps -p ${pid} -o command=`, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
201
|
+
} catch { continue; } // already gone
|
|
202
|
+
if (/unoverse-studio|@unoverse-platform\/studio|packages\/studio/.test(command)) {
|
|
203
|
+
console.log(` A previous Studio was still holding :${port}. Stopping it and taking over.`);
|
|
204
|
+
try { process.kill(Number(pid), "SIGTERM"); } catch { /* already gone */ }
|
|
205
|
+
// SIGTERM first so it closes cleanly; escalate only if it lingers.
|
|
206
|
+
const deadline = Date.now() + 3000;
|
|
207
|
+
for (;;) {
|
|
208
|
+
try { process.kill(Number(pid), 0); } catch { break; } // no longer exists
|
|
209
|
+
if (Date.now() > deadline) { try { process.kill(Number(pid), "SIGKILL"); } catch { /* gone */ } break; }
|
|
210
|
+
execSync("sleep 0.2");
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
console.error(`\n Port ${port} is held by something that is not Studio:\n ${command}\n`);
|
|
214
|
+
console.error(` Stop it (kill ${pid}) or find out what it is (lsof -nP -i :${port}), then run unoverse studio again.\n`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
freePortOfStaleStudio(4108);
|
|
221
|
+
|
|
166
222
|
const server = await createServer({
|
|
167
223
|
// The APP is the vite root; the developer's project is found by walking up from the
|
|
168
224
|
// working directory (findProjectRoot), so Studio works from anywhere inside it.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Concise Answers
|
|
3
|
+
description: Keeps replies short, direct, and free of filler — the answer first, detail only on request
|
|
4
|
+
tags: [formatting, tone]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<!-- A WORKED EXAMPLE prompt block, shipped with Studio so prompts/blocks/ is never an
|
|
8
|
+
empty folder. Edit it, copy it, or delete it — it is yours. A block is a reusable
|
|
9
|
+
prompt fragment workflows compose into an agent's system prompt: smaller than a
|
|
10
|
+
skill, no routing of its own, and it must stand alone (no references to other
|
|
11
|
+
blocks). The folder above it is its category. -->
|
|
12
|
+
|
|
13
|
+
Answer the question first, in one or two sentences, before any background.
|
|
14
|
+
|
|
15
|
+
Keep every reply as short as it can be while still being complete. Cut greetings,
|
|
16
|
+
restatements of the question, and closing filler ("I hope this helps").
|
|
17
|
+
|
|
18
|
+
Use plain words over formal ones. Prefer an example over an explanation when one
|
|
19
|
+
example makes the point.
|
|
20
|
+
|
|
21
|
+
If the full answer would be long, give the short version and offer the detail:
|
|
22
|
+
"Want the full breakdown?"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sample-complaint-handling
|
|
3
|
+
title: Complaint Handling
|
|
4
|
+
description: How the assistant responds when a customer is unhappy, from first reply to escalation
|
|
5
|
+
whenToUse: Handle an unhappy customer — a complaint, frustration with the product or service, a request for a refund, or a demand to speak to a person. Not for neutral questions or how-to requests.
|
|
6
|
+
version: 1.0.0
|
|
7
|
+
category: ai
|
|
8
|
+
triggers:
|
|
9
|
+
- complaint
|
|
10
|
+
- refund
|
|
11
|
+
- unhappy
|
|
12
|
+
- speak to a human
|
|
13
|
+
- escalate
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Complaint Handling
|
|
17
|
+
|
|
18
|
+
<!-- A WORKED EXAMPLE, shipped with Studio so prompts/skills/ is never an empty folder.
|
|
19
|
+
Edit it, copy it, or delete it — it is yours. An agent skill is prose the
|
|
20
|
+
platform's AI agents discover at runtime and follow: the frontmatter above is the
|
|
21
|
+
routing (whenToUse is ranked against what the user actually says), the body below
|
|
22
|
+
is the behavior. Format + rules: https://docs.unoverse.ai/nodes/node-discoverability.md -->
|
|
23
|
+
|
|
24
|
+
## When to Use This Skill
|
|
25
|
+
|
|
26
|
+
The customer is expressing dissatisfaction: something broke, something was late, they
|
|
27
|
+
were charged wrongly, or they are simply frustrated. The goal of every reply is that
|
|
28
|
+
the customer feels heard first and helped second.
|
|
29
|
+
|
|
30
|
+
## Approach
|
|
31
|
+
|
|
32
|
+
1. **Acknowledge before anything else.** One sentence that names their problem in
|
|
33
|
+
their words. Never open with policy.
|
|
34
|
+
2. **Ask for the one detail you need**, if any, and only one at a time.
|
|
35
|
+
3. **Offer the concrete next step you can actually do.** Do not promise outcomes you
|
|
36
|
+
cannot see through.
|
|
37
|
+
4. **Escalate when asked, and say so plainly.** If the customer asks for a person
|
|
38
|
+
twice, or mentions legal action, stop resolving and hand off: "I'm passing this to
|
|
39
|
+
our team; you'll hear from a person."
|
|
40
|
+
|
|
41
|
+
## Do / Don't
|
|
42
|
+
|
|
43
|
+
- Do keep replies to three sentences or fewer while the customer is upset.
|
|
44
|
+
- Do use their name if you have it.
|
|
45
|
+
- Don't defend the company or explain why the problem happened, unless they ask.
|
|
46
|
+
- Don't offer discounts or refunds beyond what the workflow's tools allow.
|
|
47
|
+
|
|
48
|
+
## Examples
|
|
49
|
+
|
|
50
|
+
Customer: "This is the second time my order arrived broken. Ridiculous."
|
|
51
|
+
Reply: "Twice in a row is genuinely not okay — I'm sorry. I can send a replacement
|
|
52
|
+
today or refund the order in full: which would you prefer?"
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Your project's styles
|
|
2
|
+
|
|
3
|
+
This folder restyles the whole design system for your brand — without editing a
|
|
4
|
+
single component. Three rules:
|
|
5
|
+
|
|
6
|
+
1. **Token NAMES are the contract, VALUES are yours.** Components reference names
|
|
7
|
+
like `text.primary` and `action.primary`; you change what those names mean.
|
|
8
|
+
2. **Name a token to override it; omit it to inherit.** The cascade is per token,
|
|
9
|
+
never per file. Never copy a whole file to change one line — the untouched lines
|
|
10
|
+
would stop tracking platform improvements.
|
|
11
|
+
3. **Ingredients in `base/`, meaning in `themes/`.** `base/color.yaml` names your
|
|
12
|
+
brand's raw colors (`color.brand.ink`); a theme file assigns them to roles
|
|
13
|
+
(`text.primary: {color.brand.ink}`).
|
|
14
|
+
|
|
15
|
+
Start here:
|
|
16
|
+
|
|
17
|
+
- `base/color.yaml` — name your brand ingredients (a worked example is inside).
|
|
18
|
+
- Want your own theme? Copy the foundation's theme file whole — the ONE place
|
|
19
|
+
copying is correct, because a theme must assign every role:
|
|
20
|
+
https://github.com/unoverse-platform/marketplace/tree/main/definitions/styles/themes
|
|
21
|
+
→ save as `themes/light.yaml` here, then point roles at your `color.brand.*`.
|
|
22
|
+
|
|
23
|
+
Full guide: https://docs.unoverse.ai/design/styles-and-tokens.md
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# BRAND INGREDIENTS — raw colors named for what they ARE (ink, paper, a signature
|
|
2
|
+
# hue), never for where they are used ("button-blue" is a decision, not an ingredient;
|
|
3
|
+
# a theme file makes decisions). These are inert until a theme assigns them to roles,
|
|
4
|
+
# so this file is safe to grow before you have a theme at all.
|
|
5
|
+
#
|
|
6
|
+
# Delete the examples and name your own. Guide: see README.md in this folder.
|
|
7
|
+
color:
|
|
8
|
+
$type: color
|
|
9
|
+
brand:
|
|
10
|
+
ink:
|
|
11
|
+
$value: "#1a1a2e"
|
|
12
|
+
$description: "Near-black with a hint of the brand hue — body text and headings"
|
|
13
|
+
paper:
|
|
14
|
+
$value: "#fbfaf8"
|
|
15
|
+
$description: "Warm off-white — page and card backgrounds"
|
|
16
|
+
signature:
|
|
17
|
+
$value: "#4f46e5"
|
|
18
|
+
$description: "The brand's signature hue — actions and accents"
|
package/local/scaffold.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* See docs/architecture/LOCAL_STUDIO.md.
|
|
15
15
|
*/
|
|
16
|
-
import { existsSync, mkdirSync, writeFileSync, cpSync, readdirSync } from "node:fs";
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync, cpSync, readdirSync, readFileSync } from "node:fs";
|
|
17
17
|
import { designHome } from "./design-home.mjs";
|
|
18
18
|
import { join, dirname, resolve } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
@@ -71,9 +71,73 @@ export function createProject(home, name) {
|
|
|
71
71
|
|
|
72
72
|
for (const d of ["components/welcome", "templates", "styles"]) mkdirSync(join(root, d), { recursive: true });
|
|
73
73
|
writeFileSync(join(root, "components/welcome/welcome.yaml"), WELCOME);
|
|
74
|
+
|
|
75
|
+
// Worked example styles (sample-styles: brand ingredients + the override rules), so
|
|
76
|
+
// styles/ is never an empty folder with no hint of the token cascade. Per PROJECT,
|
|
77
|
+
// because every project is a publishable org with its own pack. Copied only when the
|
|
78
|
+
// folder is empty: these are the developer's files, and a later run must never put
|
|
79
|
+
// back what they threw away.
|
|
80
|
+
const sampleStyles = join(dirname(fileURLToPath(import.meta.url)), "sample-styles");
|
|
81
|
+
if (existsSync(sampleStyles) && readdirSync(join(root, "styles")).length === 0) {
|
|
82
|
+
cpSync(sampleStyles, join(root, "styles"), { recursive: true });
|
|
83
|
+
}
|
|
74
84
|
return root;
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The worked example content (sample nodes, a sample agent skill, a sample prompt
|
|
89
|
+
* block), keyed by the workspace path each lands at. `dir` is relative to this file.
|
|
90
|
+
*
|
|
91
|
+
* A NEW ENTRY HERE REACHES EXISTING WORKSPACES on their next Studio launch, because
|
|
92
|
+
* topUpSamples runs at every launch, not only at scaffold. The record below is what
|
|
93
|
+
* makes that safe.
|
|
94
|
+
*/
|
|
95
|
+
const SAMPLES = [
|
|
96
|
+
{ id: "nodes/samples", dir: "sample-nodes/samples", to: "nodes/samples" },
|
|
97
|
+
{ id: "prompts/skills/sample-complaint-handling", dir: "sample-prompts/skills/sample-complaint-handling", to: "prompts/skills/sample-complaint-handling" },
|
|
98
|
+
{ id: "prompts/blocks/sample-concise-answers", dir: "sample-prompts/blocks/formatting/sample-concise-answers.md", to: "prompts/blocks/formatting/sample-concise-answers.md" },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Install every sample this workspace has NEVER SEEN, and only those.
|
|
103
|
+
*
|
|
104
|
+
* "Absent from disk" is ambiguous — it means either "deleted on purpose" or "shipped
|
|
105
|
+
* after this workspace was made" — so presence is not the test. The test is the
|
|
106
|
+
* RECORD (.unoverse/samples.json): a sample id listed there was installed here once,
|
|
107
|
+
* and whatever the developer did to it since (edit, rename, delete) is theirs and
|
|
108
|
+
* permanent. A sample id NOT listed is genuinely new to this workspace and arrives.
|
|
109
|
+
* That is what lets `unoverse studio` top up new samples on every launch without ever
|
|
110
|
+
* resurrecting deleted work or overwriting changed files.
|
|
111
|
+
*/
|
|
112
|
+
export function topUpSamples(home) {
|
|
113
|
+
const record = join(home, ".unoverse", "samples.json");
|
|
114
|
+
let seen = [];
|
|
115
|
+
try {
|
|
116
|
+
seen = JSON.parse(readFileSync(record, "utf8")).installed ?? [];
|
|
117
|
+
} catch {
|
|
118
|
+
// No record: a workspace from before records existed. Treat every sample already
|
|
119
|
+
// on disk as seen (it was installed by an old scaffold), so nothing is re-copied.
|
|
120
|
+
seen = SAMPLES.filter((s) => existsSync(join(home, s.to))).map((s) => s.id);
|
|
121
|
+
}
|
|
122
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
123
|
+
let changed = false;
|
|
124
|
+
for (const s of SAMPLES) {
|
|
125
|
+
if (seen.includes(s.id)) continue;
|
|
126
|
+
const src = join(here, s.dir);
|
|
127
|
+
if (!existsSync(src)) continue;
|
|
128
|
+
if (!existsSync(join(home, s.to))) {
|
|
129
|
+
mkdirSync(dirname(join(home, s.to)), { recursive: true });
|
|
130
|
+
cpSync(src, join(home, s.to), { recursive: true });
|
|
131
|
+
}
|
|
132
|
+
seen.push(s.id);
|
|
133
|
+
changed = true;
|
|
134
|
+
}
|
|
135
|
+
if (changed || !existsSync(record)) {
|
|
136
|
+
mkdirSync(dirname(record), { recursive: true });
|
|
137
|
+
writeFileSync(record, JSON.stringify({ installed: seen }, null, 2) + "\n");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
77
141
|
/**
|
|
78
142
|
* The FIRST project, plus the folders that live beside the design tree rather than inside it.
|
|
79
143
|
*
|
|
@@ -84,36 +148,20 @@ export function createFirstProject(home, name) {
|
|
|
84
148
|
const root = createProject(home, name);
|
|
85
149
|
for (const d of ["prompts/skills", "prompts/blocks", "nodes"]) mkdirSync(join(home, d), { recursive: true });
|
|
86
150
|
|
|
87
|
-
|
|
88
|
-
// heavily commented, linking docs.unoverse.ai/nodes), so `nodes/` is never an empty
|
|
89
|
-
// folder with no hint of what goes in it. Copied only when absent: these are the
|
|
90
|
-
// developer's files to edit or delete, and a later scaffold run must never put back
|
|
91
|
-
// what they threw away or overwrite what they changed.
|
|
92
|
-
const samples = join(dirname(fileURLToPath(import.meta.url)), "sample-nodes/samples");
|
|
93
|
-
if (existsSync(samples) && !existsSync(join(home, "nodes/samples"))) {
|
|
94
|
-
cpSync(samples, join(home, "nodes/samples"), { recursive: true });
|
|
95
|
-
}
|
|
151
|
+
topUpSamples(home);
|
|
96
152
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
// copies from the public mirror.
|
|
153
|
+
// SCAFFOLD WRITES NO `.claude/skills` (2026-08-21). It used to copy the Claude Code
|
|
154
|
+
// authoring skills out of `vendor/skills` and into `<home>/.claude/skills`, which meant
|
|
155
|
+
// every remote developer who launched Studio got the platform repo's own `.claude`
|
|
156
|
+
// folder reproduced on their disk, pinned to whatever Studio version they happened to
|
|
157
|
+
// install and sitting in a workspace they might commit.
|
|
103
158
|
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
.filter((e) => e.isDirectory() && existsSync(join(skillsDir, e.name, "SKILL.md")))
|
|
112
|
-
.map((e) => e.name);
|
|
113
|
-
if (names.length) mkdirSync(join(home, ".claude/skills"), { recursive: true });
|
|
114
|
-
for (const name of names) {
|
|
115
|
-
cpSync(join(skillsDir, name), join(home, ".claude/skills", name), { recursive: true });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
159
|
+
// The skills are PER DEVELOPER, not per project (the same reason prompts/ and nodes/ are
|
|
160
|
+
// made once here and never by `createProject`). They now install to `~/.claude/skills`
|
|
161
|
+
// from the public mirror, on `unoverse update` — see packages/cli/lib/skills.mjs.
|
|
162
|
+
//
|
|
163
|
+
// `vendor/skills` STAYS, and is not a distribution channel: it is what Studio's builder
|
|
164
|
+
// MCP reads to teach a connected agent the authoring rules (local/mcp.ts SKILL_DIRS).
|
|
165
|
+
// Nothing about that copy reaches the developer's filesystem.
|
|
118
166
|
return root;
|
|
119
167
|
}
|
package/package.json
CHANGED
package/src/NodesView.tsx
CHANGED
|
@@ -383,7 +383,12 @@ export function NodesView() {
|
|
|
383
383
|
</div>
|
|
384
384
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
385
385
|
{current.testData && (
|
|
386
|
-
<button
|
|
386
|
+
<button
|
|
387
|
+
onClick={loadSample}
|
|
388
|
+
disabled={!current?.testData}
|
|
389
|
+
title={current?.testData ? "Fill the form from the node's test fixture (test.yaml)" : "This node declares no test fixture"}
|
|
390
|
+
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
|
|
391
|
+
>
|
|
387
392
|
Load sample
|
|
388
393
|
</button>
|
|
389
394
|
)}
|