dsh-generative-ui 0.0.0 → 0.0.2
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/LICENSE +21 -0
- package/README.md +90 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +18568 -0
- package/lib/client.js.map +62 -0
- package/lib/index.js +1597 -0
- package/lib/types/client/canvas/CanvasLauncher.d.ts +6 -0
- package/lib/types/client/canvas/CanvasPanel.d.ts +88 -0
- package/lib/types/client/canvas/collect.d.ts +45 -0
- package/lib/types/client/canvas/index.d.ts +43 -0
- package/lib/types/client/canvas/mount.d.ts +30 -0
- package/lib/types/client/canvas/panel-css.d.ts +1 -0
- package/lib/types/client/canvas/read.d.ts +12 -0
- package/lib/types/client/canvas/subpages.d.ts +20 -0
- package/lib/types/client/canvas/useDismissable.d.ts +15 -0
- package/lib/types/client/index.d.ts +20 -0
- package/lib/types/client/runtime/GenUISurface.d.ts +159 -0
- package/lib/types/client/runtime/bindings.d.ts +143 -0
- package/lib/types/client/runtime/compiler.d.ts +35 -0
- package/lib/types/client/runtime/inline-fence.d.ts +23 -0
- package/lib/types/client/runtime/observe.d.ts +30 -0
- package/lib/types/client/runtime/register.d.ts +2 -0
- package/lib/types/client/runtime/registry.d.ts +7 -0
- package/lib/types/client/runtime/report-error.d.ts +17 -0
- package/lib/types/client/runtime/segments.d.ts +18 -0
- package/lib/types/client/runtime/state.d.ts +18 -0
- package/lib/types/client/runtime/uno-config.d.ts +16 -0
- package/lib/types/client/runtime/uno.d.ts +50 -0
- package/lib/types/client/session.d.ts +26 -0
- package/lib/types/contract-assets.d.ts +41 -0
- package/lib/types/contract.d.ts +56 -0
- package/lib/types/index.d.ts +255 -0
- package/lib/types/prompt.d.ts +13 -0
- package/lib/types/skill.d.ts +27 -0
- package/package.json +135 -9
- package/src/client/canvas/CanvasLauncher.tsx +52 -0
- package/src/client/canvas/CanvasPanel.tsx +238 -0
- package/src/client/canvas/collect.ts +188 -0
- package/src/client/canvas/index.ts +255 -0
- package/src/client/canvas/mount.ts +91 -0
- package/src/client/canvas/panel-css.ts +2 -0
- package/src/client/canvas/panel.css +242 -0
- package/src/client/canvas/read.ts +55 -0
- package/src/client/canvas/subpages.ts +109 -0
- package/src/client/canvas/useDismissable.ts +37 -0
- package/src/client/index.ts +217 -0
- package/src/client/runtime/GenUISurface.tsx +359 -0
- package/src/client/runtime/bindings.ts +292 -0
- package/src/client/runtime/compiler.ts +80 -0
- package/src/client/runtime/inline-fence.ts +222 -0
- package/src/client/runtime/observe.ts +65 -0
- package/src/client/runtime/register.ts +57 -0
- package/src/client/runtime/registry.ts +65 -0
- package/src/client/runtime/report-error.ts +79 -0
- package/src/client/runtime/segments.ts +116 -0
- package/src/client/runtime/state.ts +47 -0
- package/src/client/runtime/uno-config.ts +71 -0
- package/src/client/runtime/uno.ts +124 -0
- package/src/client/session.ts +46 -0
- package/src/contract-assets.ts +46 -0
- package/src/contract.ts +111 -0
- package/src/index.ts +583 -0
- package/src/prompt.ts +377 -0
- package/src/skill.ts +931 -0
- package/types/README.md +34 -0
- package/types/ai.d.ts +14 -0
- package/types/chat.d.ts +14 -0
- package/types/check.ts +39 -0
- package/types/exec.d.ts +17 -0
- package/types/fs.d.ts +17 -0
- package/types/importmap.json +10 -0
- package/types/standalone/ai.js +7 -0
- package/types/standalone/chat.js +6 -0
- package/types/standalone/exec.js +7 -0
- package/types/standalone/fs.js +18 -0
- package/types/standalone/importmap.json +10 -0
- package/types/standalone/state.js +24 -0
- package/types/standalone/web.js +7 -0
- package/types/state.d.ts +25 -0
- package/types/web.d.ts +31 -0
- package/index.js +0 -1
package/src/index.ts
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half — serves the @esm.sh/tsx wasm the browser half compiles TSX with.
|
|
3
|
+
*
|
|
4
|
+
* The shell's /plugins route hard-codes the `/client.js` and `/client.js.map`
|
|
5
|
+
* suffixes and 404s everything else, and dsh-host-frontend-static owns the sole
|
|
6
|
+
* fallback seat (and answers misses with index.html + 200, so dropping the wasm
|
|
7
|
+
* there would fail as a confusing magic-word error). A plugin-owned webServer
|
|
8
|
+
* route is the way to ship bytes; dsh-latex-tools serves MathJax the same way.
|
|
9
|
+
* @module dsh-generative-ui
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
19
|
+
import z from "@deepseek-ai/schemastery";
|
|
20
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
21
|
+
import type {} from "@deepseek-ai/dsh-host-webserver";
|
|
22
|
+
import type {} from "@deepseek-ai/dsh-system-prompt";
|
|
23
|
+
import type {} from "@deepseek-ai/dsh-skill";
|
|
24
|
+
// A value import, unlike the others: `llm.stream` rejects a plain `{role, content}` object,
|
|
25
|
+
// and this is the constructor that stamps the identity and source tags it requires.
|
|
26
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
27
|
+
import { AI_STREAM_PATH, ASSET_PREFIX, CANVAS_READ_PATH, EXEC_PATH, FS_PATH, WASM_PATH, WEB_SEARCH_PATH } from "./contract-assets.ts";
|
|
28
|
+
import { CANVAS_DIR, canvasChildPath, canvasIdOf, canvasPath, isCanvasId } from "./contract.ts";
|
|
29
|
+
import { inlinePrompt, PROMPT_SECTION_NAME, PROMPT_SECTION_ORDER } from "./prompt.ts";
|
|
30
|
+
import { skillBody, SKILL_DESCRIPTION, SKILL_NAME } from "./skill.ts";
|
|
31
|
+
|
|
32
|
+
export const name = "dsh-generative-ui";
|
|
33
|
+
export const inject = ["systemPrompt"];
|
|
34
|
+
|
|
35
|
+
/** The settings section this plugin owns; the key under `dsh-generative-ui:` in settings.yaml. */
|
|
36
|
+
export const SETTINGS_NAMESPACE = settingsNamespace("dsh-generative-ui");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Plugin settings. A schemastery schema, not a TypeScript type: the host validates the
|
|
40
|
+
* `settings.yaml` section against it and builds the settings UI from it, so a plain interface
|
|
41
|
+
* would be a switch nobody can find and nobody can check.
|
|
42
|
+
*
|
|
43
|
+
* `allowExec` is off by default and that default is the point. `$dsh/fs` is bounded — it takes a
|
|
44
|
+
* workspace-relative path and runs under the session's sandbox policy, so the worst it reaches is
|
|
45
|
+
* a file the user could have opened anyway. `$dsh/exec` takes an arbitrary command string, and a
|
|
46
|
+
* card is code a MODEL wrote, running in the user's browser, firing on their keystrokes. The
|
|
47
|
+
* sandbox policy still applies, but "whatever the agent's own bash tool may do" is a much larger
|
|
48
|
+
* surface than a path — and the user never approves a card's commands the way they approve the
|
|
49
|
+
* agent's.
|
|
50
|
+
*/
|
|
51
|
+
export const Config = z.object({
|
|
52
|
+
allowExec: z.boolean().default(false).description("Let generated cards run shell commands through `$dsh/exec`. A card is model-written code running in your browser; leave this off unless you want that."),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export type Config = ReturnType<typeof Config>;
|
|
56
|
+
|
|
57
|
+
// Namespaced by package name because a duplicate (kind, path) throws, and a throw during apply silently fails the whole plugin.
|
|
58
|
+
export { ASSET_PREFIX, WASM_PATH } from "./contract-assets.ts";
|
|
59
|
+
|
|
60
|
+
/** Resolved from this module's own location so pnpm's nested install is anchored against the plugin, not the profile tree. */
|
|
61
|
+
const wasmFile = (importMetaUrl: string) => createRequire(importMetaUrl).resolve("@esm.sh/tsx/pkg/tsx_bg.wasm");
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An absolute path to one of the package's import maps, or undefined when it is not there.
|
|
65
|
+
*
|
|
66
|
+
* `existsSync` is the point. `fileURLToPath` only rejects a malformed URL — it happily returns a
|
|
67
|
+
* path to a file that does not exist, which is what this used to do: installed in a shape where
|
|
68
|
+
* the package root is not two levels up, the skill was handed a path that resolves to nothing
|
|
69
|
+
* and told the model to pass it to `-i`. The failure then surfaces as `genui check` reporting
|
|
70
|
+
* `Cannot find module "$dsh/fs"` on correct code, and the model "fixes" imports that were right.
|
|
71
|
+
*/
|
|
72
|
+
export const resolvedMap = (relative: string, importMetaUrl: string): string | undefined => {
|
|
73
|
+
let path: string;
|
|
74
|
+
try {
|
|
75
|
+
path = fileURLToPath(new URL(relative, importMetaUrl));
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return existsSync(path) ? path : undefined;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Absolute path of the import map that types `$dsh/*` for `genui check`.
|
|
84
|
+
*
|
|
85
|
+
* Resolved rather than hard-coded because the plugin lives wherever the profile installed it,
|
|
86
|
+
* and the model runs the checker from the workspace — it has no way to guess that path.
|
|
87
|
+
*/
|
|
88
|
+
const typesImportMap = (importMetaUrl: string): string | undefined => resolvedMap("../types/importmap.json", importMetaUrl);
|
|
89
|
+
|
|
90
|
+
/** Absolute path of the runtime stub map `genui build` and `genui dev` resolve `$dsh/*` against. */
|
|
91
|
+
const standaloneImportMap = (importMetaUrl: string): string | undefined => resolvedMap("../types/standalone/importmap.json", importMetaUrl);
|
|
92
|
+
|
|
93
|
+
/** Exported for `test/routes.test.ts`: a prefix route that stops checking its pathname serves the whole prefix. */
|
|
94
|
+
export async function serveAsset(req: IncomingMessage, res: ServerResponse, file: string): Promise<void> {
|
|
95
|
+
if (req.method !== "GET" && req.method !== "HEAD") return void res.writeHead(405).end();
|
|
96
|
+
const pathname = new URL(req.url ?? "/", "http://x").pathname;
|
|
97
|
+
if (pathname !== WASM_PATH) return void res.writeHead(404).end();
|
|
98
|
+
// instantiateStreaming rejects anything whose content-type is not exactly application/wasm.
|
|
99
|
+
res.writeHead(200, { "content-type": "application/wasm", "cache-control": "public, max-age=31536000, immutable" });
|
|
100
|
+
res.end(await readFile(file));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Serves one canvas file's current contents, or — with no `id` — the ids of every canvas
|
|
105
|
+
* in the workspace.
|
|
106
|
+
*
|
|
107
|
+
* The client could reconstruct a canvas from `write` tool arguments alone, and does while
|
|
108
|
+
* a write streams — but a model routinely follows a write with several `edit` calls, whose
|
|
109
|
+
* arguments carry a patch rather than the file. Reading the file is the only source that
|
|
110
|
+
* stays correct across every way it can change, including edits made outside the agent.
|
|
111
|
+
*
|
|
112
|
+
* Confined to the canvas directory by construction — the id is a path segment and the path
|
|
113
|
+
* is built from the contract — and to a live session's own workspace by the `cwd` check.
|
|
114
|
+
*
|
|
115
|
+
* That check is the security boundary, not a formality. This route answers any page the
|
|
116
|
+
* user has open: a simple GET triggers no preflight, so without it `?cwd=/anywhere` turns
|
|
117
|
+
* the plugin into a file-existence oracle for the whole disk. The client only ever sends
|
|
118
|
+
* the cwd it read off the current session, so matching against live sessions costs nothing.
|
|
119
|
+
*/
|
|
120
|
+
/** Exported for `test/routes.test.ts`: the listing is the launcher's only source of truth and had no test. */
|
|
121
|
+
export async function serveCanvas(liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
122
|
+
if (req.method !== "GET") return void res.writeHead(405).end();
|
|
123
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
124
|
+
const cwd = url.searchParams.get("cwd");
|
|
125
|
+
const id = url.searchParams.get("id");
|
|
126
|
+
// The id is a path segment by contract; anything else cannot name a canvas.
|
|
127
|
+
if (cwd === null || (id !== null && !isCanvasId(id))) return void res.writeHead(400).end();
|
|
128
|
+
if (!liveWorkspaces().has(cwd)) return void res.writeHead(403).end();
|
|
129
|
+
// No id: list the directory. A canvas outlives the session that wrote it, so the panel
|
|
130
|
+
// needs a source beyond the current transcript to offer one written yesterday.
|
|
131
|
+
if (id === null) {
|
|
132
|
+
const ids = await readdir(join(cwd, CANVAS_DIR)).then(
|
|
133
|
+
(names) =>
|
|
134
|
+
names.flatMap((name) => {
|
|
135
|
+
const found = canvasIdOf(`${CANVAS_DIR}/${name}`);
|
|
136
|
+
return found === null ? [] : [found];
|
|
137
|
+
}),
|
|
138
|
+
() => [],
|
|
139
|
+
);
|
|
140
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
141
|
+
return void res.end(JSON.stringify(ids));
|
|
142
|
+
}
|
|
143
|
+
// A relative specifier written inside the canvas. Resolved through the contract, which
|
|
144
|
+
// confines it to this canvas's own child directory — see canvasChildPath.
|
|
145
|
+
const child = url.searchParams.get("child");
|
|
146
|
+
if (child !== null) {
|
|
147
|
+
const path = canvasChildPath(id, child, url.searchParams.get("from") ?? undefined);
|
|
148
|
+
if (path === null) return void res.writeHead(400).end();
|
|
149
|
+
// A specifier carries no extension, so the server is what decides which file it names —
|
|
150
|
+
// and the client needs to know, because the compiler picks its syntax from the extension.
|
|
151
|
+
for (const suffix of [".tsx", ".ts", "/index.tsx", "/index.ts", ""]) {
|
|
152
|
+
try {
|
|
153
|
+
const body = await readFile(join(cwd, path + suffix), "utf8");
|
|
154
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-ui4a-filename": path + suffix });
|
|
155
|
+
return void res.end(body);
|
|
156
|
+
} catch {
|
|
157
|
+
// Next candidate; a specifier that names none of them is a 404 below.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return void res.writeHead(404).end();
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const code = await readFile(join(cwd, canvasPath(id)), "utf8");
|
|
164
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
165
|
+
res.end(code);
|
|
166
|
+
} catch {
|
|
167
|
+
// A canvas whose write is still streaming has no file yet; the client keeps its own copy.
|
|
168
|
+
res.writeHead(404).end();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Context shape for the filesystem route; see the SessionStoreCtx note on why it is local. */
|
|
173
|
+
type FsCtx = {
|
|
174
|
+
fs: {
|
|
175
|
+
resolve: (path: string, opts?: { cwd?: string }) => Promise<FsTargetLike>;
|
|
176
|
+
readText: (target: FsTargetLike) => Promise<string>;
|
|
177
|
+
readBytes: (target: FsTargetLike, signal: AbortSignal | undefined, maxBytes: number) => Promise<Uint8Array>;
|
|
178
|
+
listDir: (target: FsTargetLike) => Promise<{ name: string; type?: string; size?: number }[]>;
|
|
179
|
+
writeText: (target: FsTargetLike, content: string, expected?: undefined, signal?: AbortSignal, policy?: unknown) => Promise<unknown>;
|
|
180
|
+
};
|
|
181
|
+
sandboxPolicy: { resolve: (request?: { session?: unknown }) => unknown };
|
|
182
|
+
sessions: { list: () => readonly { id?: string; header: { cwd?: string } }[] };
|
|
183
|
+
};
|
|
184
|
+
type FsTargetLike = { targetKey: unknown; displayPath: string };
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Reads, lists, and writes on behalf of a generated card.
|
|
188
|
+
*
|
|
189
|
+
* Everything goes through the host's `ctx.fs` carrying the session's own
|
|
190
|
+
* `ctx.sandboxPolicy`, so **a card may do exactly what the session may do** — under
|
|
191
|
+
* `read-only` the write is refused by the same fence that refuses the model's, with the
|
|
192
|
+
* same structured denial. Inventing a narrower boundary here would mean a second policy to
|
|
193
|
+
* keep in sync with the one the user actually sees in the composer.
|
|
194
|
+
*
|
|
195
|
+
* The `cwd` allowlist is still required, for the reason the canvas route documents: any page
|
|
196
|
+
* the user has open can call this, so without it the workspace is not the workspace.
|
|
197
|
+
*/
|
|
198
|
+
/** Exported for `test/fs-route.test.ts`. */
|
|
199
|
+
export async function serveFs(ctx: FsCtx, liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
200
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
201
|
+
const cwd = url.searchParams.get("cwd");
|
|
202
|
+
const path = url.searchParams.get("path");
|
|
203
|
+
if (cwd === null || path === null || path === "") return void res.writeHead(400).end();
|
|
204
|
+
if (!liveWorkspaces().has(cwd)) return void res.writeHead(403).end();
|
|
205
|
+
|
|
206
|
+
const json = (status: number, body: unknown): void => {
|
|
207
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
208
|
+
res.end(JSON.stringify(body));
|
|
209
|
+
};
|
|
210
|
+
// A denial is an answer, not a crash: the card needs to tell "you may not" from "it broke".
|
|
211
|
+
const failed = (error: unknown): void => {
|
|
212
|
+
const code = (error as { code?: string } | undefined)?.code;
|
|
213
|
+
json(code === "FS_SANDBOX_DENIED" ? 403 : 404, { error: code ?? (error instanceof Error ? error.message : String(error)) });
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const target = await ctx.fs.resolve(path, { cwd });
|
|
218
|
+
if (req.method === "GET") {
|
|
219
|
+
if (url.searchParams.get("list") !== null) {
|
|
220
|
+
// Only the three fields a card can use. The host also returns its own `target` and a
|
|
221
|
+
// `version` cache key (dev:ino:size:mtime:ctime — note mtime precedes ctime, which is
|
|
222
|
+
// not the order the name suggests); neither is contract, so neither is forwarded.
|
|
223
|
+
const entries = await ctx.fs.listDir(target);
|
|
224
|
+
return json(200, { entries: entries.map(({ name, type, size }) => ({ name, type, size })) });
|
|
225
|
+
}
|
|
226
|
+
// `?bytes=1` reads the file as bytes and answers base64. A card that wants a .mid, a
|
|
227
|
+
// wav, or an image cannot use the text path: `readText` decodes as UTF-8, so every byte
|
|
228
|
+
// above 0x7f comes back as U+FFFD and the file is silently corrupt rather than refused.
|
|
229
|
+
if (url.searchParams.get("bytes") !== null) {
|
|
230
|
+
const bytes = await ctx.fs.readBytes(target, undefined, MAX_BINARY);
|
|
231
|
+
return json(200, { base64: Buffer.from(bytes).toString("base64"), byteLength: bytes.byteLength });
|
|
232
|
+
}
|
|
233
|
+
return json(200, { content: await ctx.fs.readText(target) });
|
|
234
|
+
}
|
|
235
|
+
if (req.method !== "POST") return void res.writeHead(405).end();
|
|
236
|
+
|
|
237
|
+
let body = "";
|
|
238
|
+
for await (const chunk of req) {
|
|
239
|
+
body += chunk as string;
|
|
240
|
+
if (body.length > MAX_BODY) return void res.writeHead(413).end();
|
|
241
|
+
}
|
|
242
|
+
let content: string;
|
|
243
|
+
try {
|
|
244
|
+
({ content } = JSON.parse(body) as { content: string });
|
|
245
|
+
} catch {
|
|
246
|
+
return void res.writeHead(400).end();
|
|
247
|
+
}
|
|
248
|
+
if (typeof content !== "string") return void res.writeHead(400).end();
|
|
249
|
+
// The session's policy, not ours: the composer's access mode is what decides. Addressed
|
|
250
|
+
// by id, not found by cwd — several sessions share one workspace, and picking the first
|
|
251
|
+
// of them silently runs the write under a stranger's access mode.
|
|
252
|
+
const sessionId = url.searchParams.get("session");
|
|
253
|
+
const session = sessionId === null ? undefined : ctx.sessions.list().find((entry) => entry.id === sessionId);
|
|
254
|
+
if (session === undefined) return void res.writeHead(400).end();
|
|
255
|
+
await ctx.fs.writeText(target, content, undefined, undefined, ctx.sandboxPolicy.resolve({ session }));
|
|
256
|
+
return json(200, { written: target.displayPath });
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return failed(error);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Context shape for the shell route. `resolve` fills the executor's own defaults and caps. */
|
|
263
|
+
type ExecCtx = {
|
|
264
|
+
shell: {
|
|
265
|
+
resolve: (request: { command: string; workdir?: string; timeoutMs?: number; sandboxPolicy?: unknown; signal?: AbortSignal }) => unknown;
|
|
266
|
+
run: (spec: unknown) => Promise<{ exitCode: number | null; signal?: string | null; timedOut?: boolean; stdout: { text: string; truncated: boolean }; stderr: { text: string; truncated: boolean } }>;
|
|
267
|
+
};
|
|
268
|
+
sandboxPolicy: { resolve: (request?: { session?: unknown }) => unknown };
|
|
269
|
+
sessions: { list: () => readonly { id?: string; header: { cwd?: string } }[] };
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/** Longest a card's command may run. The card is on the user's page, waiting on a fetch. */
|
|
273
|
+
const EXEC_TIMEOUT_MS = 15_000;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Runs one command on behalf of a generated card.
|
|
277
|
+
*
|
|
278
|
+
* The whole point is that a card can answer questions only a command can answer — git
|
|
279
|
+
* history, a test run, ripgrep across a big tree — without us re-implementing each one as a
|
|
280
|
+
* route. It carries the session's own sandbox policy, so this opens no door the model's own
|
|
281
|
+
* bash tool does not already have open, and a read-only session gets a read-only shell.
|
|
282
|
+
*
|
|
283
|
+
* A non-zero exit is a RESULT, not an error: a card wants to show `git status` failing in a
|
|
284
|
+
* non-repo as much as it wants to show it succeeding. Only infrastructure failures reject.
|
|
285
|
+
*
|
|
286
|
+
* **Why this does not go through `ctx.approval`, which is the seam for "may this action
|
|
287
|
+
* proceed?".** It is the right question and dsh's own `tool-bash` asks it — but the service
|
|
288
|
+
* cannot answer it here. `approval.request()` takes an `agent` and throws outright when the
|
|
289
|
+
* session has no open turn: *"approval.request() outside an open turn … Ask from inside the turn
|
|
290
|
+
* that needs the decision."* A card's command is the opposite of that — it fires on the reader's
|
|
291
|
+
* keystroke, long after the turn that wrote the card ended, with no agent on whose behalf to ask.
|
|
292
|
+
* `ctx.userQuestions.ask()` DOES work outside a turn (its `agent` is optional), so a per-command
|
|
293
|
+
* prompt is buildable; what stops it is that a card runs one command per keystroke, and a dialog
|
|
294
|
+
* per keystroke is not a safety feature. The setting is therefore about whether the CAPABILITY
|
|
295
|
+
* exists, and the per-command fence remains the session's own sandbox policy, which this passes
|
|
296
|
+
* through unchanged. Anything genuinely destructive belongs in `sendMessage`, where the user's
|
|
297
|
+
* next turn — and with it the whole approval machinery — is what runs it.
|
|
298
|
+
*/
|
|
299
|
+
/** Exported for `test/exec-route.test.ts`. */
|
|
300
|
+
export async function serveExec(ctx: ExecCtx, liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
301
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
302
|
+
const cwd = url.searchParams.get("cwd");
|
|
303
|
+
if (cwd === null || !liveWorkspaces().has(cwd)) return void res.writeHead(cwd === null ? 400 : 403).end();
|
|
304
|
+
if (req.method !== "POST") return void res.writeHead(405).end();
|
|
305
|
+
|
|
306
|
+
const sessionId = url.searchParams.get("session");
|
|
307
|
+
const session = sessionId === null ? undefined : ctx.sessions.list().find((entry) => entry.id === sessionId);
|
|
308
|
+
if (session === undefined) return void res.writeHead(400).end();
|
|
309
|
+
|
|
310
|
+
let body = "";
|
|
311
|
+
for await (const chunk of req) {
|
|
312
|
+
body += chunk as string;
|
|
313
|
+
if (body.length > MAX_BODY) return void res.writeHead(413).end();
|
|
314
|
+
}
|
|
315
|
+
let command: string;
|
|
316
|
+
try {
|
|
317
|
+
({ command } = JSON.parse(body) as { command: string });
|
|
318
|
+
} catch {
|
|
319
|
+
return void res.writeHead(400).end();
|
|
320
|
+
}
|
|
321
|
+
if (typeof command !== "string" || command === "") return void res.writeHead(400).end();
|
|
322
|
+
|
|
323
|
+
const json = (status: number, value: unknown): void => {
|
|
324
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
325
|
+
res.end(JSON.stringify(value));
|
|
326
|
+
};
|
|
327
|
+
try {
|
|
328
|
+
// Kill the command when the caller goes away. A card that runs one command per keystroke
|
|
329
|
+
// has no other way to cancel — `bash()` returns a promise, not a handle — so without this
|
|
330
|
+
// a fast typist leaves a queue of doomed ripgreps competing for the machine.
|
|
331
|
+
const controller = new AbortController();
|
|
332
|
+
req.on("close", () => controller.abort());
|
|
333
|
+
const spec = ctx.shell.resolve({ command, workdir: cwd, timeoutMs: EXEC_TIMEOUT_MS, sandboxPolicy: ctx.sandboxPolicy.resolve({ session }), signal: controller.signal });
|
|
334
|
+
const result = await ctx.shell.run(spec);
|
|
335
|
+
return json(200, {
|
|
336
|
+
stdout: result.stdout.text,
|
|
337
|
+
stderr: result.stderr.text,
|
|
338
|
+
exitCode: result.exitCode,
|
|
339
|
+
// Per stream, not merged: a card that parses stdout needs to know whether *stdout* was
|
|
340
|
+
// cut, and one boolean for both makes a full stdout look unreliable whenever a noisy
|
|
341
|
+
// stderr overflowed.
|
|
342
|
+
truncated: { stdout: result.stdout.truncated, stderr: result.stderr.truncated },
|
|
343
|
+
timedOut: result.timedOut === true,
|
|
344
|
+
});
|
|
345
|
+
} catch (error) {
|
|
346
|
+
return json(500, { error: error instanceof Error ? error.message : String(error) });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Context shape for the search route. Only the two methods it calls, so a fake in a test is small. */
|
|
351
|
+
type WebCtx = {
|
|
352
|
+
web: {
|
|
353
|
+
search: (request: { query: string; maxResults?: number }, signal?: AbortSignal) => Promise<{ content?: string; sources: readonly { url: string; title?: string; snippet?: string; publishedAt?: string }[]; truncated: boolean }>;
|
|
354
|
+
};
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/** How many sources a card gets by default; the host's own tool-web default is 8. */
|
|
358
|
+
const SEARCH_MAX_RESULTS = 8;
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Runs one web search on behalf of a generated card.
|
|
362
|
+
*
|
|
363
|
+
* A card that wants live information — a price, a release date, what a package exports — otherwise
|
|
364
|
+
* has nothing: `fetch` from inside the surface is not the shape (no credentials, no CORS, no
|
|
365
|
+
* provider selection), and routing the question through `$dsh/ai` asks a model to recall rather
|
|
366
|
+
* than to look. `ctx.web` already owns provider selection, the result shape, and the truncation
|
|
367
|
+
* bound, so this forwards and does not re-decide any of it.
|
|
368
|
+
*
|
|
369
|
+
* SEARCH ONLY — see `WEB_SEARCH_PATH` for why `fetch` is not forwarded.
|
|
370
|
+
*
|
|
371
|
+
* `WebError` carries a `code` and the seam's own contract calls that set OPEN: a provider may
|
|
372
|
+
* raise a code this build has never seen. So the error is passed through as text rather than
|
|
373
|
+
* matched on, and the card decides what to show.
|
|
374
|
+
*/
|
|
375
|
+
/** Exported for `test/web-search-route.test.ts`. */
|
|
376
|
+
export async function serveWebSearch(ctx: WebCtx, liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
377
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
378
|
+
const cwd = url.searchParams.get("cwd");
|
|
379
|
+
if (cwd === null || !liveWorkspaces().has(cwd)) return void res.writeHead(cwd === null ? 400 : 403).end();
|
|
380
|
+
if (req.method !== "POST") return void res.writeHead(405).end();
|
|
381
|
+
|
|
382
|
+
let body = "";
|
|
383
|
+
for await (const chunk of req) {
|
|
384
|
+
body += chunk as string;
|
|
385
|
+
if (body.length > MAX_BODY) return void res.writeHead(413).end();
|
|
386
|
+
}
|
|
387
|
+
let query: string;
|
|
388
|
+
let maxResults: number | undefined;
|
|
389
|
+
try {
|
|
390
|
+
({ query, maxResults } = JSON.parse(body) as { query: string; maxResults?: number });
|
|
391
|
+
} catch {
|
|
392
|
+
return void res.writeHead(400).end();
|
|
393
|
+
}
|
|
394
|
+
if (typeof query !== "string" || query.trim() === "") return void res.writeHead(400).end();
|
|
395
|
+
|
|
396
|
+
const json = (status: number, value: unknown): void => {
|
|
397
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
398
|
+
res.end(JSON.stringify(value));
|
|
399
|
+
};
|
|
400
|
+
try {
|
|
401
|
+
// Same reason the exec route does it: a card that searches as the reader types has no other
|
|
402
|
+
// way to cancel, and the seam forwards the signal to the provider.
|
|
403
|
+
const controller = new AbortController();
|
|
404
|
+
req.on("close", () => controller.abort());
|
|
405
|
+
const result = await ctx.web.search({ query, maxResults: maxResults ?? SEARCH_MAX_RESULTS }, controller.signal);
|
|
406
|
+
return json(200, { content: result.content, sources: result.sources, truncated: result.truncated });
|
|
407
|
+
} catch (error) {
|
|
408
|
+
return json(500, { error: error instanceof Error ? error.message : String(error) });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Context shape for the two services the AI route needs; see the SessionStoreCtx note. */
|
|
413
|
+
type LlmCtx = {
|
|
414
|
+
llm: { stream: (options: { provider: string; model: string; messages: readonly unknown[]; system?: string; signal?: AbortSignal }) => AsyncIterable<{ type: string; text?: string; reason?: { kind: string; failure?: { message?: string } } }> };
|
|
415
|
+
agentDefaultModel: { currentSelection: () => { provider: string; model: string } };
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/** Largest request body accepted by either POST route, so a runaway card cannot exhaust memory. */
|
|
419
|
+
const MAX_BODY = 64 * 1024;
|
|
420
|
+
|
|
421
|
+
/** Byte cap on a binary read. Base64 inflates by a third, and this crosses a JSON response. */
|
|
422
|
+
const MAX_BINARY = 8 * 1024 * 1024;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Streams one model call on behalf of a generated card.
|
|
426
|
+
*
|
|
427
|
+
* The card cannot call a provider itself — it has no credentials and should never be given
|
|
428
|
+
* any. `ctx.llm` already owns the adapter registry, the retry policy and the keys, and
|
|
429
|
+
* `agentDefaultModel` owns which model the app is set to, so this route is a forwarder:
|
|
430
|
+
* it converts a small JSON request into `llm.stream` and pipes the text deltas back.
|
|
431
|
+
*
|
|
432
|
+
* Same `cwd` allowlist as the canvas route, and for the same reason: any page the user has
|
|
433
|
+
* open can POST here, so without it this is an open model proxy for anything on the machine.
|
|
434
|
+
*/
|
|
435
|
+
/** Exported for `test/ai-route.test.ts`. */
|
|
436
|
+
export async function serveAi(ctx: LlmCtx, liveWorkspaces: () => ReadonlySet<string>, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
437
|
+
if (req.method !== "POST") return void res.writeHead(405).end();
|
|
438
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
439
|
+
const cwd = url.searchParams.get("cwd");
|
|
440
|
+
if (cwd === null) return void res.writeHead(400).end();
|
|
441
|
+
if (!liveWorkspaces().has(cwd)) return void res.writeHead(403).end();
|
|
442
|
+
|
|
443
|
+
let body = "";
|
|
444
|
+
for await (const chunk of req) {
|
|
445
|
+
body += chunk as string;
|
|
446
|
+
if (body.length > MAX_BODY) return void res.writeHead(413).end();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
let request: { prompt?: string; system?: string };
|
|
450
|
+
try {
|
|
451
|
+
request = JSON.parse(body) as typeof request;
|
|
452
|
+
} catch {
|
|
453
|
+
return void res.writeHead(400).end();
|
|
454
|
+
}
|
|
455
|
+
if (request.prompt === undefined || request.prompt === "") return void res.writeHead(400).end();
|
|
456
|
+
// One user turn, deliberately: `llm.stream` will not take a bare `{role, content}` — a
|
|
457
|
+
// message carries an identity and a source tag — and the assistant-side constructor wants
|
|
458
|
+
// provider, model and replay state, which means a multi-turn API here would be forging
|
|
459
|
+
// turns the model never produced. Anything a card needs from an earlier turn belongs in
|
|
460
|
+
// the prompt it builds.
|
|
461
|
+
const messages = [createUserMessage({ content: [{ type: "text", text: request.prompt }], source: { kind: "plugin", plugin: "dsh-generative-ui" } })];
|
|
462
|
+
|
|
463
|
+
const selection = ctx.agentDefaultModel.currentSelection();
|
|
464
|
+
// Abort the model call when the reader navigates away or the card unmounts; without this
|
|
465
|
+
// a closed tab leaves a generation running and billing.
|
|
466
|
+
const controller = new AbortController();
|
|
467
|
+
req.on("close", () => controller.abort());
|
|
468
|
+
|
|
469
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", "x-accel-buffering": "no" });
|
|
470
|
+
try {
|
|
471
|
+
for await (const chunk of ctx.llm.stream({ ...selection, messages, system: request.system, signal: controller.signal })) {
|
|
472
|
+
if (chunk.type === "text-delta" && chunk.text !== undefined) res.write(chunk.text);
|
|
473
|
+
// A failed call finishes rather than throwing, so without this the card sees a clean
|
|
474
|
+
// empty 200 and reports "the model said nothing" — indistinguishable from a real
|
|
475
|
+
// empty answer. Trailing the reason is the only channel left once the body has begun.
|
|
476
|
+
// `reason` is an object with a `kind`, not a string: interpolating it directly writes
|
|
477
|
+
// `[object Object]`, which is how this was first shipped.
|
|
478
|
+
else if (chunk.type === "finish" && chunk.reason !== undefined && chunk.reason.kind !== "stop") res.write(`\n\n[${chunk.reason.kind}${chunk.reason.failure?.message === undefined ? "" : `: ${chunk.reason.failure.message}`}]`);
|
|
479
|
+
}
|
|
480
|
+
} catch (error) {
|
|
481
|
+
// Headers are already out, so this cannot become a status code.
|
|
482
|
+
res.write(`\n\n[error: ${error instanceof Error ? error.message : String(error)}]`);
|
|
483
|
+
}
|
|
484
|
+
res.end();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Live sessions' workspaces. Typed locally: a global `dsh-session` merge would also rewrite
|
|
488
|
+
* the client half's `ctx.sessions`, which is a different service entirely. */
|
|
489
|
+
type SessionStoreCtx = { sessions: { list: () => readonly { header: { cwd?: string } }[] } };
|
|
490
|
+
|
|
491
|
+
// `= Config({})` rather than a bare default: schemastery fills every declared default, so an
|
|
492
|
+
// omitted config is the same object the host would have built, and `allowExec` is false there.
|
|
493
|
+
// A host that calls `apply(ctx)` is not a hypothetical — the existing profile tests do.
|
|
494
|
+
export function apply(ctx: Context, config: Config = Config({})): void {
|
|
495
|
+
// `current()` rather than a captured boolean: the section is live, and a user who turns
|
|
496
|
+
// commands off in the settings UI means it now, not at the next restart.
|
|
497
|
+
let current = () => config;
|
|
498
|
+
// The prompt text and the exec route are both DECIDED at registration time, so a live setting
|
|
499
|
+
// needs somewhere to re-decide them. A cordis scope is that somewhere: everything below hangs
|
|
500
|
+
// off `configured`, and `onChange` disposes and rebuilds it, which re-registers the section
|
|
501
|
+
// with the new text and adds or removes the route. Reading `current()` inside the effects
|
|
502
|
+
// without this would change the value and leave the registrations as they were.
|
|
503
|
+
// The mounted value rides ON the handle rather than in a second variable: the two would have to
|
|
504
|
+
// be assigned in lockstep by hand, and an early return added between them later would desync the
|
|
505
|
+
// dedup from what is actually mounted.
|
|
506
|
+
let configured: { allowExec: boolean; fiber: { dispose: () => Promise<void> } } | null = null;
|
|
507
|
+
const rebuild = () => {
|
|
508
|
+
const allowExec = current().allowExec === true;
|
|
509
|
+
// `onChange` fires on every write to the section, and the section may grow other keys later.
|
|
510
|
+
// Rebuilding on a value that did not move would tear down the prompt and both routes for
|
|
511
|
+
// nothing — visible to a reader as a card losing its host mid-conversation.
|
|
512
|
+
if (configured?.allowExec === allowExec) return;
|
|
513
|
+
void configured?.fiber.dispose();
|
|
514
|
+
configured = { allowExec, fiber: ctx.plugin({ name: "dsh-generative-ui:configured", apply: (scoped: Context) => applyWith(scoped, allowExec) }) };
|
|
515
|
+
};
|
|
516
|
+
// Called here as well as from `onChange`, and that is not belt-and-braces: the whole of
|
|
517
|
+
// `installSettingsSection` sits inside `ctx.inject(["settings"])`, so on a host with no settings
|
|
518
|
+
// service — `dsh --profile headless` is one — `onChange` never fires at all and nothing would
|
|
519
|
+
// ever mount. The `mounted` check above is what keeps this from double-mounting where it does.
|
|
520
|
+
rebuild();
|
|
521
|
+
installSettingsSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
|
522
|
+
setSource: (source) => {
|
|
523
|
+
current = source;
|
|
524
|
+
},
|
|
525
|
+
onChange: rebuild,
|
|
526
|
+
});
|
|
527
|
+
ctx.effect(() => () => void configured?.fiber.dispose(), "dsh-generative-ui: settings scope");
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Everything whose shape depends on `allowExec`; rebuilt when the setting changes. */
|
|
531
|
+
function applyWith(ctx: Context, allowExec: boolean): void {
|
|
532
|
+
// The prompt has to follow the switch. With commands off, a section that documents `bash()`
|
|
533
|
+
// teaches the model to write cards that cannot work — and the failure surfaces to the user as a
|
|
534
|
+
// dead card, not as a disabled feature.
|
|
535
|
+
ctx.effect(() => ctx.systemPrompt.section({ name: PROMPT_SECTION_NAME, order: PROMPT_SECTION_ORDER, text: inlinePrompt(allowExec) }), "dsh-generative-ui: inline prompt");
|
|
536
|
+
// Both routes only matter to a browser half that exists to consume them. Scoped rather than
|
|
537
|
+
// required so the plugin still teaches the model on a profile with no web server at all —
|
|
538
|
+
// `dsh --profile headless` has no `webServer`, and a required injection there means the
|
|
539
|
+
// prompt and the skill go missing too, which is the whole plugin.
|
|
540
|
+
//
|
|
541
|
+
// `sessions` rides along because the canvas route needs it to authorize a workspace, and
|
|
542
|
+
// cordis enforces injection at access time: reading `ctx.sessions` without declaring it
|
|
543
|
+
// throws "cannot get property ... without inject" inside the request, which the host turns
|
|
544
|
+
// into a bare 400. Declaring it here rather than in the static `inject` keeps the headless
|
|
545
|
+
// profile working, same as the other two.
|
|
546
|
+
ctx.inject(["webServer", "sessions"], (scoped) => {
|
|
547
|
+
const file = wasmFile(import.meta.url);
|
|
548
|
+
const liveWorkspaces = (): ReadonlySet<string> => {
|
|
549
|
+
const sessions = (scoped as unknown as SessionStoreCtx).sessions.list();
|
|
550
|
+
return new Set(sessions.flatMap((session) => (session.header.cwd === undefined ? [] : [session.header.cwd])));
|
|
551
|
+
};
|
|
552
|
+
scoped.effect(() => scoped.webServer.register({ kind: "prefix", path: ASSET_PREFIX, handler: (req, res) => serveAsset(req, res, file) }), "dsh-generative-ui: tsx wasm");
|
|
553
|
+
scoped.effect(() => scoped.webServer.register({ kind: "exact", path: CANVAS_READ_PATH, handler: (req, res) => serveCanvas(liveWorkspaces, req, res) }), "dsh-generative-ui: canvas reads");
|
|
554
|
+
// One level deeper again: a deployment can mount a web server without an LLM runtime, and
|
|
555
|
+
// losing `$dsh/ai` there should not take the wasm and canvas routes down with it.
|
|
556
|
+
// Same shape again: a deployment can serve the web without a sandboxed filesystem, and
|
|
557
|
+
// losing `$dsh/fs` there should not cost the routes above it.
|
|
558
|
+
scoped.inject(["fs", "sandboxPolicy"], (withFs) => {
|
|
559
|
+
withFs.effect(() => withFs.webServer.register({ kind: "exact", path: FS_PATH, handler: (req, res) => serveFs(withFs as unknown as FsCtx, liveWorkspaces, req, res) }), "dsh-generative-ui: workspace files");
|
|
560
|
+
});
|
|
561
|
+
// And again for the shell: a host may compose a web server without a command executor — and
|
|
562
|
+
// now also a host that has one but has not opted in. Both mean the same thing to a card, and
|
|
563
|
+
// both are expressed the same way: no route.
|
|
564
|
+
if (allowExec) scoped.inject(["shell", "sandboxPolicy"], (withShell) => {
|
|
565
|
+
withShell.effect(() => withShell.webServer.register({ kind: "exact", path: EXEC_PATH, handler: (req, res) => serveExec(withShell as unknown as ExecCtx, liveWorkspaces, req, res) }), "dsh-generative-ui: commands");
|
|
566
|
+
});
|
|
567
|
+
// Same nesting as the rest: a host with no web capability loses `$dsh/web` and keeps
|
|
568
|
+
// everything else. `dsh-base` composes `ctx.web` with `searchProvider: deepseek-official`.
|
|
569
|
+
scoped.inject(["web"], (withWeb) => {
|
|
570
|
+
withWeb.effect(() => withWeb.webServer.register({ kind: "exact", path: WEB_SEARCH_PATH, handler: (req, res) => serveWebSearch(withWeb as unknown as WebCtx, liveWorkspaces, req, res) }), "dsh-generative-ui: web search");
|
|
571
|
+
});
|
|
572
|
+
scoped.inject(["llm", "agentDefaultModel"], (withLlm) => {
|
|
573
|
+
withLlm.effect(() => withLlm.webServer.register({ kind: "exact", path: AI_STREAM_PATH, handler: (req, res) => serveAi(withLlm as unknown as LlmCtx, liveWorkspaces, req, res) }), "dsh-generative-ui: model stream");
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
// Scoped rather than listed in `inject`: cordis has no optional injection, so naming "skills"
|
|
577
|
+
// there would keep the whole plugin — wasm route included — inactive wherever the skill
|
|
578
|
+
// subsystem is disabled. Nested, only the skill goes missing.
|
|
579
|
+
// Model-only: `/generative-ui` as a user command would just print the guidance at the user.
|
|
580
|
+
ctx.inject(["skills"], (scoped) => {
|
|
581
|
+
scoped.effect(() => scoped.skills.register({ name: SKILL_NAME, description: SKILL_DESCRIPTION, content: skillBody(typesImportMap(import.meta.url), standaloneImportMap(import.meta.url)), source: "runtime", invocation: { modelInvocable: true, userInvocable: false } }), "dsh-generative-ui: skill");
|
|
582
|
+
});
|
|
583
|
+
}
|