@rubytech/create-realagent-code 0.1.117 → 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.
Files changed (137) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/neo4j/schema.cypher +124 -1
  3. package/payload/platform/package-lock.json +141 -125
  4. package/payload/platform/plugins/.claude-plugin/marketplace.json +5 -0
  5. package/payload/platform/plugins/aeo/.claude-plugin/plugin.json +17 -0
  6. package/payload/platform/plugins/aeo/PLUGIN.md +81 -0
  7. package/payload/platform/plugins/aeo/mcp/dist/__tests__/audit-heuristics.test.d.ts +2 -0
  8. package/payload/platform/plugins/aeo/mcp/dist/__tests__/audit-heuristics.test.d.ts.map +1 -0
  9. package/payload/platform/plugins/aeo/mcp/dist/__tests__/audit-heuristics.test.js +121 -0
  10. package/payload/platform/plugins/aeo/mcp/dist/__tests__/audit-heuristics.test.js.map +1 -0
  11. package/payload/platform/plugins/aeo/mcp/dist/__tests__/schema-mapping.test.d.ts +2 -0
  12. package/payload/platform/plugins/aeo/mcp/dist/__tests__/schema-mapping.test.d.ts.map +1 -0
  13. package/payload/platform/plugins/aeo/mcp/dist/__tests__/schema-mapping.test.js +129 -0
  14. package/payload/platform/plugins/aeo/mcp/dist/__tests__/schema-mapping.test.js.map +1 -0
  15. package/payload/platform/plugins/aeo/mcp/dist/index.d.ts +2 -0
  16. package/payload/platform/plugins/aeo/mcp/dist/index.d.ts.map +1 -0
  17. package/payload/platform/plugins/aeo/mcp/dist/index.js +171 -0
  18. package/payload/platform/plugins/aeo/mcp/dist/index.js.map +1 -0
  19. package/payload/platform/plugins/aeo/mcp/dist/lib/audit-heuristics.d.ts +27 -0
  20. package/payload/platform/plugins/aeo/mcp/dist/lib/audit-heuristics.d.ts.map +1 -0
  21. package/payload/platform/plugins/aeo/mcp/dist/lib/audit-heuristics.js +274 -0
  22. package/payload/platform/plugins/aeo/mcp/dist/lib/audit-heuristics.js.map +1 -0
  23. package/payload/platform/plugins/aeo/mcp/dist/lib/neo4j.d.ts +5 -0
  24. package/payload/platform/plugins/aeo/mcp/dist/lib/neo4j.d.ts.map +1 -0
  25. package/payload/platform/plugins/aeo/mcp/dist/lib/neo4j.js +38 -0
  26. package/payload/platform/plugins/aeo/mcp/dist/lib/neo4j.js.map +1 -0
  27. package/payload/platform/plugins/aeo/mcp/dist/lib/schema-mapping.d.ts +48 -0
  28. package/payload/platform/plugins/aeo/mcp/dist/lib/schema-mapping.d.ts.map +1 -0
  29. package/payload/platform/plugins/aeo/mcp/dist/lib/schema-mapping.js +254 -0
  30. package/payload/platform/plugins/aeo/mcp/dist/lib/schema-mapping.js.map +1 -0
  31. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-audit-page.d.ts +25 -0
  32. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-audit-page.d.ts.map +1 -0
  33. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-audit-page.js +78 -0
  34. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-audit-page.js.map +1 -0
  35. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-emit-jsonld.d.ts +18 -0
  36. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-emit-jsonld.d.ts.map +1 -0
  37. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-emit-jsonld.js +56 -0
  38. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-emit-jsonld.js.map +1 -0
  39. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-write-llms-txt.d.ts +37 -0
  40. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-write-llms-txt.d.ts.map +1 -0
  41. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-write-llms-txt.js +94 -0
  42. package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-write-llms-txt.js.map +1 -0
  43. package/payload/platform/plugins/aeo/mcp/package.json +22 -0
  44. package/payload/platform/plugins/aeo/mcp/vitest.config.ts +9 -0
  45. package/payload/platform/plugins/aeo/skills/structured-answer/SKILL.md +53 -0
  46. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-erase.d.ts +12 -1
  47. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-erase.d.ts.map +1 -1
  48. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-erase.js +34 -1
  49. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-erase.js.map +1 -1
  50. package/payload/platform/plugins/docs/references/aeo.md +87 -0
  51. package/payload/platform/plugins/docs/references/visitor-graph.md +82 -0
  52. package/payload/platform/plugins/email/PLUGIN.md +6 -0
  53. package/payload/platform/plugins/email/skills/email-composition/SKILL.md +180 -0
  54. package/payload/platform/plugins/memory/mcp/dist/index.js +5 -0
  55. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  56. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/label-origin-gate.test.d.ts +2 -0
  57. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/label-origin-gate.test.d.ts.map +1 -0
  58. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/label-origin-gate.test.js +62 -0
  59. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/label-origin-gate.test.js.map +1 -0
  60. package/payload/platform/plugins/memory/mcp/dist/lib/label-origin-gate.d.ts +18 -0
  61. package/payload/platform/plugins/memory/mcp/dist/lib/label-origin-gate.d.ts.map +1 -0
  62. package/payload/platform/plugins/memory/mcp/dist/lib/label-origin-gate.js +29 -0
  63. package/payload/platform/plugins/memory/mcp/dist/lib/label-origin-gate.js.map +1 -0
  64. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js +2 -0
  65. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js.map +1 -1
  66. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +1 -1
  67. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -1
  68. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts +3 -0
  69. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
  70. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +9 -1
  71. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
  72. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  73. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +8 -0
  74. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  75. package/payload/platform/plugins/memory/mcp/vitest.config.ts +2 -0
  76. package/payload/platform/plugins/memory/references/schema-base.md +49 -1
  77. package/payload/platform/scripts/smoke-boot-services.sh +30 -2
  78. package/payload/platform/templates/specialists/agents/signal-detector.md +1 -1
  79. package/payload/premium-plugins/real-agent/plugins/buyers/.claude-plugin/plugin.json +1 -1
  80. package/payload/premium-plugins/real-agent/plugins/buyers/PLUGIN.md +33 -3
  81. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/index.d.ts +2 -0
  82. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/index.d.ts.map +1 -0
  83. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/index.js +97 -0
  84. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/index.js.map +1 -0
  85. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/lib/neo4j.d.ts +5 -0
  86. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/lib/neo4j.d.ts.map +1 -0
  87. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/lib/neo4j.js +40 -0
  88. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/lib/neo4j.js.map +1 -0
  89. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-backfill-from-logs.d.ts +24 -0
  90. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-backfill-from-logs.d.ts.map +1 -0
  91. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-backfill-from-logs.js +156 -0
  92. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-backfill-from-logs.js.map +1 -0
  93. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-engagement-score.d.ts +21 -0
  94. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-engagement-score.d.ts.map +1 -0
  95. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-engagement-score.js +65 -0
  96. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-engagement-score.js.map +1 -0
  97. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-event-ingest.d.ts +26 -0
  98. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-event-ingest.d.ts.map +1 -0
  99. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-event-ingest.js +78 -0
  100. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-event-ingest.js.map +1 -0
  101. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-page.d.ts +19 -0
  102. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-page.d.ts.map +1 -0
  103. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-page.js +41 -0
  104. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-page.js.map +1 -0
  105. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-person.d.ts +20 -0
  106. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-person.d.ts.map +1 -0
  107. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-person.js +44 -0
  108. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recent-by-person.js.map +1 -0
  109. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recommendation-ctr.d.ts +22 -0
  110. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recommendation-ctr.d.ts.map +1 -0
  111. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recommendation-ctr.js +41 -0
  112. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-recommendation-ctr.js.map +1 -0
  113. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-session-detail.d.ts +32 -0
  114. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-session-detail.d.ts.map +1 -0
  115. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-session-detail.js +87 -0
  116. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/dist/tools/visitor-session-detail.js.map +1 -0
  117. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/package-lock.json +2566 -0
  118. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/package.json +21 -0
  119. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/index.ts +145 -0
  120. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/lib/neo4j.ts +45 -0
  121. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-backfill-from-logs.ts +189 -0
  122. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-engagement-score.ts +87 -0
  123. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-event-ingest.ts +119 -0
  124. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-recent-by-page.ts +61 -0
  125. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-recent-by-person.ts +65 -0
  126. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-recommendation-ctr.ts +67 -0
  127. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-session-detail.ts +127 -0
  128. package/payload/premium-plugins/real-agent/plugins/buyers/mcp/tsconfig.json +20 -0
  129. package/payload/premium-plugins/real-agent/plugins/buyers/skills/property-recommender/SKILL.md +11 -0
  130. package/payload/premium-plugins/real-agent/plugins/leads/skills/lead-nurturing/SKILL.md +9 -0
  131. package/payload/premium-plugins/real-agent/plugins/leads/skills/morning-round/SKILL.md +11 -0
  132. package/payload/premium-plugins/real-agent/plugins/vendors/skills/vendor-updates/SKILL.md +13 -0
  133. package/payload/server/{chunk-FJCI6X3H.js → chunk-HCYM5FLU.js} +2 -0
  134. package/payload/server/maxy-edge.js +1 -1
  135. package/payload/server/public/privacy.html +66 -0
  136. package/payload/server/public/v.js +191 -0
  137. package/payload/server/server.js +473 -85
@@ -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
+ }
@@ -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
+ }
@@ -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,
@@ -13,7 +13,7 @@ import {
13
13
  sanitizeClientCorrId,
14
14
  vncLog,
15
15
  websockifyLog
16
- } from "./chunk-FJCI6X3H.js";
16
+ } from "./chunk-HCYM5FLU.js";
17
17
  import "./chunk-JSBRDJBE.js";
18
18
 
19
19
  // server/edge.ts
@@ -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
+ })()