@rubytech/create-maxy-code 0.1.440 → 0.1.442
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/dist/index.js +99 -1
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +64 -8
- package/payload/platform/plugins/cloudflare/skills/calendar-site/SKILL.md +15 -5
- package/payload/platform/plugins/cloudflare/skills/calendar-site/template/functions/api/calendar/free-busy.ts +152 -0
- package/payload/platform/plugins/cloudflare/skills/calendar-site/template/public/index.html +6 -3
- package/payload/platform/plugins/cloudflare/skills/calendar-site/template/schema.sql +11 -0
- package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
- package/payload/platform/plugins/docs/references/calendar-booking.md +1 -1
- package/payload/platform/plugins/docs/references/graph.md +19 -3
- package/payload/platform/plugins/docs/references/outlook-guide.md +7 -2
- package/payload/platform/plugins/docs/references/troubleshooting.md +35 -0
- package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/outlook/PLUGIN.md +7 -3
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/account-register.test.js +100 -10
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/account-register.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/device-flow.test.js +37 -4
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/device-flow.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.js +64 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-list.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.d.ts +2 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.js +193 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/mailbox-registry.test.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js +58 -1
- package/payload/platform/plugins/outlook/mcp/dist/__tests__/token-store.test.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.d.ts +27 -3
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.js +52 -11
- package/payload/platform/plugins/outlook/mcp/dist/auth/device-flow.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.d.ts +81 -0
- package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.js +171 -0
- package/payload/platform/plugins/outlook/mcp/dist/auth/mailbox-registry.js.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts +16 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js +25 -3
- package/payload/platform/plugins/outlook/mcp/dist/auth/token-store.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/index.js +119 -50
- package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/lib/graph-client.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.d.ts +13 -4
- package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.d.ts.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.js +54 -3
- package/payload/platform/plugins/outlook/mcp/dist/tools/account-register.js.map +1 -1
- package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.d.ts +26 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.d.ts.map +1 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.js +28 -0
- package/payload/platform/plugins/outlook/mcp/dist/tools/mailbox-list.js.map +1 -0
- package/payload/platform/plugins/outlook/references/auth.md +36 -8
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.d.ts +27 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js +241 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js.map +1 -0
- package/payload/platform/plugins/whatsapp/mcp/dist/index.js +20 -22
- package/payload/platform/plugins/whatsapp/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/whatsapp/references/channels-whatsapp.md +3 -1
- package/payload/platform/scripts/logs-read.sh +24 -6
- package/payload/platform/scripts/logs-read.test.sh +42 -0
- package/payload/platform/scripts/rss-sampler.sh +107 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
- package/payload/server/public/assets/{AdminLoginScreens-CukwQufW.js → AdminLoginScreens-BJVwtpLH.js} +1 -1
- package/payload/server/public/assets/{AdminShell-BwEBH6tT.js → AdminShell-CMu8_Xwn.js} +1 -1
- package/payload/server/public/assets/{Checkbox-B629mOPp.js → Checkbox-CzD0ORqA.js} +1 -1
- package/payload/server/public/assets/{admin-CdFRCUUZ.js → admin-D0yk2LMq.js} +1 -1
- package/payload/server/public/assets/{browser-BWR59IkB.js → browser-CvfbgznO.js} +1 -1
- package/payload/server/public/assets/{calendar-kuTcxX1t.js → calendar-fw4WethJ.js} +1 -1
- package/payload/server/public/assets/chat-DhD-NES_.js +1 -0
- package/payload/server/public/assets/chevron-left-B3xSeEDR.js +1 -0
- package/payload/server/public/assets/data-CUnDzDox.js +1 -0
- package/payload/server/public/assets/{file-download-4mKJxUi-.js → file-download-Px4s0Bhp.js} +1 -1
- package/payload/server/public/assets/{graph-NgQDY0Pb.js → graph-DbT8nA6R.js} +2 -2
- package/payload/server/public/assets/{graph-labels-B8YAdo3Z.js → graph-labels-C1YIyLiA.js} +1 -1
- package/payload/server/public/assets/{maximize-2-CxstTgoK.js → maximize-2-BwphzI9S.js} +1 -1
- package/payload/server/public/assets/{operator-oSaB1mya.js → operator-zcIfeB14.js} +1 -1
- package/payload/server/public/assets/{page-DMeJHVfA.js → page-6fCnIb45.js} +2 -2
- package/payload/server/public/assets/page-CfQFAJia.js +1 -0
- package/payload/server/public/assets/{public-C54tl6V4.js → public-C0ehZCdE.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-COGQTi7n.js → rotate-ccw-C7BqkMec.js} +1 -1
- package/payload/server/public/assets/{tasks-B97VpwEJ.js → tasks-DlgiCafL.js} +1 -1
- package/payload/server/public/assets/{time-entry-format-C09rDHo9.js → time-entry-format-CyNYVDkq.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-DHoYHiK9.js → useCopyFeedback-ByGUMN94.js} +1 -1
- package/payload/server/public/assets/useSubAccountSwitcher-1izQvPiD.css +1 -0
- package/payload/server/public/assets/useVoiceRecorder-XgpWvXpk.js +2 -0
- package/payload/server/public/browser.html +5 -5
- package/payload/server/public/calendar.html +6 -6
- package/payload/server/public/chat.html +12 -12
- package/payload/server/public/data.html +10 -10
- package/payload/server/public/graph.html +11 -11
- package/payload/server/public/index.html +13 -13
- package/payload/server/public/operator.html +14 -14
- package/payload/server/public/public.html +12 -12
- package/payload/server/public/tasks.html +5 -5
- package/payload/server/server.js +498 -180
- package/payload/server/public/assets/chat-ejnq2rck.js +0 -1
- package/payload/server/public/assets/chevron-left-u4tjq7fI.js +0 -1
- package/payload/server/public/assets/data-dZbOyLdT.js +0 -1
- package/payload/server/public/assets/page-DEkI-Gl1.js +0 -1
- package/payload/server/public/assets/useSubAccountSwitcher-Dzmd7Aip.css +0 -1
- package/payload/server/public/assets/useVoiceRecorder-b7HzTFs0.js +0 -2
- /package/payload/server/public/assets/{useSubAccountSwitcher-DLJFISqf.js → useSubAccountSwitcher-s6WlRn52.js} +0 -0
package/dist/index.js
CHANGED
|
@@ -856,6 +856,89 @@ function installSystemDeps() {
|
|
|
856
856
|
catch (err) {
|
|
857
857
|
console.error(` WARNING: failed to provision swap: ${err instanceof Error ? err.message : String(err)}`);
|
|
858
858
|
}
|
|
859
|
+
// Task 1593 — zram compressed-RAM swap ahead of the SD dphys swapfile. On a
|
|
860
|
+
// Pi, swapping to the SD card thrashes I/O and turns an approach-to-OOM into
|
|
861
|
+
// an apparent hard-freeze (the muvin 2026-07-13 incident). zram swaps to
|
|
862
|
+
// compressed RAM first (default priority 100 vs dphys -2), removing the
|
|
863
|
+
// thrash and adding effective headroom; dphys stays as the low-priority
|
|
864
|
+
// last-resort overflow tier. Best-effort: a failure warns but never aborts.
|
|
865
|
+
try {
|
|
866
|
+
console.log(" [privileged] apt-get install zram-tools");
|
|
867
|
+
shell("apt-get", ["install", "-y", "zram-tools"], { sudo: true, bestEffort: true });
|
|
868
|
+
const desiredZram = "ALGO=zstd\nPERCENT=50\n";
|
|
869
|
+
const zramConfPath = "/etc/default/zramswap";
|
|
870
|
+
let zramCurrent = "";
|
|
871
|
+
if (existsSync(zramConfPath)) {
|
|
872
|
+
try {
|
|
873
|
+
zramCurrent = readFileSync(zramConfPath, "utf-8");
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
zramCurrent = "";
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (zramCurrent.includes("ALGO=zstd") && zramCurrent.includes("PERCENT=50")) {
|
|
880
|
+
logFile(" zram: already configured (ALGO=zstd PERCENT=50)");
|
|
881
|
+
}
|
|
882
|
+
else {
|
|
883
|
+
const zramTmp = `/tmp/zramswap.${process.pid}`;
|
|
884
|
+
writeFileSync(zramTmp, desiredZram);
|
|
885
|
+
console.log(" [privileged] install /etc/default/zramswap (ALGO=zstd PERCENT=50)");
|
|
886
|
+
shell("cp", [zramTmp, zramConfPath], { sudo: true });
|
|
887
|
+
spawnSync("rm", ["-f", zramTmp]);
|
|
888
|
+
console.log(" [privileged] systemctl restart zramswap");
|
|
889
|
+
shell("systemctl", ["restart", "zramswap"], { sudo: true, bestEffort: true });
|
|
890
|
+
logFile(" zram: configured ALGO=zstd PERCENT=50 and restarted zramswap");
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
catch (err) {
|
|
894
|
+
console.error(` WARNING: failed to provision zram: ${err instanceof Error ? err.message : String(err)}`);
|
|
895
|
+
}
|
|
896
|
+
// Task 1593 — earlyoom userspace killer. Under sustained pressure it SIGTERMs
|
|
897
|
+
// the largest process at 10% MemAvailable (SIGKILL at 5%) BEFORE the kernel
|
|
898
|
+
// oom-killer wedges the box; systemd's Restart=on-failure then restarts the
|
|
899
|
+
// service, so a leak becomes "one process dies and comes back" instead of a
|
|
900
|
+
// hard freeze. --avoid protects sshd (keeps the box reachable, so SSH never
|
|
901
|
+
// drops during a kill) and systemd (init). Neo4j runs as comm=java and is
|
|
902
|
+
// crash-safe (recovers from its transaction log on restart), so it is
|
|
903
|
+
// intentionally not protected. Best-effort. The 10%/5% thresholds are the
|
|
904
|
+
// calibration target of the on-device induced-pressure test.
|
|
905
|
+
try {
|
|
906
|
+
console.log(" [privileged] apt-get install earlyoom");
|
|
907
|
+
shell("apt-get", ["install", "-y", "earlyoom"], { sudo: true, bestEffort: true });
|
|
908
|
+
// No inner quotes around the --avoid regex: the Debian earlyoom.service
|
|
909
|
+
// runs `ExecStart=/usr/bin/earlyoom $EARLYOOM_ARGS`, and systemd expands the
|
|
910
|
+
// variable by whitespace-splitting WITHOUT shell quote removal, so inner
|
|
911
|
+
// single-quotes would survive into the regex and never match sshd/systemd.
|
|
912
|
+
// The regex `^(sshd|systemd)$` contains no whitespace, so it stays one token.
|
|
913
|
+
const desiredEarlyoom = "EARLYOOM_ARGS=\"-m 10,5 -r 3600 --avoid ^(sshd|systemd)$\"\n";
|
|
914
|
+
const earlyoomConfPath = "/etc/default/earlyoom";
|
|
915
|
+
let earlyoomCurrent = "";
|
|
916
|
+
if (existsSync(earlyoomConfPath)) {
|
|
917
|
+
try {
|
|
918
|
+
earlyoomCurrent = readFileSync(earlyoomConfPath, "utf-8");
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
earlyoomCurrent = "";
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (earlyoomCurrent.includes("-m 10,5") && earlyoomCurrent.includes("--avoid ^(sshd|systemd)$")) {
|
|
925
|
+
logFile(" earlyoom: already configured (-m 10,5 --avoid sshd|systemd)");
|
|
926
|
+
}
|
|
927
|
+
else {
|
|
928
|
+
const eoTmp = `/tmp/earlyoom.${process.pid}`;
|
|
929
|
+
writeFileSync(eoTmp, desiredEarlyoom);
|
|
930
|
+
console.log(" [privileged] install /etc/default/earlyoom (-m 10,5 -r 3600)");
|
|
931
|
+
shell("cp", [eoTmp, earlyoomConfPath], { sudo: true });
|
|
932
|
+
spawnSync("rm", ["-f", eoTmp]);
|
|
933
|
+
console.log(" [privileged] systemctl enable --now earlyoom");
|
|
934
|
+
shell("systemctl", ["enable", "--now", "earlyoom"], { sudo: true, bestEffort: true });
|
|
935
|
+
shell("systemctl", ["restart", "earlyoom"], { sudo: true, bestEffort: true });
|
|
936
|
+
logFile(" earlyoom: configured -m 10,5 -r 3600 --avoid sshd|systemd and enabled");
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
catch (err) {
|
|
940
|
+
console.error(` WARNING: failed to provision earlyoom: ${err instanceof Error ? err.message : String(err)}`);
|
|
941
|
+
}
|
|
859
942
|
// Hostname resolution — four sources, in priority order:
|
|
860
943
|
// 1. --hostname flag (unconditional — the caller is the authority)
|
|
861
944
|
// 2. OS detection on same-brand upgrade (service exists → keep whatever is currently set)
|
|
@@ -4218,6 +4301,17 @@ function installService() {
|
|
|
4218
4301
|
const rssSamplerUnitName = `${BRAND.hostname}-rss-sampler.service`;
|
|
4219
4302
|
const rssSamplerScriptPath = resolve(INSTALL_DIR, "platform/scripts/rss-sampler.sh");
|
|
4220
4303
|
const rssSamplerLogPath = join(persistDir, "rss-sampler.log");
|
|
4304
|
+
// Task 1593 — the per-service unit names the sampler reads for its
|
|
4305
|
+
// op=mem-sample line, kept brand-agnostic by passing them as env. Neo4j runs
|
|
4306
|
+
// as a dedicated per-brand unit when it is off the default port, else the
|
|
4307
|
+
// shared system unit. MEM_PRESSURE_PCT sets the approach-to-OOM warning line
|
|
4308
|
+
// as a percentage of MemTotal; 20% sits above earlyoom's 10% SIGTERM line on
|
|
4309
|
+
// every fleet RAM size (8-16 GB), so the warning is logged before the kill
|
|
4310
|
+
// (a fixed MB value cannot stay above a percentage kill line across both).
|
|
4311
|
+
const memBrandService = `${BRAND.hostname}.service`;
|
|
4312
|
+
const memEdgeService = edgeUnitName;
|
|
4313
|
+
const memNeo4jUnit = NEO4J_DEDICATED ? `neo4j-${BRAND.hostname}.service` : "neo4j.service";
|
|
4314
|
+
const memPressurePct = "20";
|
|
4221
4315
|
const rssSamplerUnit = `[Unit]
|
|
4222
4316
|
Description=${BRAND.productName} RSS Sampler
|
|
4223
4317
|
|
|
@@ -4225,6 +4319,10 @@ Description=${BRAND.productName} RSS Sampler
|
|
|
4225
4319
|
Type=simple
|
|
4226
4320
|
ExecStart=/bin/bash ${rssSamplerScriptPath}
|
|
4227
4321
|
Environment=LOG_PATH=${rssSamplerLogPath}
|
|
4322
|
+
Environment=BRAND_SERVICE=${memBrandService}
|
|
4323
|
+
Environment=EDGE_SERVICE=${memEdgeService}
|
|
4324
|
+
Environment=NEO4J_UNIT=${memNeo4jUnit}
|
|
4325
|
+
Environment=MEM_PRESSURE_PCT=${memPressurePct}
|
|
4228
4326
|
Restart=on-failure
|
|
4229
4327
|
RestartSec=10
|
|
4230
4328
|
|
|
@@ -4232,7 +4330,7 @@ RestartSec=10
|
|
|
4232
4330
|
WantedBy=default.target
|
|
4233
4331
|
`;
|
|
4234
4332
|
writeFileSync(join(serviceDir, rssSamplerUnitName), rssSamplerUnit);
|
|
4235
|
-
logFile(` rss-sampler installed: ${rssSamplerUnitName} LOG_PATH=${rssSamplerLogPath}`);
|
|
4333
|
+
logFile(` rss-sampler installed: ${rssSamplerUnitName} LOG_PATH=${rssSamplerLogPath} BRAND_SERVICE=${memBrandService} EDGE_SERVICE=${memEdgeService} NEO4J_UNIT=${memNeo4jUnit} MEM_PRESSURE_PCT=${memPressurePct}`);
|
|
4236
4334
|
// the edge service: always-on front door that owns the public
|
|
4237
4335
|
// port (PORT) and the VNC stack (Xtigervnc + websockify). Its lifecycle is
|
|
4238
4336
|
// independent of the main brand service, so an in-place upgrade triggered
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: platform-architecture
|
|
3
3
|
description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
|
|
4
|
-
content-hash: sha256:
|
|
4
|
+
content-hash: sha256:93408025e99adfc1e44893f43734ca04b95c7991c596a4af5c7f471e00e884d8
|
|
5
5
|
brand: maxy-code
|
|
6
6
|
product-name: Maxy
|
|
7
7
|
---
|
|
@@ -2031,6 +2031,7 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
|
|
|
2031
2031
|
2. **Per account: register the Outlook account** — in admin chat, ask the agent to "register my Outlook account". The agent runs `outlook-account-register`, which returns a short user code and the URL `microsoft.com/devicelogin` and relays both to you.
|
|
2032
2032
|
3. **Consent on any device** — open `microsoft.com/devicelogin` on your phone or laptop, enter the code, sign in to your Microsoft account, and consent to the requested scopes (`offline_access`, `User.Read`, `Mail.ReadWrite`, `Mail.Send`, `Calendars.ReadWrite`, `Contacts.Read`). `Mail.Send` enables sending and `Calendars.ReadWrite` enables calendar control; a mailbox last consented on an older read-only scope set must re-register before send or any calendar write succeeds. The agent finishes by calling `outlook-account-register-poll`.
|
|
2033
2033
|
4. **Done.** Subsequent tool calls (mail, calendar, contacts) use the persisted refresh token transparently.
|
|
2034
|
+
5. **Several mailboxes on one account.** Register again to add a second mailbox; it is stored alongside the first, not in place of it. Every operational tool then takes an optional `mailbox` argument — an email address or graphUserId. With one mailbox, omit it. With two or more, name it; omitting it returns a refusal listing the connected mailboxes. `outlook-mailbox-list` shows every connected mailbox. Only one registration runs at a time per account; starting another while one is live returns an in-progress signal.
|
|
2034
2035
|
|
|
2035
2036
|
## Tools
|
|
2036
2037
|
|
|
@@ -2048,7 +2049,8 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
|
|
|
2048
2049
|
| `outlook-calendar-respond` | Accept, decline, or tentatively accept an invite. |
|
|
2049
2050
|
| `outlook-calendar-freebusy` | Availability for one or more addresses over a window. |
|
|
2050
2051
|
| `outlook-contacts-list` | Top contacts. Default top=50. |
|
|
2051
|
-
| `outlook-mailbox-info` | Health probe — auth state, refresh-window, folder count. |
|
|
2052
|
+
| `outlook-mailbox-info` | Health probe for one mailbox — auth state, refresh-window, folder count. Name the mailbox with `mailbox` when several are attached. |
|
|
2053
|
+
| `outlook-mailbox-list` | Every connected mailbox with its email, scopes, and token health. Local only — no network — so it also flags a mailbox whose stored credentials cannot be read. |
|
|
2052
2054
|
| `outlook-mail-send` | Send a message (`POST /me/sendMail`). to/cc/bcc arrays, subject, body, isHtml. Success asserted on Graph 202. Needs `Mail.Send`. |
|
|
2053
2055
|
| `outlook-draft` | Create a draft in Drafts (`POST /me/messages`). Same body model. Returns the draft id. Needs `Mail.ReadWrite`. |
|
|
2054
2056
|
| `outlook-draft-send` | Send an existing draft by id (`POST /me/messages/{id}/send`). Graph consumes the draft, returns 202. Needs `Mail.Send`. |
|
|
@@ -2066,7 +2068,9 @@ All log lines start with `[outlook-mcp]` and write to `server.log`. They are key
|
|
|
2066
2068
|
| Pending-store census | `pending-store-census account=<id> live=<N>` |
|
|
2067
2069
|
| Pending cleared | `pending-cleared account=<id> verified=<bool>` |
|
|
2068
2070
|
| /me failed post-token | `me-failed account=<id> status=<N>` |
|
|
2069
|
-
| Auth ok | `auth-ok account=<id> graphUserId=<id> scopes=<csv> tokenExpSec=<N>` |
|
|
2071
|
+
| Auth ok | `auth-ok account=<id> graphUserId=<id> mail=<addr> scopes=<csv> tokenExpSec=<N>` |
|
|
2072
|
+
| Registration already in flight | `register-in-progress account=<id>` (start refused; one device-code flow at a time) |
|
|
2073
|
+
| Credential resolve | `cred-resolve account=<id> mailbox=<id-or-empty> source=arg|sole|none|refused` — names the resolved mailbox and how it was chosen; `source=refused` is the ambiguity/not-found refusal |
|
|
2070
2074
|
| Token refreshed | `token-refreshed account=<id> oldExpSec=<N> newExpSec=<N>` |
|
|
2071
2075
|
| Refresh failed | `token-refresh-failed account=<id> reason=<err>` (terminal) |
|
|
2072
2076
|
| Mail list | `mail-list op=resolve account=<id> folderIn=<name> folderResolved=<token> asRawId=<bool> count=<N> elapsedMs=<N>` (on error, `error=<msg>` replaces `count`) |
|
|
@@ -2081,6 +2085,7 @@ All log lines start with `[outlook-mcp]` and write to `server.log`. They are key
|
|
|
2081
2085
|
| Scope insufficient | `scope-insufficient tool=<t> account=<id> missing=Calendars.ReadWrite` (stale read-only consent) |
|
|
2082
2086
|
| Contacts list | `contacts-list account=<id> count=<N> elapsedMs=<N>` |
|
|
2083
2087
|
| Mailbox info | `mailbox-info account=<id> tokenWithinRefreshWindow=<bool> folderCount=<N>` |
|
|
2088
|
+
| Mailbox list | `mailbox-list account=<id> count=<N> unreadable=<N>` (unreadable>0 flags an orphaned or corrupt mailbox store) |
|
|
2084
2089
|
| Graph error | `graph-error account=<id> status=<N> code=<graphErrorCode> retryAfterMs=<N-or-null>` — writes flow through the same `callGraph` loop, so a failed write logs `graph-error` too |
|
|
2085
2090
|
| On-prem rejected | `on-prem-rejected account=<id> mailServer=<host>` (terminal) |
|
|
2086
2091
|
| Scope insufficient | `scope-insufficient account=<id> tool=<t> missing=Mail.Send` (terminal — re-register to grant send) |
|
|
@@ -2292,7 +2297,7 @@ A `pendingUnreconciled` number that stays above zero across several runs means t
|
|
|
2292
2297
|
|
|
2293
2298
|
## What this does not do
|
|
2294
2299
|
|
|
2295
|
-
One meeting type only (one length, one weekly window, one buffer). No syncing to Google or iCloud calendars: your meetings in Maxy are the source of truth. No rescheduling or cancellation links, reminders, or visitor time-zone selection. The booking page shows
|
|
2300
|
+
One meeting type only (one length, one weekly window, one buffer). No syncing to Google or iCloud calendars: your meetings in Maxy are the source of truth. No rescheduling or cancellation links, reminders, or visitor time-zone selection. The booking page shows open times from a snapshot your device publishes to the edge on a short timer, so the page keeps showing availability and taking bookings even while your device is offline — a slot that gets booked drops off at once, and times that have passed are never shown. If your device stays offline a long time the open times can go stale, but they never vanish; they refresh the moment it is back online.
|
|
2296
2301
|
|
|
2297
2302
|
---
|
|
2298
2303
|
# LinkedIn Extension
|
|
@@ -2758,7 +2763,7 @@ either is a regression.
|
|
|
2758
2763
|
|
|
2759
2764
|
**`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp/operator reader (`/whatsapp`, `OperatorConversations`) omits the prop and keeps the default `renderTimeline`, so its bubble/DM chrome is unchanged — except that since the attachment-render extraction the default renderer draws inbound `operator-inbound` uploads and `agent-file` deliveries inline (image thumbnail / pdf.js preview / voice-note player / download chip) via the attachment renderer shared with `/chat`, extracted to `app/chat/attachment-render.tsx` to break the cycle with `transcript-render.tsx`; `renderChatTimeline` is untouched. Both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`, `app/chat/__tests__/pdf-preview.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.
|
|
2760
2765
|
|
|
2761
|
-
**WhatsApp reader is read/write (WhatsApp-Web surface).** A WhatsApp store conversation in the operator Conversations reader (`OperatorConversations`) and the admin root channel viewer (`app/page.tsx`) no longer opens the plain read-only DM chrome — it opens `app/whatsapp/WhatsappWebConversation.tsx`, a WhatsApp-Web-styled surface: a conversation header, the delivered-only store transcript reskinned green/white under a `.wa-web` root class, and a reply composer (`app/whatsapp/ReplyComposer.tsx`). The operator can type text, insert emoji (a codepoint-data picker — no OS input, so the `check-no-emoji-in-jsx` gate stays green), record a voice note (MediaRecorder → the existing audio send path), attach a file **from device storage** (a modal browsing the DATA_ROOT tree via `GET /api/admin/files`, sent by reference — the OS file dialog is no longer the reader's picker), reply to a specific message (a per-bubble affordance that quotes the original in the composer and threads natively on WhatsApp), and search the loaded transcript (a header control that filters to matching turns and highlights the match). The header carries only that Search control — the former Video / Phone / menu icons are gone, because no call or presence data exists behind them. Sent files honour the webchat allowlist (`SUPPORTED_MIME_SET` / 50 MB / 5 files); a device-file reference is read server-side under the same account-scope guard the `/data` download route applies. **Send is reply-only and DM-only:** `POST /api/whatsapp-reader/reply` (`requireAdminSession`) refuses any target with no existing stored conversation (`reason=no-existing-conversation`, HTTP 403) — a cold first-contact send bans the account, so the composer is never a cold-contact path, enforced server-side — and refuses `@g.us` groups (`reason=group-send-blocked`), matching the agent's observe-only group invariant; a group thread shows a read-only note in place of the composer. The endpoint is the sole store writer for a manual send: right after each send resolves it appends the record itself, stamped `origin:'operator-manual'` (attributed "You", distinct from an assistant reply), because the Baileys echo that normally persists the store is gated on message text (`manager.ts:906`) and would leave an uncaptioned media send delivered-but-invisible. The record's `messageId` matches the echo's derivation so the async text echo's own persist dedups to a no-op; the send is confirmed by re-read (`op=recorded present=<bool>`). A sent send does not spawn an assistant turn (echo dispatch stays suppressed). Attachments deliver as WhatsApp media/documents and render **inline** in the store reader (image thumbnail / pdf preview / audio player / download chip), because the manual-send path records the message with the servable `attachmentId`. The `.wa-web` skin is scoped to WhatsApp store threads only, so `/chat`, the admin agent session view, and the public visitor webchat are unchanged; the visitor webchat gains no send capability. The in-bubble delivered-tick is cosmetic (no read-receipt data is stored); inbound bubbles keep their sender name so group threads stay attributable, and both directions carry a WhatsApp callout tail (`.wa-web`-scoped only). Non-WhatsApp reader rows (Telegram/webchat session JSONLs) keep the plain read-only `Transcript`. Observability rides `[wa-manual-send]` lines correlated by a per-request `sendId`.
|
|
2766
|
+
**WhatsApp reader is read/write (WhatsApp-Web surface).** A WhatsApp store conversation in the operator Conversations reader (`OperatorConversations`) and the admin root channel viewer (`app/page.tsx`) no longer opens the plain read-only DM chrome — it opens `app/whatsapp/WhatsappWebConversation.tsx`, a WhatsApp-Web-styled surface: a conversation header, the delivered-only store transcript reskinned green/white under a `.wa-web` root class, and a reply composer (`app/whatsapp/ReplyComposer.tsx`). The operator can type text, insert emoji (a codepoint-data picker — no OS input, so the `check-no-emoji-in-jsx` gate stays green), record a voice note (MediaRecorder → the existing audio send path), attach a file **from device storage** (a modal browsing the DATA_ROOT tree via `GET /api/admin/files`, sent by reference — the OS file dialog is no longer the reader's picker), reply to a specific message (a per-bubble affordance that quotes the original in the composer and threads natively on WhatsApp), and search the loaded transcript (a header control that filters to matching turns and highlights the match). The header carries only that Search control — the former Video / Phone / menu icons are gone, because no call or presence data exists behind them. Sent files honour the webchat allowlist (`SUPPORTED_MIME_SET` / 50 MB / 5 files); a device-file reference is read server-side under the same account-scope guard the `/data` download route applies. **Send is reply-only and DM-only:** `POST /api/whatsapp-reader/reply` (`requireAdminSession`) refuses any target with no existing stored conversation (`reason=no-existing-conversation`, HTTP 403) — a cold first-contact send bans the account, so the composer is never a cold-contact path, enforced server-side — and refuses `@g.us` groups (`reason=group-send-blocked`), matching the agent's observe-only group invariant; a group thread shows a read-only note in place of the composer. The endpoint is the sole store writer for a manual send: right after each send resolves it appends the record itself, stamped `origin:'operator-manual'` (attributed "You", distinct from an assistant reply), because the Baileys echo that normally persists the store is gated on message text (`manager.ts:906`) and would leave an uncaptioned media send delivered-but-invisible. The record's `messageId` matches the echo's derivation so the async text echo's own persist dedups to a no-op; the send is confirmed by re-read (`op=recorded present=<bool>`). A sent send does not spawn an assistant turn (echo dispatch stays suppressed). Attachments deliver as WhatsApp media/documents and render **inline** in the store reader (image thumbnail / pdf preview / audio player / download chip), because the manual-send path records the message with the servable `attachmentId`. The `.wa-web` skin is scoped to WhatsApp store threads only, so `/chat`, the admin agent session view, and the public visitor webchat are unchanged; the visitor webchat gains no send capability. The in-bubble delivered-tick is cosmetic (no read-receipt data is stored); inbound bubbles keep their sender name so group threads stay attributable, and both directions carry a WhatsApp callout tail (`.wa-web`-scoped only). Non-WhatsApp reader rows (Telegram/webchat session JSONLs) keep the plain read-only `Transcript`. Observability rides `[wa-manual-send]` lines correlated by a per-request `sendId`. **Composer sizing matches the webchat composer:** the reply textarea auto-grows to fit multi-line content (measured from `scrollHeight`, clamped to a one-row minimum and a `viewport/3` maximum, scrollable past the cap with the scrollbar hidden), and a top-border grab handle drags (pointer) or nudges (ArrowUp/ArrowDown, keyboard-focusable) to an explicit height in the same clamp; a manual drag pins the height and overrides auto-grow until the field is cleared, which releases it back to auto-grow. A completed manual resize logs `[operator-ui] op=wa-composer-resize mode=drag|key h=<px>`; a bare click on the handle (no drag) logs nothing.
|
|
2762
2767
|
|
|
2763
2768
|
**`/chat` live activity line.** While a turn is in flight the transcript tail shows one ephemeral `ChatActivity` line (mounted only while `busy`, admin/operator only). It tracks real activity, not a timer: while a `Task` subagent runs it shows that subagent's headline (`agentType · description`, prefixed `N agents ·` when ≥2 are concurrent), sourced from `agent-<hex>.meta.json` via named `activity` SSE events the admin reader pushes from the session's `subagents/` dir; with no subagent active it shows a neutral word that advances only on a real turn arrival. The line carries a turn-elapsed clock and flips to a `stalled` state once nothing has been written for 5 minutes (`now − lastEmitAt`), so the operator can tell a wedge from progress without SSH. It is never added to the persisted timeline. Detail and the `[webchat-activity]` observability live in [`admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).
|
|
2764
2769
|
|
|
@@ -3182,8 +3187,10 @@ Source: https://docs.getmaxy.com/graph.md
|
|
|
3182
3187
|
# Graph View
|
|
3183
3188
|
|
|
3184
3189
|
The **Graph** admin page (`/graph`) renders a force-directed view of your
|
|
3185
|
-
account's Neo4j subgraph.
|
|
3186
|
-
|
|
3190
|
+
account's Neo4j subgraph. Node captions are hidden on first load so the
|
|
3191
|
+
whole-graph overview stays uncluttered, and appear the moment you interact.
|
|
3192
|
+
Once shown, captions follow the zoom level so you see the most useful identity
|
|
3193
|
+
at every scale.
|
|
3187
3194
|
|
|
3188
3195
|
## Default view
|
|
3189
3196
|
|
|
@@ -3239,11 +3246,25 @@ This is an in-page overlay, so the browser tab and address bar stay put. It is
|
|
|
3239
3246
|
not the operating system's full-screen mode. The maximize button shows only on
|
|
3240
3247
|
the `/graph` page, not where the graph is embedded in another pane.
|
|
3241
3248
|
|
|
3249
|
+
## Captions hidden on first load
|
|
3250
|
+
|
|
3251
|
+
When you open `/graph` (or refresh the page) the whole graph auto-fits into the
|
|
3252
|
+
viewport, which on a busy graph would render every caption at once and overlap
|
|
3253
|
+
them into unreadable clutter. So captions start hidden. They appear on your
|
|
3254
|
+
first interaction of any kind — zoom, drag, a filter chip, a search, clicking a
|
|
3255
|
+
node — and stay visible for the rest of that visit. To get the clean
|
|
3256
|
+
caption-free overview back, reload the page.
|
|
3257
|
+
|
|
3258
|
+
This is a one-off marker tied to page load, not a zoom threshold: after the
|
|
3259
|
+
first interaction, captions follow the zoom tiers below and do not hide again
|
|
3260
|
+
until the next reload.
|
|
3261
|
+
|
|
3242
3262
|
## Conversation label tiers
|
|
3243
3263
|
|
|
3244
3264
|
Conversation nodes carry the most operator-meaningful identity in the
|
|
3245
3265
|
subgraph (the conversation name or summary, the date it started, the message
|
|
3246
|
-
count).
|
|
3266
|
+
count). Once captions are showing they render in one of three tiers, switched
|
|
3267
|
+
by canvas scale:
|
|
3247
3268
|
|
|
3248
3269
|
| Zoom | Label shape | Example |
|
|
3249
3270
|
|------|-------------|---------|
|
|
@@ -4699,6 +4720,41 @@ Maxy has access to all platform logs and can filter them for you.
|
|
|
4699
4720
|
|
|
4700
4721
|
---
|
|
4701
4722
|
|
|
4723
|
+
## Box went unreachable, suspected memory exhaustion
|
|
4724
|
+
|
|
4725
|
+
**Symptom:** The Pi stopped responding to everything (no web, no SSH, no ping) and needed a power-cycle. This is the signature of memory exhaustion: the box fills RAM and swap, then wedges while the kernel tries to free memory.
|
|
4726
|
+
|
|
4727
|
+
**What protects against it:** Every install runs a standing memory audit every 30 seconds, zram compressed swap so pressure hits RAM instead of the SD card, and earlyoom, which kills the single largest process before the kernel freezes the box. A kill shows up as one service restarting, not a whole-box outage, and SSH is protected so the box stays reachable.
|
|
4728
|
+
|
|
4729
|
+
**Check the approach to OOM after a reboot.** The audit log survives reboot. Read it:
|
|
4730
|
+
|
|
4731
|
+
```
|
|
4732
|
+
~/<install>/platform/scripts/logs-read.sh --tail mem 100
|
|
4733
|
+
```
|
|
4734
|
+
|
|
4735
|
+
Replace `<install>` with the install directory (for example `realagent-code`). Then:
|
|
4736
|
+
|
|
4737
|
+
- Look for `op=mem-pressure` lines. They fire when available memory crosses the low-water mark, before any kill, and name the heaviest service (`topSlice`).
|
|
4738
|
+
- A single service's reading (`ptys`, `neo4j`, `brand`, `edge`, `vnc`) rising over time while the others stay flat is a memory leak in that service.
|
|
4739
|
+
- `op=mem-sample` lines that read `na` for the per-service fields are normal until the kernel memory controller is enabled on that box; available memory, swap, and the pressure warning still work.
|
|
4740
|
+
|
|
4741
|
+
**Confirm a kill happened and the service came back:**
|
|
4742
|
+
|
|
4743
|
+
```
|
|
4744
|
+
journalctl -t earlyoom --since -1h
|
|
4745
|
+
journalctl --user -u <hostname>.service --since -1h | grep -iE "Started|Scheduled restart"
|
|
4746
|
+
```
|
|
4747
|
+
|
|
4748
|
+
**Check whether the kernel OOM-killer fired anyway** (it should not, if earlyoom pre-empted it):
|
|
4749
|
+
|
|
4750
|
+
```
|
|
4751
|
+
journalctl -b -1 -k | grep -i oom
|
|
4752
|
+
```
|
|
4753
|
+
|
|
4754
|
+
A kernel `invoked oom-killer` line means the mitigation did not pre-empt the kernel on that occasion. A gap in the `op=mem-sample` timestamps means the sampler itself stopped. Both are worth escalating.
|
|
4755
|
+
|
|
4756
|
+
---
|
|
4757
|
+
|
|
4702
4758
|
## Cloudflare Tunnel Down (Remote Access Broken)
|
|
4703
4759
|
|
|
4704
4760
|
**Symptom:** You can reach Maxy on your local network but not via your public domain.
|
|
@@ -24,7 +24,11 @@ A booking visitor and the admin look at the booking from opposite sides — the
|
|
|
24
24
|
| Admin calendar (4 views) | the install, at `/calendar` on the admin host (resolve via `admin-hostname`) | admin sign-in | every time-bound item for the account — booked and imported meetings, agent-scheduled appointments, and tasks with a due date |
|
|
25
25
|
| Public booking page | Cloudflare Pages, on the booking domain | open | free/busy only, never any meeting detail |
|
|
26
26
|
|
|
27
|
-
Only the public page is deployed here, because it must stay reachable and keep capturing bookings even when the install device is
|
|
27
|
+
Only the public page is deployed here, because it must stay reachable and keep capturing bookings even when the install device is offline. The admin calendar is dynamic, account-scoped, and authenticated, so it is served by the install over the tunnel and is same-origin with its data. Do not try to put the admin calendar on Pages.
|
|
28
|
+
|
|
29
|
+
## Availability survives the device going offline
|
|
30
|
+
|
|
31
|
+
The booking page does not fetch open times from the device. It reads them same-origin from its own edge Function, `functions/api/calendar/free-busy.ts`, which serves a snapshot stored in the D1 `availability_snapshot` table minus any slot already booked (so a taken slot disappears at once, and two visitors never see the same slot) and with past slots filtered out (so a stale snapshot never offers an expired time). The snapshot is kept fresh by a publish job the platform server arms on a short timer: while the device is online it pushes the account's computed free/busy into `availability_snapshot`; while the device is off it simply does not run and the edge keeps serving the last snapshot until the device returns. There is no per-account cron or setup; the server arms it for the account it resolves, which is the one account whose booking site this install serves.
|
|
28
32
|
|
|
29
33
|
## Set the availability window first
|
|
30
34
|
|
|
@@ -34,7 +38,7 @@ The public page shows open slots computed as the weekly window minus busy `:Meet
|
|
|
34
38
|
<accountDir>/calendar-availability.json
|
|
35
39
|
```
|
|
36
40
|
|
|
37
|
-
Collect from the operator, then write the file: the account `timezone` (IANA), the meeting `durationMins`, the `bufferMins` between meetings, and the `weekly` window as `{ mon: [["09:00","17:00"]], ... }` with an empty array for days that are closed. Also collect `daysAhead` (the booking horizon — a positive integer count of days the page lets visitors book into, e.g. `14`) and `allowMultiSlot` (a boolean: may a visitor combine consecutive open slots into one longer back-to-back booking? default `false`). Add the `
|
|
41
|
+
Collect from the operator, then write the file: the account `timezone` (IANA), the meeting `durationMins`, the `bufferMins` between meetings, and the `weekly` window as `{ mon: [["09:00","17:00"]], ... }` with an empty array for days that are closed. Also collect `daysAhead` (the booking horizon — a positive integer count of days the page lets visitors book into, e.g. `14`) and `allowMultiSlot` (a boolean: may a visitor combine consecutive open slots into one longer back-to-back booking? default `false`). Add the `bookingDomain` (the custom domain the page will answer on) and `bookingDbName` (the D1 database name chosen below). Re-running this skill with new numbers is how availability — including the horizon and the multi-slot choice — is changed later; there is no separate editor. The device-side job that keeps the edge snapshot fresh is armed by the always-running platform server, so there is nothing per-account to schedule.
|
|
38
42
|
|
|
39
43
|
## Assemble the canonical tree
|
|
40
44
|
|
|
@@ -44,11 +48,11 @@ Copy the skill template into the one canonical Pages source path `site-deploy` e
|
|
|
44
48
|
<accountDir>/pages/<project>/
|
|
45
49
|
```
|
|
46
50
|
|
|
47
|
-
The template ships `public/index.html`, `public/booking.js`, `public/booking.css`, `functions/api/book.ts`, `wrangler.toml`, and `schema.sql`. The substitutions that make it concrete are: in `public/index.html`, set
|
|
51
|
+
The template ships `public/index.html`, `public/booking.js`, `public/booking.css`, `functions/api/book.ts`, `functions/api/calendar/free-busy.ts`, `wrangler.toml`, and `schema.sql`. The substitutions that make it concrete are: in `public/index.html`, set `<meta name="days-ahead">` content to `daysAhead` and set `<meta name="allow-multi-slot">` content to `allowMultiSlot` as the literal `true` or `false`; and fill `wrangler.toml`'s `__PROJECT_NAME__`, `__D1_DATABASE_NAME__`, and `__D1_DATABASE_ID__` from the project name and the database created below. Leave `<meta name="api-base">` empty as it ships; the page reads availability same-origin from the site's own `free-busy` edge Function (see the snapshot section below), never the device tunnel. Leave no `__...__` placeholder in the assembled tree.
|
|
48
52
|
|
|
49
53
|
## Provision the D1 bookings table
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
The booking-specific schema ships with the template: `schema.sql` creates both the `bookings` table (written by the booking page) and the `availability_snapshot` table (the single-row free/busy cache the publish job upserts and the `free-busy` edge Function reads). Applying `schema.sql` provisions both. Load the stable `<brand>-pages-d1` token from the account secrets file, minting it once only if absent and persisting it, exactly as `references/api.md` and `references/d1-data-capture.md` prescribe; the token needs both Pages Edit and D1 Edit. Create the database, record its id into `wrangler.toml`, and apply `schema.sql` to the remote database. The token is never written into the tree, never committed, never echoed.
|
|
52
56
|
|
|
53
57
|
## Deploy through site-deploy
|
|
54
58
|
|
|
@@ -59,7 +63,13 @@ Hand the assembled tree to `cloudflare:site-deploy`: it builds nothing for a sta
|
|
|
59
63
|
Done is a live behavioural signal on the booking domain, surfaced verbatim:
|
|
60
64
|
|
|
61
65
|
- The booking domain returns `HTTP/2 200` on a cache-busted request.
|
|
62
|
-
- The page's free/busy call succeeds: `curl` the
|
|
66
|
+
- The page's free/busy call succeeds same-origin: `curl` the booking domain's own `free-busy` edge Function and confirm it returns a `slots` array (with `updatedAt`) and carries no meeting title, description, or location field. Immediately after deploy it may return `{"slots":[],"updatedAt":null,"stale":true}` until the platform server's publish job runs once (within ~5 minutes); re-check until `updatedAt` is fresh and `slots` is populated. A response missing the `slots` field means the edge Function did not deploy; empty-with-`stale` after several minutes means the publish job is not reaching D1 (check the brand journal for `[calendar-availability]`).
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
curl -s "https://<booking-domain>/api/calendar/free-busy?from=$(date -u +%Y-%m-%dT00:00:00Z)&to=$(date -u -v+14d +%Y-%m-%dT00:00:00Z)" | head -c 400
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The device's own free/busy (the publish job's source) should also return a `slots` array; a `404` there means the availability window was not written:
|
|
63
73
|
|
|
64
74
|
```bash
|
|
65
75
|
curl -s "https://<brand-tunnel-domain>/api/calendar/free-busy?from=$(date -u +%Y-%m-%dT00:00:00Z)&to=$(date -u -v+14d +%Y-%m-%dT00:00:00Z)" | head -c 400
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Edge free/busy Function (calendar-site skill) — offline-resilient availability.
|
|
2
|
+
//
|
|
3
|
+
// Served at GET /api/calendar/free-busy on the booking domain, same-origin with
|
|
4
|
+
// the page. It serves open times from a snapshot the device publishes into D1
|
|
5
|
+
// on a short timer (platform/ui server → publish-availability), MINUS any slot
|
|
6
|
+
// already booked in the D1 `bookings` table:
|
|
7
|
+
// - Serving the snapshot means the public page keeps showing availability
|
|
8
|
+
// while the install device is offline (the device's own tunnel free/busy is
|
|
9
|
+
// then unreachable, but the snapshot survives on the edge).
|
|
10
|
+
// - Subtracting accepted bookings here — at the edge — means a booked slot
|
|
11
|
+
// disappears the instant it is taken, with no wait for the device to
|
|
12
|
+
// reconcile and republish, so two visitors are never shown the same slot.
|
|
13
|
+
// - Past slots are always filtered out, so a stale snapshot never offers a
|
|
14
|
+
// time that has already passed.
|
|
15
|
+
//
|
|
16
|
+
// Split like book.ts so the logic is verifiable without the Pages runtime:
|
|
17
|
+
// computeFreeBusy takes an injected DB + logger and returns the HTTP shape.
|
|
18
|
+
|
|
19
|
+
interface D1PreparedStatement {
|
|
20
|
+
bind: (...values: unknown[]) => D1PreparedStatement
|
|
21
|
+
first: <T = Record<string, unknown>>() => Promise<T | null>
|
|
22
|
+
all: <T = Record<string, unknown>>() => Promise<{ results?: T[] }>
|
|
23
|
+
}
|
|
24
|
+
interface D1Database {
|
|
25
|
+
prepare: (query: string) => D1PreparedStatement
|
|
26
|
+
}
|
|
27
|
+
interface FreeBusyEnv {
|
|
28
|
+
DB: D1Database
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Slot {
|
|
32
|
+
start: string
|
|
33
|
+
end: string
|
|
34
|
+
}
|
|
35
|
+
interface Snapshot {
|
|
36
|
+
durationMins?: number
|
|
37
|
+
timezone?: string
|
|
38
|
+
slots?: Slot[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Logger = (line: string) => void
|
|
42
|
+
|
|
43
|
+
interface FreeBusyQuery {
|
|
44
|
+
from?: string | null
|
|
45
|
+
to?: string | null
|
|
46
|
+
nowMs: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Default horizon when `to` is absent — a generous ceiling so a snapshot always
|
|
50
|
+
// covers the page's own (operator-set) horizon.
|
|
51
|
+
const DEFAULT_HORIZON_MS = 31 * 24 * 3600 * 1000
|
|
52
|
+
|
|
53
|
+
export interface FreeBusyResult {
|
|
54
|
+
durationMins: number | null
|
|
55
|
+
timezone: string | null
|
|
56
|
+
slots: Slot[]
|
|
57
|
+
updatedAt: string | null
|
|
58
|
+
stale?: boolean
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function computeFreeBusy(
|
|
62
|
+
env: FreeBusyEnv,
|
|
63
|
+
query: FreeBusyQuery,
|
|
64
|
+
log: Logger,
|
|
65
|
+
): Promise<FreeBusyResult> {
|
|
66
|
+
const { nowMs } = query
|
|
67
|
+
const from = query.from ? Date.parse(query.from) : nowMs
|
|
68
|
+
const to = query.to ? Date.parse(query.to) : nowMs + DEFAULT_HORIZON_MS
|
|
69
|
+
|
|
70
|
+
let row: { payload?: string; updatedAt?: string } | null = null
|
|
71
|
+
try {
|
|
72
|
+
row = await env.DB
|
|
73
|
+
.prepare('SELECT payload, updatedAt FROM availability_snapshot WHERE id = 1')
|
|
74
|
+
.first<{ payload?: string; updatedAt?: string }>()
|
|
75
|
+
} catch {
|
|
76
|
+
row = null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// No snapshot published yet (first deploy) is distinct from a corrupt payload
|
|
80
|
+
// below — both otherwise collapse to empty slots, so name them apart in the log.
|
|
81
|
+
if (!row || !row.payload) {
|
|
82
|
+
log('[calendar-availability] op=serve result=no-snapshot slots=0')
|
|
83
|
+
return { durationMins: null, timezone: null, slots: [], updatedAt: null, stale: true }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let snap: Snapshot
|
|
87
|
+
try {
|
|
88
|
+
snap = JSON.parse(row.payload) as Snapshot
|
|
89
|
+
} catch {
|
|
90
|
+
log('[calendar-availability] op=serve result=corrupt slots=0')
|
|
91
|
+
return { durationMins: null, timezone: null, slots: [], updatedAt: row.updatedAt ?? null, stale: true }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lower = Math.max(Number.isNaN(from) ? nowMs : from, nowMs)
|
|
95
|
+
const upper = Number.isNaN(to) ? nowMs + DEFAULT_HORIZON_MS : to
|
|
96
|
+
|
|
97
|
+
// Slots already taken (optimistic subtraction). Bound the scan to bookings at
|
|
98
|
+
// or after `lower`: reconcile only marks rows swept, never de-accepts them, so
|
|
99
|
+
// without this the accepted set grows without bound over the calendar's life —
|
|
100
|
+
// and a past booking can never match a snapshot slot that survives the
|
|
101
|
+
// start >= lower filter below, so the bound drops nothing. A failed read
|
|
102
|
+
// serves the snapshot as-is rather than 500 — the booking page still works.
|
|
103
|
+
const booked = new Set<string>()
|
|
104
|
+
try {
|
|
105
|
+
const res = await env.DB
|
|
106
|
+
.prepare("SELECT slotStart FROM bookings WHERE status = 'accepted' AND slotStart >= ?1")
|
|
107
|
+
.bind(new Date(lower).toISOString())
|
|
108
|
+
.all<{ slotStart?: string }>()
|
|
109
|
+
for (const b of res.results || []) {
|
|
110
|
+
if (b && typeof b.slotStart === 'string') booked.add(b.slotStart)
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
/* keep the snapshot as-is */
|
|
114
|
+
}
|
|
115
|
+
const slots = (snap.slots || []).filter((s) => {
|
|
116
|
+
const t = Date.parse(s.start)
|
|
117
|
+
return !Number.isNaN(t) && t >= lower && t < upper && !booked.has(s.start)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const ageMs = row.updatedAt ? nowMs - Date.parse(row.updatedAt) : null
|
|
121
|
+
log(`[calendar-availability] op=serve result=ok slots=${slots.length} ageMs=${ageMs ?? 'na'}`)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
durationMins: snap.durationMins ?? null,
|
|
125
|
+
timezone: snap.timezone ?? null,
|
|
126
|
+
slots,
|
|
127
|
+
updatedAt: row.updatedAt ?? null,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface PagesContext {
|
|
132
|
+
request: Request
|
|
133
|
+
env: FreeBusyEnv
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function onRequestGet(context: PagesContext): Promise<Response> {
|
|
137
|
+
const url = new URL(context.request.url)
|
|
138
|
+
const result = await computeFreeBusy(
|
|
139
|
+
context.env,
|
|
140
|
+
{
|
|
141
|
+
from: url.searchParams.get('from'),
|
|
142
|
+
to: url.searchParams.get('to'),
|
|
143
|
+
nowMs: Date.now(),
|
|
144
|
+
},
|
|
145
|
+
(line) => console.log(line),
|
|
146
|
+
)
|
|
147
|
+
// Free/busy carries no credentials; a wildcard origin keeps it readable if a
|
|
148
|
+
// page ever fetches it cross-origin, matching the device endpoint's CORS.
|
|
149
|
+
return Response.json(result, {
|
|
150
|
+
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
151
|
+
})
|
|
152
|
+
}
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<!--
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
<!-- Availability is read same-origin from this site's own edge Function
|
|
7
|
+
(functions/api/calendar/free-busy.ts), which serves the snapshot the
|
|
8
|
+
device publishes to D1. Empty = same-origin, so the page keeps working
|
|
9
|
+
when the install device is offline. Left blank deliberately; do not point
|
|
10
|
+
it at the device tunnel. -->
|
|
11
|
+
<meta name="api-base" content="" />
|
|
9
12
|
<!-- Operator-set booking horizon (positive integer days). The skill rewrites
|
|
10
13
|
the content; booking.js falls back to 14 when it is left unsubstituted. -->
|
|
11
14
|
<meta name="days-ahead" content="__DAYS_AHEAD__" />
|
|
@@ -16,3 +16,14 @@ CREATE TABLE IF NOT EXISTS bookings (
|
|
|
16
16
|
);
|
|
17
17
|
|
|
18
18
|
CREATE INDEX IF NOT EXISTS bookings_unswept ON bookings (status, swept);
|
|
19
|
+
|
|
20
|
+
-- Single-row cache of the account's computed free/busy, published by the device
|
|
21
|
+
-- on a short timer (platform/ui server → publish-availability). The booking
|
|
22
|
+
-- page's edge free/busy Function serves open times from this snapshot minus any
|
|
23
|
+
-- accepted booking, so the public page keeps showing availability while the
|
|
24
|
+
-- device is offline. id is pinned to 1: one account per booking site, one row.
|
|
25
|
+
CREATE TABLE IF NOT EXISTS availability_snapshot (
|
|
26
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
27
|
+
payload TEXT,
|
|
28
|
+
updatedAt TEXT
|
|
29
|
+
);
|
|
@@ -75,7 +75,7 @@ either is a regression.
|
|
|
75
75
|
|
|
76
76
|
**`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp/operator reader (`/whatsapp`, `OperatorConversations`) omits the prop and keeps the default `renderTimeline`, so its bubble/DM chrome is unchanged — except that since the attachment-render extraction the default renderer draws inbound `operator-inbound` uploads and `agent-file` deliveries inline (image thumbnail / pdf.js preview / voice-note player / download chip) via the attachment renderer shared with `/chat`, extracted to `app/chat/attachment-render.tsx` to break the cycle with `transcript-render.tsx`; `renderChatTimeline` is untouched. Both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`, `app/chat/__tests__/pdf-preview.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.
|
|
77
77
|
|
|
78
|
-
**WhatsApp reader is read/write (WhatsApp-Web surface).** A WhatsApp store conversation in the operator Conversations reader (`OperatorConversations`) and the admin root channel viewer (`app/page.tsx`) no longer opens the plain read-only DM chrome — it opens `app/whatsapp/WhatsappWebConversation.tsx`, a WhatsApp-Web-styled surface: a conversation header, the delivered-only store transcript reskinned green/white under a `.wa-web` root class, and a reply composer (`app/whatsapp/ReplyComposer.tsx`). The operator can type text, insert emoji (a codepoint-data picker — no OS input, so the `check-no-emoji-in-jsx` gate stays green), record a voice note (MediaRecorder → the existing audio send path), attach a file **from device storage** (a modal browsing the DATA_ROOT tree via `GET /api/admin/files`, sent by reference — the OS file dialog is no longer the reader's picker), reply to a specific message (a per-bubble affordance that quotes the original in the composer and threads natively on WhatsApp), and search the loaded transcript (a header control that filters to matching turns and highlights the match). The header carries only that Search control — the former Video / Phone / menu icons are gone, because no call or presence data exists behind them. Sent files honour the webchat allowlist (`SUPPORTED_MIME_SET` / 50 MB / 5 files); a device-file reference is read server-side under the same account-scope guard the `/data` download route applies. **Send is reply-only and DM-only:** `POST /api/whatsapp-reader/reply` (`requireAdminSession`) refuses any target with no existing stored conversation (`reason=no-existing-conversation`, HTTP 403) — a cold first-contact send bans the account, so the composer is never a cold-contact path, enforced server-side — and refuses `@g.us` groups (`reason=group-send-blocked`), matching the agent's observe-only group invariant; a group thread shows a read-only note in place of the composer. The endpoint is the sole store writer for a manual send: right after each send resolves it appends the record itself, stamped `origin:'operator-manual'` (attributed "You", distinct from an assistant reply), because the Baileys echo that normally persists the store is gated on message text (`manager.ts:906`) and would leave an uncaptioned media send delivered-but-invisible. The record's `messageId` matches the echo's derivation so the async text echo's own persist dedups to a no-op; the send is confirmed by re-read (`op=recorded present=<bool>`). A sent send does not spawn an assistant turn (echo dispatch stays suppressed). Attachments deliver as WhatsApp media/documents and render **inline** in the store reader (image thumbnail / pdf preview / audio player / download chip), because the manual-send path records the message with the servable `attachmentId`. The `.wa-web` skin is scoped to WhatsApp store threads only, so `/chat`, the admin agent session view, and the public visitor webchat are unchanged; the visitor webchat gains no send capability. The in-bubble delivered-tick is cosmetic (no read-receipt data is stored); inbound bubbles keep their sender name so group threads stay attributable, and both directions carry a WhatsApp callout tail (`.wa-web`-scoped only). Non-WhatsApp reader rows (Telegram/webchat session JSONLs) keep the plain read-only `Transcript`. Observability rides `[wa-manual-send]` lines correlated by a per-request `sendId`.
|
|
78
|
+
**WhatsApp reader is read/write (WhatsApp-Web surface).** A WhatsApp store conversation in the operator Conversations reader (`OperatorConversations`) and the admin root channel viewer (`app/page.tsx`) no longer opens the plain read-only DM chrome — it opens `app/whatsapp/WhatsappWebConversation.tsx`, a WhatsApp-Web-styled surface: a conversation header, the delivered-only store transcript reskinned green/white under a `.wa-web` root class, and a reply composer (`app/whatsapp/ReplyComposer.tsx`). The operator can type text, insert emoji (a codepoint-data picker — no OS input, so the `check-no-emoji-in-jsx` gate stays green), record a voice note (MediaRecorder → the existing audio send path), attach a file **from device storage** (a modal browsing the DATA_ROOT tree via `GET /api/admin/files`, sent by reference — the OS file dialog is no longer the reader's picker), reply to a specific message (a per-bubble affordance that quotes the original in the composer and threads natively on WhatsApp), and search the loaded transcript (a header control that filters to matching turns and highlights the match). The header carries only that Search control — the former Video / Phone / menu icons are gone, because no call or presence data exists behind them. Sent files honour the webchat allowlist (`SUPPORTED_MIME_SET` / 50 MB / 5 files); a device-file reference is read server-side under the same account-scope guard the `/data` download route applies. **Send is reply-only and DM-only:** `POST /api/whatsapp-reader/reply` (`requireAdminSession`) refuses any target with no existing stored conversation (`reason=no-existing-conversation`, HTTP 403) — a cold first-contact send bans the account, so the composer is never a cold-contact path, enforced server-side — and refuses `@g.us` groups (`reason=group-send-blocked`), matching the agent's observe-only group invariant; a group thread shows a read-only note in place of the composer. The endpoint is the sole store writer for a manual send: right after each send resolves it appends the record itself, stamped `origin:'operator-manual'` (attributed "You", distinct from an assistant reply), because the Baileys echo that normally persists the store is gated on message text (`manager.ts:906`) and would leave an uncaptioned media send delivered-but-invisible. The record's `messageId` matches the echo's derivation so the async text echo's own persist dedups to a no-op; the send is confirmed by re-read (`op=recorded present=<bool>`). A sent send does not spawn an assistant turn (echo dispatch stays suppressed). Attachments deliver as WhatsApp media/documents and render **inline** in the store reader (image thumbnail / pdf preview / audio player / download chip), because the manual-send path records the message with the servable `attachmentId`. The `.wa-web` skin is scoped to WhatsApp store threads only, so `/chat`, the admin agent session view, and the public visitor webchat are unchanged; the visitor webchat gains no send capability. The in-bubble delivered-tick is cosmetic (no read-receipt data is stored); inbound bubbles keep their sender name so group threads stay attributable, and both directions carry a WhatsApp callout tail (`.wa-web`-scoped only). Non-WhatsApp reader rows (Telegram/webchat session JSONLs) keep the plain read-only `Transcript`. Observability rides `[wa-manual-send]` lines correlated by a per-request `sendId`. **Composer sizing matches the webchat composer:** the reply textarea auto-grows to fit multi-line content (measured from `scrollHeight`, clamped to a one-row minimum and a `viewport/3` maximum, scrollable past the cap with the scrollbar hidden), and a top-border grab handle drags (pointer) or nudges (ArrowUp/ArrowDown, keyboard-focusable) to an explicit height in the same clamp; a manual drag pins the height and overrides auto-grow until the field is cleared, which releases it back to auto-grow. A completed manual resize logs `[operator-ui] op=wa-composer-resize mode=drag|key h=<px>`; a bare click on the handle (no drag) logs nothing.
|
|
79
79
|
|
|
80
80
|
**`/chat` live activity line.** While a turn is in flight the transcript tail shows one ephemeral `ChatActivity` line (mounted only while `busy`, admin/operator only). It tracks real activity, not a timer: while a `Task` subagent runs it shows that subagent's headline (`agentType · description`, prefixed `N agents ·` when ≥2 are concurrent), sourced from `agent-<hex>.meta.json` via named `activity` SSE events the admin reader pushes from the session's `subagents/` dir; with no subagent active it shows a neutral word that advances only on a real turn arrival. The line carries a turn-elapsed clock and flips to a `stalled` state once nothing has been written for 5 minutes (`now − lastEmitAt`), so the operator can tell a wedge from progress without SSH. It is never added to the persisted timeline. Detail and the `[webchat-activity]` observability live in [`admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).
|
|
81
81
|
|
|
@@ -55,4 +55,4 @@ A `pendingUnreconciled` number that stays above zero across several runs means t
|
|
|
55
55
|
|
|
56
56
|
## What this does not do
|
|
57
57
|
|
|
58
|
-
One meeting type only (one length, one weekly window, one buffer). No syncing to Google or iCloud calendars: your meetings in Maxy are the source of truth. No rescheduling or cancellation links, reminders, or visitor time-zone selection. The booking page shows
|
|
58
|
+
One meeting type only (one length, one weekly window, one buffer). No syncing to Google or iCloud calendars: your meetings in Maxy are the source of truth. No rescheduling or cancellation links, reminders, or visitor time-zone selection. The booking page shows open times from a snapshot your device publishes to the edge on a short timer, so the page keeps showing availability and taking bookings even while your device is offline — a slot that gets booked drops off at once, and times that have passed are never shown. If your device stays offline a long time the open times can go stale, but they never vanish; they refresh the moment it is back online.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Graph View
|
|
2
2
|
|
|
3
3
|
The **Graph** admin page (`/graph`) renders a force-directed view of your
|
|
4
|
-
account's Neo4j subgraph.
|
|
5
|
-
|
|
4
|
+
account's Neo4j subgraph. Node captions are hidden on first load so the
|
|
5
|
+
whole-graph overview stays uncluttered, and appear the moment you interact.
|
|
6
|
+
Once shown, captions follow the zoom level so you see the most useful identity
|
|
7
|
+
at every scale.
|
|
6
8
|
|
|
7
9
|
## Default view
|
|
8
10
|
|
|
@@ -58,11 +60,25 @@ This is an in-page overlay, so the browser tab and address bar stay put. It is
|
|
|
58
60
|
not the operating system's full-screen mode. The maximize button shows only on
|
|
59
61
|
the `/graph` page, not where the graph is embedded in another pane.
|
|
60
62
|
|
|
63
|
+
## Captions hidden on first load
|
|
64
|
+
|
|
65
|
+
When you open `/graph` (or refresh the page) the whole graph auto-fits into the
|
|
66
|
+
viewport, which on a busy graph would render every caption at once and overlap
|
|
67
|
+
them into unreadable clutter. So captions start hidden. They appear on your
|
|
68
|
+
first interaction of any kind — zoom, drag, a filter chip, a search, clicking a
|
|
69
|
+
node — and stay visible for the rest of that visit. To get the clean
|
|
70
|
+
caption-free overview back, reload the page.
|
|
71
|
+
|
|
72
|
+
This is a one-off marker tied to page load, not a zoom threshold: after the
|
|
73
|
+
first interaction, captions follow the zoom tiers below and do not hide again
|
|
74
|
+
until the next reload.
|
|
75
|
+
|
|
61
76
|
## Conversation label tiers
|
|
62
77
|
|
|
63
78
|
Conversation nodes carry the most operator-meaningful identity in the
|
|
64
79
|
subgraph (the conversation name or summary, the date it started, the message
|
|
65
|
-
count).
|
|
80
|
+
count). Once captions are showing they render in one of three tiers, switched
|
|
81
|
+
by canvas scale:
|
|
66
82
|
|
|
67
83
|
| Zoom | Label shape | Example |
|
|
68
84
|
|------|-------------|---------|
|