@rubytech/create-maxy 1.0.792 → 1.0.794
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/__tests__/brew-install.test.js +141 -0
- package/dist/__tests__/brew-resolve.test.js +103 -0
- package/dist/__tests__/launchd-plist.test.js +149 -0
- package/dist/__tests__/macos-version.test.js +96 -0
- package/dist/__tests__/platform-detect.test.js +50 -0
- package/dist/brew-install.js +175 -0
- package/dist/brew-resolve.js +68 -0
- package/dist/index.js +305 -27
- package/dist/launchd-plist.js +68 -0
- package/dist/macos-version.js +53 -0
- package/dist/platform-detect.js +36 -0
- package/dist/uninstall.js +47 -0
- package/package.json +1 -1
- package/payload/platform/lib/graph-search/src/__tests__/fulltext-coverage.test.ts +8 -0
- package/payload/platform/neo4j/edge-annotations.json +20 -0
- package/payload/platform/neo4j/migrations/002-project-public-agents.ts +191 -0
- package/payload/platform/neo4j/schema.cypher +69 -2
- package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +5 -2
- package/payload/platform/plugins/docs/references/deployment.md +15 -3
- package/payload/platform/plugins/docs/references/platform.md +1 -1
- package/payload/server/chunk-2N7XJW6Q.js +3428 -0
- package/payload/server/chunk-3SQJW5Y5.js +9892 -0
- package/payload/server/chunk-ZVUVUP6R.js +9892 -0
- package/payload/server/client-pool-CTMWNDMO.js +28 -0
- package/payload/server/maxy-edge.js +2 -2
- package/payload/server/public/assets/{admin-CBDpia8P.js → admin-DEhQ1wNO.js} +7 -7
- package/payload/server/public/assets/{graph-CT4W30GR.js → graph-DwzwJvlu.js} +1 -1
- package/payload/server/public/assets/page-BuoQU1c6.js +50 -0
- package/payload/server/public/graph.html +2 -2
- package/payload/server/public/index.html +2 -2
- package/payload/server/server.js +47 -7
- package/payload/server/public/assets/page-Cs2i--Z2.js +0 -50
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Task 838 — pure plist XML rendering for the macOS LaunchAgent supervisor.
|
|
2
|
+
// Mirrors the apt-resolve.ts / port-resolution.ts pattern: inputs in, decision
|
|
3
|
+
// out, no I/O. The installer wraps this with writeFileSync + spawnSync(
|
|
4
|
+
// "launchctl", ["bootstrap", `gui/${uid}`, plistPath]) — all spawn/fs lives in
|
|
5
|
+
// index.ts so this module can be unit-tested with concrete fixtures and no
|
|
6
|
+
// side effects.
|
|
7
|
+
//
|
|
8
|
+
// The Linux supervisor (systemd-user persistent unit at
|
|
9
|
+
// `~/.config/systemd/user/<service>.service`) has no analogue here: launchd's
|
|
10
|
+
// LaunchAgent format is a property-list XML document at
|
|
11
|
+
// `~/Library/LaunchAgents/<label>.plist`, and the bootstrap-into-domain flow
|
|
12
|
+
// uses launchctl(1) instead of systemctl. Both supervisors achieve the same
|
|
13
|
+
// success-criteria set: process registered with the user's session manager,
|
|
14
|
+
// auto-restart on crash, restart on logout/login, RunAtLoad on reboot.
|
|
15
|
+
/**
|
|
16
|
+
* Escape the five XML predefined entities, in order. Order matters: replace
|
|
17
|
+
* `&` first so an input of `<` doesn't double-escape via the `&` produced by
|
|
18
|
+
* the `<` rule. The output is safe to embed inside an XML <string> element.
|
|
19
|
+
*/
|
|
20
|
+
function xmlEscape(s) {
|
|
21
|
+
return s
|
|
22
|
+
.replace(/&/g, "&")
|
|
23
|
+
.replace(/</g, "<")
|
|
24
|
+
.replace(/>/g, ">")
|
|
25
|
+
.replace(/"/g, """)
|
|
26
|
+
.replace(/'/g, "'");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Render a launchd LaunchAgent plist as XML. Pure: same input → same output
|
|
30
|
+
* forever. The caller writes this to `~/Library/LaunchAgents/<label>.plist`
|
|
31
|
+
* with mode 0644 and then runs `launchctl bootstrap gui/$UID <path>`.
|
|
32
|
+
*
|
|
33
|
+
* The output is the canonical plist 1.0 form launchd accepts — XML preamble,
|
|
34
|
+
* PLIST PUBLIC DOCTYPE, <plist version="1.0"> wrapping a single <dict>. Key
|
|
35
|
+
* order inside the dict mirrors Apple's LaunchAgent examples for grep-ability:
|
|
36
|
+
* Label, ProgramArguments, StandardOutPath, StandardErrorPath, [WorkingDirectory,]
|
|
37
|
+
* KeepAlive, RunAtLoad.
|
|
38
|
+
*/
|
|
39
|
+
export function renderPlist(spec) {
|
|
40
|
+
const lines = [];
|
|
41
|
+
lines.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
|
42
|
+
lines.push(`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`);
|
|
43
|
+
lines.push(`<plist version="1.0">`);
|
|
44
|
+
lines.push(`<dict>`);
|
|
45
|
+
lines.push(` <key>Label</key>`);
|
|
46
|
+
lines.push(` <string>${xmlEscape(spec.label)}</string>`);
|
|
47
|
+
lines.push(` <key>ProgramArguments</key>`);
|
|
48
|
+
lines.push(` <array>`);
|
|
49
|
+
for (const arg of spec.programArguments) {
|
|
50
|
+
lines.push(` <string>${xmlEscape(arg)}</string>`);
|
|
51
|
+
}
|
|
52
|
+
lines.push(` </array>`);
|
|
53
|
+
lines.push(` <key>StandardOutPath</key>`);
|
|
54
|
+
lines.push(` <string>${xmlEscape(spec.stdoutPath)}</string>`);
|
|
55
|
+
lines.push(` <key>StandardErrorPath</key>`);
|
|
56
|
+
lines.push(` <string>${xmlEscape(spec.stderrPath)}</string>`);
|
|
57
|
+
if (spec.workingDirectory !== undefined) {
|
|
58
|
+
lines.push(` <key>WorkingDirectory</key>`);
|
|
59
|
+
lines.push(` <string>${xmlEscape(spec.workingDirectory)}</string>`);
|
|
60
|
+
}
|
|
61
|
+
lines.push(` <key>KeepAlive</key>`);
|
|
62
|
+
lines.push(` ${spec.keepAlive ? "<true/>" : "<false/>"}`);
|
|
63
|
+
lines.push(` <key>RunAtLoad</key>`);
|
|
64
|
+
lines.push(` ${spec.runAtLoad ? "<true/>" : "<false/>"}`);
|
|
65
|
+
lines.push(`</dict>`);
|
|
66
|
+
lines.push(`</plist>`);
|
|
67
|
+
return lines.join("\n") + "\n";
|
|
68
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Task 840 — pure macOS-version classification for the installer.
|
|
2
|
+
//
|
|
3
|
+
// The installer needs two darwin facts at pre-flight: the macOS version (from
|
|
4
|
+
// `sw_vers`) and whether that version meets the floor required by Tasks 838
|
|
5
|
+
// (modern `launchctl bootstrap gui/$UID …`) and 839 (Apple-Silicon Homebrew).
|
|
6
|
+
// Older macOS partially succeeds, then breaks at the supervisor or brew-cellar
|
|
7
|
+
// layer with cryptic errors — refusing loudly at pre-flight is the contract.
|
|
8
|
+
//
|
|
9
|
+
// Pure logic with no I/O — caller spawns `sw_vers`, captures stdout, and feeds
|
|
10
|
+
// it through parseSwVers + isSupportedMacosVersion. Mirrors the apt-resolve.ts
|
|
11
|
+
// pattern (Task 638): inputs in, decision out, every branch testable with
|
|
12
|
+
// real captured fixtures.
|
|
13
|
+
/**
|
|
14
|
+
* Parse the stdout of macOS's `sw_vers` command. Real output has the shape:
|
|
15
|
+
*
|
|
16
|
+
* ProductName: macOS
|
|
17
|
+
* ProductVersion: 14.4.1
|
|
18
|
+
* BuildVersion: 23E224
|
|
19
|
+
*
|
|
20
|
+
* Returns null when either ProductName or ProductVersion is absent — the
|
|
21
|
+
* caller treats null as "refuse with malformed-stdout diagnostic" rather than
|
|
22
|
+
* silently coercing to undefined and producing a confusing version check.
|
|
23
|
+
*/
|
|
24
|
+
export function parseSwVers(stdout) {
|
|
25
|
+
const productMatch = stdout.match(/^ProductName:\s*(.+)$/m);
|
|
26
|
+
const versionMatch = stdout.match(/^ProductVersion:\s*(.+)$/m);
|
|
27
|
+
if (!productMatch || !versionMatch)
|
|
28
|
+
return null;
|
|
29
|
+
return {
|
|
30
|
+
product: productMatch[1].trim(),
|
|
31
|
+
version: versionMatch[1].trim(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* True when `version`'s major component is ≥ 14 (the floor required by Tasks
|
|
36
|
+
* 838 + 839). Returns false for empty strings, non-numeric leading components,
|
|
37
|
+
* and any major < 14. A numeric-only check is sufficient — Apple's published
|
|
38
|
+
* macOS versions use a strict `MAJOR.MINOR[.PATCH]` integer scheme.
|
|
39
|
+
*/
|
|
40
|
+
export function isSupportedMacosVersion(version) {
|
|
41
|
+
const m = version.match(/^(\d+)\./);
|
|
42
|
+
if (!m) {
|
|
43
|
+
// Fall back to whole-version-as-integer for the macOS 14.0 / 15 form
|
|
44
|
+
// where the leading number isn't followed by a dot. Defence in depth —
|
|
45
|
+
// the regex above already handles "14.0", but a bare "14" should also
|
|
46
|
+
// pass.
|
|
47
|
+
const bare = version.match(/^(\d+)$/);
|
|
48
|
+
if (!bare)
|
|
49
|
+
return false;
|
|
50
|
+
return parseInt(bare[1], 10) >= 14;
|
|
51
|
+
}
|
|
52
|
+
return parseInt(m[1], 10) >= 14;
|
|
53
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Task 836 wedge — pure platform classification for the installer.
|
|
2
|
+
//
|
|
3
|
+
// The installer's existing `isLinux()` checks (26 sites in index.ts) collapse
|
|
4
|
+
// process.platform into a boolean and treat every non-Linux value as "skip
|
|
5
|
+
// silently." Follow-up tasks 838/839/840 widen that to an explicit ternary
|
|
6
|
+
// (linux | darwin | unsupported) so unsupported targets refuse loudly with a
|
|
7
|
+
// log line operators can grep, instead of completing in a non-functional state.
|
|
8
|
+
//
|
|
9
|
+
// This module ships dead until those follow-ups land. Pure logic with no I/O —
|
|
10
|
+
// caller injects process.platform so tests pass arbitrary fixtures without
|
|
11
|
+
// touching the host's actual platform.
|
|
12
|
+
/**
|
|
13
|
+
* Classify a Node process.platform value into the ternary the installer cares
|
|
14
|
+
* about. Anything not in { linux, darwin } returns "unsupported"; the caller
|
|
15
|
+
* decides whether to refuse the install or no-op.
|
|
16
|
+
*/
|
|
17
|
+
export function detectPlatform(nodePlatform) {
|
|
18
|
+
if (nodePlatform === "linux")
|
|
19
|
+
return "linux";
|
|
20
|
+
if (nodePlatform === "darwin")
|
|
21
|
+
return "darwin";
|
|
22
|
+
return "unsupported";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Narrow a Node process.platform to a SupportedPlatform or throw with the
|
|
26
|
+
* literal refusal message follow-up tasks log + grep on. Use this at install
|
|
27
|
+
* pre-flight where continuing on an unsupported OS would silently complete a
|
|
28
|
+
* broken install.
|
|
29
|
+
*/
|
|
30
|
+
export function requireSupportedPlatform(nodePlatform) {
|
|
31
|
+
const detected = detectPlatform(nodePlatform);
|
|
32
|
+
if (detected === "unsupported") {
|
|
33
|
+
throw new Error(`[create-maxy] platform=${nodePlatform} — refusing: only linux and darwin are supported`);
|
|
34
|
+
}
|
|
35
|
+
return detected;
|
|
36
|
+
}
|
package/dist/uninstall.js
CHANGED
|
@@ -64,6 +64,23 @@ function shell(command, args, options) {
|
|
|
64
64
|
function isLinux() {
|
|
65
65
|
return process.platform === "linux";
|
|
66
66
|
}
|
|
67
|
+
function isDarwin() {
|
|
68
|
+
return process.platform === "darwin";
|
|
69
|
+
}
|
|
70
|
+
// Task 838 — mirror the launchd identifiers from index.ts so the uninstall
|
|
71
|
+
// can locate the LaunchAgent without reaching across modules. Per the
|
|
72
|
+
// `uninstall.ts:` "Shell helpers (duplicated from index.ts ...)" policy,
|
|
73
|
+
// these are intentionally duplicated rather than shared.
|
|
74
|
+
function launchdLabel() {
|
|
75
|
+
return `com.rubytech.${BRAND.hostname}`;
|
|
76
|
+
}
|
|
77
|
+
function plistPath() {
|
|
78
|
+
return resolve(HOME, "Library/LaunchAgents", `${launchdLabel()}.plist`);
|
|
79
|
+
}
|
|
80
|
+
function gui() {
|
|
81
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
82
|
+
return `gui/${uid}`;
|
|
83
|
+
}
|
|
67
84
|
function commandExists(cmd) {
|
|
68
85
|
try {
|
|
69
86
|
execFileSync("which", [cmd], { stdio: "pipe" });
|
|
@@ -120,6 +137,15 @@ function peerBrandPresent() {
|
|
|
120
137
|
// ---------------------------------------------------------------------------
|
|
121
138
|
function stopServices() {
|
|
122
139
|
log("1", "Stopping services...");
|
|
140
|
+
// Task 838 — darwin: bootout the LaunchAgent. bootout exits 0 on success,
|
|
141
|
+
// 113 ("Unknown service") when the agent isn't loaded — both are
|
|
142
|
+
// acceptable end-states for "service stopped". The plist removal in
|
|
143
|
+
// removeSystemdService() finishes the cleanup.
|
|
144
|
+
if (isDarwin()) {
|
|
145
|
+
spawnSync("launchctl", ["bootout", `${gui()}/${launchdLabel()}`], { stdio: "pipe", timeout: 15_000 });
|
|
146
|
+
console.log(` Booted out ${launchdLabel()}`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
123
149
|
// Stop platform user service
|
|
124
150
|
try {
|
|
125
151
|
spawnSync("systemctl", ["--user", "stop", BRAND.serviceName.replace(".service", "")], { stdio: "pipe", timeout: 15_000 });
|
|
@@ -563,6 +589,27 @@ function removeSystemConfig() {
|
|
|
563
589
|
// ---------------------------------------------------------------------------
|
|
564
590
|
function removeSystemdService() {
|
|
565
591
|
log("8", "Removing systemd service...");
|
|
592
|
+
// Task 838 — darwin: bootout (idempotent — already done in stopServices,
|
|
593
|
+
// re-running is a no-op exit 113) and delete the plist. The wrapper
|
|
594
|
+
// shell script lives in CONFIG_DIR which step 4 (removeAppDirs) wipes,
|
|
595
|
+
// so no separate cleanup is needed.
|
|
596
|
+
if (isDarwin()) {
|
|
597
|
+
spawnSync("launchctl", ["bootout", `${gui()}/${launchdLabel()}`], { stdio: "pipe", timeout: 15_000 });
|
|
598
|
+
const plist = plistPath();
|
|
599
|
+
if (existsSync(plist)) {
|
|
600
|
+
try {
|
|
601
|
+
rmSync(plist);
|
|
602
|
+
console.log(` Removed ${plist}`);
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
console.log(` Failed to remove ${plist}: ${err instanceof Error ? err.message : String(err)}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
console.log(` ${plist} not found — skipping`);
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
566
613
|
// Disable the service
|
|
567
614
|
const svcUnit = BRAND.serviceName.replace(".service", "");
|
|
568
615
|
try {
|
package/package.json
CHANGED
|
@@ -85,6 +85,14 @@ const CANONICAL_TEXT_PROPERTIES = [
|
|
|
85
85
|
"contactValue",
|
|
86
86
|
// ToolCall
|
|
87
87
|
"toolName",
|
|
88
|
+
// Agent (Task 837) — public-agent projection. `displayName` mirrors
|
|
89
|
+
// config.json; `slug` is the directory-name identifier; `role` is the
|
|
90
|
+
// discriminator carried on the four owned :KnowledgeDocument projections
|
|
91
|
+
// ('identity' | 'soul' | 'knowledge' | 'knowledge-summary'). Adding any
|
|
92
|
+
// new agent-side text property to the projector requires extending both
|
|
93
|
+
// the schema's ON EACH list and this canon, otherwise BM25 silently
|
|
94
|
+
// misses operator queries that match the new field.
|
|
95
|
+
"displayName", "slug", "role",
|
|
88
96
|
];
|
|
89
97
|
|
|
90
98
|
interface IndexDeclaration {
|
|
@@ -111,6 +111,26 @@
|
|
|
111
111
|
"OBSERVED_IN": {
|
|
112
112
|
"direction": "(*)-[:OBSERVED_IN]->(Conversation)",
|
|
113
113
|
"note": "Observation provenance."
|
|
114
|
+
},
|
|
115
|
+
"HAS_IDENTITY": {
|
|
116
|
+
"direction": "(Agent)-[:HAS_IDENTITY]->(KnowledgeDocument)",
|
|
117
|
+
"note": "Task 837 — public-agent IDENTITY.md projection (KnowledgeDocument with role='identity', namespaced attachmentId='agent:<slug>:identity')."
|
|
118
|
+
},
|
|
119
|
+
"HAS_SOUL": {
|
|
120
|
+
"direction": "(Agent)-[:HAS_SOUL]->(KnowledgeDocument)",
|
|
121
|
+
"note": "Task 837 — public-agent SOUL.md projection (role='soul')."
|
|
122
|
+
},
|
|
123
|
+
"HAS_KNOWLEDGE": {
|
|
124
|
+
"direction": "(Agent)-[:HAS_KNOWLEDGE]->(KnowledgeDocument)",
|
|
125
|
+
"note": "Task 837 — public-agent KNOWLEDGE.md projection (role='knowledge'); KNOWLEDGE-SUMMARY.md uses the same edge with role='knowledge-summary'."
|
|
126
|
+
},
|
|
127
|
+
"USES_KNOWLEDGE": {
|
|
128
|
+
"direction": "(Agent)-[:USES_KNOWLEDGE]->(KnowledgeDocument)",
|
|
129
|
+
"note": "Task 837 — operator-tagged docs (slug ∈ k.agents). Materialised at projection time only; runtime memory-search reads k.agents directly."
|
|
130
|
+
},
|
|
131
|
+
"HANDLED_BY": {
|
|
132
|
+
"direction": "(Conversation)-[:HANDLED_BY]->(Agent)",
|
|
133
|
+
"note": "Task 837 — public conversations only. Written by ensureConversation via OPTIONAL MATCH so orphan slugs do not block conversation creation."
|
|
114
134
|
}
|
|
115
135
|
},
|
|
116
136
|
"sublabels": {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 002 — Project file-based public agents into the graph (Task 837).
|
|
3
|
+
*
|
|
4
|
+
* Two passes:
|
|
5
|
+
*
|
|
6
|
+
* 1. Walk every account directory under data/accounts/<accountId>/agents/
|
|
7
|
+
* and call projectAgent(accountId, accountDir, slug) for each non-admin
|
|
8
|
+
* agent that has a config.json. The projector is idempotent: re-running
|
|
9
|
+
* this migration produces no duplicate nodes or edges.
|
|
10
|
+
*
|
|
11
|
+
* 2. For every existing public Conversation that carries an agentSlug
|
|
12
|
+
* property, MATCH the corresponding :Agent and MERGE the
|
|
13
|
+
* (:Conversation)-[:HANDLED_BY]->(:Agent) edge. Conversations whose
|
|
14
|
+
* slug doesn't resolve to an Agent (orphan slugs from deleted agents,
|
|
15
|
+
* or DM-channel public flows that haven't been extended to register a
|
|
16
|
+
* slug) are left edge-less; they will gain the edge automatically once
|
|
17
|
+
* the slug is registered or a new agent at that slug is projected.
|
|
18
|
+
*
|
|
19
|
+
* Run via the platform/ui standalone runtime so it picks up the same
|
|
20
|
+
* NEO4J_URI / accounts-directory resolution as the server:
|
|
21
|
+
*
|
|
22
|
+
* cd platform/ui && \
|
|
23
|
+
* NEO4J_URI=bolt://… NEO4J_PASSWORD=… \
|
|
24
|
+
* npx tsx ../neo4j/migrations/002-project-public-agents.ts
|
|
25
|
+
*
|
|
26
|
+
* Output: structured `[agent-graph-backfill]` lines per account + a final
|
|
27
|
+
* totals line. A non-zero exit code on any per-account failure surfaces to
|
|
28
|
+
* the operator; subsequent accounts are still attempted (the migration is
|
|
29
|
+
* isolating by account, not all-or-nothing).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
33
|
+
import { resolve } from "node:path";
|
|
34
|
+
import {
|
|
35
|
+
projectAgent,
|
|
36
|
+
getSession,
|
|
37
|
+
} from "../../ui/app/lib/neo4j-store";
|
|
38
|
+
import { ACCOUNTS_DIR } from "../../ui/app/lib/claude-agent/account";
|
|
39
|
+
|
|
40
|
+
interface PerAccountStats {
|
|
41
|
+
accountId: string;
|
|
42
|
+
agents: number;
|
|
43
|
+
agentFailures: number;
|
|
44
|
+
convEdges: number;
|
|
45
|
+
convOrphans: number;
|
|
46
|
+
convCandidates: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function projectAccountAgents(
|
|
50
|
+
accountId: string,
|
|
51
|
+
accountDir: string,
|
|
52
|
+
): Promise<{ agents: number; agentFailures: number }> {
|
|
53
|
+
const agentsDir = resolve(accountDir, "agents");
|
|
54
|
+
if (!existsSync(agentsDir)) return { agents: 0, agentFailures: 0 };
|
|
55
|
+
|
|
56
|
+
let agents = 0;
|
|
57
|
+
let agentFailures = 0;
|
|
58
|
+
for (const entry of readdirSync(agentsDir, { withFileTypes: true })) {
|
|
59
|
+
if (!entry.isDirectory()) continue;
|
|
60
|
+
if (entry.name === "admin") continue;
|
|
61
|
+
|
|
62
|
+
const configPath = resolve(agentsDir, entry.name, "config.json");
|
|
63
|
+
if (!existsSync(configPath)) continue;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await projectAgent(accountId, accountDir, entry.name);
|
|
67
|
+
agents++;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
agentFailures++;
|
|
70
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
71
|
+
console.error(
|
|
72
|
+
`[agent-graph-backfill] account=${accountId.slice(0, 8)} agent=${entry.name} project FAILED error="${msg}"`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { agents, agentFailures };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface HandledByBackfillStats {
|
|
80
|
+
candidates: number;
|
|
81
|
+
edges: number;
|
|
82
|
+
orphans: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Two-pass: count candidate Conversations (those carrying agentSlug),
|
|
87
|
+
* then OPTIONAL MATCH to surface orphans separately from successful merges.
|
|
88
|
+
* A hard MATCH-then-MATCH chain would silently filter orphan-slug rows out,
|
|
89
|
+
* making `edges=0` indistinguishable from "no public conversations exist"
|
|
90
|
+
* vs "every slug is orphaned" — different incidents, different fixes.
|
|
91
|
+
*/
|
|
92
|
+
async function backfillHandledByEdges(accountId: string): Promise<HandledByBackfillStats> {
|
|
93
|
+
const session = getSession();
|
|
94
|
+
try {
|
|
95
|
+
const result = await session.run(
|
|
96
|
+
`MATCH (c:Conversation {accountId: $accountId, agentType: 'public'})
|
|
97
|
+
WHERE c.agentSlug IS NOT NULL
|
|
98
|
+
OPTIONAL MATCH (a:Agent {accountId: $accountId, slug: c.agentSlug})
|
|
99
|
+
FOREACH (_ IN CASE WHEN a IS NULL THEN [] ELSE [1] END | MERGE (c)-[:HANDLED_BY]->(a))
|
|
100
|
+
RETURN
|
|
101
|
+
count(c) AS candidates,
|
|
102
|
+
sum(CASE WHEN a IS NULL THEN 0 ELSE 1 END) AS edges,
|
|
103
|
+
sum(CASE WHEN a IS NULL THEN 1 ELSE 0 END) AS orphans`,
|
|
104
|
+
{ accountId },
|
|
105
|
+
);
|
|
106
|
+
const toNum = (v: unknown): number => {
|
|
107
|
+
if (typeof v === "number") return v;
|
|
108
|
+
if (v && typeof (v as { toNumber: () => number }).toNumber === "function") {
|
|
109
|
+
return (v as { toNumber: () => number }).toNumber();
|
|
110
|
+
}
|
|
111
|
+
return 0;
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
candidates: toNum(result.records[0]?.get("candidates")),
|
|
115
|
+
edges: toNum(result.records[0]?.get("edges")),
|
|
116
|
+
orphans: toNum(result.records[0]?.get("orphans")),
|
|
117
|
+
};
|
|
118
|
+
} finally {
|
|
119
|
+
await session.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function main(): Promise<void> {
|
|
124
|
+
const start = Date.now();
|
|
125
|
+
|
|
126
|
+
if (!existsSync(ACCOUNTS_DIR)) {
|
|
127
|
+
console.error(`[agent-graph-backfill] ACCOUNTS_DIR missing at ${ACCOUNTS_DIR} — nothing to do`);
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const accountEntries = readdirSync(ACCOUNTS_DIR, { withFileTypes: true })
|
|
132
|
+
.filter((e) => e.isDirectory());
|
|
133
|
+
|
|
134
|
+
console.error(`[agent-graph-backfill] start accounts=${accountEntries.length}`);
|
|
135
|
+
|
|
136
|
+
let totalAgents = 0;
|
|
137
|
+
let totalAgentFailures = 0;
|
|
138
|
+
let totalConvEdges = 0;
|
|
139
|
+
let totalConvOrphans = 0;
|
|
140
|
+
let totalConvCandidates = 0;
|
|
141
|
+
const perAccount: PerAccountStats[] = [];
|
|
142
|
+
|
|
143
|
+
for (const entry of accountEntries) {
|
|
144
|
+
const accountDir = resolve(ACCOUNTS_DIR, entry.name);
|
|
145
|
+
const accountId = entry.name;
|
|
146
|
+
const accountStart = Date.now();
|
|
147
|
+
|
|
148
|
+
const { agents, agentFailures } = await projectAccountAgents(accountId, accountDir);
|
|
149
|
+
totalAgents += agents;
|
|
150
|
+
totalAgentFailures += agentFailures;
|
|
151
|
+
|
|
152
|
+
let convStats: HandledByBackfillStats = { candidates: 0, edges: 0, orphans: 0 };
|
|
153
|
+
try {
|
|
154
|
+
convStats = await backfillHandledByEdges(accountId);
|
|
155
|
+
totalConvEdges += convStats.edges;
|
|
156
|
+
totalConvOrphans += convStats.orphans;
|
|
157
|
+
totalConvCandidates += convStats.candidates;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
160
|
+
console.error(
|
|
161
|
+
`[agent-graph-backfill] account=${accountId.slice(0, 8)} handled-by-backfill FAILED error="${msg}"`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
perAccount.push({
|
|
166
|
+
accountId,
|
|
167
|
+
agents,
|
|
168
|
+
agentFailures,
|
|
169
|
+
convEdges: convStats.edges,
|
|
170
|
+
convOrphans: convStats.orphans,
|
|
171
|
+
convCandidates: convStats.candidates,
|
|
172
|
+
});
|
|
173
|
+
const ms = Date.now() - accountStart;
|
|
174
|
+
console.error(
|
|
175
|
+
`[agent-graph-backfill] account=${accountId.slice(0, 8)} agents=${agents} failures=${agentFailures} conv-candidates=${convStats.candidates} conv-edges=${convStats.edges} conv-orphans=${convStats.orphans} ms=${ms}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ms = Date.now() - start;
|
|
180
|
+
console.error(
|
|
181
|
+
`[agent-graph-backfill] done totals: agents=${totalAgents} agent-failures=${totalAgentFailures} conv-candidates=${totalConvCandidates} conv-edges=${totalConvEdges} conv-orphans=${totalConvOrphans} ms=${ms}`,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
process.exit(totalAgentFailures > 0 ? 1 : 0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
main().catch((err) => {
|
|
188
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
189
|
+
console.error(`[agent-graph-backfill] fatal error="${msg}"`);
|
|
190
|
+
process.exit(2);
|
|
191
|
+
});
|
|
@@ -286,6 +286,7 @@ OPTIONS {
|
|
|
286
286
|
// - Email: Email, EmailAccount
|
|
287
287
|
// - Review signals: ReviewAlert
|
|
288
288
|
// - CV/career sublabels: Position, Credential
|
|
289
|
+
// - Public agents: Agent (Task 837 — projection of file-based public agents)
|
|
289
290
|
//
|
|
290
291
|
// Property union — every textual property the schema's writers assign:
|
|
291
292
|
// - Generic: name, title, summary, body, content, text, description, headline, abstract,
|
|
@@ -301,6 +302,11 @@ OPTIONS {
|
|
|
301
302
|
// - Credential: authority
|
|
302
303
|
// - AccessGrant: contactValue
|
|
303
304
|
// - ToolCall: toolName
|
|
305
|
+
// - Agent (Task 837): displayName, slug, role
|
|
306
|
+
// `displayName` = operator-facing name; `slug` = directory-name identifier;
|
|
307
|
+
// `role` = discriminator on KnowledgeDocument projections
|
|
308
|
+
// ('identity'|'soul'|'knowledge'|'knowledge-summary') so BM25 hits surface
|
|
309
|
+
// which file backed the result. Distinct from Person.role (no shadow).
|
|
304
310
|
CREATE FULLTEXT INDEX entity_search IF NOT EXISTS
|
|
305
311
|
FOR (n:LocalBusiness|Service|PriceSpecification|OpeningHoursSpecification|Organization
|
|
306
312
|
|Person|UserProfile|Preference|AdminUser|AccessGrant
|
|
@@ -309,12 +315,13 @@ FOR (n:LocalBusiness|Service|PriceSpecification|OpeningHoursSpecification|Organi
|
|
|
309
315
|
|Task|Project|Event
|
|
310
316
|
|Workflow|WorkflowStep|WorkflowRun|StepResult
|
|
311
317
|
|OnboardingState|Email|EmailAccount|ReviewAlert
|
|
312
|
-
|Position|Credential)
|
|
318
|
+
|Position|Credential|Agent)
|
|
313
319
|
ON EACH [n.name, n.firstName, n.lastName, n.givenName, n.familyName,
|
|
314
320
|
n.title, n.currentTitle, n.summary, n.body, n.content, n.text, n.description, n.headline, n.abstract,
|
|
315
321
|
n.email, n.note, n.label, n.value, n.message, n.preview, n.tagline,
|
|
316
322
|
n.subject, n.bodyPreview, n.fromName, n.fromAddress, n.agentAddress, n.screeningReason,
|
|
317
|
-
n.authority, n.contactValue, n.toolName
|
|
323
|
+
n.authority, n.contactValue, n.toolName,
|
|
324
|
+
n.displayName, n.slug, n.role];
|
|
318
325
|
|
|
319
326
|
// Project node (Task 740) — a standalone creative-output node distinct from
|
|
320
327
|
// :Section. Anchored via (:UserProfile)-[:CREATED]->(:Project), with optional
|
|
@@ -724,6 +731,66 @@ FOR (ag:AccessGrant) ON (ag.magicToken);
|
|
|
724
731
|
CREATE INDEX access_grant_status IF NOT EXISTS
|
|
725
732
|
FOR (ag:AccessGrant) ON (ag.status);
|
|
726
733
|
|
|
734
|
+
// ----------------------------------------------------------
|
|
735
|
+
// Agent node — projection of file-based public agents (Task 837)
|
|
736
|
+
//
|
|
737
|
+
// Source of truth remains the filesystem: accountDir/agents/<slug>/
|
|
738
|
+
// {config.json, IDENTITY.md, SOUL.md, KNOWLEDGE.md, KNOWLEDGE-SUMMARY.md}.
|
|
739
|
+
// The Agent node is a graph projection so operators can see, on /graph,
|
|
740
|
+
// which public agents exist, what knowledge they have access to, and
|
|
741
|
+
// which conversations they have handled. Re-running the projector
|
|
742
|
+
// is idempotent and never mutates the on-disk files.
|
|
743
|
+
//
|
|
744
|
+
// Properties (mirrored from config.json + filesystem mtime):
|
|
745
|
+
// slug — directory name; immutable identifier within an account
|
|
746
|
+
// displayName — operator-facing name from config.json
|
|
747
|
+
// status — 'active' | 'inactive' | <other> per config.json
|
|
748
|
+
// model — Anthropic model id used by the public agent
|
|
749
|
+
// liveMemory — bool; whether memory-search runs at message time
|
|
750
|
+
// knowledgeKeywords — string[] (live-search keyword subscriptions)
|
|
751
|
+
// role — always 'agent' (mirrors KnowledgeDocument.role
|
|
752
|
+
// discriminator; surfaces in BM25 hits)
|
|
753
|
+
// createdAt — ISO 8601, set on first projection
|
|
754
|
+
// updatedAt — ISO 8601, set on every projection
|
|
755
|
+
//
|
|
756
|
+
// Owned KnowledgeDocument projections (deleted on agent delete):
|
|
757
|
+
// (Agent)-[:HAS_IDENTITY]->(:KnowledgeDocument {role:'identity'})
|
|
758
|
+
// (Agent)-[:HAS_SOUL ]->(:KnowledgeDocument {role:'soul'})
|
|
759
|
+
// (Agent)-[:HAS_KNOWLEDGE]->(:KnowledgeDocument {role:'knowledge'})
|
|
760
|
+
// (Agent)-[:HAS_KNOWLEDGE]->(:KnowledgeDocument {role:'knowledge-summary'})
|
|
761
|
+
// attachmentId is namespaced as "agent:<accountId>:<slug>:<role>" so the
|
|
762
|
+
// projection reuses the existing knowledge_doc_id_unique constraint without
|
|
763
|
+
// a parallel uniqueness scheme. The accountId segment is load-bearing —
|
|
764
|
+
// without it, two accounts with the same agent slug would collide on the
|
|
765
|
+
// global unique constraint and the second projection would silently
|
|
766
|
+
// re-parent the first account's doc via the MERGE+SET path. Account
|
|
767
|
+
// isolation is doctrine.
|
|
768
|
+
//
|
|
769
|
+
// Operator-tagged docs (NOT deleted on agent delete — only edges removed):
|
|
770
|
+
// (Agent)-[:USES_KNOWLEDGE]->(:KnowledgeDocument)
|
|
771
|
+
// where slug ∈ KnowledgeDocument.agents. Materialised at projection time
|
|
772
|
+
// only; the runtime memory-search path continues to read k.agents directly,
|
|
773
|
+
// so a doc tagged after the last projection becomes graph-visible at the
|
|
774
|
+
// next re-projection but is reachable at runtime immediately. The runtime
|
|
775
|
+
// path is unchanged — see [public-agent.ts:130-160].
|
|
776
|
+
//
|
|
777
|
+
// Conversation→Agent edge (written by ensureConversation when
|
|
778
|
+
// agentType='public' and agentSlug is set):
|
|
779
|
+
// (Conversation)-[:HANDLED_BY]->(Agent)
|
|
780
|
+
// OPTIONAL MATCH on the Agent so a public conversation with an orphan
|
|
781
|
+
// slug still writes (single `[agent-graph] handled-by-skip` log line).
|
|
782
|
+
//
|
|
783
|
+
// Composite uniqueness: one Agent per (accountId, slug) — the directory
|
|
784
|
+
// name is the natural key within an account, slugs collide across
|
|
785
|
+
// accounts and that's allowed.
|
|
786
|
+
// ----------------------------------------------------------
|
|
787
|
+
|
|
788
|
+
CREATE CONSTRAINT agent_account_slug_unique IF NOT EXISTS
|
|
789
|
+
FOR (a:Agent) REQUIRE (a.accountId, a.slug) IS UNIQUE;
|
|
790
|
+
|
|
791
|
+
CREATE INDEX agent_account IF NOT EXISTS
|
|
792
|
+
FOR (a:Agent) ON (a.accountId);
|
|
793
|
+
|
|
727
794
|
// ----------------------------------------------------------
|
|
728
795
|
// AdminUser node — device-level admin identity
|
|
729
796
|
// Platform-native. Represents a human who administers one or
|
|
@@ -195,7 +195,8 @@ After creation, no template metadata persists in the agent's files. The resultin
|
|
|
195
195
|
9. **KNOWLEDGE.md generation** — populate from the now-tagged set plus keyword matches using the `update-knowledge` skill workflow
|
|
196
196
|
10. Write `config.json` with selected model, plugins, status "active", `liveMemory`, and `knowledgeKeywords`. This is the last gated write — placed after IDENTITY.md, SOUL.md, and KNOWLEDGE.md to prevent cascade failure if one gate stalls.
|
|
197
197
|
11. Check context budget — auto-summarise if over threshold
|
|
198
|
-
12.
|
|
198
|
+
12. **Project the agent into the graph** — delegate to the `database-operator` specialist with the instruction: "Project public agent `{slug}` into the graph by POSTing to `/api/admin/agents/{slug}/project` (Task 837 surface)." The route reads the on-disk files and idempotently MERGEs the `:Agent` node, the four owned `:KnowledgeDocument` projections (IDENTITY/SOUL/KNOWLEDGE/KNOWLEDGE-SUMMARY when present, namespaced `attachmentId="agent:<slug>:<role>"`), the `HAS_*` edges, and the `USES_KNOWLEDGE` edges to every operator-tagged doc. Loud-fail: if the route returns non-2xx, surface the error to the user verbatim — the agent's files exist on disk but its graph projection is incomplete, which means the operator's /graph view will not show this agent. Re-running the projection is safe; it is the same idempotent MERGE.
|
|
199
|
+
13. Confirm creation: "Agent created. Visitors can reach it at `/{slug}`"
|
|
199
200
|
|
|
200
201
|
### List
|
|
201
202
|
|
|
@@ -231,6 +232,8 @@ For knowledge scope changes:
|
|
|
231
232
|
- Allow toggling `liveMemory` on/off (update `config.json`).
|
|
232
233
|
- After changes, offer to refresh KNOWLEDGE.md using the `update-knowledge` skill.
|
|
233
234
|
|
|
235
|
+
**After every Edit operation that touches IDENTITY/SOUL/KNOWLEDGE/KNOWLEDGE-SUMMARY/`config.json` (including `liveMemory`, `knowledgeKeywords`, or direct-tag mutations), delegate to `database-operator` to re-project: POST `/api/admin/agents/{slug}/project`.** Without re-projection the on-disk files diverge from the graph state — the operator sees stale `:Agent` properties and stale `USES_KNOWLEDGE` edges in /graph. Loud-fail: surface non-2xx errors verbatim.
|
|
236
|
+
|
|
234
237
|
### Delete
|
|
235
238
|
|
|
236
239
|
Deletion removes the entire agent directory (`agents/{slug}/`) — not individual files. A partial deletion leaves ghost state that poisons future creation into the same slug.
|
|
@@ -242,7 +245,7 @@ Deletion removes the entire agent directory (`agents/{slug}/`) — not individua
|
|
|
242
245
|
- If no other agents remain (or the user declines to set a new default), clear the `defaultAgent` field by calling `account-update` with `field: "defaultAgent"`, `value: ""`. Tell the user that visitors to the root URL will see a "no agents" message until a new agent is created.
|
|
243
246
|
|
|
244
247
|
**Cleanup sequence:**
|
|
245
|
-
-
|
|
248
|
+
- Issue `DELETE /api/admin/agents/{slug}` — the route runs `deleteAgentProjection` (Task 837) FIRST to remove the `:Agent` node and its four owned `:KnowledgeDocument` projections, then `rmSync` the directory. Loud-fail: if graph cleanup throws, files are preserved and a 500 is returned with the error text. Re-issuing the DELETE is safe (idempotent on both layers). Operator-tagged docs survive the projection delete — only the `USES_KNOWLEDGE` edges from this Agent are removed.
|
|
246
249
|
- Verify the directory no longer exists
|
|
247
250
|
- Report what was removed (list the files that were in the directory)
|
|
248
251
|
|
|
@@ -2,11 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## Hardware Requirements
|
|
4
4
|
|
|
5
|
-
- Raspberry Pi 5 (16GB RAM minimum)
|
|
6
|
-
-
|
|
7
|
-
-
|
|
5
|
+
- Raspberry Pi 5 (16GB RAM minimum) with Raspberry Pi OS, **or**
|
|
6
|
+
- Mac with macOS 14 (Sonoma) or newer — both Apple Silicon and Intel
|
|
7
|
+
- 256GB storage minimum
|
|
8
8
|
- Always-on power and network connection
|
|
9
9
|
|
|
10
|
+
## macOS install
|
|
11
|
+
|
|
12
|
+
On macOS the installer uses Homebrew + launchd instead of apt + systemd, but the operator command is the same:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx -y @rubytech/create-maxy
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Prerequisite:** Homebrew. If `brew` is missing, the installer refuses with `Homebrew not found. Install from https://brew.sh and re-run.` Install Homebrew once via the official one-liner, then re-run.
|
|
19
|
+
|
|
20
|
+
The installer registers {{productName}} as a launchd LaunchAgent at `~/Library/LaunchAgents/com.rubytech.maxy.plist` — survives logout/login and reboot. Use `launchctl print gui/$UID/com.rubytech.maxy` for service state. `--hostname <h>` sets HostName / LocalHostName / ComputerName via `sudo scutil`. macOS < 14 is refused at pre-flight.
|
|
21
|
+
|
|
10
22
|
## Initial Setup
|
|
11
23
|
|
|
12
24
|
The {{productName}} installer handles the full setup. Run it on your Pi:
|
|
@@ -65,7 +65,7 @@ There is no dashboard, no settings panel, no menus. Everything is done through c
|
|
|
65
65
|
|
|
66
66
|
The chat input auto-grows as you type — it expands to fit your message and shrinks back when you delete text. You can also drag the resize handle above the input to set a custom height.
|
|
67
67
|
|
|
68
|
-
The admin interface is a three-pane layout: a sidebar on the left with your brand mark, navigation (Chat, Projects, Artefacts), and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu — holding the surface side-by-side with the conversation so the chat stays live while you work in it. The sidebar's nav rows swap the list view in place — Chat shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable — type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood — clicking a second project swaps the focus rather than stacking on top. The chat / artefact divider is drag-resizable — drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat / artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On phones (<640px) the sidebar slides in as a drawer from the left when you tap the menu icon in the chat header.
|
|
68
|
+
The admin interface is a three-pane layout: a sidebar on the left with your brand mark, navigation (Chat, People, Agents, Projects, Tasks, Artefacts), and your recent conversations; the chat in the middle; and an artefact pane on the right that opens when you select a document, click a project, or open Browser, Data, or Graph from the menu — holding the surface side-by-side with the conversation so the chat stays live while you work in it. The sidebar's nav rows swap the list view in place — Chat shows recent conversations, Projects shows your active work projects, and Artefacts lists every KnowledgeDocument plus this account's agent templates (your admin agent's IDENTITY, SOUL, and KNOWLEDGE files plus one entry per enabled specialist). The People, Agents, and Tasks rows are graph shortcuts: clicking each opens the artefact-pane Graph filtered to every Person, every public Agent, or every Task in your account respectively, with no side-list — the graph itself is the result. Public agents become first-class graph entities the moment you create them, with edges to their IDENTITY/SOUL/KNOWLEDGE files, edges to every knowledge document they have access to, and edges from every conversation they have handled, so a single Agents click reveals the whole shape of who knows what and who has been talking to whom. Click an artefact row to open the document. KnowledgeDocuments and your admin agent's templates are editable — type in the document and changes save automatically; specialist agent templates are read-only because they ship with Maxy and your edits would be overwritten on the next install. PDF artefacts render inline so you can read them without leaving the pane. If your browser doesn't have a built-in PDF viewer, a Download button appears instead. Artefacts that have no readable file backing them (orphan rows, files removed from disk, unsupported content types) show a one-line banner explaining the skip instead of opening to a blank pane. Click a project row to open the Graph view focused on that project's neighbourhood — clicking a second project swaps the focus rather than stacking on top. The chat / artefact divider is drag-resizable — drag the line between the columns to make either side wider; double-click it to reset to half of the available width (viewport minus sidebar), clamped to the chat / artefact min-width floors. Your chosen width is remembered across reloads. On wider screens (>1280px) all three panes are visible. The sidebar narrows at 1280px, the artefact pane hides at 1080px (Browser, Data, and Graph then open as full-window pages instead), and the sidebar collapses to a 56px icon rail at 820px. On phones (<640px) the sidebar slides in as a drawer from the left when you tap the menu icon in the chat header.
|
|
69
69
|
|
|
70
70
|
Page titles are brand-aware: the browser tab shows your product name (e.g. `Real Agent` instead of `Maxy`) on every shell — chat, graph, and data — so a non-default brand never leaks the default name in tab strips or browser history.
|
|
71
71
|
|