@uniqbit/mate-core 0.18.1-canary.4 → 0.18.1
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/package.json +1 -1
- package/src/cli/commands/report/delivery.ts +50 -0
- package/src/cli/commands/report/index.ts +22 -4
- package/src/cli/commands/studio/access.ts +6 -0
- package/src/cli/commands/studio/actions.ts +87 -0
- package/src/cli/commands/studio/reports.ts +176 -0
- package/src/cli/commands/studio/routes.ts +12 -0
- package/src/cli/commands/studio/selection.ts +2 -1
- package/src/cli/commands/studio/server.ts +276 -4
- package/src/cli/commands/studio/terminal-launch.ts +38 -3
- package/src/cli/commands/studio/terminal.ts +48 -4
- package/src/cli/commands/studio/vault.ts +55 -8
- package/src/cli/commands/studio/views/client.ts +263 -27
- package/src/cli/commands/studio/views/document.tsx +168 -20
- package/src/cli/commands/studio/views/model.ts +2 -0
- package/src/cli/commands/studio/views/reports/index.tsx +49 -0
- package/src/cli/commands/studio/views/specs/index.tsx +65 -9
- package/src/cli/commands/studio/views/styles.ts +26 -6
- package/src/cli/commands/studio/views/terminal.tsx +62 -4
- package/src/cli/commands/studio/views/vault/state.ts +1 -0
- package/src/templates/mate-skills/agents/mate-show-me/SKILL.md +11 -5
- package/src/templates/mate-skills/claude/mate-show-me/SKILL.md +11 -5
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +12 -12
- package/src/tools/setup/mate.ts +1 -1
package/package.json
CHANGED
|
@@ -77,3 +77,53 @@ export function openReportInBrowser(
|
|
|
77
77
|
});
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
export const STUDIO_PUBLISH_TIMEOUT_MS = 3_000;
|
|
82
|
+
|
|
83
|
+
export interface StudioPublishDeps {
|
|
84
|
+
env?: NodeJS.ProcessEnv;
|
|
85
|
+
fetch?: typeof fetch;
|
|
86
|
+
timeoutMs?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface HostedReport {
|
|
90
|
+
id: string;
|
|
91
|
+
/** Present only when Studio has a configured public origin. */
|
|
92
|
+
url?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Publishes to the Studio that spawned this process; `null` outside one. Errors
|
|
97
|
+
* never carry the credential or the loopback endpoint.
|
|
98
|
+
*/
|
|
99
|
+
export async function publishReportToStudio(
|
|
100
|
+
title: string,
|
|
101
|
+
html: string,
|
|
102
|
+
deps: StudioPublishDeps = {},
|
|
103
|
+
): Promise<HostedReport | null> {
|
|
104
|
+
const env = deps.env ?? process.env;
|
|
105
|
+
const endpoint = env.MATE_STUDIO_REPORT_URL;
|
|
106
|
+
const token = env.MATE_STUDIO_REPORT_TOKEN;
|
|
107
|
+
if (!endpoint || !token) return null;
|
|
108
|
+
|
|
109
|
+
let response: Response;
|
|
110
|
+
try {
|
|
111
|
+
response = await (deps.fetch ?? fetch)(new URL("/api/reports", endpoint), {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
114
|
+
body: JSON.stringify({ title, html }),
|
|
115
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? STUDIO_PUBLISH_TIMEOUT_MS),
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
throw new Error("Studio did not answer in time");
|
|
119
|
+
}
|
|
120
|
+
if (!response.ok) throw new Error(`Studio refused the report (HTTP ${response.status})`);
|
|
121
|
+
let body: { id?: unknown; url?: unknown };
|
|
122
|
+
try {
|
|
123
|
+
body = (await response.json()) as typeof body;
|
|
124
|
+
} catch {
|
|
125
|
+
throw new Error("Studio answered with an unreadable response");
|
|
126
|
+
}
|
|
127
|
+
if (typeof body.id !== "string") throw new Error("Studio answered with an unreadable response");
|
|
128
|
+
return { id: body.id, ...(typeof body.url === "string" ? { url: body.url } : {}) };
|
|
129
|
+
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import type { CollectorDeps } from "./collector";
|
|
12
12
|
import { reportDataToDocument } from "./adapter";
|
|
13
13
|
import { parseReportDocument, type ReportValidationError } from "./contract";
|
|
14
|
-
import { openReportInBrowser, writeTemporaryReport } from "./delivery";
|
|
14
|
+
import { openReportInBrowser, publishReportToStudio, writeTemporaryReport } from "./delivery";
|
|
15
15
|
import { renderHTML, renderJSON } from "./renderer";
|
|
16
16
|
import type { ReportData, ReportDocument, ReportOptions } from "./types";
|
|
17
17
|
|
|
@@ -20,6 +20,7 @@ interface ReportCommandDeps extends CollectorDeps {
|
|
|
20
20
|
resolveFrameworkContext?: typeof resolveFrameworkContext;
|
|
21
21
|
writeTemporaryReport?: typeof writeTemporaryReport;
|
|
22
22
|
openReportInBrowser?: typeof openReportInBrowser;
|
|
23
|
+
publishReportToStudio?: typeof publishReportToStudio;
|
|
23
24
|
readInput?: (input: string) => Promise<string>;
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -196,11 +197,28 @@ export async function runReportCommand(
|
|
|
196
197
|
return;
|
|
197
198
|
}
|
|
198
199
|
|
|
199
|
-
|
|
200
|
+
const html = renderHTML(reportDocument);
|
|
200
201
|
try {
|
|
201
|
-
|
|
202
|
-
|
|
202
|
+
const hosted = await (deps.publishReportToStudio ?? publishReportToStudio)(
|
|
203
|
+
reportDocument.title,
|
|
204
|
+
html,
|
|
203
205
|
);
|
|
206
|
+
if (hosted) {
|
|
207
|
+
console.log(
|
|
208
|
+
hosted.url
|
|
209
|
+
? `Report hosted in Studio: ${hosted.url}`
|
|
210
|
+
: `Report hosted in Studio: open Studio → Reports (report ${hosted.id})`,
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
216
|
+
console.warn(`Unable to host the report in Studio: ${message}; opening it locally instead`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let reportPath: string | undefined;
|
|
220
|
+
try {
|
|
221
|
+
reportPath = await (deps.writeTemporaryReport ?? writeTemporaryReport)(html);
|
|
204
222
|
await (deps.openReportInBrowser ?? openReportInBrowser)(reportPath);
|
|
205
223
|
console.log(`Report opened from ${reportPath}`);
|
|
206
224
|
} catch (error) {
|
|
@@ -86,6 +86,12 @@ export function tokensEqual(candidate: string, expected: string): boolean {
|
|
|
86
86
|
return timingSafeEqual(digest(candidate), digest(expected));
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** The `Authorization: Bearer` credential, or `null`; callers compare it with `tokensEqual`. */
|
|
90
|
+
export function readBearer(request: Request): string | null {
|
|
91
|
+
const match = /^Bearer ([^\s]+)$/i.exec(request.headers.get("authorization") ?? "");
|
|
92
|
+
return match ? match[1]! : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
89
95
|
function readCookie(request: Request, name: string): string | null {
|
|
90
96
|
const header = request.headers.get("cookie");
|
|
91
97
|
if (!header) return null;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { StudioSkillInventory } from "./mate-inventory";
|
|
2
|
+
import type { TerminalAgent } from "./terminal";
|
|
3
|
+
|
|
4
|
+
/** Lowercase letters, digits and hyphens, so a subject can never carry shell syntax or a flag. */
|
|
5
|
+
export const SUBJECT_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A skill Studio may launch with a subject. Fixed in code: the registry, not
|
|
9
|
+
* an installed skill directory, authorizes an action.
|
|
10
|
+
*/
|
|
11
|
+
export interface StudioAction {
|
|
12
|
+
id: string;
|
|
13
|
+
skill: string;
|
|
14
|
+
label: string;
|
|
15
|
+
subject: "spec";
|
|
16
|
+
prompts: Record<TerminalAgent, (subject: string) => string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const STUDIO_ACTIONS: readonly StudioAction[] = [
|
|
20
|
+
{
|
|
21
|
+
id: "show-me",
|
|
22
|
+
skill: "mate-show-me",
|
|
23
|
+
label: "Show me",
|
|
24
|
+
subject: "spec",
|
|
25
|
+
prompts: {
|
|
26
|
+
claude: (subject) => `/mate-show-me ${subject}`,
|
|
27
|
+
opencode: (subject) => `Use the mate-show-me skill on spec ${subject}.`,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export function findAction(id: unknown): StudioAction | null {
|
|
33
|
+
return STUDIO_ACTIONS.find((action) => action.id === id) ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Skill trees each agent loads; Claude does not read the shared Agents tree. */
|
|
37
|
+
const SKILL_TREES: Record<TerminalAgent, (keyof StudioSkillInventory)[]> = {
|
|
38
|
+
claude: ["claude"],
|
|
39
|
+
opencode: ["opencode", "agents"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Availability only: a directory shows the skill is installed, not that Mate shipped it. */
|
|
43
|
+
export function skillInstalledFor(
|
|
44
|
+
inventory: StudioSkillInventory,
|
|
45
|
+
skill: string,
|
|
46
|
+
agent: TerminalAgent,
|
|
47
|
+
): boolean {
|
|
48
|
+
return SKILL_TREES[agent].some((tree) => inventory[tree].includes(skill));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The first-turn prompt arguments for one agent: a trailing positional for Claude, `--prompt` for OpenCode. */
|
|
52
|
+
export function promptArgs(action: StudioAction, agent: TerminalAgent, subject: string): string[] {
|
|
53
|
+
const prompt = action.prompts[agent](subject);
|
|
54
|
+
return agent === "opencode" ? ["--prompt", prompt] : [prompt];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What the Specs view renders for one action on the displayed companion. */
|
|
58
|
+
export interface SpecActionOffer {
|
|
59
|
+
action: StudioAction;
|
|
60
|
+
/** Agents a run button may start; empty without a matching terminal. */
|
|
61
|
+
runAgents: TerminalAgent[];
|
|
62
|
+
/** Agents whose prompt Copy may offer. */
|
|
63
|
+
copyAgents: TerminalAgent[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const AGENTS: readonly TerminalAgent[] = ["claude", "opencode"];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run needs a terminal targeting the displayed companion and a launchable
|
|
70
|
+
* agent with the skill; Copy needs only the installed skill.
|
|
71
|
+
*/
|
|
72
|
+
export function specActionOffers(
|
|
73
|
+
companionPath: string,
|
|
74
|
+
inventory: StudioSkillInventory | undefined,
|
|
75
|
+
terminal: { target: { path: string } | null; agents: TerminalAgent[] } | null,
|
|
76
|
+
): SpecActionOffer[] {
|
|
77
|
+
if (!inventory) return [];
|
|
78
|
+
const targeted = terminal?.target?.path === companionPath;
|
|
79
|
+
return STUDIO_ACTIONS.flatMap((action) => {
|
|
80
|
+
const copyAgents = AGENTS.filter((agent) => skillInstalledFor(inventory, action.skill, agent));
|
|
81
|
+
if (copyAgents.length === 0) return [];
|
|
82
|
+
const runAgents = targeted
|
|
83
|
+
? copyAgents.filter((agent) => terminal?.agents.includes(agent))
|
|
84
|
+
: [];
|
|
85
|
+
return [{ action, runAgents, copyAgents }];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { FRAMEWORK_NAME } from "../../../framework";
|
|
7
|
+
import { COMPANION_DIGEST_PATTERN } from "./selection";
|
|
8
|
+
|
|
9
|
+
export const REPORT_LIMITS = {
|
|
10
|
+
reports: 20,
|
|
11
|
+
bytes: 50 * 1024 * 1024,
|
|
12
|
+
ageMs: 7 * 24 * 60 * 60 * 1000,
|
|
13
|
+
/** The bundled inlined Mermaid runtime alone is about 5.3 MiB. */
|
|
14
|
+
uploadBytes: 10 * 1024 * 1024,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** What listings expose: no paths, no content. */
|
|
18
|
+
export interface StoredReport {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
bytes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReportStoreOptions {
|
|
26
|
+
/** Defaults to `~/.mate/studio/reports`. */
|
|
27
|
+
root?: string;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
limits?: Partial<typeof REPORT_LIMITS>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ReportStore {
|
|
33
|
+
publish(companion: string, report: { title: string; html: string }): Promise<StoredReport>;
|
|
34
|
+
/** Newest first. Never expires by age: only `publish` and `cleanup` remove. */
|
|
35
|
+
list(companion: string): Promise<StoredReport[]>;
|
|
36
|
+
/** The HTML of a stored report of any companion, or `null`. */
|
|
37
|
+
read(id: string): Promise<string | null>;
|
|
38
|
+
/** Removes expired reports from every companion. */
|
|
39
|
+
cleanup(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const REPORT_ID_PATTERN = /^[0-9a-f]{32}$/;
|
|
43
|
+
|
|
44
|
+
export function isReportId(value: string): boolean {
|
|
45
|
+
return REPORT_ID_PATTERN.test(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultReportRoot(): string {
|
|
49
|
+
return path.join(os.homedir(), `.${FRAMEWORK_NAME}`, "studio", "reports");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isEntry(value: unknown): value is StoredReport {
|
|
53
|
+
if (!value || typeof value !== "object") return false;
|
|
54
|
+
const entry = value as Record<string, unknown>;
|
|
55
|
+
return (
|
|
56
|
+
typeof entry.id === "string" &&
|
|
57
|
+
isReportId(entry.id) &&
|
|
58
|
+
typeof entry.title === "string" &&
|
|
59
|
+
typeof entry.createdAt === "number" &&
|
|
60
|
+
typeof entry.bytes === "number"
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createReportStore(options: ReportStoreOptions = {}): ReportStore {
|
|
65
|
+
const root = options.root ?? defaultReportRoot();
|
|
66
|
+
const now = options.now ?? Date.now;
|
|
67
|
+
const limits = { ...REPORT_LIMITS, ...options.limits };
|
|
68
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
69
|
+
|
|
70
|
+
/** One writer at a time: index read-modify-write must not interleave. */
|
|
71
|
+
const serialized = <T>(task: () => Promise<T>): Promise<T> => {
|
|
72
|
+
const run = queue.then(task, task);
|
|
73
|
+
queue = run.catch(() => {});
|
|
74
|
+
return run;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const directory = (companion: string) => {
|
|
78
|
+
if (!COMPANION_DIGEST_PATTERN.test(companion)) throw new Error("invalid companion digest");
|
|
79
|
+
return path.join(root, companion);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const readIndex = async (dir: string): Promise<StoredReport[]> => {
|
|
83
|
+
try {
|
|
84
|
+
const parsed: unknown = JSON.parse(await readFile(path.join(dir, "index.json"), "utf8"));
|
|
85
|
+
return Array.isArray(parsed) ? parsed.filter(isEntry) : [];
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const writeIndex = async (dir: string, entries: StoredReport[]) => {
|
|
92
|
+
const temporary = path.join(dir, `index.${randomBytes(4).toString("hex")}.tmp`);
|
|
93
|
+
await writeFile(temporary, JSON.stringify(entries), "utf8");
|
|
94
|
+
await rename(temporary, path.join(dir, "index.json"));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const remove = (dir: string, entry: StoredReport) =>
|
|
98
|
+
rm(path.join(dir, `${entry.id}.html`), { force: true });
|
|
99
|
+
|
|
100
|
+
/** Newest first; drops expired entries, then evicts oldest until both caps hold. */
|
|
101
|
+
const prune = async (dir: string, entries: StoredReport[], keep?: string) => {
|
|
102
|
+
const cutoff = now() - limits.ageMs;
|
|
103
|
+
const kept: StoredReport[] = [];
|
|
104
|
+
let bytes = 0;
|
|
105
|
+
for (const entry of entries.toSorted((a, b) => b.createdAt - a.createdAt)) {
|
|
106
|
+
const evictable =
|
|
107
|
+
entry.createdAt < cutoff ||
|
|
108
|
+
kept.length >= limits.reports ||
|
|
109
|
+
bytes + entry.bytes > limits.bytes;
|
|
110
|
+
if (evictable && entry.id !== keep) {
|
|
111
|
+
await remove(dir, entry);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
kept.push(entry);
|
|
115
|
+
bytes += entry.bytes;
|
|
116
|
+
}
|
|
117
|
+
return kept;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
publish: (companion, report) =>
|
|
122
|
+
serialized(async () => {
|
|
123
|
+
const dir = directory(companion);
|
|
124
|
+
await mkdir(dir, { recursive: true });
|
|
125
|
+
const entry: StoredReport = {
|
|
126
|
+
id: randomBytes(16).toString("hex"),
|
|
127
|
+
title: report.title,
|
|
128
|
+
createdAt: now(),
|
|
129
|
+
bytes: Buffer.byteLength(report.html),
|
|
130
|
+
};
|
|
131
|
+
await writeFile(path.join(dir, `${entry.id}.html`), report.html, "utf8");
|
|
132
|
+
const kept = await prune(dir, [entry, ...(await readIndex(dir))], entry.id);
|
|
133
|
+
await writeIndex(dir, kept);
|
|
134
|
+
return entry;
|
|
135
|
+
}),
|
|
136
|
+
list: (companion) =>
|
|
137
|
+
serialized(async () =>
|
|
138
|
+
(await readIndex(directory(companion))).toSorted((a, b) => b.createdAt - a.createdAt),
|
|
139
|
+
),
|
|
140
|
+
read: (id) =>
|
|
141
|
+
serialized(async () => {
|
|
142
|
+
if (!isReportId(id)) return null;
|
|
143
|
+
let companions: string[];
|
|
144
|
+
try {
|
|
145
|
+
companions = await readdir(root);
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
for (const companion of companions) {
|
|
150
|
+
if (!COMPANION_DIGEST_PATTERN.test(companion)) continue;
|
|
151
|
+
const dir = path.join(root, companion);
|
|
152
|
+
if (!(await readIndex(dir)).some((entry) => entry.id === id)) continue;
|
|
153
|
+
try {
|
|
154
|
+
return await readFile(path.join(dir, `${id}.html`), "utf8");
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}),
|
|
161
|
+
cleanup: () =>
|
|
162
|
+
serialized(async () => {
|
|
163
|
+
let companions: string[];
|
|
164
|
+
try {
|
|
165
|
+
companions = await readdir(root);
|
|
166
|
+
} catch {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
for (const companion of companions) {
|
|
170
|
+
if (!COMPANION_DIGEST_PATTERN.test(companion)) continue;
|
|
171
|
+
const dir = path.join(root, companion);
|
|
172
|
+
await writeIndex(dir, await prune(dir, await readIndex(dir)));
|
|
173
|
+
}
|
|
174
|
+
}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -1,2 +1,14 @@
|
|
|
1
1
|
/** Default bind stays loopback because payloads carry absolute paths and names. */
|
|
2
2
|
export const STUDIO_HOSTNAME = "127.0.0.1";
|
|
3
|
+
|
|
4
|
+
/** A folder's entries as an HTML fragment, fetched when the viewer first expands it. */
|
|
5
|
+
export const VAULT_DIR_ROUTE = "/api/vault/dir";
|
|
6
|
+
/** Name-filter matches over the whole listed tree, as an HTML fragment. */
|
|
7
|
+
export const VAULT_FILTER_ROUTE = "/api/vault/filter";
|
|
8
|
+
/** Server-sent listing generations: a newer one marks the displayed tree stale. */
|
|
9
|
+
export const VAULT_CHANGES_ROUTE = "/api/vault/changes";
|
|
10
|
+
|
|
11
|
+
/** `GET` lists a companion's hosted reports; `POST` publishes with a session credential. */
|
|
12
|
+
export const REPORTS_ROUTE = "/api/reports";
|
|
13
|
+
/** A hosted report's HTML is served at this prefix plus its id. */
|
|
14
|
+
export const REPORT_VIEW_PREFIX = "/studio/reports/";
|
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
|
|
3
3
|
import type { StudioInventory, StudioInventoryCompanion } from "./inventory";
|
|
4
4
|
|
|
5
|
-
export type StudioView = "dashboard" | "workflow" | "specs" | "skills" | "vault";
|
|
5
|
+
export type StudioView = "dashboard" | "workflow" | "specs" | "skills" | "vault" | "reports";
|
|
6
6
|
|
|
7
7
|
export const STUDIO_VIEWS: readonly StudioView[] = [
|
|
8
8
|
"dashboard",
|
|
@@ -10,6 +10,7 @@ export const STUDIO_VIEWS: readonly StudioView[] = [
|
|
|
10
10
|
"specs",
|
|
11
11
|
"skills",
|
|
12
12
|
"vault",
|
|
13
|
+
"reports",
|
|
13
14
|
];
|
|
14
15
|
|
|
15
16
|
export const COMPANION_PARAM = "companion";
|