@unoverse-platform/studio 0.1.90 → 0.1.92
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/local/plugin.ts +21 -65
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +153 -0
- package/vendor/base/src/agent-mcp/lean.ts +114 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +138 -0
- package/vendor/base/src/auth/auth.ts +307 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +155 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +659 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +246 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +212 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.d.mts +28 -0
- package/vendor/base/src/lint/nodes/index.mjs +82 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +110 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +156 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +196 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +157 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
- package/local/mcp.ts +0 -265
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where node manifests come from.
|
|
3
|
+
*
|
|
4
|
+
* A SOURCE is deliberately an interface with one method, because the manifest
|
|
5
|
+
* format only pays off if a node can be installed and updated without restarting a
|
|
6
|
+
* universe. Disk is the source today; Postgres is the one that matters, and it must
|
|
7
|
+
* be able to arrive without touching anything downstream of here.
|
|
8
|
+
*
|
|
9
|
+
* Everything past this file works on the composed definition and never learns where
|
|
10
|
+
* it came from.
|
|
11
|
+
*
|
|
12
|
+
* See docs/architecture/DECLARATIVE_NODES.md.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import * as path from "path";
|
|
16
|
+
|
|
17
|
+
/** One node folder's raw files, before composition. */
|
|
18
|
+
export interface RawNode {
|
|
19
|
+
/** Folder name. Should equal the node's `type`. */
|
|
20
|
+
dir: string;
|
|
21
|
+
/** Absolute path, or a synthetic id for non-disk sources. Used for $ref bases and errors. */
|
|
22
|
+
origin: string;
|
|
23
|
+
/**
|
|
24
|
+
* The node's yaml, flat. A section split across a folder appears with its path
|
|
25
|
+
* kept: "api/run.yaml". One level only, since a section has keys, not a tree.
|
|
26
|
+
*/
|
|
27
|
+
files: Record<string, string>;
|
|
28
|
+
/**
|
|
29
|
+
* The content hash recorded when this node was PUBLISHED, if the source has one.
|
|
30
|
+
*
|
|
31
|
+
* Absent for disk, and that is correct rather than a gap: manifests in the monorepo are
|
|
32
|
+
* governed by git, which beats a hash file living beside the thing it protects. The
|
|
33
|
+
* check exists for rows, where there is no commit history and no review (SECURITY.md
|
|
34
|
+
* §"Manifest integrity").
|
|
35
|
+
*/
|
|
36
|
+
hash?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RawPackage {
|
|
40
|
+
/** Package id, e.g. "openai". */
|
|
41
|
+
name: string;
|
|
42
|
+
origin: string;
|
|
43
|
+
/** package.yaml, if present. */
|
|
44
|
+
packageFile?: string;
|
|
45
|
+
/** credentials/<name>.yaml → contents. */
|
|
46
|
+
credentials: Record<string, string>;
|
|
47
|
+
/** shared/<name>.yaml → contents, for $ref resolution. */
|
|
48
|
+
shared: Record<string, string>;
|
|
49
|
+
nodes: RawNode[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ManifestSource {
|
|
53
|
+
/** For logs: "disk", "postgres". */
|
|
54
|
+
readonly name: string;
|
|
55
|
+
listPackages(): Promise<RawPackage[]>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const YAML_RE = /\.ya?ml$/;
|
|
59
|
+
const read = (p: string) => fs.readFileSync(p, "utf-8");
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Manifests from the `items` table: the source that makes installing a node an EFFECT
|
|
63
|
+
* rather than a record.
|
|
64
|
+
*
|
|
65
|
+
* A row stores the raw manifest exactly as authored (its files, plus the package
|
|
66
|
+
* envelope its $refs resolve against), so composition downstream is bit-for-bit the
|
|
67
|
+
* same work it does for disk. That is the point: one composition path means a node
|
|
68
|
+
* installed from a row and the same node read from a folder cannot diverge.
|
|
69
|
+
*
|
|
70
|
+
* Rows arrive grouped by package, because a package's credentials and shared fragments
|
|
71
|
+
* are shared by its nodes and composing one without them would fail on a $ref.
|
|
72
|
+
*/
|
|
73
|
+
export interface ItemRow {
|
|
74
|
+
name: string;
|
|
75
|
+
/**
|
|
76
|
+
* The hash recorded at publish. A COLUMN, not a key inside `definition`: the whole job
|
|
77
|
+
* of this value is to be compared against a hash computed from `definition`, so it must
|
|
78
|
+
* not be part of what is hashed.
|
|
79
|
+
*/
|
|
80
|
+
hash?: string | null;
|
|
81
|
+
definition: {
|
|
82
|
+
package?: { name?: string; packageFile?: string | null; credentials?: Record<string, string>; shared?: Record<string, string> };
|
|
83
|
+
dir?: string;
|
|
84
|
+
files?: Record<string, string>;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function rowsSource(fetchRows: () => Promise<ItemRow[]>): ManifestSource {
|
|
89
|
+
return {
|
|
90
|
+
name: "postgres",
|
|
91
|
+
async listPackages(): Promise<RawPackage[]> {
|
|
92
|
+
const rows = await fetchRows();
|
|
93
|
+
const byPackage = new Map<string, RawPackage>();
|
|
94
|
+
|
|
95
|
+
for (const row of rows) {
|
|
96
|
+
const d = row.definition;
|
|
97
|
+
if (!d?.files?.["node.yaml"]) continue; // not a manifest node row
|
|
98
|
+
const pkgName = d.package?.name ?? "unknown";
|
|
99
|
+
|
|
100
|
+
let pkg = byPackage.get(pkgName);
|
|
101
|
+
if (!pkg) {
|
|
102
|
+
pkg = {
|
|
103
|
+
name: pkgName,
|
|
104
|
+
origin: `postgres:${pkgName}`,
|
|
105
|
+
packageFile: d.package?.packageFile ?? undefined,
|
|
106
|
+
credentials: d.package?.credentials ?? {},
|
|
107
|
+
shared: d.package?.shared ?? {},
|
|
108
|
+
nodes: [],
|
|
109
|
+
};
|
|
110
|
+
byPackage.set(pkgName, pkg);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pkg.nodes.push({
|
|
114
|
+
dir: d.dir ?? row.name,
|
|
115
|
+
origin: `postgres:${pkgName}/${row.name}`,
|
|
116
|
+
files: d.files,
|
|
117
|
+
hash: row.hash ?? undefined,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return [...byPackage.values()];
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Manifests on disk: <home>/<package>/nodes/<Node>/*.yaml
|
|
128
|
+
*
|
|
129
|
+
* A package with no `nodes/` folder is skipped entirely, so a pure-code package
|
|
130
|
+
* costs nothing here. A MIXED package (some manifest nodes, some code) is normal
|
|
131
|
+
* during migration and loads through both paths.
|
|
132
|
+
*/
|
|
133
|
+
export function diskSource(home: string): ManifestSource {
|
|
134
|
+
return {
|
|
135
|
+
name: "disk",
|
|
136
|
+
async listPackages(): Promise<RawPackage[]> {
|
|
137
|
+
return readDiskPackages(home);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The same read, synchronous. Disk settles at once; the source above is async only
|
|
144
|
+
* because the row source beside it must be. The publish collector (items/collect.ts) is
|
|
145
|
+
* synchronous end to end, so it reads here rather than growing a promise it cannot await.
|
|
146
|
+
*/
|
|
147
|
+
export function readDiskPackages(home: string): RawPackage[] {
|
|
148
|
+
if (!fs.existsSync(home)) return [];
|
|
149
|
+
const packages: RawPackage[] = [];
|
|
150
|
+
|
|
151
|
+
for (const entry of fs.readdirSync(home, { withFileTypes: true })) {
|
|
152
|
+
if (!entry.isDirectory() || entry.name.startsWith("_") || entry.name.startsWith(".")) continue;
|
|
153
|
+
const pkgDir = path.join(home, entry.name);
|
|
154
|
+
const nodesDir = path.join(pkgDir, "nodes");
|
|
155
|
+
// A package earns a look if it has manifest nodes OR credential manifests.
|
|
156
|
+
// Credentials are declared per package and are NOT conditional on any node
|
|
157
|
+
// being migrated: skipping on `nodes/` alone would drop a package's
|
|
158
|
+
// credential types the moment its last manifest node was removed.
|
|
159
|
+
if (!fs.existsSync(nodesDir) && !fs.existsSync(path.join(pkgDir, "credentials"))) continue;
|
|
160
|
+
|
|
161
|
+
const pkg: RawPackage = {
|
|
162
|
+
name: entry.name,
|
|
163
|
+
origin: pkgDir,
|
|
164
|
+
packageFile: fs.existsSync(path.join(pkgDir, "package.yaml")) ? read(path.join(pkgDir, "package.yaml")) : undefined,
|
|
165
|
+
credentials: {},
|
|
166
|
+
shared: {},
|
|
167
|
+
nodes: [],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (const [folder, into] of [["credentials", pkg.credentials], ["shared", pkg.shared]] as const) {
|
|
171
|
+
const dir = path.join(pkgDir, folder);
|
|
172
|
+
if (!fs.existsSync(dir)) continue;
|
|
173
|
+
for (const f of fs.readdirSync(dir).filter((f) => YAML_RE.test(f))) into[f] = read(path.join(dir, f));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const nodeEntry of fs.existsSync(nodesDir) ? fs.readdirSync(nodesDir, { withFileTypes: true }) : []) {
|
|
177
|
+
if (!nodeEntry.isDirectory()) continue;
|
|
178
|
+
const nodeDir = path.join(nodesDir, nodeEntry.name);
|
|
179
|
+
const files: Record<string, string> = {};
|
|
180
|
+
for (const f of fs.readdirSync(nodeDir, { withFileTypes: true })) {
|
|
181
|
+
// A section may be ONE file or a FOLDER of one file per key. A big api.yaml
|
|
182
|
+
// is several independent calls stacked in a single file; splitting them is
|
|
183
|
+
// how a person edits one call without reading the other three.
|
|
184
|
+
if (f.isDirectory()) {
|
|
185
|
+
for (const inner of fs.readdirSync(path.join(nodeDir, f.name)).filter((i) => YAML_RE.test(i)))
|
|
186
|
+
files[`${f.name}/${inner}`] = read(path.join(nodeDir, f.name, inner));
|
|
187
|
+
} else if (YAML_RE.test(f.name)) files[f.name] = read(path.join(nodeDir, f.name));
|
|
188
|
+
}
|
|
189
|
+
if (!files["node.yaml"]) continue; // not a manifest node
|
|
190
|
+
pkg.nodes.push({ dir: nodeEntry.name, origin: nodeDir, files });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (pkg.nodes.length || Object.keys(pkg.credentials).length) packages.push(pkg);
|
|
194
|
+
}
|
|
195
|
+
return packages;
|
|
196
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watch node manifests and reload them on change. Development only.
|
|
3
|
+
*
|
|
4
|
+
* A manifest is data, so editing one should behave like editing design/ — save the file,
|
|
5
|
+
* see the change. Needing a restart would make the format feel worse than the code it
|
|
6
|
+
* replaces, when the whole argument for it is that data can move without a deploy.
|
|
7
|
+
*
|
|
8
|
+
* NOT enabled in production: there, manifests arrive through the marketplace or a
|
|
9
|
+
* database row, both of which already call the loader themselves. A filesystem watcher
|
|
10
|
+
* on a server nobody edits by hand is a way to reload half-written files.
|
|
11
|
+
*
|
|
12
|
+
* Uses node:fs.watch, which is native and free. No dependency for a dev nicety.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "fs";
|
|
15
|
+
import { loadManifests } from "./index.js";
|
|
16
|
+
import { NODES_HOME } from "../paths.js";
|
|
17
|
+
import { boot } from "../boot.js";
|
|
18
|
+
|
|
19
|
+
let watcher: fs.FSWatcher | null = null;
|
|
20
|
+
let pending: NodeJS.Timeout | null = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Start watching, unless already watching or in production.
|
|
24
|
+
*
|
|
25
|
+
* `onReloaded` is where the engine's catalog snapshot gets refreshed — without it the
|
|
26
|
+
* server has the new node and Canvas still shows the old catalog, which reads as "the
|
|
27
|
+
* reload did not work".
|
|
28
|
+
*/
|
|
29
|
+
export function watchManifests(onReloaded?: () => Promise<void> | void): void {
|
|
30
|
+
if (watcher || process.env.NODE_ENV === "production") return;
|
|
31
|
+
if (!fs.existsSync(NODES_HOME)) return;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
watcher = fs.watch(NODES_HOME, { recursive: true }, (_event, filename) => {
|
|
35
|
+
if (!filename || !/\.ya?ml$/.test(filename)) return;
|
|
36
|
+
|
|
37
|
+
// Editors write a file in several operations (temp file, rename, truncate), so a
|
|
38
|
+
// single save fires repeatedly. Debounce, or a reload reads a half-written file.
|
|
39
|
+
if (pending) clearTimeout(pending);
|
|
40
|
+
pending = setTimeout(async () => {
|
|
41
|
+
pending = null;
|
|
42
|
+
try {
|
|
43
|
+
const result = await loadManifests();
|
|
44
|
+
console.log(`[unoverse:manifests] reloaded after ${filename}: ${result.nodes} node(s)`);
|
|
45
|
+
for (const e of result.errors) console.warn(` ⚠ ${e}`);
|
|
46
|
+
await onReloaded?.();
|
|
47
|
+
} catch (err: any) {
|
|
48
|
+
// A reload must never take the server down: a half-typed manifest is a
|
|
49
|
+
// normal state while someone is editing one.
|
|
50
|
+
console.warn(`[unoverse:manifests] reload failed: ${err?.message ?? err}`);
|
|
51
|
+
}
|
|
52
|
+
}, 250);
|
|
53
|
+
});
|
|
54
|
+
boot.watching(NODES_HOME);
|
|
55
|
+
} catch (err: any) {
|
|
56
|
+
// recursive:true is unsupported on some platforms; a missing watcher is a lost dev
|
|
57
|
+
// nicety, never a failure to boot.
|
|
58
|
+
console.warn(`[unoverse:manifests] could not watch for changes: ${err?.message ?? err}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function stopWatchingManifests(): void {
|
|
63
|
+
watcher?.close();
|
|
64
|
+
watcher = null;
|
|
65
|
+
if (pending) clearTimeout(pending);
|
|
66
|
+
pending = null;
|
|
67
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UNOVERSE MARKDOWN — a document SHAPED FOR THE RENDERER: areas into tabs, and the text
|
|
3
|
+
* projection for anything wanting words.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `agent-mcp/` because it was never the agent harness's: it is the render half
|
|
6
|
+
* of Unoverse Markdown, and it sat under the harness only because content cards were the
|
|
7
|
+
* first thing to need it. The schema half is beside it now.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mark the first section of each group, so a renderer can print the group label once.
|
|
12
|
+
*
|
|
13
|
+
* A repeated template cannot see the item before it, which is how a hand-written renderer
|
|
14
|
+
* decided this in React. Deriving it into the DATA keeps the rule in one place and gives
|
|
15
|
+
* every surface the same answer.
|
|
16
|
+
*/
|
|
17
|
+
export function withGroupStarts<T>(sections: T): T {
|
|
18
|
+
if (!Array.isArray(sections)) return sections;
|
|
19
|
+
let previous = "";
|
|
20
|
+
return sections.map((s) => {
|
|
21
|
+
const section = (s ?? {}) as Record<string, unknown>;
|
|
22
|
+
const group = typeof section.group === "string" ? section.group : "";
|
|
23
|
+
const groupStart = Boolean(group) && group !== previous;
|
|
24
|
+
if (group) previous = group;
|
|
25
|
+
return { ...section, groupStart };
|
|
26
|
+
}) as unknown as T;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** How many tab slots the Document component authors. Four is a strip a reader takes in at
|
|
30
|
+
* a glance; beyond that the tabs stop helping. Later groups fold into the last tab rather
|
|
31
|
+
* than disappearing. */
|
|
32
|
+
export const MAX_TABS = 4;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Shape a record's sections for the Document component.
|
|
36
|
+
*
|
|
37
|
+
* SDUI switches on STATIC case names, and a group label is whatever the source called it,
|
|
38
|
+
* so tabs are addressed by POSITION: `tab0`…`tab5`, each with its own label. Flat, static,
|
|
39
|
+
* and renderable by a component that cannot know a group's name in advance.
|
|
40
|
+
*
|
|
41
|
+
* Two rules carried over from the renderer this replaces, because both are load-bearing:
|
|
42
|
+
* ungrouped material stays above the tabs (what the offering IS is never hidden behind
|
|
43
|
+
* one), and fine print appears on every tab rather than only the one it happened to land in.
|
|
44
|
+
*/
|
|
45
|
+
export function toDocument(sections: unknown, layout?: string): Record<string, unknown> {
|
|
46
|
+
const list = (Array.isArray(sections) ? sections : []) as Record<string, unknown>[];
|
|
47
|
+
|
|
48
|
+
const groups: string[] = [];
|
|
49
|
+
for (const s of list) {
|
|
50
|
+
const g = typeof s?.group === "string" ? s.group : "";
|
|
51
|
+
if (g && !groups.includes(g)) groups.push(g);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The layout pass decides. Absent a decision, tabs earn their interaction cost only when
|
|
55
|
+
// there is enough material that one page would be a wall.
|
|
56
|
+
const tabbed = groups.length >= 2 && (layout === "tabs" || (!layout && list.length > 4));
|
|
57
|
+
if (!tabbed) {
|
|
58
|
+
// ONE group is not a grouping: a lone label applied to every section says nothing and
|
|
59
|
+
// would print an eyebrow over the whole document. Drop it and read as one page.
|
|
60
|
+
const flat = groups.length < 2 ? list.map((s) => ({ ...s, group: "" })) : list;
|
|
61
|
+
return { tabbed: false, sections: withGroupStarts(flat) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// FINE PRINT, DEDUPED. It repeats into every tab, so two components carrying the same
|
|
65
|
+
// words print that text twice per tab. A source that says "Terms and conditions apply"
|
|
66
|
+
// under each of its benefits is not stating two different things.
|
|
67
|
+
const seenFine = new Set<string>();
|
|
68
|
+
const finePrint = list.filter((s) => {
|
|
69
|
+
if (s.kind !== "finePrint") return false;
|
|
70
|
+
const key = String(s.body ?? "").trim().toLowerCase().replace(/[\s.]+$/, "");
|
|
71
|
+
if (!key || seenFine.has(key)) return key ? false : true;
|
|
72
|
+
seenFine.add(key);
|
|
73
|
+
return true;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// WHAT LEADS IS WHAT COMES FIRST, not everything unlabelled. The rule is "material that
|
|
77
|
+
// introduces the offering is never hidden behind a tab", and introductory material is at
|
|
78
|
+
// the TOP. Leading every unlabelled component pulled a stray from the middle of the
|
|
79
|
+
// document up above the tab strip (observed live: a Protection list the model forgot to
|
|
80
|
+
// label rendered above the tabs, ahead of the opening paragraph).
|
|
81
|
+
const firstGrouped = list.findIndex((s) => typeof s.group === "string" && s.group);
|
|
82
|
+
const leadEnd = firstGrouped === -1 ? list.length : firstGrouped;
|
|
83
|
+
const out: Record<string, unknown> = {
|
|
84
|
+
tabbed: true,
|
|
85
|
+
lead: withGroupStarts(list.slice(0, leadEnd).filter((s) => !s.group && s.kind !== "finePrint")),
|
|
86
|
+
// ALWAYS carried, even tabbed. The component switches on `tabbed` and ignores it, but a
|
|
87
|
+
// host asking "does this record have a body?" has one field to look at either way. A
|
|
88
|
+
// card guarding on `sections` rendered nothing for a tabbed record without it.
|
|
89
|
+
sections: withGroupStarts(list),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const shown = groups.slice(0, MAX_TABS);
|
|
93
|
+
shown.forEach((group, i) => {
|
|
94
|
+
const last = i === MAX_TABS - 1;
|
|
95
|
+
// The final tab absorbs every remaining group, so a fifth group is folded in rather
|
|
96
|
+
// than dropped. Its sections keep their labels, since that tab now holds more than one.
|
|
97
|
+
const mine = last ? groups.slice(i) : [group];
|
|
98
|
+
// An unlabelled component after the lead inherits the area it sits inside, so a
|
|
99
|
+
// component the model forgot to label stays where the reader found it.
|
|
100
|
+
let carried = "";
|
|
101
|
+
const placed = list.map((s, idx) => {
|
|
102
|
+
if (idx < leadEnd) return s;
|
|
103
|
+
const g = typeof s.group === "string" && s.group ? s.group : carried;
|
|
104
|
+
carried = g || carried;
|
|
105
|
+
return { ...s, group: g };
|
|
106
|
+
});
|
|
107
|
+
const own = placed.filter((s) => mine.includes(s.group as string) && s.kind !== "finePrint");
|
|
108
|
+
out[`tab${i}Label`] = group;
|
|
109
|
+
// The tab IS the label for a single group, so its eyebrow would only repeat it. A
|
|
110
|
+
// folded tab keeps them, because it holds several.
|
|
111
|
+
const labelled = last && mine.length > 1 ? withGroupStarts(own) : own.map((s) => ({ ...s, group: "", groupStart: false }));
|
|
112
|
+
out[`tab${i}`] = [...(labelled as Record<string, unknown>[]), ...finePrint];
|
|
113
|
+
});
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A record's sections as plain text, for embeddings and for anything that wants words
|
|
119
|
+
* rather than a document.
|
|
120
|
+
*
|
|
121
|
+
* DERIVED, never authored: the sections are the body, and this is a projection of them.
|
|
122
|
+
* The old order (markdown first, sections filed from it) is what made two representations
|
|
123
|
+
* of the same copy able to disagree.
|
|
124
|
+
*/
|
|
125
|
+
export function sectionsToText(sections: unknown): string {
|
|
126
|
+
if (!Array.isArray(sections)) return "";
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
for (const raw of sections) {
|
|
129
|
+
const s = (raw ?? {}) as Record<string, any>;
|
|
130
|
+
if (s.heading) parts.push(`## ${s.heading}`);
|
|
131
|
+
if (s.body) parts.push(String(s.body));
|
|
132
|
+
for (const f of Array.isArray(s.facts) ? s.facts : [])
|
|
133
|
+
parts.push([f?.label, f?.value, f?.qualifier].filter(Boolean).join(": "));
|
|
134
|
+
for (const i of Array.isArray(s.items) ? s.items : [])
|
|
135
|
+
parts.push([i?.title, i?.body].filter(Boolean).join(" — ").replace(" — ", ": "));
|
|
136
|
+
if (Array.isArray(s.columns) && s.columns.length) parts.push(s.columns.join(" | "));
|
|
137
|
+
for (const row of Array.isArray(s.rows) ? s.rows : [])
|
|
138
|
+
if (Array.isArray(row)) parts.push(row.join(" | "));
|
|
139
|
+
}
|
|
140
|
+
return parts.filter(Boolean).join("\n\n");
|
|
141
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE THE CONTENT LIVES. One root, everything else derived from it.
|
|
3
|
+
*
|
|
4
|
+
* This used to be five environment variables over a repo-root walk that looked for a folder
|
|
5
|
+
* called `apps/unoverse`. That made the package repo-shaped: installed anywhere else, the
|
|
6
|
+
* walk finds nothing and every path silently comes out wrong, which surfaces as "the
|
|
7
|
+
* definition does not load" rather than as a path error.
|
|
8
|
+
*
|
|
9
|
+
* Now there is ONE knob, `UNOVERSE_HOME`, and the rest are folders inside it. A host that
|
|
10
|
+
* wants a different layout calls `setPaths()`; it does not need five more variables.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
import { existsSync, renameSync } from "fs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Find the content home when nobody said.
|
|
19
|
+
*
|
|
20
|
+
* ANCHORED ON A MARKER, never on a depth. Counting `../` from this file's own location is
|
|
21
|
+
* what broke every path in the platform the last time this file moved: DESIGN_HOME landed one
|
|
22
|
+
* directory above the repo and every loader quietly found nothing.
|
|
23
|
+
*/
|
|
24
|
+
function findHome(): string {
|
|
25
|
+
const explicit = process.env.UNOVERSE_HOME;
|
|
26
|
+
if (explicit) return path.resolve(explicit);
|
|
27
|
+
|
|
28
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
for (;;) {
|
|
30
|
+
// The in-repo home. Checked for `nodes` as well as the folder itself, so a stray
|
|
31
|
+
// directory of the same name elsewhere cannot claim it.
|
|
32
|
+
const candidate = path.join(dir, "apps/unoverse");
|
|
33
|
+
if (existsSync(path.join(candidate, "nodes"))) return candidate;
|
|
34
|
+
const parent = path.dirname(dir);
|
|
35
|
+
if (parent === dir) break;
|
|
36
|
+
dir = parent;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Installed outside the monorepo with nothing configured. Returning a guess here is what
|
|
40
|
+
// produced the silent-wrong-path failures, so say so instead: every export below becomes
|
|
41
|
+
// a path under cwd, and anything reading them will fail somewhere obvious.
|
|
42
|
+
console.warn(
|
|
43
|
+
"[unoverse:base] UNOVERSE_HOME is not set and no apps/unoverse was found above this package. " +
|
|
44
|
+
"Falling back to the working directory; set UNOVERSE_HOME to the folder holding nodes/, design/ and prompts/.",
|
|
45
|
+
);
|
|
46
|
+
return process.cwd();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let HOME = findHome();
|
|
50
|
+
|
|
51
|
+
/** Everything the runtime reads off disk, in one place. */
|
|
52
|
+
export interface Paths {
|
|
53
|
+
/** Node manifests and node packages authored for this deployment. */
|
|
54
|
+
nodes: string;
|
|
55
|
+
/** Design data: components, templates, styles. */
|
|
56
|
+
design: string;
|
|
57
|
+
/** Authored agent skills (SKILL.md). */
|
|
58
|
+
skills: string;
|
|
59
|
+
/** Reusable prompt snippets. */
|
|
60
|
+
promptBlocks: string;
|
|
61
|
+
/** Where `npm install` puts legacy code node packages. */
|
|
62
|
+
plugins: string;
|
|
63
|
+
/** LEGACY: the pre-migration node package folders. Usually absent. */
|
|
64
|
+
packages: string;
|
|
65
|
+
/** LEGACY: the pre-migration marketplace folder. Usually absent. */
|
|
66
|
+
marketplace: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The design folder's on-disk name, renamed `rx` → `design` (2026-08-20).
|
|
71
|
+
*
|
|
72
|
+
* MIGRATE-ONCE, not dual-read: a root still holding the legacy `rx/` is renamed to
|
|
73
|
+
* `design/` the first time anything resolves it, and the platform knows ONE name from
|
|
74
|
+
* then on. Resolution doubling as migration is deliberate — every reader passes through
|
|
75
|
+
* here, so no entry point can forget to migrate, and no second code path survives to
|
|
76
|
+
* drift. If the rename is impossible (read-only mount), the warning says exactly what
|
|
77
|
+
* to do and resolution still returns `design/`: the failure is loud and immediate,
|
|
78
|
+
* never a silent read from a folder half the platform no longer knows about.
|
|
79
|
+
*/
|
|
80
|
+
export function designDir(root: string): string {
|
|
81
|
+
const design = path.join(root, "design");
|
|
82
|
+
const legacy = path.join(root, "rx");
|
|
83
|
+
if (!existsSync(design) && existsSync(legacy)) {
|
|
84
|
+
try {
|
|
85
|
+
renameSync(legacy, design);
|
|
86
|
+
console.warn(`[unoverse:base] migrated ${legacy} → ${design} (rx/ is now design/)`);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.warn(
|
|
89
|
+
`[unoverse:base] could not rename ${legacy} → ${design} (${(e as Error).message}). ` +
|
|
90
|
+
`Rename it by hand; the platform reads design/ only.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return design;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function derive(home: string): Paths {
|
|
98
|
+
return {
|
|
99
|
+
nodes: path.join(home, "nodes"),
|
|
100
|
+
design: designDir(home),
|
|
101
|
+
skills: path.join(home, "prompts/skills"),
|
|
102
|
+
promptBlocks: path.join(home, "prompts/blocks"),
|
|
103
|
+
plugins: path.join(home, "plugins"),
|
|
104
|
+
// DELIBERATELY under the home, not at the repo root.
|
|
105
|
+
//
|
|
106
|
+
// `packages` used to mean "node packages" and now means "workspace libraries", and the
|
|
107
|
+
// old default pointed at the new folder. `discoverPlugins()` scans it for anything in
|
|
108
|
+
// the @unoverse-platform scope with an entry point, so it found THIS package and
|
|
109
|
+
// returned ["@unoverse-platform/base"] — the runtime being offered as a node.
|
|
110
|
+
packages: path.join(home, "packages"),
|
|
111
|
+
marketplace: path.join(home, "packages-marketplace"),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let paths: Paths = derive(HOME);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Point the runtime at a different layout. For a host that does not lay content out this
|
|
119
|
+
* way, and the reason there is no environment variable per folder.
|
|
120
|
+
*/
|
|
121
|
+
export function setPaths(next: Partial<Paths> & { home?: string }): void {
|
|
122
|
+
if (next.home) {
|
|
123
|
+
HOME = path.resolve(next.home);
|
|
124
|
+
paths = derive(HOME);
|
|
125
|
+
}
|
|
126
|
+
const { home: _ignored, ...rest } = next;
|
|
127
|
+
paths = { ...paths, ...rest };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The content home in force. */
|
|
131
|
+
export function unoverseHome(): string {
|
|
132
|
+
return HOME;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Everything, as one object. Prefer this over the individual exports below. */
|
|
136
|
+
export function getPaths(): Paths {
|
|
137
|
+
return paths;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Named exports, kept because the tree already reads them. They are RESOLVED ONCE at import,
|
|
141
|
+
// so a host calling setPaths() must do it before anything else imports this module.
|
|
142
|
+
export const NODES_HOME = paths.nodes;
|
|
143
|
+
export const DESIGN_HOME = paths.design;
|
|
144
|
+
export const SKILLS_HOME = paths.skills;
|
|
145
|
+
export const PROMPT_BLOCKS_HOME = paths.promptBlocks;
|
|
146
|
+
/**
|
|
147
|
+
* Where INSTALLED items are written so the loaders can read them (items/hydrate.ts).
|
|
148
|
+
*
|
|
149
|
+
* A SEPARATE ROOT, never inside the design tree. In the monorepo `DESIGN_HOME` is somebody's authoring
|
|
150
|
+
* tree, and hydrating a database into it would overwrite work in progress. Keeping it
|
|
151
|
+
* apart is also what makes the precedence rule enforceable: the on-disk tiers are searched
|
|
152
|
+
* first and an installed row can only ever fill a gap, never replace what the platform
|
|
153
|
+
* ships.
|
|
154
|
+
*
|
|
155
|
+
* Rebuilt whole on every hydrate, so nothing here is worth backing up and editing it by
|
|
156
|
+
* hand achieves nothing.
|
|
157
|
+
*/
|
|
158
|
+
export const INSTALLED_HOME = process.env.UNOVERSE_INSTALLED_HOME?.trim() || path.join(HOME, ".installed");
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `UNOVERSE_DATABASE_ONLY=1` — behave like a deployed universe on a developer's machine.
|
|
162
|
+
*
|
|
163
|
+
* A server holds no design tree, no prompts and no node manifests, so everything it serves comes
|
|
164
|
+
* from its database. In the monorepo those folders are full, they are searched first, and
|
|
165
|
+
* they win — which is correct for authoring and means the production path is never
|
|
166
|
+
* exercised until it is deployed. This turns the on-disk tiers off so the database is the
|
|
167
|
+
* only source, exactly as it is in production.
|
|
168
|
+
*
|
|
169
|
+
* A TEST SWITCH, NOT A MODE. It changes nothing about how anything is stored or resolved,
|
|
170
|
+
* only which tiers are consulted, so a bug found with it on is a real bug rather than an
|
|
171
|
+
* artefact of a second code path. It is read from the environment and never written to,
|
|
172
|
+
* and a deployed universe does not set it: there, the folders are empty anyway.
|
|
173
|
+
*/
|
|
174
|
+
export function databaseOnly(): boolean {
|
|
175
|
+
const v = process.env.UNOVERSE_DATABASE_ONLY?.trim().toLowerCase();
|
|
176
|
+
return v === "1" || v === "true" || v === "yes";
|
|
177
|
+
}
|
|
178
|
+
export const PLUGINS_DIR = paths.plugins;
|
|
179
|
+
export const PACKAGES_PATH = paths.packages;
|
|
180
|
+
export const MARKETPLACE_PATH = paths.marketplace;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distributed Audio Manager
|
|
3
|
+
* Provides the audio WebSocket manager interface voice plugins (aws-nova /
|
|
4
|
+
* openai-realtime) use. Live audio now streams DIRECTLY from this process to the
|
|
5
|
+
* channel SDK over the in-process `/ws/gravity` WS (UNOVERSE_MCP_APP_PROTOCOL
|
|
6
|
+
* §5a-refined) — NOT relayed through the legacy gateway. Component publishing
|
|
7
|
+
* (publishToClient / SERVICE_PUBLISH) still goes to the gateway and is untouched.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { clientTransport } from "./clientTransport.js";
|
|
11
|
+
|
|
12
|
+
export interface AudioWebSocketManager {
|
|
13
|
+
setAudioDataHandler(handler: (conversationId: string, audioData: ArrayBuffer, metadata?: any) => Promise<void>): void;
|
|
14
|
+
setControlMessageHandler(handler: (conversationId: string, message: any) => Promise<void>): void;
|
|
15
|
+
startAudioSession(conversationId: string, novaSessionId?: string): void;
|
|
16
|
+
endAudioSession(conversationId: string): void;
|
|
17
|
+
isConnected(conversationId: string): boolean;
|
|
18
|
+
sendAudio(conversationId: string, audioData: Buffer | ArrayBuffer): boolean;
|
|
19
|
+
sendControl(conversationId: string, message: Record<string, any>): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class DistributedAudioManager implements AudioWebSocketManager {
|
|
23
|
+
private audioSessions: Map<string, { novaSessionId?: string; active: boolean }> = new Map();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set audio data handler (called by WebSocketAudioSubscriber)
|
|
27
|
+
*/
|
|
28
|
+
setAudioDataHandler(
|
|
29
|
+
handler: (conversationId: string, audioData: ArrayBuffer, metadata?: any) => Promise<void>
|
|
30
|
+
): void {
|
|
31
|
+
clientTransport().setAudioDataHandler(async (conversationId, audioData) => {
|
|
32
|
+
const session = this.audioSessions.get(conversationId);
|
|
33
|
+
if (session?.active) {
|
|
34
|
+
await handler(conversationId, audioData, { novaSessionId: session.novaSessionId });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
// Wiring, not news.
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Set control message handler (called by WebSocketAudioSubscriber)
|
|
42
|
+
*/
|
|
43
|
+
setControlMessageHandler(handler: (conversationId: string, message: any) => Promise<void>): void {
|
|
44
|
+
clientTransport().setAudioControlHandler(handler);
|
|
45
|
+
// Wiring, not news.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Start audio session
|
|
50
|
+
*/
|
|
51
|
+
startAudioSession(conversationId: string, novaSessionId?: string): void {
|
|
52
|
+
this.audioSessions.set(conversationId, { novaSessionId, active: true });
|
|
53
|
+
console.log(`[DistributedAudioManager] Audio session started: ${conversationId}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* End audio session
|
|
58
|
+
*/
|
|
59
|
+
endAudioSession(conversationId: string): void {
|
|
60
|
+
const session = this.audioSessions.get(conversationId);
|
|
61
|
+
if (session) {
|
|
62
|
+
session.active = false;
|
|
63
|
+
}
|
|
64
|
+
console.log(`[DistributedAudioManager] Audio session ended: ${conversationId}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check if connected (always true if WebSocket is connected)
|
|
69
|
+
*/
|
|
70
|
+
isConnected(conversationId: string): boolean {
|
|
71
|
+
return this.audioSessions.has(conversationId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Send audio to client via server
|
|
76
|
+
*/
|
|
77
|
+
sendAudio(conversationId: string, audioData: Buffer | ArrayBuffer): boolean {
|
|
78
|
+
return clientTransport().sendAudioToClient(conversationId, audioData);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Send control/state message to client via server
|
|
83
|
+
*/
|
|
84
|
+
sendControl(conversationId: string, message: Record<string, any>): boolean {
|
|
85
|
+
if (message.type === "AUDIO_STATE" || message.state) {
|
|
86
|
+
return clientTransport().sendAudioStateToClient(conversationId, message.state || message.type, message.metadata);
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Singleton instance
|
|
93
|
+
let distributedAudioManager: DistributedAudioManager | null = null;
|
|
94
|
+
|
|
95
|
+
export function getDistributedAudioManager(): AudioWebSocketManager {
|
|
96
|
+
if (!distributedAudioManager) {
|
|
97
|
+
distributedAudioManager = new DistributedAudioManager();
|
|
98
|
+
}
|
|
99
|
+
return distributedAudioManager;
|
|
100
|
+
}
|