@unoverse-platform/studio 0.1.90 → 0.1.91
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 +20 -12
- 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 +140 -0
- package/vendor/base/src/agent-mcp/lean.ts +111 -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 +172 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +130 -0
- package/vendor/base/src/auth/auth.ts +278 -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 +128 -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 +643 -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 +260 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +199 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +198 -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.mjs +64 -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 +107 -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 +125 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +150 -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 +542 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +187 -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 +154 -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 +438 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRANSCRIBED VERBATIM from the retired smart-document package (service/sectionizer.ts),
|
|
3
|
+
* import paths aside. The parsing rules are load-bearing: a doc stored under the old node
|
|
4
|
+
* must parse identically under this one, or every section hash changes and every agent's
|
|
5
|
+
* next edit fails STALE_SECTION at once.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, randomBytes } from "crypto";
|
|
8
|
+
import type { Section, SectionLevel, Doc } from "./types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse raw markdown into a flat section list.
|
|
12
|
+
* Only H1/H2 (configurable) become sections; H3+ stays in body.
|
|
13
|
+
* ATX headings only (# / ##). Setext (=== / ---) and fenced-code-lines-that-look-like-headings
|
|
14
|
+
* are intentionally ignored for simplicity.
|
|
15
|
+
*/
|
|
16
|
+
export function parseMarkdown(source: string, sectionizeAt: SectionLevel = 2): Section[] {
|
|
17
|
+
const lines = source.split("\n");
|
|
18
|
+
const sections: Section[] = [];
|
|
19
|
+
let current: { level: SectionLevel; heading: string; body: string[] } | null = null;
|
|
20
|
+
let inFence = false;
|
|
21
|
+
let fenceMarker = "";
|
|
22
|
+
|
|
23
|
+
const flush = () => {
|
|
24
|
+
if (current === null) return;
|
|
25
|
+
// Strip trailing empty lines from body
|
|
26
|
+
const body = current.body.join("\n").replace(/\s+$/g, "");
|
|
27
|
+
sections.push(makeSection(current.level, current.heading, body, null, sections.length));
|
|
28
|
+
current = null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
33
|
+
if (fenceMatch) {
|
|
34
|
+
if (!inFence) {
|
|
35
|
+
inFence = true;
|
|
36
|
+
fenceMarker = fenceMatch[1];
|
|
37
|
+
} else if (line.startsWith(fenceMarker)) {
|
|
38
|
+
inFence = false;
|
|
39
|
+
fenceMarker = "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!inFence) {
|
|
44
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
45
|
+
if (headingMatch) {
|
|
46
|
+
const level = headingMatch[1].length;
|
|
47
|
+
const heading = headingMatch[2].trim();
|
|
48
|
+
if (level <= sectionizeAt) {
|
|
49
|
+
flush();
|
|
50
|
+
current = { level: level as SectionLevel, heading, body: [] };
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (current === null) {
|
|
57
|
+
// Content before the first qualifying heading — if non-empty, stash it.
|
|
58
|
+
if (line.trim().length > 0) {
|
|
59
|
+
current = { level: 1, heading: "", body: [line] };
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
current.body.push(line);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
flush();
|
|
67
|
+
|
|
68
|
+
// Build parentId relationships: H2s nest under the most recent H1.
|
|
69
|
+
let lastH1Id: string | null = null;
|
|
70
|
+
for (const s of sections) {
|
|
71
|
+
if (s.level === 1) {
|
|
72
|
+
lastH1Id = s.id;
|
|
73
|
+
s.parentId = null;
|
|
74
|
+
} else if (s.level === 2) {
|
|
75
|
+
s.parentId = lastH1Id;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return sections;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Serialize a section list back to markdown.
|
|
84
|
+
* Order is the section array order (which is kept in render order by the caller).
|
|
85
|
+
*/
|
|
86
|
+
export function renderMarkdown(sections: Section[]): string {
|
|
87
|
+
const parts: string[] = [];
|
|
88
|
+
for (const s of sections) {
|
|
89
|
+
if (s.heading.length > 0) {
|
|
90
|
+
parts.push(`${"#".repeat(s.level)} ${s.heading}`);
|
|
91
|
+
}
|
|
92
|
+
if (s.body.length > 0) {
|
|
93
|
+
parts.push(s.body);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return parts.join("\n\n") + "\n";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reject bodies that contain H1/H2 headings (would corrupt the outline).
|
|
101
|
+
* Returns the offending heading text, or null if clean.
|
|
102
|
+
*/
|
|
103
|
+
export function findDisallowedHeading(body: string, sectionizeAt: SectionLevel = 2): string | null {
|
|
104
|
+
const lines = body.split("\n");
|
|
105
|
+
let inFence = false;
|
|
106
|
+
let fenceMarker = "";
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
109
|
+
if (fenceMatch) {
|
|
110
|
+
if (!inFence) {
|
|
111
|
+
inFence = true;
|
|
112
|
+
fenceMarker = fenceMatch[1];
|
|
113
|
+
} else if (line.startsWith(fenceMarker)) {
|
|
114
|
+
inFence = false;
|
|
115
|
+
fenceMarker = "";
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (inFence) continue;
|
|
120
|
+
const m = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
|
|
121
|
+
if (m && m[1].length <= sectionizeAt) {
|
|
122
|
+
return line;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function makeSection(
|
|
129
|
+
level: SectionLevel,
|
|
130
|
+
heading: string,
|
|
131
|
+
body: string,
|
|
132
|
+
parentId: string | null,
|
|
133
|
+
order: number,
|
|
134
|
+
): Section {
|
|
135
|
+
return {
|
|
136
|
+
id: generateId(),
|
|
137
|
+
level,
|
|
138
|
+
heading,
|
|
139
|
+
body,
|
|
140
|
+
parentId,
|
|
141
|
+
order,
|
|
142
|
+
hash: hashSection(heading, body),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function generateId(): string {
|
|
147
|
+
return "sec_" + randomBytes(6).toString("base64url").replace(/[^a-z0-9]/gi, "").slice(0, 8).toLowerCase();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function hashSection(heading: string, body: string): string {
|
|
151
|
+
return createHash("sha256").update(heading + "\n\n" + body).digest("hex").slice(0, 12);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Reconcile section order / parentId consistency after a mutation.
|
|
156
|
+
* H2 sections automatically re-parent to the most recent preceding H1.
|
|
157
|
+
* Rewrites `order` so siblings have contiguous ascending indices.
|
|
158
|
+
*/
|
|
159
|
+
export function reconcile(doc: Doc): void {
|
|
160
|
+
// 1. Re-parent H2s to most recent H1 based on array order.
|
|
161
|
+
let lastH1Id: string | null = null;
|
|
162
|
+
for (const s of doc.sections) {
|
|
163
|
+
if (s.level === 1) {
|
|
164
|
+
lastH1Id = s.id;
|
|
165
|
+
s.parentId = null;
|
|
166
|
+
} else if (s.level === 2) {
|
|
167
|
+
s.parentId = lastH1Id;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2. Rewrite sibling order indices.
|
|
172
|
+
const groups = new Map<string | null, Section[]>();
|
|
173
|
+
for (const s of doc.sections) {
|
|
174
|
+
const key = s.parentId;
|
|
175
|
+
const arr = groups.get(key) ?? [];
|
|
176
|
+
arr.push(s);
|
|
177
|
+
groups.set(key, arr);
|
|
178
|
+
}
|
|
179
|
+
for (const arr of groups.values()) {
|
|
180
|
+
arr.forEach((s, i) => {
|
|
181
|
+
s.order = i;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DOC'S HOME IN REDIS, transcribed from the retired smart-document package
|
|
3
|
+
* (service/markdownStore.ts) with ONE change: functions take the RAW REDIS CLIENT instead
|
|
4
|
+
* of the plugin `api`, because this now lives in the manifest runtime where the executor
|
|
5
|
+
* already holds the client (the same inversion `makeStateStore` uses).
|
|
6
|
+
*
|
|
7
|
+
* WATCH/MULTI optimistic locking is the reason this is an executor capability and not a
|
|
8
|
+
* sequence of `state` calls: read-check-write must be one transaction, retried on conflict,
|
|
9
|
+
* and no manifest call list can express "and if someone wrote in between, start over".
|
|
10
|
+
*
|
|
11
|
+
* KEYS ARE DELIBERATELY NOT NAMESPACED (`md:<user>:<workflow>:<conversation>:<node>`),
|
|
12
|
+
* exactly as the retired node wrote them — a live doc mid-conversation must stay readable
|
|
13
|
+
* across this migration, and hashing or prefixing the key would orphan every one of them.
|
|
14
|
+
*/
|
|
15
|
+
import type { Doc, Section, SectionLevel } from "./types.js";
|
|
16
|
+
import { parseMarkdown, reconcile } from "./sectionizer.js";
|
|
17
|
+
|
|
18
|
+
const TTL_SECONDS = 60 * 60 * 6;
|
|
19
|
+
const MAX_RETRIES = 5;
|
|
20
|
+
|
|
21
|
+
export function keyFor(
|
|
22
|
+
userId: string,
|
|
23
|
+
workflowId: string,
|
|
24
|
+
conversationId: string,
|
|
25
|
+
nodeId: string,
|
|
26
|
+
): string {
|
|
27
|
+
return `md:${userId}:${workflowId}:${conversationId}:${nodeId}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the doc. Lazy-migrates the legacy `{ content, version, updatedAt }` shape to the
|
|
32
|
+
* `{ sections, version, updatedAt }` shape on first access.
|
|
33
|
+
*/
|
|
34
|
+
export async function getDoc(
|
|
35
|
+
redis: any,
|
|
36
|
+
key: string,
|
|
37
|
+
sectionizeAt: SectionLevel = 2,
|
|
38
|
+
): Promise<Doc | null> {
|
|
39
|
+
const raw = await redis.get(key);
|
|
40
|
+
if (!raw) return null;
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(raw);
|
|
43
|
+
if (Array.isArray(parsed?.sections)) {
|
|
44
|
+
return parsed as Doc;
|
|
45
|
+
}
|
|
46
|
+
if (typeof parsed?.content === "string") {
|
|
47
|
+
const migrated = migrateLegacy(parsed.content, parsed.version ?? 1, sectionizeAt);
|
|
48
|
+
await redis.set(key, JSON.stringify(migrated), "EX", TTL_SECONDS);
|
|
49
|
+
return migrated;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function migrateLegacy(content: string, version: number, sectionizeAt: SectionLevel): Doc {
|
|
58
|
+
const sections = parseMarkdown(content, sectionizeAt);
|
|
59
|
+
const doc: Doc = {
|
|
60
|
+
sections,
|
|
61
|
+
version: version + 1,
|
|
62
|
+
updatedAt: new Date().toISOString(),
|
|
63
|
+
};
|
|
64
|
+
reconcile(doc);
|
|
65
|
+
return doc;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Initialise the doc from `initialMarkdown` if it doesn't exist. No-op when present. */
|
|
69
|
+
export async function initDoc(
|
|
70
|
+
redis: any,
|
|
71
|
+
key: string,
|
|
72
|
+
initialMarkdown: string,
|
|
73
|
+
sectionizeAt: SectionLevel = 2,
|
|
74
|
+
): Promise<Doc> {
|
|
75
|
+
const existing = await getDoc(redis, key, sectionizeAt);
|
|
76
|
+
if (existing) return existing;
|
|
77
|
+
const sections = parseMarkdown(initialMarkdown, sectionizeAt);
|
|
78
|
+
const doc: Doc = {
|
|
79
|
+
sections,
|
|
80
|
+
version: 1,
|
|
81
|
+
updatedAt: new Date().toISOString(),
|
|
82
|
+
};
|
|
83
|
+
reconcile(doc);
|
|
84
|
+
await redis.set(key, JSON.stringify(doc), "EX", TTL_SECONDS);
|
|
85
|
+
return doc;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Force a fresh doc from initialMarkdown, discarding existing state. Fresh IDs. */
|
|
89
|
+
export async function resetDoc(
|
|
90
|
+
redis: any,
|
|
91
|
+
key: string,
|
|
92
|
+
initialMarkdown: string,
|
|
93
|
+
sectionizeAt: SectionLevel = 2,
|
|
94
|
+
): Promise<Doc> {
|
|
95
|
+
const sections = parseMarkdown(initialMarkdown, sectionizeAt);
|
|
96
|
+
const doc: Doc = {
|
|
97
|
+
sections,
|
|
98
|
+
version: 1,
|
|
99
|
+
updatedAt: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
reconcile(doc);
|
|
102
|
+
await redis.set(key, JSON.stringify(doc), "EX", TTL_SECONDS);
|
|
103
|
+
return doc;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Optimistic-locking mutation with Redis WATCH/MULTI. `mutate` receives the current doc and
|
|
108
|
+
* may either mutate it in place + return ok, or return an error object. Retries up to
|
|
109
|
+
* MAX_RETRIES on WATCH conflict.
|
|
110
|
+
*/
|
|
111
|
+
export async function mutateDoc(
|
|
112
|
+
redis: any,
|
|
113
|
+
key: string,
|
|
114
|
+
mutate: (doc: Doc) =>
|
|
115
|
+
| { ok: true }
|
|
116
|
+
| { ok: false; error: string; extra?: Record<string, any> },
|
|
117
|
+
): Promise<
|
|
118
|
+
| { ok: true; doc: Doc }
|
|
119
|
+
| { ok: false; error: string; extra?: Record<string, any> }
|
|
120
|
+
> {
|
|
121
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
122
|
+
await redis.watch(key);
|
|
123
|
+
const raw = await redis.get(key);
|
|
124
|
+
if (!raw) {
|
|
125
|
+
await redis.unwatch();
|
|
126
|
+
return { ok: false, error: "NOT_INITIALISED" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let doc: Doc;
|
|
130
|
+
try {
|
|
131
|
+
const parsed = JSON.parse(raw);
|
|
132
|
+
if (!Array.isArray(parsed?.sections)) {
|
|
133
|
+
await redis.unwatch();
|
|
134
|
+
return { ok: false, error: "NOT_INITIALISED" };
|
|
135
|
+
}
|
|
136
|
+
doc = parsed as Doc;
|
|
137
|
+
} catch {
|
|
138
|
+
await redis.unwatch();
|
|
139
|
+
return { ok: false, error: "NOT_INITIALISED" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const result = mutate(doc);
|
|
143
|
+
if (!result.ok) {
|
|
144
|
+
await redis.unwatch();
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
doc.version = doc.version + 1;
|
|
149
|
+
doc.updatedAt = new Date().toISOString();
|
|
150
|
+
reconcile(doc);
|
|
151
|
+
|
|
152
|
+
const execResult = await redis
|
|
153
|
+
.multi()
|
|
154
|
+
.set(key, JSON.stringify(doc), "EX", TTL_SECONDS)
|
|
155
|
+
.exec();
|
|
156
|
+
|
|
157
|
+
if (execResult !== null) {
|
|
158
|
+
return { ok: true, doc };
|
|
159
|
+
}
|
|
160
|
+
// else retry
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { ok: false, error: "CONCURRENT_UPDATE" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function findSection(doc: Doc, id: string): Section | undefined {
|
|
167
|
+
return doc.sections.find((s) => s.id === id);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function findSectionIndex(doc: Doc, id: string): number {
|
|
171
|
+
return doc.sections.findIndex((s) => s.id === id);
|
|
172
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DOC STORE'S SHAPES, transcribed from the retired smart-document package
|
|
3
|
+
* (util/types.ts). The design rationale lives in _legacy/nodes/smart-document/CLAUDE.md and
|
|
4
|
+
* is worth reading whole: every field here exists to defeat one of the two failure modes of
|
|
5
|
+
* LLM-edited long documents — hallucinated addressing and stale addressing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type SectionLevel = 1 | 2;
|
|
9
|
+
|
|
10
|
+
export interface Section {
|
|
11
|
+
/** Random, server-generated, never positional and never reused. */
|
|
12
|
+
id: string;
|
|
13
|
+
level: SectionLevel;
|
|
14
|
+
/** No leading #. */
|
|
15
|
+
heading: string;
|
|
16
|
+
/** Raw markdown. May contain H3+ freely; must never contain a heading at or above the sectionize level. */
|
|
17
|
+
body: string;
|
|
18
|
+
parentId: string | null;
|
|
19
|
+
/** Sibling position. Server-managed, never an agent input. */
|
|
20
|
+
order: number;
|
|
21
|
+
/** sha256 of heading + "\n\n" + body, first 12 hex chars — the staleness check on every edit. */
|
|
22
|
+
hash: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Doc {
|
|
26
|
+
sections: Section[];
|
|
27
|
+
/** Bumped on every mutation; agents detect drift by comparing across calls. */
|
|
28
|
+
version: number;
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ErrorCode =
|
|
33
|
+
| "STALE_SECTION"
|
|
34
|
+
| "STALE_DOC"
|
|
35
|
+
| "CONCURRENT_UPDATE"
|
|
36
|
+
| "NOT_FOUND"
|
|
37
|
+
| "NOT_UNIQUE"
|
|
38
|
+
| "INVALID_STRUCTURE"
|
|
39
|
+
| "INVALID_PLACEMENT"
|
|
40
|
+
| "INVALID_PARAMS"
|
|
41
|
+
| "NOT_INITIALISED"
|
|
42
|
+
| "UNKNOWN_METHOD";
|
|
43
|
+
|
|
44
|
+
export interface ServiceError {
|
|
45
|
+
ok: false;
|
|
46
|
+
error: ErrorCode;
|
|
47
|
+
hint?: string;
|
|
48
|
+
currentHash?: string;
|
|
49
|
+
currentBody?: string;
|
|
50
|
+
currentVersion?: number;
|
|
51
|
+
matches?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ServiceSuccess {
|
|
55
|
+
ok: true;
|
|
56
|
+
version: number;
|
|
57
|
+
[key: string]: any;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ServiceResult = ServiceSuccess | ServiceError;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE AUDIO LANE — the platform's socket to the browser, and everything about bytes.
|
|
3
|
+
*
|
|
4
|
+
* Split from `duplex.ts` because there are TWO SOCKETS in a voice call and conflating them is the
|
|
5
|
+
* mistake this whole area invites:
|
|
6
|
+
*
|
|
7
|
+
* duplex.ts the VENDOR socket. The conversation with OpenAI or xAI: handshake, inbound
|
|
8
|
+
* event routing, tool calls, teardown. Described by `api/run.yaml`.
|
|
9
|
+
* this file the AUDIO LANE. The platform's own socket to the browser, bound by
|
|
10
|
+
* `api/audio.yaml`, and it exists for ONE reason: MCP cannot carry binary audio.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is COMPUTATION OVER BYTES OR TIME, which is why it is executor code rather than
|
|
13
|
+
* anything a manifest could say: resampling is interpolation across samples, coalescing is a byte
|
|
14
|
+
* budget and a timer, barge-in is discarding a buffer rather than flushing it. All identical for
|
|
15
|
+
* every vendor, which is the test for belonging here (DECLARATIVE_NODES.md §2).
|
|
16
|
+
*
|
|
17
|
+
* These are also the parts that FAIL SILENTLY, so they are unit-tested in
|
|
18
|
+
* `tests/manifest-duplex.test.ts`: a wrong resample rate is not an error anywhere, the vendor just
|
|
19
|
+
* transcribes at the wrong speed and it reads as poor accuracy.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The platform's audio lane, INJECTED so this file touches no platform global — the same
|
|
23
|
+
* arrangement `state.ts` uses for Redis. `null` when the platform has no lane, which is not an
|
|
24
|
+
* error: a voice node with nowhere to send audio still holds a valid conversation, and the
|
|
25
|
+
* transcripts still reach the workflow.
|
|
26
|
+
*/
|
|
27
|
+
export interface AudioLane {
|
|
28
|
+
sendAudio(conversationId: string, bytes: Buffer): void;
|
|
29
|
+
sendControl(conversationId: string, message: unknown): void;
|
|
30
|
+
setAudioDataHandler?(fn: (sessionId: string, data: ArrayBuffer) => void | Promise<void>): void;
|
|
31
|
+
setControlMessageHandler?(fn: (sessionId: string, message: any) => void | Promise<void>): void;
|
|
32
|
+
startAudioSession?(sessionId: string, conversationId: string): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
/** Bytes per sample. PCM16 throughout, which is what every vendor here speaks. */
|
|
37
|
+
const BYTES_PER_SAMPLE = 2;
|
|
38
|
+
const DEFAULT_COALESCE_BYTES = 32768;
|
|
39
|
+
const DEFAULT_COALESCE_MS = 50;
|
|
40
|
+
/** What the browser captures at. Not configurable, because the capture side is ours. */
|
|
41
|
+
export const CLIENT_RATE = 16000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resample PCM16 by linear interpolation.
|
|
45
|
+
*
|
|
46
|
+
* Interpolating rather than dropping or repeating samples: nearest-neighbour at these ratios
|
|
47
|
+
* is audibly grainy, and speech recognition is measurably worse on it. Transcribed from the
|
|
48
|
+
* retired node unchanged, because it was already correct and a rewrite here would be a
|
|
49
|
+
* behaviour change disguised as a refactor.
|
|
50
|
+
*/
|
|
51
|
+
export function resamplePcm16(input: Buffer, fromRate: number, toRate: number): Buffer {
|
|
52
|
+
if (fromRate === toRate) return input;
|
|
53
|
+
const srcSamples = Math.floor(input.length / BYTES_PER_SAMPLE);
|
|
54
|
+
if (srcSamples === 0) return input;
|
|
55
|
+
|
|
56
|
+
const dstSamples = Math.round((srcSamples * toRate) / fromRate);
|
|
57
|
+
const output = Buffer.alloc(dstSamples * BYTES_PER_SAMPLE);
|
|
58
|
+
const ratio = srcSamples / dstSamples;
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < dstSamples; i++) {
|
|
61
|
+
const srcPos = i * ratio;
|
|
62
|
+
const srcIndex = Math.floor(srcPos);
|
|
63
|
+
const frac = srcPos - srcIndex;
|
|
64
|
+
const s0 = input.readInt16LE(srcIndex * BYTES_PER_SAMPLE);
|
|
65
|
+
const s1 = srcIndex + 1 < srcSamples ? input.readInt16LE((srcIndex + 1) * BYTES_PER_SAMPLE) : s0;
|
|
66
|
+
const sample = Math.round(s0 + frac * (s1 - s0));
|
|
67
|
+
output.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i * BYTES_PER_SAMPLE);
|
|
68
|
+
}
|
|
69
|
+
return output;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Coalesce vendor audio deltas into fewer, larger frames.
|
|
74
|
+
*
|
|
75
|
+
* `discard` is not `flush` and the difference is the whole point: on barge-in the buffered
|
|
76
|
+
* audio must be thrown away, because the user is already talking over the sentence it belongs
|
|
77
|
+
* to. Transcribed from the retired WebSocketAudioPublisher.
|
|
78
|
+
*/
|
|
79
|
+
export function makeAudioBuffer(
|
|
80
|
+
lane: AudioLane,
|
|
81
|
+
conversationId: string,
|
|
82
|
+
targetBytes = DEFAULT_COALESCE_BYTES,
|
|
83
|
+
maxDelayMs = DEFAULT_COALESCE_MS,
|
|
84
|
+
) {
|
|
85
|
+
let chunks: Buffer[] = [];
|
|
86
|
+
let total = 0;
|
|
87
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
88
|
+
|
|
89
|
+
const clear = () => {
|
|
90
|
+
if (timer) clearTimeout(timer);
|
|
91
|
+
timer = undefined;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* SAMPLE-ALIGNED, and it must be. PCM16 is TWO BYTES PER SAMPLE, and the client decodes a frame
|
|
96
|
+
* with `new Int16Array(buffer)`, which throws outright on an odd byte length:
|
|
97
|
+
*
|
|
98
|
+
* RangeError: byte length of Int16Array should be a multiple of 2
|
|
99
|
+
*
|
|
100
|
+
* The vendor's audio deltas are chunks of a byte stream and are NOT guaranteed to end on a sample
|
|
101
|
+
* boundary, so concatenating them freely produces odd-length frames. Any trailing odd byte is
|
|
102
|
+
* therefore held back and becomes the first byte of the next frame — which is also the only
|
|
103
|
+
* correct thing to do with it, since it is half of a real sample whose other half has not arrived.
|
|
104
|
+
* Dropping it instead would inject a click and shift the phase of everything after it.
|
|
105
|
+
*/
|
|
106
|
+
const flush = () => {
|
|
107
|
+
clear();
|
|
108
|
+
if (!chunks.length) return;
|
|
109
|
+
const combined = Buffer.concat(chunks);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* SEND A WHOLE BUFFER, NEVER A VIEW. This is what the retired node did and the drift from it
|
|
113
|
+
* was audible: nothing but a light crackle.
|
|
114
|
+
*
|
|
115
|
+
* `Buffer.concat` can return a POOLED buffer, and `subarray` returns a view whose byteLength is
|
|
116
|
+
* smaller than the ArrayBuffer behind it. A transport that takes the underlying ArrayBuffer
|
|
117
|
+
* rather than honouring the view's offset and length then puts whatever surrounds the audio on
|
|
118
|
+
* the wire, and the client decodes that as sound. The retired publisher passed
|
|
119
|
+
* `Buffer.concat(chunks)` straight through, so it never had a view to mishandle.
|
|
120
|
+
*
|
|
121
|
+
* The even case is therefore byte-for-byte what the legacy sent. Only an odd trailing byte
|
|
122
|
+
* needs slicing, and that path COPIES so the frame is still a standalone, exactly-sized buffer.
|
|
123
|
+
*/
|
|
124
|
+
if (combined.length % 2 === 0) {
|
|
125
|
+
chunks = [];
|
|
126
|
+
total = 0;
|
|
127
|
+
lane.sendAudio(conversationId, combined);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* An odd length means the vendor's delta boundary split a sample. PCM16 is two bytes per sample
|
|
133
|
+
* and the client decodes with `new Int16Array(buffer)`, which throws on an odd length, so the
|
|
134
|
+
* trailing byte is held back to become the first byte of the next frame — it is half of a real
|
|
135
|
+
* sample whose other half has not arrived. Dropping it would click and shift the phase of
|
|
136
|
+
* everything after it.
|
|
137
|
+
*/
|
|
138
|
+
const aligned = combined.length - 1;
|
|
139
|
+
if (aligned === 0) {
|
|
140
|
+
chunks = [combined];
|
|
141
|
+
total = combined.length;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
chunks = [Buffer.from(combined.subarray(aligned))];
|
|
145
|
+
total = 1;
|
|
146
|
+
lane.sendAudio(conversationId, Buffer.from(combined.subarray(0, aligned)));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
add(chunk: Buffer) {
|
|
151
|
+
chunks.push(chunk);
|
|
152
|
+
total += chunk.length;
|
|
153
|
+
clear();
|
|
154
|
+
if (total >= targetBytes) flush();
|
|
155
|
+
else timer = setTimeout(flush, maxDelayMs);
|
|
156
|
+
},
|
|
157
|
+
flush,
|
|
158
|
+
/** Barge-in. Drop everything held, send nothing. */
|
|
159
|
+
discard() {
|
|
160
|
+
clear();
|
|
161
|
+
chunks = [];
|
|
162
|
+
total = 0;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|