@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.
- package/package.json +1 -1
- package/payload/platform/neo4j/schema.cypher +124 -1
- package/payload/platform/package-lock.json +141 -125
- package/payload/platform/plugins/.claude-plugin/marketplace.json +5 -0
- package/payload/platform/plugins/aeo/.claude-plugin/plugin.json +17 -0
- package/payload/platform/plugins/aeo/PLUGIN.md +81 -0
- package/payload/platform/plugins/aeo/mcp/dist/__tests__/audit-heuristics.test.d.ts +2 -0
- package/payload/platform/plugins/aeo/mcp/dist/__tests__/audit-heuristics.test.d.ts.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/__tests__/audit-heuristics.test.js +121 -0
- package/payload/platform/plugins/aeo/mcp/dist/__tests__/audit-heuristics.test.js.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/__tests__/schema-mapping.test.d.ts +2 -0
- package/payload/platform/plugins/aeo/mcp/dist/__tests__/schema-mapping.test.d.ts.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/__tests__/schema-mapping.test.js +129 -0
- package/payload/platform/plugins/aeo/mcp/dist/__tests__/schema-mapping.test.js.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/index.d.ts +2 -0
- package/payload/platform/plugins/aeo/mcp/dist/index.d.ts.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/index.js +171 -0
- package/payload/platform/plugins/aeo/mcp/dist/index.js.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/audit-heuristics.d.ts +27 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/audit-heuristics.d.ts.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/audit-heuristics.js +274 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/audit-heuristics.js.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/neo4j.js +38 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/schema-mapping.d.ts +48 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/schema-mapping.d.ts.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/schema-mapping.js +254 -0
- package/payload/platform/plugins/aeo/mcp/dist/lib/schema-mapping.js.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-audit-page.d.ts +25 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-audit-page.d.ts.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-audit-page.js +78 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-audit-page.js.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-emit-jsonld.d.ts +18 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-emit-jsonld.d.ts.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-emit-jsonld.js +56 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-emit-jsonld.js.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-write-llms-txt.d.ts +37 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-write-llms-txt.d.ts.map +1 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-write-llms-txt.js +94 -0
- package/payload/platform/plugins/aeo/mcp/dist/tools/aeo-write-llms-txt.js.map +1 -0
- package/payload/platform/plugins/aeo/mcp/package.json +22 -0
- package/payload/platform/plugins/aeo/mcp/vitest.config.ts +9 -0
- package/payload/platform/plugins/aeo/skills/structured-answer/SKILL.md +53 -0
- 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 +87 -0
- package/payload/platform/plugins/docs/references/visitor-graph.md +82 -0
- package/payload/platform/plugins/email/PLUGIN.md +6 -0
- package/payload/platform/plugins/email/skills/email-composition/SKILL.md +180 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js +5 -0
- package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/label-origin-gate.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/label-origin-gate.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/label-origin-gate.test.js +62 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/label-origin-gate.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/label-origin-gate.d.ts +18 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/label-origin-gate.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/label-origin-gate.js +29 -0
- package/payload/platform/plugins/memory/mcp/dist/lib/label-origin-gate.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts +3 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +9 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +8 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/vitest.config.ts +2 -0
- package/payload/platform/plugins/memory/references/schema-base.md +49 -1
- package/payload/platform/scripts/smoke-boot-services.sh +30 -2
- package/payload/platform/templates/specialists/agents/signal-detector.md +1 -1
- 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
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maxy/buyers",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
14
|
+
"neo4j-driver": "^5.28.1"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"typescript": "^5.7.0",
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"vitest": "^4.1.2"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// buyers MCP server — visitor-graph tools (Task 357).
|
|
2
|
+
//
|
|
3
|
+
// Seven tools, all admin-side (none exposed on the public agent):
|
|
4
|
+
// visitor-recent-by-person query
|
|
5
|
+
// visitor-recent-by-page query
|
|
6
|
+
// visitor-engagement-score query
|
|
7
|
+
// visitor-recommendation-ctr query (replaces 336 log-scan after parity)
|
|
8
|
+
// visitor-session-detail query
|
|
9
|
+
// visitor-event-ingest write (test harness / manual replay)
|
|
10
|
+
// visitor-backfill-from-logs write (one-shot importer + parity check)
|
|
11
|
+
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
import { closeDriver } from "./lib/neo4j.js";
|
|
17
|
+
import { visitorRecentByPerson } from "./tools/visitor-recent-by-person.js";
|
|
18
|
+
import { visitorRecentByPage } from "./tools/visitor-recent-by-page.js";
|
|
19
|
+
import { visitorEngagementScore } from "./tools/visitor-engagement-score.js";
|
|
20
|
+
import { visitorRecommendationCtr } from "./tools/visitor-recommendation-ctr.js";
|
|
21
|
+
import { visitorSessionDetail } from "./tools/visitor-session-detail.js";
|
|
22
|
+
import { visitorEventIngest } from "./tools/visitor-event-ingest.js";
|
|
23
|
+
import { visitorBackfillFromLogs } from "./tools/visitor-backfill-from-logs.js";
|
|
24
|
+
|
|
25
|
+
const accountId = process.env.ACCOUNT_ID ?? "local";
|
|
26
|
+
|
|
27
|
+
const server = new McpServer({ name: "maxy-buyers", version: "0.1.0" });
|
|
28
|
+
|
|
29
|
+
console.error(`[buyers] server started, account=${accountId}`);
|
|
30
|
+
|
|
31
|
+
function jsonHandler<T>(fn: (p: Record<string, unknown>) => Promise<T>) {
|
|
32
|
+
return async (params: Record<string, unknown>) => {
|
|
33
|
+
try {
|
|
34
|
+
const result = await fn({ ...params, accountId });
|
|
35
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
39
|
+
isError: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
server.tool(
|
|
46
|
+
"visitor-recent-by-person",
|
|
47
|
+
"List recent visitor sessions for a known :Person. Returns per-session pageview/click/scroll/dwell counts and the listings each session touched. Use for morning-round briefings and 1:1 outreach context.",
|
|
48
|
+
{
|
|
49
|
+
personId: z.string().min(1).describe(":Person elementId"),
|
|
50
|
+
sinceDays: z.number().int().positive().optional().describe("Lookback window in days (default 30)"),
|
|
51
|
+
limit: z.number().int().positive().max(200).optional().describe("Max sessions to return (default 50)"),
|
|
52
|
+
},
|
|
53
|
+
jsonHandler((p) => visitorRecentByPerson(p as unknown as Parameters<typeof visitorRecentByPerson>[0])),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
server.tool(
|
|
57
|
+
"visitor-recent-by-page",
|
|
58
|
+
"List recent visitors (Person + AnonVisitor) of a given listing slug or URL. Use to answer 'who has shown interest in 12 Oak Street this week?'.",
|
|
59
|
+
{
|
|
60
|
+
listingSlug: z.string().optional().describe("Listing slug (one of slug/url required)"),
|
|
61
|
+
url: z.string().optional().describe("Page URL (one of slug/url required)"),
|
|
62
|
+
sinceDays: z.number().int().positive().optional().describe("Lookback window in days (default 7)"),
|
|
63
|
+
limit: z.number().int().positive().max(200).optional().describe("Max visitors to return (default 50)"),
|
|
64
|
+
},
|
|
65
|
+
jsonHandler((p) => visitorRecentByPage(p as unknown as Parameters<typeof visitorRecentByPage>[0])),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
server.tool(
|
|
69
|
+
"visitor-engagement-score",
|
|
70
|
+
"Engagement-ranked :Person list. score = visits * recency * scroll_depth * dwell. Use to build the nurture-list and prioritise outbound.",
|
|
71
|
+
{
|
|
72
|
+
sinceDays: z.number().int().positive().optional().describe("Lookback window in days (default 30)"),
|
|
73
|
+
limit: z.number().int().positive().max(200).optional().describe("Max persons (default 50)"),
|
|
74
|
+
},
|
|
75
|
+
jsonHandler((p) => visitorEngagementScore(p as unknown as Parameters<typeof visitorEngagementScore>[0])),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
server.tool(
|
|
79
|
+
"visitor-recommendation-ctr",
|
|
80
|
+
"Graph-backed click-through rate for property recommendations over a window. Replaces the Task 336 log-scan after the 14-day parity cutover.",
|
|
81
|
+
{
|
|
82
|
+
sinceDays: z.number().int().positive().optional().describe("Lookback window in days (default 7)"),
|
|
83
|
+
listingSlug: z.string().optional().describe("Optional listing-slug filter"),
|
|
84
|
+
},
|
|
85
|
+
jsonHandler((p) => visitorRecommendationCtr(p as unknown as Parameters<typeof visitorRecommendationCtr>[0])),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
server.tool(
|
|
89
|
+
"visitor-session-detail",
|
|
90
|
+
"Full event timeline for one :Session (pageviews, clicks, scroll milestones in chronological order). Use for diagnosis and outreach prep.",
|
|
91
|
+
{
|
|
92
|
+
sessionId: z.string().min(1).describe(":Session.sessionId"),
|
|
93
|
+
},
|
|
94
|
+
jsonHandler((p) => visitorSessionDetail(p as unknown as Parameters<typeof visitorSessionDetail>[0])),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
server.tool(
|
|
98
|
+
"visitor-event-ingest",
|
|
99
|
+
"Admin-side companion to POST /v/event for test harness and manual replay. Writes a single event into the graph.",
|
|
100
|
+
{
|
|
101
|
+
type: z.enum(["pageview", "click", "scroll", "dwell"]).describe("Event type"),
|
|
102
|
+
sessionId: z.string().min(1).describe("Client-provided session id"),
|
|
103
|
+
visitorId: z.string().min(1).describe("Client-provided visitor id"),
|
|
104
|
+
personId: z.string().optional().nullable().describe("Optional :Person elementId override"),
|
|
105
|
+
url: z.string().min(1).describe("Page URL"),
|
|
106
|
+
path: z.string().optional(),
|
|
107
|
+
title: z.string().optional(),
|
|
108
|
+
pageViewId: z.string().optional(),
|
|
109
|
+
site: z.string().optional(),
|
|
110
|
+
referrer: z.string().optional(),
|
|
111
|
+
label: z.string().optional().describe("Click label (data-track value)"),
|
|
112
|
+
href: z.string().optional(),
|
|
113
|
+
depth: z.number().int().min(0).max(100).optional().describe("Scroll depth (25/50/75/100)"),
|
|
114
|
+
dwellMs: z.number().int().min(0).optional(),
|
|
115
|
+
occurredAt: z.string().optional().describe("ISO timestamp override"),
|
|
116
|
+
},
|
|
117
|
+
jsonHandler((p) => visitorEventIngest(p as unknown as Parameters<typeof visitorEventIngest>[0])),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
server.tool(
|
|
121
|
+
"visitor-backfill-from-logs",
|
|
122
|
+
"One-shot importer: parse [property-recommended] and [property-card-click] log lines and write into the visitor graph. Idempotent for Recommendations; immutable for Click events. Reports a parityCheck comparing graph counts to log counts.",
|
|
123
|
+
{
|
|
124
|
+
logPath: z.string().min(1).describe("Absolute path to the platform log file"),
|
|
125
|
+
sinceIso: z.string().optional().describe("ISO timestamp lower bound (inclusive)"),
|
|
126
|
+
untilIso: z.string().optional().describe("ISO timestamp upper bound (exclusive)"),
|
|
127
|
+
batchSize: z.number().int().positive().max(2000).optional().describe("Lines per Cypher batch (default 200)"),
|
|
128
|
+
},
|
|
129
|
+
jsonHandler((p) => visitorBackfillFromLogs(p as unknown as Parameters<typeof visitorBackfillFromLogs>[0])),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
process.on("SIGINT", async () => {
|
|
133
|
+
console.error("[buyers] SIGINT — closing driver");
|
|
134
|
+
await closeDriver();
|
|
135
|
+
process.exit(0);
|
|
136
|
+
});
|
|
137
|
+
process.on("SIGTERM", async () => {
|
|
138
|
+
console.error("[buyers] SIGTERM — closing driver");
|
|
139
|
+
await closeDriver();
|
|
140
|
+
process.exit(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const transport = new StdioServerTransport();
|
|
144
|
+
await server.connect(transport);
|
|
145
|
+
console.error("[buyers] ready");
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Neo4j driver/session helper — same pattern as loop/mcp/src/lib/neo4j.ts.
|
|
2
|
+
// NEO4J_URI must be explicit; defaulting to localhost cross-pollinates
|
|
3
|
+
// Maxy and Real Agent databases on a shared dev host.
|
|
4
|
+
|
|
5
|
+
import neo4j, { Driver, Session } from "neo4j-driver";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
let driver: Driver | null = null;
|
|
10
|
+
|
|
11
|
+
function readPassword(): string {
|
|
12
|
+
if (process.env.NEO4J_PASSWORD) return process.env.NEO4J_PASSWORD;
|
|
13
|
+
const passwordFile = resolve(
|
|
14
|
+
process.env.PLATFORM_ROOT ?? resolve(import.meta.dirname, "../../../.."),
|
|
15
|
+
"config/.neo4j-password",
|
|
16
|
+
);
|
|
17
|
+
try {
|
|
18
|
+
return readFileSync(passwordFile, "utf-8").trim();
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error(`Neo4j password not found. Expected at ${passwordFile} or in NEO4J_PASSWORD env var.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDriver(): Driver {
|
|
25
|
+
if (!driver) {
|
|
26
|
+
const uri = process.env.NEO4J_URI;
|
|
27
|
+
if (!uri) throw new Error("[buyers] NEO4J_URI unset — refusing to default to bolt://localhost:7687");
|
|
28
|
+
const user = process.env.NEO4J_USER ?? "neo4j";
|
|
29
|
+
const password = readPassword();
|
|
30
|
+
console.error(`[buyers] resolved neo4j_uri=${uri}`);
|
|
31
|
+
driver = neo4j.driver(uri, neo4j.auth.basic(user, password));
|
|
32
|
+
}
|
|
33
|
+
return driver;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getSession(): Session {
|
|
37
|
+
return getDriver().session();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function closeDriver(): Promise<void> {
|
|
41
|
+
if (driver) {
|
|
42
|
+
await driver.close();
|
|
43
|
+
driver = null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// visitor-backfill-from-logs — one-shot importer.
|
|
2
|
+
//
|
|
3
|
+
// Parses [property-recommended] and [property-card-click] log lines from
|
|
4
|
+
// the platform log and writes them into the visitor graph:
|
|
5
|
+
//
|
|
6
|
+
// [property-recommended] sessionKey=<sk> listingSlug=<slug> ts=<iso>
|
|
7
|
+
// -> MERGE (Session {sessionKey})
|
|
8
|
+
// CREATE (Recommendation {sessionKey, listingSlug, recommendedAt})
|
|
9
|
+
// MERGE (Recommendation)-[:FOR_SESSION]->(Session)
|
|
10
|
+
//
|
|
11
|
+
// [property-card-click] sessionKey=<sk> listingSlug=<slug> ts=<iso>
|
|
12
|
+
// -> MATCH/MERGE Session, CREATE Click {label:'card', listingSlug}
|
|
13
|
+
// MERGE Session-[:HAS_EVENT]->Click
|
|
14
|
+
//
|
|
15
|
+
// Parity check after backfill: graph counts vs log-scan counts for the
|
|
16
|
+
// same window. parityCheck is 'ok' iff both deltas are zero, otherwise
|
|
17
|
+
// 'mismatch' with the delta surfaced for diagnosis.
|
|
18
|
+
//
|
|
19
|
+
// Idempotency: re-running over the same log window does not duplicate
|
|
20
|
+
// Recommendation nodes (MERGE on the exact sessionKey + slug + ts tuple).
|
|
21
|
+
// Click nodes are immutable event records by design; if you re-run on a
|
|
22
|
+
// window already processed you get duplicates. Operator runs once per
|
|
23
|
+
// cutover.
|
|
24
|
+
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { getSession } from "../lib/neo4j.js";
|
|
27
|
+
|
|
28
|
+
export interface BackfillParams {
|
|
29
|
+
accountId: string;
|
|
30
|
+
logPath: string;
|
|
31
|
+
sinceIso?: string;
|
|
32
|
+
untilIso?: string;
|
|
33
|
+
batchSize?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BackfillResult {
|
|
37
|
+
windowStart: string | null;
|
|
38
|
+
windowEnd: string | null;
|
|
39
|
+
recommendedParsed: number;
|
|
40
|
+
recommendedWritten: number;
|
|
41
|
+
recommendedSkippedDuplicate: number;
|
|
42
|
+
clickedParsed: number;
|
|
43
|
+
clickedWritten: number;
|
|
44
|
+
unparseable: number;
|
|
45
|
+
parityCheck: "ok" | "mismatch";
|
|
46
|
+
parityDelta: { recommended: number; clicked: number };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RecLine { kind: "rec"; sessionKey: string; listingSlug: string; ts: string }
|
|
50
|
+
interface ClickLine { kind: "click"; sessionKey: string; listingSlug: string; ts: string }
|
|
51
|
+
type Line = RecLine | ClickLine
|
|
52
|
+
|
|
53
|
+
const REC_RE = /\[property-recommended\]\s+sessionKey=(\S+)\s+listingSlug=(\S+)\s+ts=(\S+)/;
|
|
54
|
+
const CLICK_RE = /\[property-card-click\]\s+sessionKey=(\S+)\s+listingSlug=(\S+)\s+ts=(\S+)/;
|
|
55
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
|
56
|
+
|
|
57
|
+
function parseLine(line: string): Line | null {
|
|
58
|
+
let m = REC_RE.exec(line);
|
|
59
|
+
if (m) return { kind: "rec", sessionKey: m[1], listingSlug: m[2], ts: m[3] };
|
|
60
|
+
m = CLICK_RE.exec(line);
|
|
61
|
+
if (m) return { kind: "click", sessionKey: m[1], listingSlug: m[2], ts: m[3] };
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function inWindow(iso: string, sinceMs: number | null, untilMs: number | null): boolean {
|
|
66
|
+
if (!ISO_RE.test(iso)) return false;
|
|
67
|
+
const t = Date.parse(iso);
|
|
68
|
+
if (Number.isNaN(t)) return false;
|
|
69
|
+
if (sinceMs !== null && t < sinceMs) return false;
|
|
70
|
+
if (untilMs !== null && t >= untilMs) return false;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function visitorBackfillFromLogs(p: BackfillParams): Promise<BackfillResult> {
|
|
75
|
+
const sinceMs = p.sinceIso ? Date.parse(p.sinceIso) : null;
|
|
76
|
+
const untilMs = p.untilIso ? Date.parse(p.untilIso) : null;
|
|
77
|
+
const batchSize = Math.max(10, Math.min(p.batchSize ?? 200, 2000));
|
|
78
|
+
|
|
79
|
+
let body: string;
|
|
80
|
+
try {
|
|
81
|
+
body = readFileSync(p.logPath, "utf-8");
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`[visitor-backfill] log read failed path=${p.logPath} err=${(err as Error).message}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rec: RecLine[] = [];
|
|
87
|
+
const click: ClickLine[] = [];
|
|
88
|
+
let unparseable = 0;
|
|
89
|
+
let windowStart: string | null = null;
|
|
90
|
+
let windowEnd: string | null = null;
|
|
91
|
+
|
|
92
|
+
for (const raw of body.split("\n")) {
|
|
93
|
+
if (!raw || (!raw.includes("[property-recommended]") && !raw.includes("[property-card-click]"))) continue;
|
|
94
|
+
const parsed = parseLine(raw);
|
|
95
|
+
if (!parsed) { unparseable += 1; continue; }
|
|
96
|
+
if (!inWindow(parsed.ts, sinceMs, untilMs)) continue;
|
|
97
|
+
if (parsed.kind === "rec") rec.push(parsed);
|
|
98
|
+
else click.push(parsed);
|
|
99
|
+
if (windowStart === null || parsed.ts < windowStart) windowStart = parsed.ts;
|
|
100
|
+
if (windowEnd === null || parsed.ts > windowEnd) windowEnd = parsed.ts;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const session = getSession();
|
|
104
|
+
let recommendedWritten = 0;
|
|
105
|
+
let recommendedSkippedDuplicate = 0;
|
|
106
|
+
let clickedWritten = 0;
|
|
107
|
+
|
|
108
|
+
console.error(`[visitor-backfill] start accountId=${p.accountId} window=${windowStart}..${windowEnd} rec=${rec.length} click=${click.length}`);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
for (let i = 0; i < rec.length; i += batchSize) {
|
|
112
|
+
const slice = rec.slice(i, i + batchSize);
|
|
113
|
+
const result = await session.run(
|
|
114
|
+
`UNWIND $rows AS row
|
|
115
|
+
MERGE (s:Session {accountId: $accountId, sessionId: row.sessionKey})
|
|
116
|
+
ON CREATE SET s.startedAt = datetime(row.ts), s.lastSeenAt = datetime(row.ts), s.backfilled = true
|
|
117
|
+
ON MATCH SET s.lastSeenAt = CASE WHEN s.lastSeenAt > datetime(row.ts) THEN s.lastSeenAt ELSE datetime(row.ts) END
|
|
118
|
+
MERGE (r:Recommendation {accountId: $accountId, sessionKey: row.sessionKey, listingSlug: row.listingSlug, recommendedAt: datetime(row.ts)})
|
|
119
|
+
ON CREATE SET r.createdAt = datetime(), r.created = true
|
|
120
|
+
ON MATCH SET r.created = false
|
|
121
|
+
MERGE (r)-[:FOR_SESSION]->(s)
|
|
122
|
+
RETURN sum(CASE WHEN r.created THEN 1 ELSE 0 END) AS written,
|
|
123
|
+
sum(CASE WHEN r.created THEN 0 ELSE 1 END) AS skipped`,
|
|
124
|
+
{ accountId: p.accountId, rows: slice },
|
|
125
|
+
);
|
|
126
|
+
const row = result.records[0];
|
|
127
|
+
recommendedWritten += Number(row?.get("written") ?? 0);
|
|
128
|
+
recommendedSkippedDuplicate += Number(row?.get("skipped") ?? 0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < click.length; i += batchSize) {
|
|
132
|
+
const slice = click.slice(i, i + batchSize);
|
|
133
|
+
const result = await session.run(
|
|
134
|
+
`UNWIND $rows AS row
|
|
135
|
+
MERGE (s:Session {accountId: $accountId, sessionId: row.sessionKey})
|
|
136
|
+
ON CREATE SET s.startedAt = datetime(row.ts), s.lastSeenAt = datetime(row.ts), s.backfilled = true
|
|
137
|
+
ON MATCH SET s.lastSeenAt = CASE WHEN s.lastSeenAt > datetime(row.ts) THEN s.lastSeenAt ELSE datetime(row.ts) END
|
|
138
|
+
CREATE (cl:Click {accountId: $accountId, label: 'card', href: '', listingSlug: row.listingSlug, pageViewId: '', occurredAt: datetime(row.ts), backfilled: true})
|
|
139
|
+
MERGE (s)-[:HAS_EVENT]->(cl)
|
|
140
|
+
RETURN count(cl) AS written`,
|
|
141
|
+
{ accountId: p.accountId, rows: slice },
|
|
142
|
+
);
|
|
143
|
+
const row = result.records[0];
|
|
144
|
+
clickedWritten += Number(row?.get("written") ?? 0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const parityRes = await session.run(
|
|
148
|
+
`MATCH (rec:Recommendation {accountId: $accountId})
|
|
149
|
+
WHERE rec.recommendedAt >= datetime($since) AND rec.recommendedAt < datetime($until)
|
|
150
|
+
WITH count(DISTINCT rec) AS recCount
|
|
151
|
+
OPTIONAL MATCH (cl:Click {accountId: $accountId, label: 'card'})
|
|
152
|
+
WHERE cl.occurredAt >= datetime($since) AND cl.occurredAt < datetime($until)
|
|
153
|
+
AND cl.backfilled = true
|
|
154
|
+
RETURN recCount, count(DISTINCT cl) AS clickCount`,
|
|
155
|
+
{
|
|
156
|
+
accountId: p.accountId,
|
|
157
|
+
since: windowStart ?? new Date(0).toISOString(),
|
|
158
|
+
until: windowEnd ? new Date(Date.parse(windowEnd) + 1).toISOString() : new Date().toISOString(),
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
const head = parityRes.records[0];
|
|
162
|
+
const recGraph = Number(head?.get("recCount") ?? 0);
|
|
163
|
+
const clickGraph = Number(head?.get("clickCount") ?? 0);
|
|
164
|
+
const parityDelta = {
|
|
165
|
+
recommended: recGraph - rec.length,
|
|
166
|
+
clicked: clickGraph - click.length,
|
|
167
|
+
};
|
|
168
|
+
const parityCheck: "ok" | "mismatch" = parityDelta.recommended === 0 && parityDelta.clicked === 0 ? "ok" : "mismatch";
|
|
169
|
+
|
|
170
|
+
console.error(
|
|
171
|
+
`[visitor-backfill] done parityCheck=${parityCheck} recParsed=${rec.length} recWritten=${recommendedWritten} clickParsed=${click.length} clickWritten=${clickedWritten} unparseable=${unparseable}`,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
windowStart,
|
|
176
|
+
windowEnd,
|
|
177
|
+
recommendedParsed: rec.length,
|
|
178
|
+
recommendedWritten,
|
|
179
|
+
recommendedSkippedDuplicate,
|
|
180
|
+
clickedParsed: click.length,
|
|
181
|
+
clickedWritten,
|
|
182
|
+
unparseable,
|
|
183
|
+
parityCheck,
|
|
184
|
+
parityDelta,
|
|
185
|
+
};
|
|
186
|
+
} finally {
|
|
187
|
+
await session.close();
|
|
188
|
+
}
|
|
189
|
+
}
|
package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-engagement-score.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// visitor-engagement-score — engagement-ranked Person list.
|
|
2
|
+
//
|
|
3
|
+
// score = visit_count * recency_weight * depth_weight * dwell_weight
|
|
4
|
+
// visit_count = number of :Session in the window
|
|
5
|
+
// recency_weight = exp(-daysSinceLast / 14) (half-life 14d)
|
|
6
|
+
// depth_weight = avg(maxScrollDepth / 100)
|
|
7
|
+
// dwell_weight = log1p(totalDwellMs / 60000) (per minute)
|
|
8
|
+
//
|
|
9
|
+
// Score is unitless; the nurture-list skill consumes it for ranking, not
|
|
10
|
+
// absolute meaning. Returns top N most engaged Persons within sinceDays.
|
|
11
|
+
|
|
12
|
+
import { getSession } from "../lib/neo4j.js";
|
|
13
|
+
|
|
14
|
+
export interface EngagementParams {
|
|
15
|
+
accountId: string;
|
|
16
|
+
sinceDays?: number;
|
|
17
|
+
limit?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface EngagementRow {
|
|
21
|
+
personId: string;
|
|
22
|
+
personName: string | null;
|
|
23
|
+
email: string | null;
|
|
24
|
+
telephone: string | null;
|
|
25
|
+
visitCount: number;
|
|
26
|
+
lastSeenAt: string;
|
|
27
|
+
daysSinceLast: number;
|
|
28
|
+
avgScrollDepth: number;
|
|
29
|
+
totalDwellMs: number;
|
|
30
|
+
score: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function visitorEngagementScore(p: EngagementParams): Promise<{ persons: EngagementRow[] }> {
|
|
34
|
+
const since = p.sinceDays ?? 30;
|
|
35
|
+
const limit = Math.max(1, Math.min(p.limit ?? 50, 200));
|
|
36
|
+
const session = getSession();
|
|
37
|
+
try {
|
|
38
|
+
const result = await session.run(
|
|
39
|
+
`MATCH (per:Person {accountId: $accountId})-[:VISITED]->(s:Session)
|
|
40
|
+
WHERE s.startedAt >= datetime() - duration({days: $since})
|
|
41
|
+
OPTIONAL MATCH (s)-[:HAS_EVENT]->(sm:ScrollMilestone)
|
|
42
|
+
OPTIONAL MATCH (s)-[:HAS_EVENT]->(pv:PageView)
|
|
43
|
+
WITH per, s,
|
|
44
|
+
coalesce(avg(sm.maxDepth), 0) AS avgDepth,
|
|
45
|
+
coalesce(sum(pv.dwellMs), 0) AS dwellMs
|
|
46
|
+
WITH per, count(s) AS visits, max(s.lastSeenAt) AS lastSeenAt,
|
|
47
|
+
avg(avgDepth) AS avgDepth, sum(dwellMs) AS totalDwellMs
|
|
48
|
+
WITH per, visits, lastSeenAt, avgDepth, totalDwellMs,
|
|
49
|
+
duration.between(lastSeenAt, datetime()).days AS daysSinceLast
|
|
50
|
+
WITH per, visits, lastSeenAt, avgDepth, totalDwellMs, daysSinceLast,
|
|
51
|
+
visits
|
|
52
|
+
* exp(-daysSinceLast / 14.0)
|
|
53
|
+
* (avgDepth / 100.0 + 0.1)
|
|
54
|
+
* log(1.0 + totalDwellMs / 60000.0)
|
|
55
|
+
AS score
|
|
56
|
+
RETURN elementId(per) AS personId,
|
|
57
|
+
per.name AS personName,
|
|
58
|
+
per.email AS email,
|
|
59
|
+
per.telephone AS telephone,
|
|
60
|
+
visits AS visitCount,
|
|
61
|
+
toString(lastSeenAt) AS lastSeenAt,
|
|
62
|
+
daysSinceLast,
|
|
63
|
+
avgDepth AS avgScrollDepth,
|
|
64
|
+
totalDwellMs,
|
|
65
|
+
score
|
|
66
|
+
ORDER BY score DESC
|
|
67
|
+
LIMIT $limit`,
|
|
68
|
+
{ accountId: p.accountId, since, limit },
|
|
69
|
+
);
|
|
70
|
+
return {
|
|
71
|
+
persons: result.records.map((r) => ({
|
|
72
|
+
personId: r.get("personId"),
|
|
73
|
+
personName: r.get("personName"),
|
|
74
|
+
email: r.get("email"),
|
|
75
|
+
telephone: r.get("telephone"),
|
|
76
|
+
visitCount: Number(r.get("visitCount") ?? 0),
|
|
77
|
+
lastSeenAt: r.get("lastSeenAt"),
|
|
78
|
+
daysSinceLast: Number(r.get("daysSinceLast") ?? 0),
|
|
79
|
+
avgScrollDepth: Number(r.get("avgScrollDepth") ?? 0),
|
|
80
|
+
totalDwellMs: Number(r.get("totalDwellMs") ?? 0),
|
|
81
|
+
score: Number(r.get("score") ?? 0),
|
|
82
|
+
})),
|
|
83
|
+
};
|
|
84
|
+
} finally {
|
|
85
|
+
await session.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-event-ingest.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// visitor-event-ingest — admin-side companion to POST /v/event for test
|
|
2
|
+
// harness work and manual replay. Takes a single event payload identical
|
|
3
|
+
// in shape to what the pixel sends, writes it into the graph against the
|
|
4
|
+
// caller's accountId. Not exposed on the public agent surface.
|
|
5
|
+
|
|
6
|
+
import { getSession } from "../lib/neo4j.js";
|
|
7
|
+
|
|
8
|
+
export interface IngestParams {
|
|
9
|
+
accountId: string;
|
|
10
|
+
type: "pageview" | "click" | "scroll" | "dwell";
|
|
11
|
+
sessionId: string;
|
|
12
|
+
visitorId: string;
|
|
13
|
+
/** optional Person elementId — overrides identity resolution */
|
|
14
|
+
personId?: string | null;
|
|
15
|
+
url: string;
|
|
16
|
+
path?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
pageViewId?: string;
|
|
19
|
+
site?: string;
|
|
20
|
+
referrer?: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
href?: string;
|
|
23
|
+
depth?: number;
|
|
24
|
+
dwellMs?: number;
|
|
25
|
+
occurredAt?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IngestResult {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
type: string;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function visitorEventIngest(p: IngestParams): Promise<IngestResult> {
|
|
35
|
+
const session = getSession();
|
|
36
|
+
const occurredAt = p.occurredAt ?? new Date().toISOString();
|
|
37
|
+
try {
|
|
38
|
+
if (p.type === "pageview") {
|
|
39
|
+
await session.run(
|
|
40
|
+
`MERGE (s:Session {accountId: $accountId, sessionId: $sid})
|
|
41
|
+
ON CREATE SET s.startedAt = datetime($occurredAt), s.lastSeenAt = datetime($occurredAt)
|
|
42
|
+
ON MATCH SET s.lastSeenAt = datetime($occurredAt)
|
|
43
|
+
MERGE (pg:Page {accountId: $accountId, url: $url})
|
|
44
|
+
ON CREATE SET pg.firstSeenAt = datetime($occurredAt), pg.title = $title
|
|
45
|
+
CREATE (pv:PageView {
|
|
46
|
+
accountId: $accountId, pageViewId: $pvId, occurredAt: datetime($occurredAt),
|
|
47
|
+
referrer: $referrer, path: $path
|
|
48
|
+
})
|
|
49
|
+
MERGE (s)-[:HAS_EVENT]->(pv)
|
|
50
|
+
MERGE (pv)-[:OF_PAGE]->(pg)`,
|
|
51
|
+
{
|
|
52
|
+
accountId: p.accountId,
|
|
53
|
+
sid: p.sessionId,
|
|
54
|
+
url: p.url,
|
|
55
|
+
title: p.title ?? "",
|
|
56
|
+
pvId: p.pageViewId ?? `${p.sessionId}_${Date.parse(occurredAt)}`,
|
|
57
|
+
path: p.path ?? "",
|
|
58
|
+
referrer: p.referrer ?? "",
|
|
59
|
+
occurredAt,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
} else if (p.type === "click") {
|
|
63
|
+
await session.run(
|
|
64
|
+
`MERGE (s:Session {accountId: $accountId, sessionId: $sid})
|
|
65
|
+
ON CREATE SET s.startedAt = datetime($occurredAt), s.lastSeenAt = datetime($occurredAt)
|
|
66
|
+
ON MATCH SET s.lastSeenAt = datetime($occurredAt)
|
|
67
|
+
CREATE (cl:Click {accountId: $accountId, label: $label, href: $href, pageViewId: $pvId, occurredAt: datetime($occurredAt)})
|
|
68
|
+
MERGE (s)-[:HAS_EVENT]->(cl)`,
|
|
69
|
+
{
|
|
70
|
+
accountId: p.accountId,
|
|
71
|
+
sid: p.sessionId,
|
|
72
|
+
label: (p.label ?? "unknown").slice(0, 64),
|
|
73
|
+
href: (p.href ?? "").slice(0, 2048),
|
|
74
|
+
pvId: p.pageViewId ?? "",
|
|
75
|
+
occurredAt,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
} else if (p.type === "scroll") {
|
|
79
|
+
await session.run(
|
|
80
|
+
`MERGE (s:Session {accountId: $accountId, sessionId: $sid})
|
|
81
|
+
MERGE (sm:ScrollMilestone {accountId: $accountId, sessionId: $sid, pageViewId: $pvId})
|
|
82
|
+
ON CREATE SET sm.maxDepth = $depth, sm.firstAt = datetime($occurredAt), sm.lastAt = datetime($occurredAt)
|
|
83
|
+
ON MATCH SET sm.maxDepth = CASE WHEN sm.maxDepth >= $depth THEN sm.maxDepth ELSE $depth END,
|
|
84
|
+
sm.lastAt = datetime($occurredAt)
|
|
85
|
+
MERGE (s)-[:HAS_EVENT]->(sm)`,
|
|
86
|
+
{ accountId: p.accountId, sid: p.sessionId, pvId: p.pageViewId ?? "", depth: p.depth ?? 0, occurredAt },
|
|
87
|
+
);
|
|
88
|
+
} else if (p.type === "dwell") {
|
|
89
|
+
await session.run(
|
|
90
|
+
`MATCH (pv:PageView {accountId: $accountId, pageViewId: $pvId})
|
|
91
|
+
SET pv.dwellMs = coalesce(pv.dwellMs, 0) + $ms,
|
|
92
|
+
pv.dwellLastAt = datetime($occurredAt)`,
|
|
93
|
+
{ accountId: p.accountId, pvId: p.pageViewId ?? "", ms: p.dwellMs ?? 0, occurredAt },
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (p.personId) {
|
|
98
|
+
await session.run(
|
|
99
|
+
`MATCH (per:Person {accountId: $accountId}) WHERE elementId(per) = $personId
|
|
100
|
+
MATCH (s:Session {accountId: $accountId, sessionId: $sid})
|
|
101
|
+
MERGE (per)-[:VISITED {accountId: $accountId}]->(s)`,
|
|
102
|
+
{ accountId: p.accountId, personId: p.personId, sid: p.sessionId },
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
await session.run(
|
|
106
|
+
`MERGE (av:AnonVisitor {accountId: $accountId, visitorId: $vid})
|
|
107
|
+
ON CREATE SET av.firstSeenAt = datetime($occurredAt), av.lastSeenAt = datetime($occurredAt)
|
|
108
|
+
ON MATCH SET av.lastSeenAt = datetime($occurredAt)
|
|
109
|
+
WITH av
|
|
110
|
+
MATCH (s:Session {accountId: $accountId, sessionId: $sid})
|
|
111
|
+
MERGE (av)-[:VISITED {accountId: $accountId}]->(s)`,
|
|
112
|
+
{ accountId: p.accountId, vid: p.visitorId, sid: p.sessionId, occurredAt },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, type: p.type, sessionId: p.sessionId };
|
|
116
|
+
} finally {
|
|
117
|
+
await session.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
package/payload/premium-plugins/real-agent/plugins/buyers/mcp/src/tools/visitor-recent-by-page.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// visitor-recent-by-page — who viewed a given page/listing recently.
|
|
2
|
+
// Operator-side query: "who has shown interest in 12 Oak Street this week?"
|
|
3
|
+
|
|
4
|
+
import { getSession } from "../lib/neo4j.js";
|
|
5
|
+
|
|
6
|
+
export interface RecentByPageParams {
|
|
7
|
+
accountId: string;
|
|
8
|
+
/** Either listingSlug or url; one is required. */
|
|
9
|
+
listingSlug?: string;
|
|
10
|
+
url?: string;
|
|
11
|
+
sinceDays?: number;
|
|
12
|
+
limit?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface VisitorSummary {
|
|
16
|
+
personId: string | null;
|
|
17
|
+
personName: string | null;
|
|
18
|
+
anonVisitorId: string | null;
|
|
19
|
+
pageviewCount: number;
|
|
20
|
+
lastSeenAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function visitorRecentByPage(p: RecentByPageParams): Promise<{ visitors: VisitorSummary[] }> {
|
|
24
|
+
if (!p.listingSlug && !p.url) {
|
|
25
|
+
throw new Error("visitor-recent-by-page requires listingSlug or url");
|
|
26
|
+
}
|
|
27
|
+
const since = p.sinceDays ?? 7;
|
|
28
|
+
const limit = Math.max(1, Math.min(p.limit ?? 50, 200));
|
|
29
|
+
const session = getSession();
|
|
30
|
+
try {
|
|
31
|
+
const result = await session.run(
|
|
32
|
+
`MATCH (pv:PageView {accountId: $accountId})
|
|
33
|
+
WHERE pv.occurredAt >= datetime() - duration({days: $since})
|
|
34
|
+
AND ($url IS NULL OR EXISTS { MATCH (pv)-[:OF_PAGE]->(:Page {accountId: $accountId, url: $url}) })
|
|
35
|
+
AND ($slug IS NULL OR EXISTS { MATCH (pv)-[:OF_LISTING]->(:Listing {accountId: $accountId, slug: $slug, scope:'public'}) })
|
|
36
|
+
MATCH (s:Session)-[:HAS_EVENT]->(pv)
|
|
37
|
+
OPTIONAL MATCH (per:Person)-[:VISITED]->(s)
|
|
38
|
+
OPTIONAL MATCH (av:AnonVisitor)-[:VISITED]->(s)
|
|
39
|
+
WITH per, av, s, count(pv) AS pageviews
|
|
40
|
+
RETURN coalesce(elementId(per), null) AS personId,
|
|
41
|
+
coalesce(per.name, null) AS personName,
|
|
42
|
+
coalesce(av.visitorId, null) AS anonVisitorId,
|
|
43
|
+
sum(pageviews) AS totalPageviews,
|
|
44
|
+
toString(max(s.lastSeenAt)) AS lastSeenAt
|
|
45
|
+
ORDER BY lastSeenAt DESC
|
|
46
|
+
LIMIT $limit`,
|
|
47
|
+
{ accountId: p.accountId, url: p.url ?? null, slug: p.listingSlug ?? null, since, limit },
|
|
48
|
+
);
|
|
49
|
+
return {
|
|
50
|
+
visitors: result.records.map((r) => ({
|
|
51
|
+
personId: r.get("personId"),
|
|
52
|
+
personName: r.get("personName"),
|
|
53
|
+
anonVisitorId: r.get("anonVisitorId"),
|
|
54
|
+
pageviewCount: Number(r.get("totalPageviews") ?? 0),
|
|
55
|
+
lastSeenAt: r.get("lastSeenAt"),
|
|
56
|
+
})),
|
|
57
|
+
};
|
|
58
|
+
} finally {
|
|
59
|
+
await session.close();
|
|
60
|
+
}
|
|
61
|
+
}
|