@lazyingart/agent-web 0.1.40

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.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/docs/architecture.md +503 -0
  4. package/package.json +43 -0
  5. package/src/aginti-adapter.js +602 -0
  6. package/src/chat-context.js +1020 -0
  7. package/src/chat-migrations.js +947 -0
  8. package/src/chat-store.js +3308 -0
  9. package/src/cli.js +134 -0
  10. package/src/cloud-server.js +2043 -0
  11. package/src/contracts.js +103 -0
  12. package/src/deterministic-context-summarizer.js +254 -0
  13. package/src/direct-chat-capability-limits.js +66 -0
  14. package/src/direct-chat-contract.js +3 -0
  15. package/src/errors.js +50 -0
  16. package/src/http-contract.js +592 -0
  17. package/src/index.js +88 -0
  18. package/src/localllm-connector.js +667 -0
  19. package/src/migrations.js +231 -0
  20. package/src/operator-health.js +184 -0
  21. package/src/password-verifier.js +131 -0
  22. package/src/service-config.js +547 -0
  23. package/src/service.js +408 -0
  24. package/src/sqlite-health.js +83 -0
  25. package/src/storage-path.js +130 -0
  26. package/src/store.js +914 -0
  27. package/src/validation.js +181 -0
  28. package/src/vision-attachment.js +404 -0
  29. package/src/web/aginti-client.js +552 -0
  30. package/src/web/aginti-protocol.js +1146 -0
  31. package/src/web/asset-map.js +462 -0
  32. package/src/web/browser-app.js +6491 -0
  33. package/src/web/cloud-session-client.js +427 -0
  34. package/src/web/direct-chat-client.js +1482 -0
  35. package/src/web/index.js +10 -0
  36. package/src/web/presentation-state.js +107 -0
  37. package/src/web/pwa-assets.js +854 -0
  38. package/src/web/pwa-update-handoff-store.js +179 -0
  39. package/src/web/safe-rendering.js +836 -0
  40. package/src/web/vision-image-client.js +546 -0
  41. package/src/web/vision-image-sanitizer.js +168 -0
  42. package/src/web/web-release.js +28 -0
@@ -0,0 +1,854 @@
1
+ export const AGENT_WEB_GENERATOR_VERSION = "3";
2
+ export const AGENT_WEB_CACHE_PREFIX = "lazying-agent-web-";
3
+ export const AGENT_WEB_KATEX_VERSION = "0.16.47";
4
+ export const AGENT_WEB_RELEASE_ROOT = "/assets/r";
5
+ const AGENT_WEB_ACTIVATION_CLIENT_TIMEOUT_MS = 2_000;
6
+ export const AGENT_WEB_EMERGENCY_PREDECESSOR_DIGESTS = Object.freeze([
7
+ "17a3bdd37f3eb6a2d6438de08c6eaeec5330ef4c467428fb1653def9e5dc5d8a",
8
+ "19b1b6fc7542a0688410805ebb1f10eabdf27295d2bacd47502518d2594f8a13",
9
+ "480879404f6a3e666c5982243b60c33b169a7e0101b18d3c3202a5ff3eb7558a",
10
+ "497eb6f9c7be203713f8c80981c04a63816a9164a4edd38e8d2e3efd46b1eacb",
11
+ "56faff53823b04b923ca79895879efcedb16b1422296a9f49b7a666346cefa81",
12
+ "7387e08bf395559b0718616f51acfde88e3aa738c65198c67bf77955031e36e0",
13
+ "8ca822161842b784a6ab29e3f56d43d8794abbd49e0132bec4b1e7a41adcc8e9",
14
+ "93ddb89692bd1f60fb1624f02df1f26b08e0b9731d89ade868e7ce19a1a77225",
15
+ "9496cadb920f3f5f89d00bff55dcdba39010621b0a680bc14b062c637e788f13",
16
+ "9582db23fa8ff50d32170e6e0d4fef22341628898b42d10f5280095c5e4eb0a5",
17
+ "966eaa758b0a2df838579746c0ea8f683f582f31502b4dffbfd9b9c8e379ce9f",
18
+ "b491a975961abf6357bd7e6c091a80da5044608b013834d6eeafceaa9ad6bf87",
19
+ "b998bac4dbf27545d98ebfe44cc768a0d4da83efd15b0c03ee1f23bd4491eb19",
20
+ "d790c404c1afcbb3c2e9ff753cdeac55c463584f3efbf2c6421d9f5637f4a741",
21
+ "d8dd3d04190973610c7a8fcfc3bed1a7c3b514ac3e6bc6bf18f101ff013661cc",
22
+ ]);
23
+ export const AGENT_WEB_MODULE_ROUTES = Object.freeze([
24
+ "/assets/app.js",
25
+ "/assets/browser-app.js",
26
+ "/assets/pwa-update-handoff-store.js",
27
+ "/assets/cloud-session-client.js",
28
+ "/assets/web-release.js",
29
+ "/assets/direct-chat-client.js",
30
+ "/assets/vision-image-client.js",
31
+ "/assets/vision-image-sanitizer.js",
32
+ "/assets/aginti-client.js",
33
+ "/assets/aginti-protocol.js",
34
+ "/assets/presentation-state.js",
35
+ "/assets/pwa-assets.js",
36
+ "/assets/safe-rendering.js",
37
+ "/assets/katex.mjs",
38
+ ]);
39
+
40
+ export function validateAgentWebRelease(value) {
41
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._~-]{0,95}$/u.test(value)) {
42
+ throw new TypeError("releaseVersion must be a portable 1-96 character identifier");
43
+ }
44
+ return value;
45
+ }
46
+
47
+ export function agentWebBuildQuery(version) {
48
+ return `?v=${encodeURIComponent(validateAgentWebRelease(version))}`;
49
+ }
50
+
51
+ export function normalizeAgentWebBasePath(value = "/") {
52
+ if (typeof value !== "string" || value.length < 1 || value.length > 160
53
+ || !/^\/[A-Za-z0-9._~/-]*$/u.test(value) || value.includes("//")
54
+ || value.split("/").some((part) => part === "." || part === "..")) {
55
+ throw new TypeError("basePath must be a normalized injection-safe absolute path");
56
+ }
57
+ const withoutTrailing = value.length > 1 ? value.replace(/\/+$/u, "") : value;
58
+ return withoutTrailing === "/" ? "/" : `${withoutTrailing}/`;
59
+ }
60
+
61
+ export function agentWebScopeIdentity(basePath = "/") {
62
+ const scope = normalizeAgentWebBasePath(basePath);
63
+ return [...new TextEncoder().encode(scope)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
64
+ }
65
+
66
+ export function versionedAgentWebAsset(pathname, version) {
67
+ const path = normalizedPath(pathname, "asset pathname");
68
+ if (!path.startsWith("/assets/")) throw new TypeError("versioned asset pathname must be below /assets/");
69
+ return `${AGENT_WEB_RELEASE_ROOT}/${encodeURIComponent(validateAgentWebRelease(version))}${path.slice("/assets".length)}`;
70
+ }
71
+
72
+ export function agentWebCacheName(version, { basePath = "/" } = {}) {
73
+ return `${AGENT_WEB_CACHE_PREFIX}${agentWebScopeIdentity(basePath)}-${validateAgentWebRelease(version)}`;
74
+ }
75
+
76
+ const THEMES = new Set(["bright", "dark", "system"]);
77
+
78
+ function codeUnitCompare(left, right) {
79
+ return left < right ? -1 : (left > right ? 1 : 0);
80
+ }
81
+
82
+ function boundedText(value, name, maximum) {
83
+ if (typeof value !== "string" || value.length < 1 || value.length > maximum || /[\u0000-\u001f\u007f<>]/u.test(value)) {
84
+ throw new TypeError(`${name} is invalid`);
85
+ }
86
+ return value;
87
+ }
88
+
89
+ function normalizedPath(value, name, { trailingSlash = false } = {}) {
90
+ if (typeof value !== "string" || value.length > 256 || !/^\/[A-Za-z0-9._~/-]*$/u.test(value)
91
+ || value.includes("//") || value.split("/").some((part) => part === "." || part === "..")) {
92
+ throw new TypeError(`${name} must be a normalized absolute path`);
93
+ }
94
+ if (trailingSlash) return value.endsWith("/") ? value : `${value}/`;
95
+ return value.length > 1 ? value.replace(/\/$/u, "") : value;
96
+ }
97
+
98
+ function escapeHtml(value) {
99
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
100
+ .replaceAll("\"", "&quot;").replaceAll("'", "&#39;");
101
+ }
102
+
103
+ function concatenate(parts) {
104
+ const size = parts.reduce((total, part) => total + part.byteLength, 0);
105
+ const result = new Uint8Array(size);
106
+ let offset = 0;
107
+ for (const part of parts) {
108
+ result.set(part, offset);
109
+ offset += part.byteLength;
110
+ }
111
+ return result;
112
+ }
113
+
114
+ function uint32(value) {
115
+ return Uint8Array.of(value >>> 24, value >>> 16, value >>> 8, value);
116
+ }
117
+
118
+ function crc32(value) {
119
+ let crc = 0xffffffff;
120
+ for (const byte of value) {
121
+ crc ^= byte;
122
+ for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
123
+ }
124
+ return (crc ^ 0xffffffff) >>> 0;
125
+ }
126
+
127
+ function pngChunk(name, data) {
128
+ const type = new TextEncoder().encode(name);
129
+ return concatenate([uint32(data.byteLength), type, data, uint32(crc32(concatenate([type, data])))]);
130
+ }
131
+
132
+ function adler32(value) {
133
+ let a = 1;
134
+ let b = 0;
135
+ for (const byte of value) {
136
+ a = (a + byte) % 65_521;
137
+ b = (b + a) % 65_521;
138
+ }
139
+ return ((b << 16) | a) >>> 0;
140
+ }
141
+
142
+ function uncompressedZlib(value) {
143
+ const blocks = [Uint8Array.of(0x78, 0x01)];
144
+ for (let offset = 0; offset < value.byteLength;) {
145
+ const length = Math.min(65_535, value.byteLength - offset);
146
+ const final = offset + length === value.byteLength;
147
+ blocks.push(Uint8Array.of(
148
+ final ? 1 : 0,
149
+ length & 0xff,
150
+ length >>> 8,
151
+ (~length) & 0xff,
152
+ ((~length) >>> 8) & 0xff,
153
+ ));
154
+ blocks.push(value.slice(offset, offset + length));
155
+ offset += length;
156
+ }
157
+ blocks.push(uint32(adler32(value)));
158
+ return concatenate(blocks);
159
+ }
160
+
161
+ const iconCache = new Map();
162
+
163
+ export function createPwaIcon(size) {
164
+ if (![192, 512].includes(size)) throw new TypeError("PWA icon size must be 192 or 512");
165
+ if (iconCache.has(size)) return iconCache.get(size).slice();
166
+ const rowBytes = Math.ceil(size / 8);
167
+ const scanlines = new Uint8Array((rowBytes + 1) * size);
168
+ const scale = size / 512;
169
+ const bars = [[166, 235, 202, 336], [223, 165, 259, 336], [280, 201, 316, 336]];
170
+ for (let y = 0; y < size; y += 1) {
171
+ const py = y / scale;
172
+ const row = y * (rowBytes + 1);
173
+ for (let x = 0; x < size; x += 1) {
174
+ const px = x / scale;
175
+ const foreground = bars.some(([left, top, right, bottom]) => px >= left && px <= right && py >= top && py <= bottom);
176
+ if (foreground) scanlines[row + 1 + (x >>> 3)] |= 1 << (7 - (x & 7));
177
+ }
178
+ }
179
+ const ihdr = concatenate([uint32(size), uint32(size), Uint8Array.of(1, 3, 0, 0, 0)]);
180
+ const palette = Uint8Array.of(20, 125, 117, 225, 255, 249);
181
+ const png = concatenate([
182
+ Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
183
+ pngChunk("IHDR", ihdr),
184
+ pngChunk("PLTE", palette),
185
+ pngChunk("IDAT", uncompressedZlib(scanlines)),
186
+ pngChunk("IEND", new Uint8Array()),
187
+ ]);
188
+ iconCache.set(size, png);
189
+ return png.slice();
190
+ }
191
+
192
+ export function createPwaManifest({
193
+ basePath = "/",
194
+ name = "LazyingArt Agent",
195
+ shortName = "Lazying Agent",
196
+ version,
197
+ } = {}) {
198
+ const scope = normalizeAgentWebBasePath(basePath);
199
+ boundedText(name, "name", 80);
200
+ boundedText(shortName, "shortName", 24);
201
+ const iconBase = scope === "/" ? "" : scope.slice(0, -1);
202
+ return Object.freeze({
203
+ name,
204
+ short_name: shortName,
205
+ description: "A cloud presentation surface for AgInTi Agent with direct LocalLLM chat fallback.",
206
+ id: scope,
207
+ start_url: scope,
208
+ scope,
209
+ display: "standalone",
210
+ background_color: "#f4f7f6",
211
+ theme_color: "#f7faf9",
212
+ orientation: "any",
213
+ categories: Object.freeze(["productivity", "utilities"]),
214
+ icons: Object.freeze([
215
+ Object.freeze({ src: `${iconBase}${versionedAgentWebAsset("/assets/icon-192.png", version)}`, sizes: "192x192", type: "image/png", purpose: "any maskable" }),
216
+ Object.freeze({ src: `${iconBase}${versionedAgentWebAsset("/assets/icon-512.png", version)}`, sizes: "512x512", type: "image/png", purpose: "any maskable" }),
217
+ ]),
218
+ });
219
+ }
220
+
221
+ function validatedShellAssets(value, scope) {
222
+ if (!Array.isArray(value) || value.length < 2 || value.length > 32) {
223
+ throw new TypeError("shellAssets must contain 2-32 integrity entries");
224
+ }
225
+ const base = new URL(scope, "https://shell.invalid");
226
+ const seen = new Set();
227
+ let totalSize = 0;
228
+ const assets = value.map((entry, index) => {
229
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)
230
+ || Object.getPrototypeOf(entry) !== Object.prototype
231
+ || Object.keys(entry).sort(codeUnitCompare).join(",") !== "contentType,headers,sha256,size,url") {
232
+ throw new TypeError(`shellAssets[${index}] is invalid`);
233
+ }
234
+ if (typeof entry.url !== "string" || entry.url.length > 320 || !entry.url.startsWith("/")
235
+ || /[\\#\u0000-\u001f\u007f]/u.test(entry.url) || /%(?:2e|2f|5c)/iu.test(entry.url)) {
236
+ throw new TypeError(`shellAssets[${index}].url is invalid`);
237
+ }
238
+ const target = new URL(entry.url, base);
239
+ if (target.origin !== base.origin || !target.pathname.startsWith(scope)
240
+ || target.username || target.password || target.hash || seen.has(target.pathname + target.search)) {
241
+ throw new TypeError(`shellAssets[${index}].url is outside or duplicated within the scope`);
242
+ }
243
+ if (typeof entry.contentType !== "string" || ![
244
+ "text/html", "application/manifest+json", "text/css", "text/javascript", "image/png",
245
+ ].includes(entry.contentType)) throw new TypeError(`shellAssets[${index}].contentType is unsupported`);
246
+ if (typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/u.test(entry.sha256)) {
247
+ throw new TypeError(`shellAssets[${index}].sha256 is invalid`);
248
+ }
249
+ if (entry.headers === null || typeof entry.headers !== "object" || Array.isArray(entry.headers)
250
+ || Object.getPrototypeOf(entry.headers) !== Object.prototype || Object.keys(entry.headers).length > 16) {
251
+ throw new TypeError(`shellAssets[${index}].headers is invalid`);
252
+ }
253
+ const headers = {};
254
+ for (const [name, headerValue] of Object.entries(entry.headers).sort(([left], [right]) => codeUnitCompare(left, right))) {
255
+ if (!/^[a-z][a-z0-9-]{0,63}$/u.test(name)
256
+ || ["cache-control", "content-length", "content-type", "set-cookie"].includes(name)
257
+ || typeof headerValue !== "string" || headerValue.length < 1 || headerValue.length > 2_048
258
+ || /[\r\n\u0000]/u.test(headerValue)) {
259
+ throw new TypeError(`shellAssets[${index}].headers contains an invalid contract`);
260
+ }
261
+ headers[name] = headerValue;
262
+ }
263
+ if (!Number.isSafeInteger(entry.size) || entry.size < 1 || entry.size > 4 * 1024 * 1024) {
264
+ throw new TypeError(`shellAssets[${index}].size is invalid`);
265
+ }
266
+ totalSize += entry.size;
267
+ if (totalSize > 16 * 1024 * 1024) throw new TypeError("shellAssets exceed the total shell size bound");
268
+ const url = target.pathname + target.search;
269
+ seen.add(url);
270
+ return Object.freeze({
271
+ url,
272
+ contentType: entry.contentType,
273
+ headers: Object.freeze(headers),
274
+ sha256: entry.sha256,
275
+ size: entry.size,
276
+ });
277
+ });
278
+ if (!seen.has(scope)) throw new TypeError("shellAssets must contain the exact navigation scope");
279
+ return Object.freeze(assets);
280
+ }
281
+
282
+ export function createServiceWorkerSource({
283
+ basePath = "/",
284
+ version,
285
+ contentDigest,
286
+ shellAssets,
287
+ } = {}) {
288
+ const scope = normalizeAgentWebBasePath(basePath);
289
+ const releaseVersion = validateAgentWebRelease(version);
290
+ if (typeof contentDigest !== "string" || !/^[a-f0-9]{64}$/u.test(contentDigest)) {
291
+ throw new TypeError("contentDigest must be a SHA-256 digest");
292
+ }
293
+ const shell = validatedShellAssets(shellAssets, scope);
294
+ const base = scope === "/" ? "" : scope.slice(0, -1);
295
+ const scopeIdentity = agentWebScopeIdentity(scope);
296
+ const cacheScopePrefix = `${AGENT_WEB_CACHE_PREFIX}${scopeIdentity}-`;
297
+ const cacheName = agentWebCacheName(releaseVersion, { basePath: scope });
298
+ const metaKey = `${base}/.lazying-agent-cache-${scopeIdentity}.json`;
299
+ const stateCacheName = `${AGENT_WEB_CACHE_PREFIX}state-${scopeIdentity}`;
300
+ const activeKey = `${base}/.lazying-agent-active-${scopeIdentity}.json`;
301
+ return `"use strict";\n\n`
302
+ + `const VERSION = ${JSON.stringify(releaseVersion)};\n`
303
+ + `const CONTENT_DIGEST = ${JSON.stringify(contentDigest)};\n`
304
+ + `const BASE = ${JSON.stringify(base)};\n`
305
+ + `const SCOPE_ID = ${JSON.stringify(scopeIdentity)};\n`
306
+ + `const CACHE_SCOPE_PREFIX = ${JSON.stringify(cacheScopePrefix)};\n`
307
+ + `const CACHE_NAME = ${JSON.stringify(cacheName)};\n`
308
+ + `const META_KEY = ${JSON.stringify(metaKey)};\n`
309
+ + `const STATE_CACHE_NAME = ${JSON.stringify(stateCacheName)};\n`
310
+ + `const ACTIVE_KEY = ${JSON.stringify(activeKey)};\n`
311
+ + `const CLIENT_OPERATION_TIMEOUT_MS = ${AGENT_WEB_ACTIVATION_CLIENT_TIMEOUT_MS};\n`
312
+ + `const EMERGENCY_PREDECESSOR_DIGESTS = new Set(${JSON.stringify(AGENT_WEB_EMERGENCY_PREDECESSOR_DIGESTS)});\n`
313
+ + `const SHELL = Object.freeze(${JSON.stringify(shell)});\n`
314
+ + `const STATIC = new Set(SHELL.slice(1).map((asset) => asset.url));\n\n`
315
+ + `async function digestHex(bytes) {\n`
316
+ + ` const digest = await crypto.subtle.digest("SHA-256", bytes);\n`
317
+ + ` return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");\n`
318
+ + `}\n\n`
319
+ + `async function verifiedResponse(asset) {\n`
320
+ + ` const expected = new URL(asset.url, self.location.origin).href;\n`
321
+ + ` const response = await fetch(new Request(asset.url, { cache: "reload", credentials: "same-origin", redirect: "error" }));\n`
322
+ + ` const contentType = String(response.headers.get("content-type") || "").toLowerCase().split(";", 1)[0].trim();\n`
323
+ + ` const headersMatch = Object.entries(asset.headers).every(([name, value]) => response.headers.get(name) === value);\n`
324
+ + ` if (!response.ok || response.status !== 200 || response.redirected || response.type === "opaque" || response.url !== expected\n`
325
+ + ` || contentType !== asset.contentType || !headersMatch) throw new Error("PWA asset response contract mismatch");\n`
326
+ + ` const bytes = await response.clone().arrayBuffer();\n`
327
+ + ` if (bytes.byteLength !== asset.size || await digestHex(bytes) !== asset.sha256) throw new Error("PWA asset integrity mismatch");\n`
328
+ + ` return { asset, response };\n`
329
+ + `}\n\n`
330
+ + `self.addEventListener("install", (event) => {\n`
331
+ + ` event.waitUntil(Promise.all(SHELL.map(verifiedResponse)).then(async (responses) => {\n`
332
+ + ` try {\n`
333
+ + ` const cache = await caches.open(CACHE_NAME);\n`
334
+ + ` await Promise.all(responses.map(({ asset, response }) => cache.put(asset.url, response)));\n`
335
+ + ` await cache.put(META_KEY, new Response(JSON.stringify({ cacheName: CACHE_NAME, contentDigest: CONTENT_DIGEST, scopeId: SCOPE_ID, installedAt: Date.now() }), { headers: { "content-type": "application/json" } }));\n`
336
+ + ` if (await emergencyPredecessor()) await self.skipWaiting();\n`
337
+ + ` } catch (error) {\n`
338
+ + ` await caches.delete(CACHE_NAME);\n`
339
+ + ` throw error;\n`
340
+ + ` }\n`
341
+ + ` }));\n`
342
+ + `});\n\n`
343
+ + `self.addEventListener("message", (event) => {\n`
344
+ + ` if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting();\n`
345
+ + ` if (event.data && event.data.type === "GET_LAZYING_AGENT_RELEASE") {\n`
346
+ + ` const reply = event.ports && event.ports[0] && typeof event.ports[0].postMessage === "function" ? event.ports[0] : event.source;\n`
347
+ + ` if (reply && typeof reply.postMessage === "function") reply.postMessage({ type: "LAZYING_AGENT_RELEASE", releaseId: VERSION });\n`
348
+ + ` }\n`
349
+ + `});\n\n`
350
+ + `async function cacheRecord(name) {\n`
351
+ + ` if (!name.startsWith(CACHE_SCOPE_PREFIX)) return null;\n`
352
+ + ` try {\n`
353
+ + ` const metadata = await (await (await caches.open(name)).match(META_KEY))?.json();\n`
354
+ + ` if (!metadata || Object.keys(metadata).sort().join(",") !== "cacheName,contentDigest,installedAt,scopeId"\n`
355
+ + ` || metadata.cacheName !== name || metadata.scopeId !== SCOPE_ID || !/^[a-f0-9]{64}$/.test(metadata.contentDigest)\n`
356
+ + ` || !name.endsWith("-" + metadata.contentDigest)\n`
357
+ + ` || !Number.isFinite(metadata.installedAt)) return null;\n`
358
+ + ` return { name, metadata };\n`
359
+ + ` } catch { return null; }\n`
360
+ + `}\n\n`
361
+ + `async function activePointer() {\n`
362
+ + ` try {\n`
363
+ + ` const value = await (await (await caches.open(STATE_CACHE_NAME)).match(ACTIVE_KEY))?.json();\n`
364
+ + ` if (!value || Object.keys(value).sort().join(",") !== "current,previous,scopeId" || value.scopeId !== SCOPE_ID\n`
365
+ + ` || typeof value.current !== "string" || !value.current.startsWith(CACHE_SCOPE_PREFIX)\n`
366
+ + ` || (value.previous !== null && (typeof value.previous !== "string" || !value.previous.startsWith(CACHE_SCOPE_PREFIX)))) return null;\n`
367
+ + ` return value;\n`
368
+ + ` } catch { return null; }\n`
369
+ + `}\n\n`
370
+ + `function boundedClientOperation(operation) {\n`
371
+ + ` return new Promise((resolve) => {\n`
372
+ + ` let settled = false;\n`
373
+ + ` let timer = null;\n`
374
+ + ` const finish = (value) => {\n`
375
+ + ` if (settled) return;\n`
376
+ + ` settled = true;\n`
377
+ + ` if (timer !== null) clearTimeout(timer);\n`
378
+ + ` resolve(value);\n`
379
+ + ` };\n`
380
+ + ` timer = setTimeout(() => finish(false), CLIENT_OPERATION_TIMEOUT_MS);\n`
381
+ + ` Promise.resolve().then(operation).then(finish, () => finish(false));\n`
382
+ + ` });\n`
383
+ + `}\n\n`
384
+ + `async function emergencyPredecessor() {\n`
385
+ + ` const records = (await Promise.all((await caches.keys()).map(cacheRecord))).filter(Boolean);\n`
386
+ + ` const pointer = await activePointer();\n`
387
+ + ` if (pointer) {\n`
388
+ + ` const active = records.find((record) => record.name === pointer.current);\n`
389
+ + ` return Boolean(active && active.name !== CACHE_NAME && EMERGENCY_PREDECESSOR_DIGESTS.has(active.metadata.contentDigest));\n`
390
+ + ` }\n`
391
+ + ` return records.some((record) => record.name !== CACHE_NAME && EMERGENCY_PREDECESSOR_DIGESTS.has(record.metadata.contentDigest));\n`
392
+ + `}\n\n`
393
+ + `self.addEventListener("activate", (event) => {\n`
394
+ + ` event.waitUntil(caches.keys().then(async (names) => {\n`
395
+ + ` const emergency = await emergencyPredecessor();\n`
396
+ + ` const records = (await Promise.all(names.map(cacheRecord))).filter(Boolean);\n`
397
+ + ` const current = records.find((record) => record.name === CACHE_NAME);\n`
398
+ + ` if (!current) throw new Error("current PWA cache metadata is unavailable");\n`
399
+ + ` const pointer = await activePointer();\n`
400
+ + ` const candidate = pointer?.current === CACHE_NAME ? pointer.previous : pointer?.current;\n`
401
+ + ` const previous = candidate && records.some((record) => record.name === candidate) ? candidate : null;\n`
402
+ + ` const keep = new Set([CACHE_NAME, previous].filter(Boolean));\n`
403
+ + ` await (await caches.open(STATE_CACHE_NAME)).put(ACTIVE_KEY, new Response(JSON.stringify({ scopeId: SCOPE_ID, current: CACHE_NAME, previous }), { headers: { "content-type": "application/json" } }));\n`
404
+ + ` const claim = boundedClientOperation(() => self.clients.claim());\n`
405
+ + ` if (emergency) {\n`
406
+ + ` const target = new URL(BASE + "/", self.location.origin);\n`
407
+ + ` target.search = "?v=" + encodeURIComponent(VERSION);\n`
408
+ + ` const windows = await boundedClientOperation(() => self.clients.matchAll({ type: "window", includeUncontrolled: true }));\n`
409
+ + ` await Promise.all((Array.isArray(windows) ? windows : []).map((client) => {\n`
410
+ + ` try {\n`
411
+ + ` const current = new URL(client.url);\n`
412
+ + ` if (current.origin !== self.location.origin || !current.pathname.startsWith(BASE + "/") || typeof client.navigate !== "function") return false;\n`
413
+ + ` return boundedClientOperation(() => client.navigate(target.href));\n`
414
+ + ` } catch { return false; }\n`
415
+ + ` }));\n`
416
+ + ` }\n`
417
+ + ` await claim;\n`
418
+ + ` await Promise.all(names.map((name) => name.startsWith(CACHE_SCOPE_PREFIX) && !keep.has(name) ? caches.delete(name) : false));\n`
419
+ + ` }));\n`
420
+ + `});\n\n`
421
+ + `self.addEventListener("fetch", (event) => {\n`
422
+ + ` const request = event.request;\n`
423
+ + ` const accept = String(request.headers.get("accept") || "").toLowerCase();\n`
424
+ + ` if (request.method !== "GET" || request.headers.has("range") || accept.includes("text/event-stream")) return;\n`
425
+ + ` const url = new URL(request.url);\n`
426
+ + ` if (url.origin !== self.location.origin || url.hash || !url.pathname.startsWith(BASE + "/")) return;\n`
427
+ + ` const relative = url.pathname.slice(BASE.length);\n`
428
+ + ` if (relative.startsWith("/api/") || relative.startsWith("/agent/") || relative.startsWith("/v1/")) return;\n`
429
+ + ` if (request.mode === "navigate" && !url.search && url.pathname === ${JSON.stringify(scope)}) {\n`
430
+ + ` event.respondWith(fetch(request).catch(() => caches.open(CACHE_NAME).then((cache) => cache.match(${JSON.stringify(scope)})).then((cached) => cached || Response.error())));\n`
431
+ + ` return;\n`
432
+ + ` }\n`
433
+ + ` const cacheKey = url.pathname + url.search;\n`
434
+ + ` if (!STATIC.has(cacheKey)) return;\n`
435
+ + ` event.respondWith(caches.open(CACHE_NAME).then((cache) => cache.match(cacheKey).then((cached) => cached || Response.error())));\n`
436
+ + `});\n`;
437
+ }
438
+
439
+ export function createAppShellHtml({
440
+ basePath = "/",
441
+ title = "LazyingArt Agent",
442
+ loginPath = "/api/login",
443
+ version,
444
+ } = {}) {
445
+ const scope = normalizeAgentWebBasePath(basePath);
446
+ const base = scope === "/" ? "" : scope.slice(0, -1);
447
+ const safeTitle = escapeHtml(boundedText(title, "title", 80));
448
+ const safeLoginPath = escapeHtml(normalizedPath(loginPath, "loginPath"));
449
+ const build = agentWebBuildQuery(version);
450
+ const modulePreloads = AGENT_WEB_MODULE_ROUTES.map((route) => (
451
+ ` <link rel="modulepreload" href="${base}${versionedAgentWebAsset(route, version)}">`
452
+ )).join("\n");
453
+ return `<!doctype html>
454
+ <html lang="en" data-theme="bright">
455
+ <head>
456
+ <meta charset="utf-8">
457
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
458
+ <meta name="theme-color" content="#f7faf9" id="theme-color">
459
+ <meta name="color-scheme" content="light dark">
460
+ <meta name="referrer" content="same-origin">
461
+ <title>${safeTitle}</title>
462
+ <meta name="lazying-agent-release" content="${escapeHtml(validateAgentWebRelease(version))}">
463
+ <meta name="lazying-agent-base-path" content="${scope}">
464
+ <meta name="lazying-agent-service-worker" content="${base}/sw.js">
465
+ <link rel="manifest" href="${base}/manifest.webmanifest${build}">
466
+ <link rel="stylesheet" href="${base}${versionedAgentWebAsset("/assets/app.css", version)}">
467
+ ${modulePreloads}
468
+ </head>
469
+ <body>
470
+ <div id="update-banner" class="notice update-notice" role="status" hidden>A safe app update is ready. <button id="apply-update" type="button">Update</button> <button id="defer-update" type="button">Later</button></div>
471
+ <main id="login-view" class="login-view" aria-labelledby="login-title">
472
+ <form id="login-form" class="login-card" method="post" action="${safeLoginPath}" autocomplete="on" aria-busy="true">
473
+ <p class="eyebrow">Private cloud workspace</p>
474
+ <h1 id="login-title">${safeTitle}</h1>
475
+ <p class="muted">Sign in to resume your server-held session. The app does not save your password.</p>
476
+ <label>Username<input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required maxlength="128"></label>
477
+ <label>Password<input id="password" name="password" type="password" autocomplete="current-password" required maxlength="1024"></label>
478
+ <label class="remember"><input id="remember-session" name="remember" type="checkbox" checked> Keep this device signed in</label>
479
+ <p id="login-error" class="form-error" role="alert" hidden></p>
480
+ <button id="login-submit" type="submit" class="primary" disabled>Preparing secure sign-in…</button>
481
+ <p class="privacy-note">Password saving is handled only by your browser or password manager.</p>
482
+ </form>
483
+ </main>
484
+
485
+ <div id="app-view" class="app-view" hidden>
486
+ <aside id="sidebar" class="sidebar" aria-label="Conversations">
487
+ <header class="brand"><span class="brand-mark" aria-hidden="true">LA</span><strong>${safeTitle}</strong></header>
488
+ <button id="new-thread" class="primary" type="button">New conversation</button>
489
+ <nav id="thread-list" class="thread-list" aria-label="Saved conversations"></nav>
490
+ <footer>
491
+ <span id="signed-in-user"></span>
492
+ <button id="logout" type="button" disabled>Sign out</button>
493
+ <button id="install-app" class="install-app" type="button" hidden>Install app</button>
494
+ </footer>
495
+ </aside>
496
+ <button id="sidebar-scrim" class="sidebar-scrim" type="button" aria-label="Close conversations" hidden></button>
497
+
498
+ <section id="workspace" class="workspace" data-mode="chat" data-status="idle">
499
+ <header class="topbar">
500
+ <button id="open-sidebar" class="icon-button" type="button" aria-label="Open conversations">☰</button>
501
+ <div class="conversation-meta">
502
+ <strong id="conversation-title">New conversation</strong>
503
+ <span id="connection-state" class="connection-state" role="status">Connecting</span>
504
+ </div>
505
+ <div id="mode-switch" class="mode-switch" role="group" aria-label="Conversation mode" hidden>
506
+ <button id="agent-mode" type="button" aria-pressed="false">Agent</button>
507
+ <button id="chat-mode" type="button" aria-pressed="true">Chat</button>
508
+ </div>
509
+ <label class="theme-label">Theme
510
+ <select id="theme-picker" autocomplete="off">
511
+ <option value="bright" selected>Bright</option>
512
+ <option value="dark">Dark</option>
513
+ <option value="system">System</option>
514
+ </select>
515
+ </label>
516
+ <details class="topbar-info">
517
+ <summary aria-label="Show app and capability information">Info</summary>
518
+ <p id="capability-note" class="capability-note">Chat · LocalLLM text only · no tools, file creation, or web search.</p>
519
+ </details>
520
+ </header>
521
+
522
+ <div id="offline-banner" class="notice" role="status" hidden>You are offline. Messages stay in the composer until the connection returns.</div>
523
+ <div id="context-indicator" class="context-indicator" data-testid="context-compaction" hidden><span id="context-indicator-text"></span></div>
524
+
525
+ <div id="chat-scroll" class="chat-scroll">
526
+ <section id="welcome" class="welcome">
527
+ <p class="eyebrow" id="welcome-eyebrow">Direct LocalLLM chat</p>
528
+ <h1 id="welcome-title">What can I help you work through?</h1>
529
+ <p id="welcome-copy">Agent mode appears only after AgInTi proves its exact capability contract.</p>
530
+ </section>
531
+ <section id="messages" class="messages" aria-live="polite" aria-relevant="additions text"></section>
532
+ </div>
533
+
534
+ <aside id="activity-panel" class="activity-panel" aria-label="AgInTi run activity" hidden>
535
+ <details id="activity-disclosure" class="activity-disclosure">
536
+ <summary><strong>Agent activity</strong><span id="run-state">Idle</span></summary>
537
+ <div class="activity-details">
538
+ <ol id="agent-plan" class="agent-plan" data-testid="agent-plan"></ol>
539
+ <ol id="agent-timeline" class="agent-timeline" data-testid="tool-timeline"></ol>
540
+ <section id="agent-artifacts" class="agent-artifacts" data-testid="artifact-panel" hidden></section>
541
+ </div>
542
+ </details>
543
+ </aside>
544
+
545
+ <form id="composer" class="composer" autocomplete="off">
546
+ <input id="image-input" type="file" accept="image/jpeg,image/png,image/heic,image/heif,.jpg,.jpeg,.png,.heic,.heif" multiple hidden>
547
+ <button id="add-image" class="image-button" type="button" aria-label="Add images" aria-live="polite" hidden>Images</button>
548
+ <div id="image-preview" class="image-preview" hidden>
549
+ <img id="image-preview-thumbnail" alt="First selected image preview">
550
+ <span id="image-preview-label"></span>
551
+ <button id="remove-image" type="button" aria-label="Remove all selected images">Remove</button>
552
+ </div>
553
+ <div id="search-controls" class="search-controls" hidden>
554
+ <button id="search-toggle" type="button" aria-pressed="false">Search</button>
555
+ <div id="search-options" class="search-options" hidden>
556
+ <label>Sources
557
+ <select id="search-mode" autocomplete="off">
558
+ <option value="web" selected>Web</option>
559
+ <option value="papers">Papers</option>
560
+ <option value="both">Web + papers</option>
561
+ </select>
562
+ </label>
563
+ <label>Limit
564
+ <input id="search-limit" type="number" inputmode="numeric" min="1" max="20" value="8" required>
565
+ </label>
566
+ </div>
567
+ </div>
568
+ <label class="sr-only" for="message-input">Message</label>
569
+ <textarea id="message-input" name="message" rows="1" maxlength="32000" placeholder="Message LocalLLM" required></textarea>
570
+ <div class="composer-actions">
571
+ <button id="resume-run" type="button" hidden>Resume</button>
572
+ <button id="stop-run" type="button" hidden>Stop</button>
573
+ <button id="send-message" class="primary" type="submit" aria-label="Send Chat">Send Chat</button>
574
+ </div>
575
+ </form>
576
+ </section>
577
+ </div>
578
+ <div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
579
+ <script type="module" src="${base}${versionedAgentWebAsset("/assets/app.js", version)}"></script>
580
+ </body>
581
+ </html>\n`;
582
+ }
583
+
584
+ export const BRIGHT_APP_CSS = `:root {
585
+ color-scheme: light;
586
+ --bg: #f4f7f6;
587
+ --surface: #ffffff;
588
+ --surface-soft: #eef4f2;
589
+ --text: #17302d;
590
+ --muted: #617571;
591
+ --line: #d5e1de;
592
+ --accent: #147d75;
593
+ --accent-strong: #0d625c;
594
+ --accent-soft: #d9f1ed;
595
+ --danger: #a63838;
596
+ --shadow: 0 18px 50px rgb(30 67 62 / 12%);
597
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
598
+ }
599
+ :root[data-theme="dark"] {
600
+ color-scheme: dark;
601
+ --bg: #0f1716;
602
+ --surface: #182321;
603
+ --surface-soft: #202e2b;
604
+ --text: #ecf5f2;
605
+ --muted: #9fb3ae;
606
+ --line: #314440;
607
+ --accent: #62c8bc;
608
+ --accent-strong: #8bd9d0;
609
+ --accent-soft: #193d39;
610
+ --danger: #ff9a9a;
611
+ --shadow: 0 18px 50px rgb(0 0 0 / 32%);
612
+ }
613
+ @media (prefers-color-scheme: dark) {
614
+ :root[data-theme="system"] {
615
+ color-scheme: dark;
616
+ --bg: #0f1716; --surface: #182321; --surface-soft: #202e2b; --text: #ecf5f2;
617
+ --muted: #9fb3ae; --line: #314440; --accent: #62c8bc; --accent-strong: #8bd9d0;
618
+ --accent-soft: #193d39; --danger: #ff9a9a; --shadow: 0 18px 50px rgb(0 0 0 / 32%);
619
+ }
620
+ }
621
+ * { box-sizing: border-box; }
622
+ html, body { min-height: 100%; margin: 0; background: var(--bg); color: var(--text); }
623
+ button, input, textarea, select { color: inherit; font: inherit; }
624
+ button, select, input, textarea { border: 1px solid var(--line); background: var(--surface); border-radius: 10px; }
625
+ button { cursor: pointer; padding: .62rem .9rem; }
626
+ button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent); outline-offset: 2px; }
627
+ button:disabled { cursor: not-allowed; opacity: .55; }
628
+ .primary { border-color: var(--accent); background: var(--accent); color: white; font-weight: 650; }
629
+ .primary:hover { background: var(--accent-strong); }
630
+ .login-view { min-height: 100dvh; display: grid; place-items: center; padding: 1.25rem; }
631
+ .login-card { width: min(100%, 430px); display: grid; gap: 1rem; padding: clamp(1.5rem, 4vw, 2.5rem); background: var(--surface); border: 1px solid var(--line); border-radius: 24px; box-shadow: var(--shadow); }
632
+ .login-card h1, .welcome h1 { margin: 0; letter-spacing: -.035em; }
633
+ .login-card label:not(.remember) { display: grid; gap: .4rem; font-weight: 600; }
634
+ .login-card input { min-height: 46px; padding: .7rem .8rem; }
635
+ .remember { display: flex; align-items: center; gap: .55rem; color: var(--muted); }
636
+ .remember input { width: 1.05rem; height: 1.05rem; }
637
+ .muted, .privacy-note, .capability-note { color: var(--muted); }
638
+ .privacy-note, .capability-note { font-size: .82rem; }
639
+ .form-error { margin: 0; color: var(--danger); }
640
+ .eyebrow { margin: 0; color: var(--accent); font-size: .78rem; font-weight: 750; letter-spacing: .1em; text-transform: uppercase; }
641
+ .app-view { height: 100vh; height: 100dvh; min-height: 0; display: grid; overflow: hidden; grid-template-columns: 280px minmax(0, 1fr); }
642
+ .sidebar { position: sticky; top: 0; height: 100dvh; min-height: 0; display: flex; overflow: hidden; flex-direction: column; gap: 1rem; padding: 1rem; background: var(--surface); border-right: 1px solid var(--line); z-index: 4; }
643
+ .brand { display: flex; gap: .7rem; align-items: center; min-height: 44px; }
644
+ .brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; background: var(--accent-soft); color: var(--accent-strong); font-size: .78rem; font-weight: 800; }
645
+ .thread-list { min-height: 0; flex: 1 1 0; display: grid; align-content: start; gap: .35rem; overflow-y: auto; overscroll-behavior: contain; }
646
+ .thread-row { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) minmax(4.5rem, max-content); gap: .3rem; align-items: stretch; }
647
+ .thread-list button { min-width: 0; overflow: hidden; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
648
+ .thread-open { width: 100%; min-height: 44px; }
649
+ .thread-delete { width: 100%; min-width: 4.5rem; min-height: 44px; padding-inline: .65rem; color: var(--danger); text-align: center !important; }
650
+ .thread-delete:hover:not(:disabled) { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 10%, var(--surface)); }
651
+ .sidebar footer { flex: 0 0 auto; display: grid; gap: .5rem; }
652
+ .workspace { min-width: 0; min-height: 0; height: 100%; grid-column: 2; display: grid; overflow: hidden; grid-template-areas: "topbar" "offline" "context" "chat" "activity" "composer"; grid-template-rows: auto auto auto minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr); }
653
+ .topbar { grid-area: topbar; min-width: 0; min-height: 56px; display: flex; flex-wrap: nowrap; align-items: center; gap: .65rem; padding: max(.3rem, env(safe-area-inset-top)) .75rem .3rem; border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface) 92%, transparent); backdrop-filter: blur(16px); white-space: nowrap; }
654
+ .conversation-meta { min-width: 0; flex: 1 1 auto; display: flex; align-items: baseline; gap: .5rem; overflow: hidden; }
655
+ #conversation-title { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
656
+ .connection-state { min-width: 0; max-width: min(12rem, 28vw); flex: 0 1 auto; overflow: hidden; color: var(--muted); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
657
+ .mode-switch { display: flex; padding: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); }
658
+ .mode-switch button { min-height: 44px; border: 0; padding: .45rem .75rem; background: transparent; }
659
+ .mode-switch button[aria-pressed="true"] { background: var(--surface); color: var(--accent-strong); box-shadow: 0 1px 5px rgb(0 0 0 / 8%); }
660
+ .theme-label { display: flex; align-items: center; gap: .4rem; color: var(--muted); font-size: .8rem; }
661
+ .theme-label select { padding: .42rem; }
662
+ .topbar-info { position: relative; flex: 0 0 auto; }
663
+ .topbar-info > summary { min-width: 44px; min-height: 44px; display: inline-grid; place-items: center; padding: .45rem .7rem; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--accent-strong); cursor: pointer; font-size: .82rem; font-weight: 700; list-style: none; user-select: none; }
664
+ .topbar-info > summary::-webkit-details-marker { display: none; }
665
+ .topbar-info > summary::marker { content: ""; }
666
+ .topbar-info > summary:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent); outline-offset: 2px; }
667
+ .capability-note { position: absolute; z-index: 6; top: calc(100% + .4rem); right: 0; width: min(22rem, calc(100vw - 1rem)); margin: 0; padding: .7rem .8rem; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); box-shadow: var(--shadow); line-height: 1.4; text-align: left; white-space: normal; }
668
+ .notice, .context-indicator { margin: .65rem 1rem 0; padding: .65rem .8rem; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); color: var(--muted); }
669
+ #offline-banner { grid-area: offline; }
670
+ .context-indicator { grid-area: context; }
671
+ .update-notice { position: fixed; z-index: 10; top: max(.4rem, env(safe-area-inset-top)); left: 50%; width: min(calc(100% - 1rem), 620px); margin: 0; transform: translateX(-50%); box-shadow: var(--shadow); }
672
+ .chat-scroll { grid-area: chat; min-height: 0; overflow-y: auto; padding: clamp(1rem, 4vw, 3rem) max(1rem, calc((100% - 850px) / 2)); }
673
+ .welcome { margin: 11vh auto 2rem; max-width: 680px; text-align: center; }
674
+ .welcome p:last-child { color: var(--muted); }
675
+ .messages { display: grid; gap: 1.2rem; }
676
+ .message { min-width: 0; max-width: min(86%, 760px); padding: .85rem 1rem; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); }
677
+ .message[data-role="user"] { justify-self: end; background: var(--accent-soft); }
678
+ .message[data-role="assistant"] { width: min(100%, 760px); max-width: min(100%, 760px); justify-self: start; }
679
+ .agent-run-failure { margin: 0; color: var(--danger); overflow-wrap: anywhere; }
680
+ .message pre { overflow-x: auto; padding: .8rem; border-radius: 10px; background: var(--surface-soft); }
681
+ .message-artifacts { min-width: 0; display: grid; gap: .75rem; margin-top: .85rem; padding-top: .85rem; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
682
+ .message-artifacts[hidden] { display: none; }
683
+ .artifact, .artifact > div { width: 100%; min-width: 0; max-width: 100%; }
684
+ .message table, .artifact-table { width: 100%; border-collapse: collapse; }
685
+ .message th, .message td, .artifact-table th, .artifact-table td { padding: .55rem; border: 1px solid var(--line); text-align: left; }
686
+ .table-scroll, .artifact-table-scroll { overflow-x: auto; }
687
+ .math-display { overflow-x: auto; padding: .5rem 0; }
688
+ .activity-panel { grid-area: activity; min-height: 0; overflow: hidden; border-top: 1px solid var(--line); background: var(--surface-soft); }
689
+ .activity-disclosure > summary { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .35rem max(1rem, calc((100% - 850px) / 2)); cursor: pointer; user-select: none; }
690
+ .activity-disclosure > summary::-webkit-details-marker { display: none; }
691
+ .activity-disclosure > summary::marker { content: ""; }
692
+ .activity-disclosure > summary strong { margin-right: auto; }
693
+ .activity-disclosure > summary::after { content: "Show details ⌄"; min-width: max-content; color: var(--accent-strong); font-size: .82rem; font-weight: 700; }
694
+ .activity-disclosure[open] > summary::after { content: "Hide details ⌃"; }
695
+ .activity-disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
696
+ .activity-details { max-height: min(30dvh, 20rem); overflow-y: auto; overscroll-behavior: contain; padding: 0 max(1rem, calc((100% - 850px) / 2)) .75rem; }
697
+ .agent-plan, .agent-timeline { display: grid; gap: .35rem; padding-left: 1.4rem; }
698
+ .agent-artifacts { display: grid; gap: .75rem; }
699
+ .artifact-plot { display: block; width: 100%; height: auto; max-width: 100%; max-height: 420px; aspect-ratio: 720 / 390; }
700
+ .plot-grid { stroke: var(--line); }
701
+ .plot-axis { stroke: var(--muted); }
702
+ .plot-grid, .plot-axis, .plot-series path { vector-effect: non-scaling-stroke; }
703
+ .plot-tick { fill: var(--muted); font-size: 13px; }
704
+ .plot-axis-label { font-size: 14px; font-weight: 650; }
705
+ .plot-axis-offset { font-size: 11px; font-variant-numeric: tabular-nums; }
706
+ .plot-label-compact { display: none; }
707
+ .artifact-legend { display: flex; min-width: 0; max-width: 100%; flex-wrap: wrap; gap: .55rem .85rem; margin: .65rem 0 0; padding: 0; line-height: 1.35; list-style: none; }
708
+ .artifact-legend li { min-width: 0; max-width: 100%; display: inline-flex; align-items: center; overflow-wrap: anywhere; }
709
+ .artifact-swatch { display: inline-block; width: .7rem; height: .7rem; flex: 0 0 auto; margin-right: .35rem; border-radius: 50%; }
710
+ .artifact-swatch-0 { background: #147d75; }
711
+ .artifact-swatch-1 { background: #4472ca; }
712
+ .artifact-swatch-2 { background: #c55c37; }
713
+ .artifact-swatch-3 { background: #8c5bbd; }
714
+ .artifact-swatch-4 { background: #73802d; }
715
+ .artifact-swatch-5 { background: #bb4f7b; }
716
+ .artifact-swatch-6 { background: #427f9e; }
717
+ .artifact-swatch-7 { background: #9b6b2f; }
718
+ .artifact-rejected { color: var(--danger); }
719
+ .composer { grid-area: composer; display: flex; gap: .75rem; align-items: end; padding: .8rem max(1rem, calc((100% - 850px) / 2)) max(.8rem, env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: var(--surface); }
720
+ .composer textarea { min-height: 48px; max-height: 180px; flex: 1; resize: vertical; padding: .75rem; }
721
+ .search-controls { display: flex; align-items: end; gap: .45rem; }
722
+ .search-controls > button { min-height: 48px; }
723
+ .search-controls > button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); }
724
+ .search-options { display: flex; gap: .4rem; }
725
+ .search-options label { display: grid; gap: .2rem; color: var(--muted); font-size: .72rem; }
726
+ .search-options select, .search-options input { min-height: 32px; padding: .35rem; }
727
+ .search-options input { width: 4.5rem; }
728
+ .artifact-sources { display: grid; gap: .65rem; padding: 0; list-style: none; }
729
+ .artifact-source-card { padding: .75rem; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); }
730
+ .artifact-source-title { margin: 0; font-size: 1rem; }
731
+ .artifact-source-snippet { margin: .45rem 0; }
732
+ .artifact-source-metadata { margin: .35rem 0 0; color: var(--muted); font-size: .78rem; }
733
+ .artifact-file-metadata { margin: 0 0 .6rem; overflow-wrap: anywhere; font-weight: 650; }
734
+ .artifact-file-controls { display: flex; min-width: 0; flex-wrap: wrap; gap: .55rem; }
735
+ .artifact-file-action { min-width: min(100%, 7.5rem); min-height: 44px; display: inline-flex; align-items: center; justify-content: center; padding: .65rem .9rem; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--accent-strong); font-weight: 700; text-decoration: none; touch-action: manipulation; }
736
+ .artifact-file-action:hover { border-color: var(--accent); background: var(--accent-soft); }
737
+ .artifact-file-download { background: var(--accent); color: white; }
738
+ .artifact-file-privacy { margin: .55rem 0 0; color: var(--muted); font-size: .78rem; overflow-wrap: anywhere; }
739
+ .image-button { min-height: 48px; }
740
+ .image-preview { display: flex; max-width: 190px; align-items: center; gap: .45rem; padding: .35rem; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); }
741
+ .image-preview img { width: 44px; height: 44px; flex: 0 0 auto; border-radius: 8px; object-fit: cover; }
742
+ .image-preview span { overflow: hidden; color: var(--muted); font-size: .75rem; text-overflow: ellipsis; white-space: nowrap; }
743
+ .image-preview button { padding: .35rem .5rem; }
744
+ .message-attachments { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: min(100%, 620px); gap: .5rem; margin-bottom: .65rem; }
745
+ .message-attachment-item { min-width: 0; }
746
+ .message-attachment { display: block; width: 100%; max-height: 520px; border-radius: 12px; object-fit: contain; }
747
+ .message > .message-attachment { max-width: min(100%, 620px); margin-bottom: .65rem; }
748
+ .message-attachment-status { padding: .35rem .5rem; border-color: transparent; background: transparent; text-align: left; }
749
+ .composer-actions { display: flex; gap: .4rem; }
750
+ .icon-button { display: none; }
751
+ .sidebar-scrim { display: none; }
752
+ .install-app { width: 100%; }
753
+ .toast { position: fixed; left: 50%; bottom: 1rem; z-index: 8; transform: translateX(-50%); padding: .7rem 1rem; border-radius: 12px; background: var(--text); color: var(--surface); }
754
+ .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
755
+ [hidden] { display: none !important; }
756
+ @media (max-width: 760px) {
757
+ .app-view { grid-template-columns: 1fr; }
758
+ .workspace { grid-column: 1; }
759
+ .sidebar { position: fixed; left: 0; width: min(84vw, 310px); transform: translateX(-102%); transition: transform .18s ease; box-shadow: var(--shadow); }
760
+ .sidebar[data-open="true"] { transform: translateX(0); }
761
+ .sidebar-scrim { position: fixed; inset: 0; z-index: 3; display: block; border: 0; border-radius: 0; background: rgb(0 0 0 / 30%); }
762
+ .icon-button { display: inline-grid; }
763
+ #open-sidebar, #send-message { min-width: 48px; min-height: 48px; }
764
+ .theme-label { display: none; }
765
+ .topbar { gap: .35rem; padding: max(.2rem, env(safe-area-inset-top)) .5rem .2rem; }
766
+ .connection-state { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
767
+ .mode-switch button { padding-inline: .55rem; }
768
+ .composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: .5rem; padding: .6rem .75rem max(.6rem, env(safe-area-inset-bottom)); }
769
+ .composer > .image-button, .composer > .image-preview, .composer > .search-controls { grid-column: 1 / -1; }
770
+ .composer textarea { grid-column: 1; width: 100%; }
771
+ .composer-actions { grid-column: 2; }
772
+ .search-controls { align-items: stretch; }
773
+ .search-options { flex: 1; }
774
+ .search-options label { flex: 1; }
775
+ .search-options select, .search-options input { width: 100%; }
776
+ .image-preview { max-width: 100%; }
777
+ .message-attachments { grid-template-columns: minmax(0, 1fr); }
778
+ .composer-actions { justify-content: flex-end; }
779
+ .message { max-width: 94%; }
780
+ .message[data-role="assistant"] { width: 94%; max-width: 94%; }
781
+ .plot-tick { font-size: 24px; }
782
+ .plot-y-tick { font-size: 22px; }
783
+ .plot-axis-label { font-size: 24px; }
784
+ .plot-axis-offset { font-size: 24px; }
785
+ .plot-label-wide { display: none; }
786
+ .plot-label-compact { display: inline; }
787
+ .artifact-legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); gap: .4rem .7rem; font-size: .92rem; }
788
+ .activity-details { max-height: min(24dvh, 14rem); }
789
+ }
790
+ @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } }
791
+ `;
792
+
793
+ export function createBrowserRuntimeConfig({
794
+ sessionEndpoint = "/api/session",
795
+ agentTransportEndpoint = "/api/transport",
796
+ } = {}) {
797
+ return Object.freeze({
798
+ sessionEndpoint: normalizedPath(sessionEndpoint, "sessionEndpoint"),
799
+ agentTransportEndpoint: normalizedPath(agentTransportEndpoint, "agentTransportEndpoint"),
800
+ });
801
+ }
802
+
803
+ export async function offerPasswordManagerSave(form, navigatorObject = globalThis.navigator) {
804
+ if (!form || typeof form !== "object") throw new TypeError("login form is required");
805
+ const PasswordCredentialConstructor = globalThis.PasswordCredential;
806
+ if (typeof PasswordCredentialConstructor !== "function" || typeof navigatorObject?.credentials?.store !== "function") return false;
807
+ const credential = new PasswordCredentialConstructor(form);
808
+ await navigatorObject.credentials.store(credential);
809
+ return true;
810
+ }
811
+
812
+ export function applyTheme(theme, {
813
+ document = globalThis.document,
814
+ storage = globalThis.localStorage,
815
+ } = {}) {
816
+ if (!THEMES.has(theme)) throw new TypeError("theme must be bright, dark, or system");
817
+ if (!document?.documentElement?.dataset) throw new TypeError("documentElement dataset is unavailable");
818
+ document.documentElement.dataset.theme = theme;
819
+ try { storage?.setItem("lazying-agent-theme", theme); } catch { /* Theme persistence is optional. */ }
820
+ return theme;
821
+ }
822
+
823
+ export function restoreTheme({
824
+ document = globalThis.document,
825
+ storage = globalThis.localStorage,
826
+ } = {}) {
827
+ let theme = "bright";
828
+ try {
829
+ const stored = storage?.getItem("lazying-agent-theme");
830
+ if (THEMES.has(stored)) theme = stored;
831
+ } catch { /* Bright remains the fail-safe default. */ }
832
+ return applyTheme(theme, { document, storage });
833
+ }
834
+
835
+ const WORKSPACE_MODES = new Set(["agent", "chat"]);
836
+
837
+ export function rememberWorkspaceMode(mode, {
838
+ storage = globalThis.localStorage,
839
+ } = {}) {
840
+ if (!WORKSPACE_MODES.has(mode)) throw new TypeError("workspace mode must be agent or chat");
841
+ try { storage?.setItem("lazying-agent-workspace-mode", mode); } catch { /* Mode preference is optional. */ }
842
+ return mode;
843
+ }
844
+
845
+ export function restoreWorkspaceMode({
846
+ storage = globalThis.localStorage,
847
+ } = {}) {
848
+ try {
849
+ const stored = storage?.getItem("lazying-agent-workspace-mode");
850
+ return WORKSPACE_MODES.has(stored) ? stored : null;
851
+ } catch {
852
+ return null;
853
+ }
854
+ }