@rubytech/create-realagent-code 0.1.118 → 0.1.120
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/package.json +1 -1
- package/payload/platform/neo4j/schema.cypher +93 -1
- package/payload/platform/package-lock.json +141 -125
- package/payload/platform/plugins/aeo/PLUGIN.md +1 -1
- package/payload/platform/plugins/contacts/mcp/dist/tools/contact-erase.d.ts +12 -1
- package/payload/platform/plugins/contacts/mcp/dist/tools/contact-erase.d.ts.map +1 -1
- package/payload/platform/plugins/contacts/mcp/dist/tools/contact-erase.js +34 -1
- package/payload/platform/plugins/contacts/mcp/dist/tools/contact-erase.js.map +1 -1
- package/payload/platform/plugins/docs/references/aeo.md +2 -2
- package/payload/platform/plugins/docs/references/visitor-graph.md +82 -0
- package/payload/platform/plugins/memory/references/schema-base.md +44 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/.claude-plugin/plugin.json +1 -1
- package/payload/premium-plugins/real-agent/plugins/buyers/PLUGIN.md +33 -3
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/index.d.ts +2 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/index.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/index.js +97 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/index.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/lib/neo4j.js +40 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-backfill-from-logs.d.ts +24 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-backfill-from-logs.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-backfill-from-logs.js +156 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-backfill-from-logs.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-engagement-score.d.ts +21 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-engagement-score.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-engagement-score.js +65 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-engagement-score.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-event-ingest.d.ts +26 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-event-ingest.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-event-ingest.js +78 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-event-ingest.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-page.d.ts +19 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-page.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-page.js +41 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-page.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-person.d.ts +20 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-person.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-person.js +44 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-person.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recommendation-ctr.d.ts +22 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recommendation-ctr.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recommendation-ctr.js +41 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recommendation-ctr.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-session-detail.d.ts +32 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-session-detail.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-session-detail.js +87 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-session-detail.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/package-lock.json +2566 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/package.json +21 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/index.ts +145 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/lib/neo4j.ts +45 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-backfill-from-logs.ts +189 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-engagement-score.ts +87 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-event-ingest.ts +119 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-recent-by-page.ts +61 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-recent-by-person.ts +65 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-recommendation-ctr.ts +67 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-session-detail.ts +127 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/mcp/tsconfig.json +20 -0
- package/payload/premium-plugins/real-agent/plugins/buyers/skills/property-recommender/SKILL.md +11 -0
- package/payload/premium-plugins/real-agent/plugins/leads/skills/lead-nurturing/SKILL.md +9 -0
- package/payload/premium-plugins/real-agent/plugins/leads/skills/morning-round/SKILL.md +11 -0
- package/payload/premium-plugins/real-agent/plugins/vendors/skills/vendor-updates/SKILL.md +13 -0
- package/payload/server/{chunk-FJCI6X3H.js → chunk-HCYM5FLU.js} +2 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/privacy.html +66 -0
- package/payload/server/public/v.js +191 -0
- package/payload/server/server.js +473 -85
package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-recent-by-person.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// visitor-recent-by-person — list recent :Session activity for a :Person.
|
|
2
|
+
// Walks (Person)-[:VISITED]->(Session)-[:HAS_EVENT]->(*) and returns a
|
|
3
|
+
// chronological summary the morning-round skill folds into its briefing.
|
|
4
|
+
|
|
5
|
+
import { getSession } from "../lib/neo4j.js";
|
|
6
|
+
|
|
7
|
+
export interface RecentByPersonParams {
|
|
8
|
+
accountId: string;
|
|
9
|
+
personId: string;
|
|
10
|
+
sinceDays?: number;
|
|
11
|
+
limit?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SessionSummary {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
startedAt: string;
|
|
17
|
+
lastSeenAt: string;
|
|
18
|
+
pageviewCount: number;
|
|
19
|
+
clickCount: number;
|
|
20
|
+
maxScrollDepth: number;
|
|
21
|
+
listingSlugs: string[];
|
|
22
|
+
dwellMs: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function visitorRecentByPerson(p: RecentByPersonParams): Promise<{ sessions: SessionSummary[] }> {
|
|
26
|
+
const since = p.sinceDays ?? 30;
|
|
27
|
+
const limit = Math.max(1, Math.min(p.limit ?? 50, 200));
|
|
28
|
+
const session = getSession();
|
|
29
|
+
try {
|
|
30
|
+
const result = await session.run(
|
|
31
|
+
`MATCH (per:Person {accountId: $accountId}) WHERE elementId(per) = $personId
|
|
32
|
+
MATCH (per)-[:VISITED]->(s:Session)
|
|
33
|
+
WHERE s.startedAt >= datetime() - duration({days: $since})
|
|
34
|
+
OPTIONAL MATCH (s)-[:HAS_EVENT]->(pv:PageView)
|
|
35
|
+
OPTIONAL MATCH (s)-[:HAS_EVENT]->(cl:Click)
|
|
36
|
+
OPTIONAL MATCH (s)-[:HAS_EVENT]->(sm:ScrollMilestone)
|
|
37
|
+
OPTIONAL MATCH (pv)-[:OF_LISTING]->(l:Listing)
|
|
38
|
+
WITH s, count(DISTINCT pv) AS pageviews, count(DISTINCT cl) AS clicks,
|
|
39
|
+
coalesce(max(sm.maxDepth), 0) AS maxDepth,
|
|
40
|
+
collect(DISTINCT l.slug) AS slugs,
|
|
41
|
+
coalesce(sum(pv.dwellMs), 0) AS dwellMs
|
|
42
|
+
RETURN s.sessionId AS sessionId,
|
|
43
|
+
toString(s.startedAt) AS startedAt,
|
|
44
|
+
toString(s.lastSeenAt) AS lastSeenAt,
|
|
45
|
+
pageviews, clicks, maxDepth, slugs, dwellMs
|
|
46
|
+
ORDER BY s.lastSeenAt DESC
|
|
47
|
+
LIMIT $limit`,
|
|
48
|
+
{ accountId: p.accountId, personId: p.personId, since, limit },
|
|
49
|
+
);
|
|
50
|
+
return {
|
|
51
|
+
sessions: result.records.map((r) => ({
|
|
52
|
+
sessionId: r.get("sessionId"),
|
|
53
|
+
startedAt: r.get("startedAt"),
|
|
54
|
+
lastSeenAt: r.get("lastSeenAt"),
|
|
55
|
+
pageviewCount: Number(r.get("pageviews") ?? 0),
|
|
56
|
+
clickCount: Number(r.get("clicks") ?? 0),
|
|
57
|
+
maxScrollDepth: Number(r.get("maxDepth") ?? 0),
|
|
58
|
+
listingSlugs: (r.get("slugs") ?? []).filter((s: unknown): s is string => typeof s === "string"),
|
|
59
|
+
dwellMs: Number(r.get("dwellMs") ?? 0),
|
|
60
|
+
})),
|
|
61
|
+
};
|
|
62
|
+
} finally {
|
|
63
|
+
await session.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// visitor-recommendation-ctr — graph-backed CTR replacement for the
|
|
2
|
+
// log-scan aggregation that ships with Task 336. Operates on Recommendation
|
|
3
|
+
// nodes (materialised from [property-recommended] log lines via backfill)
|
|
4
|
+
// joined to :Click events. Output schema matches the log-scan tool's so
|
|
5
|
+
// the cutover (separate follow-up task) is a one-line swap.
|
|
6
|
+
|
|
7
|
+
import { getSession } from "../lib/neo4j.js";
|
|
8
|
+
|
|
9
|
+
export interface RecommendationCtrParams {
|
|
10
|
+
accountId: string;
|
|
11
|
+
/** Days back from now. Defaults to 7. */
|
|
12
|
+
sinceDays?: number;
|
|
13
|
+
/** Optional listingSlug filter. */
|
|
14
|
+
listingSlug?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CtrRow {
|
|
18
|
+
listingSlug: string;
|
|
19
|
+
recommended: number;
|
|
20
|
+
clicked: number;
|
|
21
|
+
ctr: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CtrSummary {
|
|
25
|
+
windowDays: number;
|
|
26
|
+
totalRecommended: number;
|
|
27
|
+
totalClicked: number;
|
|
28
|
+
ctr: number;
|
|
29
|
+
perListing: CtrRow[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function visitorRecommendationCtr(p: RecommendationCtrParams): Promise<CtrSummary> {
|
|
33
|
+
const since = p.sinceDays ?? 7;
|
|
34
|
+
const session = getSession();
|
|
35
|
+
try {
|
|
36
|
+
const result = await session.run(
|
|
37
|
+
`MATCH (rec:Recommendation {accountId: $accountId})
|
|
38
|
+
WHERE rec.recommendedAt >= datetime() - duration({days: $since})
|
|
39
|
+
AND ($slug IS NULL OR rec.listingSlug = $slug)
|
|
40
|
+
OPTIONAL MATCH (rec)-[:FOR_SESSION]->(s:Session)-[:HAS_EVENT]->(cl:Click {label: 'card'})
|
|
41
|
+
WITH rec.listingSlug AS slug,
|
|
42
|
+
count(DISTINCT rec) AS recommended,
|
|
43
|
+
count(DISTINCT cl) AS clicked
|
|
44
|
+
RETURN slug AS listingSlug, recommended, clicked,
|
|
45
|
+
CASE WHEN recommended > 0 THEN toFloat(clicked) / recommended ELSE 0.0 END AS ctr
|
|
46
|
+
ORDER BY recommended DESC`,
|
|
47
|
+
{ accountId: p.accountId, since, slug: p.listingSlug ?? null },
|
|
48
|
+
);
|
|
49
|
+
const perListing: CtrRow[] = result.records.map((r) => ({
|
|
50
|
+
listingSlug: r.get("listingSlug") ?? "",
|
|
51
|
+
recommended: Number(r.get("recommended") ?? 0),
|
|
52
|
+
clicked: Number(r.get("clicked") ?? 0),
|
|
53
|
+
ctr: Number(r.get("ctr") ?? 0),
|
|
54
|
+
}));
|
|
55
|
+
const totalRecommended = perListing.reduce((a, b) => a + b.recommended, 0);
|
|
56
|
+
const totalClicked = perListing.reduce((a, b) => a + b.clicked, 0);
|
|
57
|
+
return {
|
|
58
|
+
windowDays: since,
|
|
59
|
+
totalRecommended,
|
|
60
|
+
totalClicked,
|
|
61
|
+
ctr: totalRecommended > 0 ? totalClicked / totalRecommended : 0,
|
|
62
|
+
perListing,
|
|
63
|
+
};
|
|
64
|
+
} finally {
|
|
65
|
+
await session.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-session-detail.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// visitor-session-detail — full event timeline for one :Session.
|
|
2
|
+
// Operator-side query: "show me everything Sarah did in session abc12".
|
|
3
|
+
|
|
4
|
+
import { getSession } from "../lib/neo4j.js";
|
|
5
|
+
|
|
6
|
+
export interface SessionDetailParams {
|
|
7
|
+
accountId: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SessionEvent {
|
|
12
|
+
kind: "pageview" | "click" | "scroll";
|
|
13
|
+
occurredAt: string;
|
|
14
|
+
/** PageView only */
|
|
15
|
+
url?: string;
|
|
16
|
+
path?: string;
|
|
17
|
+
pageViewId?: string;
|
|
18
|
+
referrer?: string;
|
|
19
|
+
dwellMs?: number;
|
|
20
|
+
/** Click only */
|
|
21
|
+
label?: string;
|
|
22
|
+
href?: string;
|
|
23
|
+
/** Scroll only */
|
|
24
|
+
maxDepth?: number;
|
|
25
|
+
/** Optional listing link */
|
|
26
|
+
listingSlug?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SessionDetail {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
startedAt: string | null;
|
|
32
|
+
lastSeenAt: string | null;
|
|
33
|
+
personId: string | null;
|
|
34
|
+
personName: string | null;
|
|
35
|
+
anonVisitorId: string | null;
|
|
36
|
+
events: SessionEvent[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function visitorSessionDetail(p: SessionDetailParams): Promise<SessionDetail> {
|
|
40
|
+
const session = getSession();
|
|
41
|
+
try {
|
|
42
|
+
const sessionRes = await session.run(
|
|
43
|
+
`MATCH (s:Session {accountId: $accountId, sessionId: $sessionId})
|
|
44
|
+
OPTIONAL MATCH (per:Person)-[:VISITED]->(s)
|
|
45
|
+
OPTIONAL MATCH (av:AnonVisitor)-[:VISITED]->(s)
|
|
46
|
+
RETURN toString(s.startedAt) AS startedAt,
|
|
47
|
+
toString(s.lastSeenAt) AS lastSeenAt,
|
|
48
|
+
elementId(per) AS personId,
|
|
49
|
+
per.name AS personName,
|
|
50
|
+
av.visitorId AS anonVisitorId
|
|
51
|
+
LIMIT 1`,
|
|
52
|
+
{ accountId: p.accountId, sessionId: p.sessionId },
|
|
53
|
+
);
|
|
54
|
+
if (sessionRes.records.length === 0) {
|
|
55
|
+
return {
|
|
56
|
+
sessionId: p.sessionId,
|
|
57
|
+
startedAt: null,
|
|
58
|
+
lastSeenAt: null,
|
|
59
|
+
personId: null,
|
|
60
|
+
personName: null,
|
|
61
|
+
anonVisitorId: null,
|
|
62
|
+
events: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const head = sessionRes.records[0];
|
|
66
|
+
|
|
67
|
+
const eventsRes = await session.run(
|
|
68
|
+
`MATCH (s:Session {accountId: $accountId, sessionId: $sessionId})-[:HAS_EVENT]->(e)
|
|
69
|
+
OPTIONAL MATCH (e)-[:OF_PAGE]->(pg:Page)
|
|
70
|
+
OPTIONAL MATCH (e)-[:OF_LISTING]->(l:Listing)
|
|
71
|
+
RETURN labels(e) AS labels,
|
|
72
|
+
toString(e.occurredAt) AS occurredAt,
|
|
73
|
+
toString(coalesce(e.firstAt, e.occurredAt)) AS firstAt,
|
|
74
|
+
e.url AS url,
|
|
75
|
+
pg.url AS pageUrl,
|
|
76
|
+
e.path AS path,
|
|
77
|
+
e.pageViewId AS pageViewId,
|
|
78
|
+
e.referrer AS referrer,
|
|
79
|
+
e.dwellMs AS dwellMs,
|
|
80
|
+
e.label AS label,
|
|
81
|
+
e.href AS href,
|
|
82
|
+
e.maxDepth AS maxDepth,
|
|
83
|
+
l.slug AS listingSlug
|
|
84
|
+
ORDER BY firstAt ASC`,
|
|
85
|
+
{ accountId: p.accountId, sessionId: p.sessionId },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const events: SessionEvent[] = eventsRes.records.map((r) => {
|
|
89
|
+
const labels = r.get("labels") as string[];
|
|
90
|
+
const kind: SessionEvent["kind"] = labels.includes("PageView")
|
|
91
|
+
? "pageview"
|
|
92
|
+
: labels.includes("Click")
|
|
93
|
+
? "click"
|
|
94
|
+
: "scroll";
|
|
95
|
+
const base: SessionEvent = {
|
|
96
|
+
kind,
|
|
97
|
+
occurredAt: r.get("occurredAt") ?? r.get("firstAt") ?? "",
|
|
98
|
+
};
|
|
99
|
+
if (kind === "pageview") {
|
|
100
|
+
base.url = r.get("pageUrl") ?? r.get("url") ?? "";
|
|
101
|
+
base.path = r.get("path") ?? undefined;
|
|
102
|
+
base.pageViewId = r.get("pageViewId") ?? undefined;
|
|
103
|
+
base.referrer = r.get("referrer") ?? undefined;
|
|
104
|
+
base.dwellMs = r.get("dwellMs") != null ? Number(r.get("dwellMs")) : undefined;
|
|
105
|
+
base.listingSlug = r.get("listingSlug") ?? undefined;
|
|
106
|
+
} else if (kind === "click") {
|
|
107
|
+
base.label = r.get("label") ?? "unknown";
|
|
108
|
+
base.href = r.get("href") ?? undefined;
|
|
109
|
+
} else {
|
|
110
|
+
base.maxDepth = r.get("maxDepth") != null ? Number(r.get("maxDepth")) : undefined;
|
|
111
|
+
}
|
|
112
|
+
return base;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
sessionId: p.sessionId,
|
|
117
|
+
startedAt: head.get("startedAt"),
|
|
118
|
+
lastSeenAt: head.get("lastSeenAt"),
|
|
119
|
+
personId: head.get("personId"),
|
|
120
|
+
personName: head.get("personName"),
|
|
121
|
+
anonVisitorId: head.get("anonVisitorId"),
|
|
122
|
+
events,
|
|
123
|
+
};
|
|
124
|
+
} finally {
|
|
125
|
+
await session.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"declarationMap": true,
|
|
16
|
+
"sourceMap": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src"],
|
|
19
|
+
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
|
|
20
|
+
}
|
package/payload/premium-plugins/real-agent/plugins/buyers/skills/property-recommender/SKILL.md
CHANGED
|
@@ -114,3 +114,14 @@ awk -F'sessionKey=' '/\[property-card-click\]/{split($2,a," "); print a[1]}' ser
|
|
|
114
114
|
```
|
|
115
115
|
|
|
116
116
|
Sessions appearing in the first count but not the second never clicked through. A high uniform zero is the load-bearing alarm — the recommendation is not earning the click. Sessions where `sessionKey=invalid` appears on the click line indicate a malformed `session` query param on the redirect URL (skill template defect); the click still landed but cannot be joined to its fire log.
|
|
117
|
+
|
|
118
|
+
## Visitor-graph integration (Task 357)
|
|
119
|
+
|
|
120
|
+
This skill runs on the public chat where the visitor is anonymous by default, so the rendered card link does **not** append a signed `&v=<token>` query parameter. The click still records `[property-card-click]`, but attribution remains anonymous unless the visitor previously clicked a marketing link that bound the `mxy_v` cookie. The `/listings/:slug/click` handler resolves identity from the cookie, not from the card markdown.
|
|
121
|
+
|
|
122
|
+
Outbound marketing surfaces (the morning-round briefing, the nurture-list outreach, the vendor weekly email) are responsible for minting `&v=<token>` on the URLs they send out, because they know whose `:Person` they're contacting. See `morning-round`, `lead-nurturing`, and `vendor-updates` for that side of the contract.
|
|
123
|
+
|
|
124
|
+
### Recommendation feedback loop
|
|
125
|
+
|
|
126
|
+
When a visitor has prior `:Session` activity (cookie identified, or AnonVisitor merged in), memory-search returns `:Person`-attributed visit counts in the `related` array of any prior `:Listing` recommendations. Use `visitor-recent-by-person` (admin-side query, not public-chat) to verify repeat interest before recommending the same listing twice in one session. The principle: a returning visitor who has already viewed a listing three times this week wants a different listing surfaced, not the same one.
|
|
127
|
+
|
|
@@ -135,3 +135,12 @@ Encourage the user to capture:
|
|
|
135
135
|
- **Neighbour notification letter** → `references/neighbour-notification-letter.md`
|
|
136
136
|
- **Buyer search letter** → `references/buyer-search-letter.md`
|
|
137
137
|
- **Database reactivation** → `references/database-reactivation.md`
|
|
138
|
+
|
|
139
|
+
## Visitor-graph nurture queue (Task 357)
|
|
140
|
+
|
|
141
|
+
Call `visitor-engagement-score` with the default 30-day window to rank `:Person` contacts by engagement: visit count × recency × scroll depth × dwell time. The output is the nurture queue. Top of the queue gets outreach today; bottom decays into low-touch.
|
|
142
|
+
|
|
143
|
+
When composing outbound messages that include a listing URL, append `&v=<token>` to the click URL. The token is minted server-side against the recipient's `:Person` elementId; the agent receives it as a sentinel in the system prompt for that outreach. Without the token the recipient's click is anonymous, defeating the point of the nurture loop.
|
|
144
|
+
|
|
145
|
+
The signal that says "this person stopped engaging": their score drops two consecutive weeks. That's a prompt for a pattern-break message (different listing, different framing), not a follow-up of the same thread.
|
|
146
|
+
|
|
@@ -70,3 +70,14 @@ The chase-progression workflow uses the same `chain-progression-tracker` skill t
|
|
|
70
70
|
- `realagent.stalled_threshold_days.broker`
|
|
71
71
|
- `realagent.stalled_threshold_days.surveyor`
|
|
72
72
|
- `realagent.awaiting_reply_lookback_days`
|
|
73
|
+
|
|
74
|
+
## Visitor-graph briefing (Task 357)
|
|
75
|
+
|
|
76
|
+
Before the morning round, call `visitor-recent-by-person` for every `:Person` in the active follow-up queue with `sinceDays: 1` to surface overnight activity. Persons with non-empty results land at the top of the briefing — they've been on the site since you last spoke. The briefing line takes the shape:
|
|
77
|
+
|
|
78
|
+
> _Sarah Chen — viewed Oak Street twice last night, opened the floorplan, scrolled the listing to 75%. Last seen 06:12._
|
|
79
|
+
|
|
80
|
+
Also call `visitor-recent-by-page` for any listing that picks up activity from previously-unidentified visitors — these are anonymous prospects worth surfacing to the operator even when no `:Person` is attached.
|
|
81
|
+
|
|
82
|
+
Outbound URLs in the briefing (links the operator might click to send to the contact) are minted via `mint-visitor-token` against the `:Person` and appended as `&v=<token>` to the existing `/listings/<slug>/click?session=<sk>` URL. The token binds the recipient's browser to the `:Person` on click.
|
|
83
|
+
|
|
@@ -151,3 +151,16 @@ Escalate to the agent (human) for:
|
|
|
151
151
|
- Requests to change agent terms
|
|
152
152
|
- Threats to withdraw instruction
|
|
153
153
|
- Legal or compliance queries
|
|
154
|
+
|
|
155
|
+
## Visitor-graph for the weekly vendor email (Task 357)
|
|
156
|
+
|
|
157
|
+
The weekly vendor email reports on viewing activity against the vendor's listing. Pull the numbers via `visitor-recent-by-page` with `listingSlug: <slug>` and `sinceDays: 7`. Report:
|
|
158
|
+
|
|
159
|
+
- total identified visitors (named `:Person`s) and total anonymous browsers in the window,
|
|
160
|
+
- the count of repeat visitors (the same `:Person` with two or more sessions),
|
|
161
|
+
- max-scroll-depth and average dwell across all PageViews on the listing.
|
|
162
|
+
|
|
163
|
+
The vendor sees real engagement, not just enquiry count. Tone stays factual — "twelve known visitors and twenty-three anonymous browsers" is honest; "interest is strong" is editorial. Let the numbers speak.
|
|
164
|
+
|
|
165
|
+
Outbound URLs in vendor emails are NOT tokenised — the vendor is not the visitor, and binding the vendor's browser to their own listing's `:Person` is meaningless.
|
|
166
|
+
|
|
@@ -2705,6 +2705,7 @@ var REMOTE_SESSION_SECRET_FILE = resolve(MAXY_DIR, "credentials", "remote-sessio
|
|
|
2705
2705
|
var ADMIN_SESSION_SECRET_FILE = resolve(MAXY_DIR, "credentials", "admin-session-secret");
|
|
2706
2706
|
var TELEGRAM_WEBHOOK_SECRET_FILE = resolve(MAXY_DIR, ".telegram-webhook-secret");
|
|
2707
2707
|
var TELEGRAM_ADMIN_WEBHOOK_SECRET_FILE = resolve(MAXY_DIR, ".telegram-admin-webhook-secret");
|
|
2708
|
+
var VISITOR_TOKEN_SECRET_FILE = resolve(MAXY_DIR, "credentials", "visitor-token-secret");
|
|
2708
2709
|
var CLAUDE_CREDENTIALS_FILE = resolve(MAXY_DIR, ".claude", ".credentials.json");
|
|
2709
2710
|
|
|
2710
2711
|
// app/lib/vnc-logger.ts
|
|
@@ -5476,6 +5477,7 @@ export {
|
|
|
5476
5477
|
USERS_FILE,
|
|
5477
5478
|
LOG_DIR,
|
|
5478
5479
|
BIN_DIR,
|
|
5480
|
+
VISITOR_TOKEN_SECRET_FILE,
|
|
5479
5481
|
CLAUDE_CREDENTIALS_FILE,
|
|
5480
5482
|
vncLog,
|
|
5481
5483
|
newCorrId,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Privacy</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --fg:#222; --bg:#FAFAF8; --muted:#666; --accent:#7C8C72; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; color: var(--fg); background: var(--bg); }
|
|
11
|
+
main { max-width: 720px; margin: 0 auto; padding: 48px 24px 96px; }
|
|
12
|
+
h1 { margin-top: 0; font-size: 32px; letter-spacing: -0.01em; }
|
|
13
|
+
h2 { margin-top: 36px; font-size: 20px; }
|
|
14
|
+
p, li { color: #333; }
|
|
15
|
+
ul { padding-left: 20px; }
|
|
16
|
+
code { background: #eee; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
|
|
17
|
+
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
|
18
|
+
th, td { border-bottom: 1px solid #ddd; padding: 8px 6px; text-align: left; vertical-align: top; }
|
|
19
|
+
th { background: #f0f0ee; font-weight: 600; }
|
|
20
|
+
.muted { color: var(--muted); font-size: 14px; }
|
|
21
|
+
.accent { color: var(--accent); }
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<main>
|
|
26
|
+
<h1>Privacy</h1>
|
|
27
|
+
<p>This page describes the data this site collects about your visit, why, and how to ask for it to be removed.</p>
|
|
28
|
+
|
|
29
|
+
<h2>What we collect</h2>
|
|
30
|
+
<p>When you load a property listing page, a small first-party script (<code>/v.js</code>) records:</p>
|
|
31
|
+
<table>
|
|
32
|
+
<thead><tr><th>Field</th><th>Why</th><th>Stored as</th></tr></thead>
|
|
33
|
+
<tbody>
|
|
34
|
+
<tr><td>Page URL, page title, referrer</td><td>So we know which listings get visitors and where they came from.</td><td><code>:PageView</code> node</td></tr>
|
|
35
|
+
<tr><td>Visitor ID (random, browser-local)</td><td>So we can tell two visits from one device apart from two devices.</td><td><code>:AnonVisitor</code> node (anonymous), or linked to a <code>:Person</code> if you arrived via a personalised marketing link.</td></tr>
|
|
36
|
+
<tr><td>Session ID (random, per tab)</td><td>So one tab's activity stays together.</td><td><code>:Session</code> node</td></tr>
|
|
37
|
+
<tr><td>Clicks on tagged elements (gallery, floorplan, etc.)</td><td>So we know which parts of a listing get interaction.</td><td><code>:Click</code> node</td></tr>
|
|
38
|
+
<tr><td>Scroll depth — 25%, 50%, 75%, 100%</td><td>So we know how far you read.</td><td><code>:ScrollMilestone</code> node (one per page view, max depth recorded)</td></tr>
|
|
39
|
+
<tr><td>Time spent on a page (dwell)</td><td>Same.</td><td>Property of the <code>:PageView</code> node</td></tr>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
<p class="muted">We do not collect: cursor positions, keystrokes, form contents, screen recordings, or anything from third-party tracking networks. The pixel is loaded from the same site as the listing, never from a third party.</p>
|
|
43
|
+
|
|
44
|
+
<h2>Cookies</h2>
|
|
45
|
+
<p>One cookie is set when you arrive via a personalised marketing link from the agent:</p>
|
|
46
|
+
<ul>
|
|
47
|
+
<li><code>mxy_v</code> — signed token containing your contact identifier. Set <code>HttpOnly</code>, <code>SameSite=Lax</code>, <code>Secure</code>. Lives for 30 days. Lets the site recognise you on later visits without you clicking the marketing link again.</li>
|
|
48
|
+
</ul>
|
|
49
|
+
|
|
50
|
+
<h2>Cross-session attribution</h2>
|
|
51
|
+
<p>If you visit anonymously today and later click a personalised marketing link from the agent, prior anonymous visits from the same browser are linked to your contact record. This is the only way an anonymous visit ever becomes named.</p>
|
|
52
|
+
|
|
53
|
+
<h2>Retention</h2>
|
|
54
|
+
<p>Visit data is kept for <strong>180 days</strong> by default, then removed automatically. The operator can change this — your acting agent will tell you the current value if you ask.</p>
|
|
55
|
+
|
|
56
|
+
<h2>Right to access and erasure</h2>
|
|
57
|
+
<p>To see what we hold or to ask for it to be removed, contact the agent. We honour GDPR Article 15 (access) and Article 17 (erasure). Erasure cascades: removing your contact removes every session, page view, click, and scroll milestone tied to your identity. Anonymous page views from browsers we cannot link to you are not removed individually — they cannot be identified, and they expire under the retention window above.</p>
|
|
58
|
+
|
|
59
|
+
<h2>Bots and crawlers</h2>
|
|
60
|
+
<p>Search engines and link checkers are filtered out by user-agent before any data is written. They are also excluded from rate-limit budgets so legitimate visitors are never blocked because a crawler hammered the endpoint.</p>
|
|
61
|
+
|
|
62
|
+
<h2>This page</h2>
|
|
63
|
+
<p class="muted">This is a static description. The behaviour above is implemented in <code>platform/ui/server/routes/visitor-event.ts</code> and <code>platform/ui/public/v.js</code>. The schema is declared in <code>platform/neo4j/schema.cypher</code> under the Visitor Graph section.</p>
|
|
64
|
+
</main>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/* Visitor-graph pixel (Task 357).
|
|
2
|
+
*
|
|
3
|
+
* Single IIFE. Plain JS. No build step. ~3 KB minified.
|
|
4
|
+
*
|
|
5
|
+
* Loaded by hosted listing pages:
|
|
6
|
+
*
|
|
7
|
+
* <script defer src="https://<brand>/v.js" data-site="<slug>"></script>
|
|
8
|
+
*
|
|
9
|
+
* Emits to POST /v/event:
|
|
10
|
+
*
|
|
11
|
+
* pageview — once per session per URL (idempotent within 5s for SPA nav)
|
|
12
|
+
* click — DOM clicks on elements with data-track="<label>", or
|
|
13
|
+
* anchors flagged data-track-gallery / data-track-floorplan
|
|
14
|
+
* scroll — roll-up: one event per milestone {25,50,75,100} crossed
|
|
15
|
+
* dwell — on visibility change to hidden / pagehide, sendBeacon
|
|
16
|
+
*
|
|
17
|
+
* Identity: the server resolves identity from the mxy_v cookie. The pixel
|
|
18
|
+
* sends visitorId from localStorage (mints one if absent) as a fallback so
|
|
19
|
+
* cross-tab and same-browser-no-cookie cases still cluster.
|
|
20
|
+
*/
|
|
21
|
+
(function () {
|
|
22
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
|
23
|
+
var script = document.currentScript
|
|
24
|
+
if (!script) return
|
|
25
|
+
var siteSlug = script.getAttribute('data-site') || ''
|
|
26
|
+
var endpoint = new URL('/v/event', script.src).toString()
|
|
27
|
+
|
|
28
|
+
var VISITOR_KEY = 'mxy_visitor_id'
|
|
29
|
+
var SESSION_KEY = 'mxy_session_id'
|
|
30
|
+
var SESSION_PV_KEY = 'mxy_session_pv'
|
|
31
|
+
var LAST_PV_KEY = 'mxy_last_pv'
|
|
32
|
+
var SCROLL_KEY = 'mxy_scroll_depth'
|
|
33
|
+
var DWELL_KEY = 'mxy_dwell_start'
|
|
34
|
+
|
|
35
|
+
function randomId() {
|
|
36
|
+
if (window.crypto && window.crypto.getRandomValues) {
|
|
37
|
+
var buf = new Uint8Array(16)
|
|
38
|
+
window.crypto.getRandomValues(buf)
|
|
39
|
+
return Array.prototype.map.call(buf, function (b) {
|
|
40
|
+
return ('0' + b.toString(16)).slice(-2)
|
|
41
|
+
}).join('')
|
|
42
|
+
}
|
|
43
|
+
return String(Date.now()) + '_' + Math.random().toString(36).slice(2, 10)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getVisitorId() {
|
|
47
|
+
try {
|
|
48
|
+
var v = localStorage.getItem(VISITOR_KEY)
|
|
49
|
+
if (v) return v
|
|
50
|
+
var fresh = randomId()
|
|
51
|
+
localStorage.setItem(VISITOR_KEY, fresh)
|
|
52
|
+
return fresh
|
|
53
|
+
} catch (_e) {
|
|
54
|
+
return 'noltc-' + randomId()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getSessionId() {
|
|
59
|
+
try {
|
|
60
|
+
var s = sessionStorage.getItem(SESSION_KEY)
|
|
61
|
+
if (s) return s
|
|
62
|
+
var fresh = randomId()
|
|
63
|
+
sessionStorage.setItem(SESSION_KEY, fresh)
|
|
64
|
+
return fresh
|
|
65
|
+
} catch (_e) {
|
|
66
|
+
return 'nots-' + randomId()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
var visitorId = getVisitorId()
|
|
71
|
+
var sessionId = getSessionId()
|
|
72
|
+
|
|
73
|
+
function send(payload) {
|
|
74
|
+
payload.site = siteSlug
|
|
75
|
+
payload.vid = visitorId
|
|
76
|
+
payload.sid = sessionId
|
|
77
|
+
payload.url = location.href
|
|
78
|
+
payload.path = location.pathname
|
|
79
|
+
payload.title = (document.title || '').slice(0, 256)
|
|
80
|
+
payload.ts = Date.now()
|
|
81
|
+
try {
|
|
82
|
+
var body = JSON.stringify(payload)
|
|
83
|
+
if (navigator.sendBeacon) {
|
|
84
|
+
var blob = new Blob([body], { type: 'application/json' })
|
|
85
|
+
if (navigator.sendBeacon(endpoint, blob)) return
|
|
86
|
+
}
|
|
87
|
+
fetch(endpoint, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
body: body,
|
|
91
|
+
credentials: 'include',
|
|
92
|
+
keepalive: true,
|
|
93
|
+
}).catch(function () { /* fire-and-forget */ })
|
|
94
|
+
} catch (_e) {
|
|
95
|
+
/* silently drop — pixel must never break the page */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─────────── pageview (idempotent within 5s for SPA nav) ───────────
|
|
100
|
+
function emitPageview() {
|
|
101
|
+
try {
|
|
102
|
+
var last = sessionStorage.getItem(LAST_PV_KEY)
|
|
103
|
+
if (last) {
|
|
104
|
+
var parsed = JSON.parse(last)
|
|
105
|
+
if (parsed && parsed.url === location.href && Date.now() - parsed.ts < 5000) {
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
sessionStorage.setItem(LAST_PV_KEY, JSON.stringify({ url: location.href, ts: Date.now() }))
|
|
110
|
+
sessionStorage.setItem(SESSION_PV_KEY, randomId()) // PageView id for milestone roll-up
|
|
111
|
+
sessionStorage.setItem(SCROLL_KEY, '0')
|
|
112
|
+
} catch (_e) { /* localStorage / sessionStorage disabled */ }
|
|
113
|
+
|
|
114
|
+
var pvId = ''
|
|
115
|
+
try { pvId = sessionStorage.getItem(SESSION_PV_KEY) || '' } catch (_e) {}
|
|
116
|
+
send({
|
|
117
|
+
type: 'pageview',
|
|
118
|
+
pvId: pvId,
|
|
119
|
+
referrer: document.referrer || '',
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─────────── click tracking ───────────
|
|
124
|
+
document.addEventListener('click', function (ev) {
|
|
125
|
+
var el = ev.target && (ev.target.closest ? ev.target.closest('[data-track]') : null)
|
|
126
|
+
if (!el) return
|
|
127
|
+
var label = el.getAttribute('data-track') || 'unknown'
|
|
128
|
+
var pvId = ''
|
|
129
|
+
try { pvId = sessionStorage.getItem(SESSION_PV_KEY) || '' } catch (_e) {}
|
|
130
|
+
send({
|
|
131
|
+
type: 'click',
|
|
132
|
+
label: label.slice(0, 64),
|
|
133
|
+
pvId: pvId,
|
|
134
|
+
href: el.getAttribute('href') || '',
|
|
135
|
+
})
|
|
136
|
+
}, true)
|
|
137
|
+
|
|
138
|
+
// ─────────── scroll milestones (roll-up) ───────────
|
|
139
|
+
var scrollScheduled = false
|
|
140
|
+
function onScroll() {
|
|
141
|
+
if (scrollScheduled) return
|
|
142
|
+
scrollScheduled = true
|
|
143
|
+
requestAnimationFrame(function () {
|
|
144
|
+
scrollScheduled = false
|
|
145
|
+
var doc = document.documentElement
|
|
146
|
+
var max = Math.max(doc.scrollHeight, document.body.scrollHeight) - window.innerHeight
|
|
147
|
+
if (max <= 0) return
|
|
148
|
+
var pct = Math.floor(((window.scrollY || doc.scrollTop) / max) * 100)
|
|
149
|
+
var threshold = pct >= 100 ? 100 : pct >= 75 ? 75 : pct >= 50 ? 50 : pct >= 25 ? 25 : 0
|
|
150
|
+
if (threshold === 0) return
|
|
151
|
+
try {
|
|
152
|
+
var prev = parseInt(sessionStorage.getItem(SCROLL_KEY) || '0', 10) || 0
|
|
153
|
+
if (threshold <= prev) return
|
|
154
|
+
sessionStorage.setItem(SCROLL_KEY, String(threshold))
|
|
155
|
+
} catch (_e) {}
|
|
156
|
+
var pvId = ''
|
|
157
|
+
try { pvId = sessionStorage.getItem(SESSION_PV_KEY) || '' } catch (_e) {}
|
|
158
|
+
send({ type: 'scroll', depth: threshold, pvId: pvId })
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
162
|
+
|
|
163
|
+
// ─────────── dwell on unload / visibility change ───────────
|
|
164
|
+
try { sessionStorage.setItem(DWELL_KEY, String(Date.now())) } catch (_e) {}
|
|
165
|
+
function emitDwell() {
|
|
166
|
+
var start = 0
|
|
167
|
+
try { start = parseInt(sessionStorage.getItem(DWELL_KEY) || '0', 10) || 0 } catch (_e) {}
|
|
168
|
+
if (!start) return
|
|
169
|
+
var ms = Date.now() - start
|
|
170
|
+
if (ms < 250) return // ignore drive-bys
|
|
171
|
+
var pvId = ''
|
|
172
|
+
try { pvId = sessionStorage.getItem(SESSION_PV_KEY) || '' } catch (_e) {}
|
|
173
|
+
send({ type: 'dwell', ms: ms, pvId: pvId })
|
|
174
|
+
}
|
|
175
|
+
document.addEventListener('visibilitychange', function () {
|
|
176
|
+
if (document.visibilityState === 'hidden') emitDwell()
|
|
177
|
+
})
|
|
178
|
+
window.addEventListener('pagehide', emitDwell)
|
|
179
|
+
|
|
180
|
+
// SPA navigation hooks — many listing sites are client-routed.
|
|
181
|
+
var origPushState = history.pushState
|
|
182
|
+
history.pushState = function () {
|
|
183
|
+
var r = origPushState.apply(this, arguments)
|
|
184
|
+
setTimeout(emitPageview, 0)
|
|
185
|
+
return r
|
|
186
|
+
}
|
|
187
|
+
window.addEventListener('popstate', emitPageview)
|
|
188
|
+
|
|
189
|
+
// Boot.
|
|
190
|
+
emitPageview()
|
|
191
|
+
})()
|