@rubytech/create-maxy-code 0.1.59 → 0.1.61

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 (284) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  3. package/payload/platform/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
  4. package/payload/platform/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
  5. package/payload/platform/plugins/docs/references/admin-session.md +2 -2
  6. package/payload/platform/plugins/docs/references/internals.md +1 -1
  7. package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
  8. package/payload/platform/plugins/epc/.claude-plugin/plugin.json +17 -0
  9. package/payload/platform/plugins/epc/PLUGIN.md +85 -0
  10. package/payload/platform/plugins/epc/mcp/dist/index.d.ts +2 -0
  11. package/payload/platform/plugins/epc/mcp/dist/index.d.ts.map +1 -0
  12. package/payload/platform/plugins/epc/mcp/dist/index.js +117 -0
  13. package/payload/platform/plugins/epc/mcp/dist/index.js.map +1 -0
  14. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
  15. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
  16. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js +72 -0
  17. package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
  18. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
  19. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
  20. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
  21. package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
  22. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
  23. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  24. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
  25. package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
  26. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
  27. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
  28. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js +130 -0
  29. package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
  30. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
  31. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
  32. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
  33. package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
  34. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
  35. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
  36. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
  37. package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
  38. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
  39. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
  40. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js +10 -0
  41. package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
  42. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
  43. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
  44. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js +36 -0
  45. package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
  46. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
  47. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
  48. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js +30 -0
  49. package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
  50. package/payload/platform/plugins/epc/mcp/package-lock.json +2566 -0
  51. package/payload/platform/plugins/epc/mcp/package.json +21 -0
  52. package/payload/platform/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
  53. package/payload/platform/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
  54. package/payload/platform/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
  55. package/payload/platform/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
  56. package/payload/platform/plugins/epc/mcp/src/index.ts +156 -0
  57. package/payload/platform/plugins/epc/mcp/src/lib/crypto.ts +79 -0
  58. package/payload/platform/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
  59. package/payload/platform/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
  60. package/payload/platform/plugins/epc/mcp/src/lib/key-store.ts +172 -0
  61. package/payload/platform/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
  62. package/payload/platform/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
  63. package/payload/platform/plugins/epc/mcp/src/tools/key-list.ts +10 -0
  64. package/payload/platform/plugins/epc/mcp/src/tools/key-register.ts +44 -0
  65. package/payload/platform/plugins/epc/mcp/src/tools/lookup.ts +43 -0
  66. package/payload/platform/plugins/epc/mcp/tsconfig.json +20 -0
  67. package/payload/platform/plugins/epc/mcp/vitest.config.ts +8 -0
  68. package/payload/platform/plugins/loop/PLUGIN.md +13 -1
  69. package/payload/platform/plugins/loop/mcp/dist/index.js +8 -7
  70. package/payload/platform/plugins/loop/mcp/dist/index.js.map +1 -1
  71. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.d.ts +3 -0
  72. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  73. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.js +48 -0
  74. package/payload/platform/plugins/loop/mcp/dist/lib/file-crypto.js.map +1 -0
  75. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.d.ts +22 -0
  76. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.d.ts.map +1 -0
  77. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.js +165 -0
  78. package/payload/platform/plugins/loop/mcp/dist/lib/key-store.js.map +1 -0
  79. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.d.ts +1 -1
  80. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
  81. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.js +4 -39
  82. package/payload/platform/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
  83. package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  84. package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.js +5 -15
  85. package/payload/platform/plugins/loop/mcp/dist/tools/key-deregister.js.map +1 -1
  86. package/payload/platform/plugins/loop/mcp/dist/tools/key-register.d.ts.map +1 -1
  87. package/payload/platform/plugins/loop/mcp/dist/tools/key-register.js +11 -28
  88. package/payload/platform/plugins/loop/mcp/dist/tools/key-register.js.map +1 -1
  89. package/payload/platform/plugins/loop/mcp/src/index.ts +10 -7
  90. package/payload/platform/plugins/loop/mcp/src/lib/file-crypto.ts +54 -0
  91. package/payload/platform/plugins/loop/mcp/src/lib/key-store.ts +221 -0
  92. package/payload/platform/plugins/loop/mcp/src/lib/loop-api.ts +5 -42
  93. package/payload/platform/plugins/loop/mcp/src/tools/key-deregister.ts +5 -20
  94. package/payload/platform/plugins/loop/mcp/src/tools/key-register.ts +14 -39
  95. package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
  96. package/payload/platform/plugins/preval/PLUGIN.md +23 -17
  97. package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +78 -38
  98. package/payload/platform/plugins/preval/skills/property-preval/references/render.py +288 -81
  99. package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
  100. package/payload/platform/plugins/preval/skills/property-preval/references/template.html +184 -124
  101. package/payload/platform/plugins/property-data/PLUGIN.md +14 -1
  102. package/payload/platform/plugins/property-data/mcp/dist/index.js +14 -11
  103. package/payload/platform/plugins/property-data/mcp/dist/index.js.map +1 -1
  104. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.d.ts +3 -0
  105. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  106. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.js +49 -0
  107. package/payload/platform/plugins/property-data/mcp/dist/lib/file-crypto.js.map +1 -0
  108. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.d.ts +15 -0
  109. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.d.ts.map +1 -0
  110. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.js +135 -0
  111. package/payload/platform/plugins/property-data/mcp/dist/lib/key-store.js.map +1 -0
  112. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +0 -1
  113. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -1
  114. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js +11 -22
  115. package/payload/platform/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -1
  116. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
  117. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js +2 -2
  118. package/payload/platform/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
  119. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  120. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js +5 -15
  121. package/payload/platform/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -1
  122. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -1
  123. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js +30 -23
  124. package/payload/platform/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -1
  125. package/payload/platform/plugins/property-data/mcp/src/index.ts +16 -11
  126. package/payload/platform/plugins/property-data/mcp/src/lib/file-crypto.ts +55 -0
  127. package/payload/platform/plugins/property-data/mcp/src/lib/key-store.ts +181 -0
  128. package/payload/platform/plugins/property-data/mcp/src/lib/propertydata-api.ts +9 -24
  129. package/payload/platform/plugins/property-data/mcp/src/tools/endpoints.ts +2 -2
  130. package/payload/platform/plugins/property-data/mcp/src/tools/key-deregister.ts +5 -17
  131. package/payload/platform/plugins/property-data/mcp/src/tools/key-register.ts +29 -33
  132. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  133. package/payload/platform/services/claude-session-manager/dist/http-server.js +6 -1
  134. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  135. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
  136. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  137. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -0
  138. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  139. package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
  140. package/payload/platform/templates/agents/admin/SOUL.md +1 -1
  141. package/payload/premium-plugins/real-agent/BUNDLE.md +4 -2
  142. package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
  143. package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
  144. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
  145. package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
  146. package/payload/premium-plugins/real-agent/plugins/epc/.claude-plugin/plugin.json +17 -0
  147. package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +85 -0
  148. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts +2 -0
  149. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts.map +1 -0
  150. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js +117 -0
  151. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js.map +1 -0
  152. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
  153. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
  154. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js +72 -0
  155. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
  156. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
  157. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
  158. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
  159. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
  160. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
  161. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  162. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
  163. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
  164. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
  165. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
  166. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js +130 -0
  167. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
  168. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
  169. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
  170. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
  171. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
  172. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
  173. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
  174. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
  175. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
  176. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
  177. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
  178. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js +10 -0
  179. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
  180. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
  181. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
  182. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js +36 -0
  183. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
  184. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
  185. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
  186. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js +30 -0
  187. package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
  188. package/payload/premium-plugins/real-agent/plugins/epc/mcp/package-lock.json +2566 -0
  189. package/payload/premium-plugins/real-agent/plugins/epc/mcp/package.json +21 -0
  190. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
  191. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
  192. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
  193. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
  194. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/index.ts +156 -0
  195. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/crypto.ts +79 -0
  196. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
  197. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
  198. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/key-store.ts +172 -0
  199. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
  200. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
  201. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-list.ts +10 -0
  202. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-register.ts +44 -0
  203. package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/lookup.ts +43 -0
  204. package/payload/premium-plugins/real-agent/plugins/epc/mcp/tsconfig.json +20 -0
  205. package/payload/premium-plugins/real-agent/plugins/epc/mcp/vitest.config.ts +8 -0
  206. package/payload/premium-plugins/real-agent/plugins/loop/PLUGIN.md +13 -1
  207. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js +8 -7
  208. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/index.js.map +1 -1
  209. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.d.ts +3 -0
  210. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  211. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.js +48 -0
  212. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/file-crypto.js.map +1 -0
  213. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.d.ts +22 -0
  214. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.d.ts.map +1 -0
  215. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.js +165 -0
  216. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/key-store.js.map +1 -0
  217. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts +1 -1
  218. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.d.ts.map +1 -1
  219. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js +4 -39
  220. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/lib/loop-api.js.map +1 -1
  221. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  222. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.js +5 -15
  223. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-deregister.js.map +1 -1
  224. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.d.ts.map +1 -1
  225. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.js +11 -28
  226. package/payload/premium-plugins/real-agent/plugins/loop/mcp/dist/tools/key-register.js.map +1 -1
  227. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/index.ts +10 -7
  228. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/file-crypto.ts +54 -0
  229. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/key-store.ts +221 -0
  230. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/lib/loop-api.ts +5 -42
  231. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/key-deregister.ts +5 -20
  232. package/payload/premium-plugins/real-agent/plugins/loop/mcp/src/tools/key-register.ts +14 -39
  233. package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
  234. package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +23 -17
  235. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +78 -38
  236. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +288 -81
  237. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
  238. package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +184 -124
  239. package/payload/premium-plugins/real-agent/plugins/property-data/PLUGIN.md +14 -1
  240. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js +14 -11
  241. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/index.js.map +1 -1
  242. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.d.ts +3 -0
  243. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.d.ts.map +1 -0
  244. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.js +49 -0
  245. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/file-crypto.js.map +1 -0
  246. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.d.ts +15 -0
  247. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.d.ts.map +1 -0
  248. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.js +135 -0
  249. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/key-store.js.map +1 -0
  250. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts +0 -1
  251. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.d.ts.map +1 -1
  252. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js +11 -22
  253. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/lib/propertydata-api.js.map +1 -1
  254. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.d.ts.map +1 -1
  255. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js +2 -2
  256. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/endpoints.js.map +1 -1
  257. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.d.ts.map +1 -1
  258. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js +5 -15
  259. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-deregister.js.map +1 -1
  260. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.d.ts.map +1 -1
  261. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js +30 -23
  262. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/dist/tools/key-register.js.map +1 -1
  263. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/index.ts +16 -11
  264. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/file-crypto.ts +55 -0
  265. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/key-store.ts +181 -0
  266. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/lib/propertydata-api.ts +9 -24
  267. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/endpoints.ts +2 -2
  268. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-deregister.ts +5 -17
  269. package/payload/premium-plugins/real-agent/plugins/property-data/mcp/src/tools/key-register.ts +29 -33
  270. package/payload/server/public/assets/{admin-nqCBFiSl.js → admin-DhN3G8W7.js} +51 -51
  271. package/payload/server/public/assets/{data-BGUAGVkV.js → data-B2ZVXOcE.js} +1 -1
  272. package/payload/server/public/assets/{graph-MvYxZOBF.js → graph-BAMGPHrK.js} +1 -1
  273. package/payload/server/public/assets/{graph-labels-D865qb3K.js → graph-labels-D9eBbvxo.js} +1 -1
  274. package/payload/server/public/assets/{page-C2b1nlOc.js → page-CV27Al6Z.js} +1 -1
  275. package/payload/server/public/assets/{page--hOVRrgN.js → page-DjdVMWCz.js} +1 -1
  276. package/payload/server/public/data.html +3 -3
  277. package/payload/server/public/graph.html +3 -3
  278. package/payload/server/public/index.html +4 -4
  279. package/payload/server/server.js +7 -19
  280. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts +0 -51
  281. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.d.ts.map +0 -1
  282. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js +0 -196
  283. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/dist/index.js.map +0 -1
  284. package/payload/premium-plugins/real-agent/lib/mcp-stderr-tee/package.json +0 -7
@@ -1,78 +1,118 @@
1
1
  ---
2
2
  name: property-preval
3
- description: Use when an operator asks for a pre-valuation pack for a single UK property — input is one full UK address, output is a 4-page A4 PDF covering valuation, area, and demand/trend. Triggers include "preval <address>", "pre-valuation for <address>", "what's <address> worth", "valuation pack for <address>", "market and area report for <address>".
3
+ description: Use when an operator wants a pre-valuation pack to pitch for a UK property listing a 5-page A4 PDF that combines a market-data report for the postcode with the subject property's own valuation range, full-width sold + asking comparables, and the agent's recent successes as the sales close. Triggers include "preval <address>", "pre-valuation for <address>", "valuation pitch for <address>", "what's <address> worth and pitch us to list it", "pre-appraisal pack for <address>".
4
4
  ---
5
5
 
6
6
  # Property Preval
7
7
 
8
- Outcome: turn **one full UK address** into a 4-page A4 pre-valuation PDF the operator can send to a vendor before the appraisal visit. The skill owns the fan-out and the render; the operator supplies only the address.
8
+ A 5-page A4 PDF that pitches the agent for the listing: cover (full-bleed subject hero), subject valuation (sqft × £/sqft band with operator adjustments), market context, full-width comparables (sold + asking stacked), and the agent's recent successes + disclaimer. **Preval is a sales pitch, not just a data sheet** — every page either tells the vendor what the property is worth or what the agent has recently delivered nearby.
9
9
 
10
- ## Input
10
+ ## Typography is fixed; only colours and logos come from the agent
11
11
 
12
- A single string the full UK address, ending in a valid UK postcode. Examples:
12
+ Same typographic lock as `property-market-report`:
13
13
 
14
- - `47 Phoenix Drive, Bishop's Stortford, CM23 2UJ`
15
- - `Flat 3, 122 Kensington High Street, London, W8 4SG`
14
+ - **Display** Cormorant Garamond (italic for headlines, KPI values, prices, addresses)
15
+ - **Body** Lora
16
+ - **Sans / labels** — Inter (eyebrows, KPI labels, chips, footer meta)
16
17
 
17
- The skill derives the postcode by matching the UK postcode regex on the final token group. If no postcode is matched, the skill stops and asks the operator for one.
18
+ Never substitute the agent's body or display fonts even if their `DESIGN.md` specifies different families. The pack reads as an editorial / luxury document across every brand. The agent's identity comes through the **palette and logo only**.
18
19
 
19
- ## Output
20
-
21
- A directory under `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/` containing:
20
+ ## Inputs (never invented)
22
21
 
23
- - `index.html` single self-contained file.
24
- - `<slug>-<date>.pdf`exactly 4 A4 pages. If `mdls -name kMDItemNumberOfPages` returns 4, the renderer raises rather than ship an overflowing report.
25
-
26
- Return the PDF path to the operator.
22
+ - **`address`** — full UK address ending in a valid postcode (e.g. `9 Phoenix Drive, Bishop's Stortford, CM23 2UJ`). Skill derives the postcode by matching the UK postcode regex on the final token group; stops if no match.
23
+ - **`brand`**agent design tokens, same shape as property-market-report: `name`, `tagline`, hex tokens `primary` / `primary_dark` / `accent` / `paper` / `paper_banded` / `rule`. Optional `logo_light` / `logo_dark` filesystem paths. Resolved from the agent's `DESIGN.md` (e.g. `/estate-agents/muvin/DESIGN.md`); read the canonical hex values directly, never substitute "close" colours. Only colour tokens are consumed; typography/spacing/radius tokens in the agent's `DESIGN.md` are ignored by design.
24
+ - **`subject`** — paste-in details about the property being valued:
25
+ - `cover_hero` (filesystem path or URL to a photo of the subject — required for the cover).
26
+ - Optional `beds`, `type`, `tenure`, `condition` (well / average / needs work).
27
+ - `adjustments[]` — operator-curated array of `{label, delta_pct}` covering esoteric factors that shift the headline range (e.g. `{"label":"South-facing garden","delta_pct":3}`, `{"label":"Needs full reno","delta_pct":-8}`). Deltas sum linearly into the final range.
28
+ - `narrative` — one short paragraph the operator writes about the property.
29
+ - **Floor area is auto-pulled from the EPC register** via `real-agent-epc` (`epc-lookup-by-address`) and surfaced as `sqft_m2` + derived `sqft`. If EPC has no record (pre-2008 property never sold or let; Scotland/NI; unmatched), operator pastes `subject.sqft` directly. Without a floor area the renderer prints "Estimate requires floor area" and shows only the £/sqft benchmark — never invents a number.
30
+ - **`agent_listings`** — the agent's recent sold / under-offer / live listings to feature on the closing page. Up to 6 entries of `{url, address, price, beds, type, image, status, slug}` where `status ∈ {"Sold","Under offer","For sale"}`. Operator picks the comps that make the strongest pitch; this is *not* the PropertyData sample.
31
+ - **`market_summary`** — `headline` (one display-italic line), `paragraphs[]` (2–3 short paragraphs), `demand_gauge_position` (percentage string like `"62%"`), `price_growth_note`, `demand_note`. The operator drafts or accepts an LLM stub.
27
32
 
28
- ## Tools the skill calls (in order)
33
+ ## Tools the skill calls
29
34
 
30
- Fan-out is fixed at **12** `real-agent-property-data` MCP tools, keyed on the derived postcode (and address attributes where the tool needs them):
35
+ Parallel fan-out keyed on the derived postcode + the subject address:
31
36
 
37
+ **PropertyData (12 calls — credit cost 12):**
32
38
  1. `property-data-prices` — asking £ distribution + raw asking comps.
33
39
  2. `property-data-prices-per-sqf` — asking £/sqft.
34
40
  3. `property-data-sold-prices` — Land Registry sold transactions + raw sold comps.
35
- 4. `property-data-sold-prices-per-sqf` — achieved £/sqft.
41
+ 4. `property-data-sold-prices-per-sqf` — achieved £/sqft (plus 70pc band used for the valuation range).
36
42
  5. `property-data-growth` — 7y price series.
37
43
  6. `property-data-growth-psf` — 7y £/sqft series.
38
44
  7. `property-data-demand` — for-sale, sales/mo, turnover, MoI, DoM, demand rating.
39
45
  8. `property-data-demand-rent` — rental-side demand pillar.
40
- 9. `property-data-property-types` — mix doughnut (detached / semi / terraced / flat).
46
+ 9. `property-data-property-types` — mix (detached / semi / terraced / flat).
41
47
  10. `property-data-crime` — area crime rating + breakdown.
42
48
  11. `property-data-flood-risk` — area flood risk (full postcode only).
43
- 12. `property-data-council-tax` — band D cost + per-property bands (full postcode only).
49
+ 12. `property-data-council-tax` — band D + per-property bands (full postcode only).
44
50
 
45
- ## Tools the skill deliberately does NOT call
51
+ **EPC (1 call free, rate-limited):**
46
52
 
47
- - `property-data-address-match-uprn` — UPRN is CRM hygiene, not preval data.
48
- - `property-data-planning-applications` — planning is a separate dev-precedent report.
53
+ 13. `epc-lookup-by-address(address, postcode)` — subject floor area + energy ratings + lodgement date.
49
54
 
50
- Both remain available via the underlying `property-data` skill; preval omits them to keep the output focused and the credit cost at 12 per pack.
55
+ `address-match-uprn` and `planning-applications` are deliberately omitted (CRM hygiene and dev-precedent reports respectively).
56
+
57
+ ## Output
58
+
59
+ Directory under `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/` containing:
60
+
61
+ - `index.html` — single self-contained file.
62
+ - `img/` — copied logos and the subject hero image.
63
+ - `<slug>-<date>.pdf` — exactly **5** A4 pages. Renderer asserts via `pdfinfo` and raises if ≠ 5.
64
+
65
+ Return the PDF path to the operator.
66
+
67
+ ## Pages
68
+
69
+ 1. **Cover** — full-bleed `subject.cover_hero` with vertical gradient scrim; light logo top-left; eyebrow "`<brand.name>` · Pre-valuation"; address in display-italic; postcode chip; four-up meta strip (Report · Address · Postcode · Generated). Lifted from property-market-report's cover contract.
70
+ 2. **Subject valuation** — KPI strip (sqft, beds, EPC rating, type). Centre panel: headline range = `sqft × sold_psf_low–high` (Land Registry 70pc band) with `sqft × asking_psf` shown as a cross-check. Operator `adjustments[]` displayed as a stacked list (label · delta% · running £). Final range pill at the foot. If `sqft` missing the panel switches to the "requires floor area" mode (£/sqft benchmark only). One paragraph of `subject.narrative` under the panel.
71
+ 3. **Market context** — KPI cube (avg asking, avg sold, £/sqft, YoY); pill + gauge for demand; 7y growth sparkline (£ solid + £/sqft dashed); summary paragraphs. Same patterns as property-market-report's page 2.
72
+ 4. **Comparables (full-width, stacked)** — Nearest sold table (top 6) full-width across the page; Live asking tile grid (top 4) full-width below. Vertically stacked, never side-by-side — corrects the cramped two-column layout in the v1 preval where the asking tiles got pushed to ~40mm columns of italic addresses.
73
+ 5. **Recently with `<brand.name>` + sources** — agent's own listings as a clickable tile grid (`<a>` per card, status badge: Sold / Under offer / For sale), then the sources / methodology / accuracy / investment block. Closes with `Real Agent · Generated <date> · <postcode>` in the footer.
51
74
 
52
75
  ## How to execute
53
76
 
54
- 1. Match the postcode out of the address (`/[A-Z]{1,2}[0-9][A-Z0-9]?\s?[0-9][A-Z]{2}/i`). Stop and ask if no match.
55
- 2. Run the 12 fan-out tools in the order above. Any `Error: key-not-registered` aborts the run surface the registration prompt and stop.
56
- 3. Assemble the `inputs.json` shape per `references/template-inputs.schema.json`.
57
- 4. Run `python3 references/render.py inputs.json`. The renderer fills the template, writes the HTML, drives Chrome `--print-to-pdf`, and asserts page count = 4.
58
- 5. Return the PDF path.
77
+ 1. Match the postcode out of the address (`/[A-Z]{1,2}[0-9][A-Z0-9]?\s?[0-9][A-Z]{2}/i`). Stop if no match.
78
+ 2. Run the 13 fan-out tools (12 PropertyData + 1 EPC) in parallel. Failure handling differs by source:
79
+ - **PropertyData `key-not-registered`** → abort with the `property-data-key-register` prompt. The pack cannot render without market data.
80
+ - **PropertyData `http-429`** back off, retry only the throttled tool. Do not re-run the whole fan-out.
81
+ - **EPC failure of any kind** — `key-not-registered`, `http-404 (no record on file)`, `http-429`, `http-500`, network failure, or MCP not configured on the account — is treated as a single class: **prompt the operator** with the relevant cause (e.g. "EPC API key not registered — register with `epc-key-register`, or paste subject.sqft to continue"; "No EPC on file for this address — paste subject.sqft to continue"; "EPC API unavailable — paste subject.sqft to continue"). If the operator pastes `subject.sqft`, the pack renders normally with the EPC-derived KPI cells switching to "—" + "No EPC on file" / "EPC API unavailable" sub-labels. If the operator declines to paste, the valuation panel switches to the "Estimate requires floor area" benchmark-only mode and the pack still renders the other four pages.
82
+ 3. Compute valuation range:
83
+ - `sold_psf_low` = `sold-prices-per-sqf` `70pc_range[0]`
84
+ - `sold_psf_high` = `sold-prices-per-sqf` `70pc_range[1]`
85
+ - `low = sqft × sold_psf_low`, `mid = sqft × sold_psf`, `high = sqft × sold_psf_high`
86
+ - Apply `adjustments` linearly: `factor = 1 + Σ delta_pct / 100`. Final range = `[low × factor, high × factor]`, midpoint = `mid × factor`.
87
+ 4. Assemble the `inputs.json` shape per `references/template-inputs.schema.json`.
88
+ 5. `python3 references/render.py inputs.json`. Renderer fills the template, copies logos + hero into `img/`, drives Chrome `--print-to-pdf`, asserts page count == 5.
89
+ 6. Return the PDF path.
59
90
 
60
- ## Observability
91
+ ## Adjustments — linear vs compound
61
92
 
62
- Emit at start: `[preval] start address="<addr>" postcode=<pc>`.
63
- Emit at done: `[preval] done pdf=<path> pages=<n> ms=<n>`.
93
+ Deltas sum linearly (`-8 + 3 = -5%`, applied as `× 0.95`). Compound interpretation (`× 0.92 × 1.03`) would give a slightly different number (-5.24%) but the operator presents the figures verbally as a sum, so the displayed maths must match the verbal claim. If a future operator wants compound adjustments, this is a deliberate skill-level rewrite, not a config switch.
94
+
95
+ ## Observability
64
96
 
65
- Each of the 12 fan-out calls produces its own `[property-data] tool=<name>` line in `server.log` (inherited contract — no new instrumentation).
97
+ - `[preval] start address="<addr>" postcode=<pc>` at run start.
98
+ - `[preval] done pdf=<path> pages=<n> ms=<n>` at run end.
99
+ - Each fan-out call emits its own `[property-data] tool=<name>` or `[epc] tool=<name>` line in `server.log` (inherited contracts; no new instrumentation here).
66
100
 
67
101
  ## Failure signatures
68
102
 
69
- - `pages=5` (or anything 4) in the `done` line → template overflow on one page. Diagnose with `pdftoppm -png -f 5 -l 5 <pdf>` to see which content bled, then trim that panel's padding.
103
+ - `pages=6` (or ≠ 5) in the `done` line → template overflow on one page. Diagnose with `pdftoppm -png -f <n> -l <n> <pdf>` to see which page bled, then trim that panel's padding only.
70
104
  - `start` with no matching `done` within 60s → renderer crashed. Inspect with `grep -B2 -A20 '\[preval\] start address="<echoed>"' server.log`.
71
- - Wrong postcode derivation → check `grep '\[preval\] start' server.log | awk -F'postcode=' '{print $2}' | sort -u`; the regex must keep the inward part of the postcode (e.g. `CM23 2UJ`, not `CM23`).
105
+ - `[epc] status=http-404` property has no EPC; prompt "no EPC on file for this address paste subject.sqft to continue".
106
+ - `[epc] … status=key-not-registered` → prompt "EPC API key not registered — run `epc-key-register`, or paste subject.sqft to continue".
107
+ - `[epc] … status=http-429` | `http-5xx` | `bad-response` | `network-error` → prompt "EPC API unavailable — paste subject.sqft to continue". The MCP-not-installed case (no `[epc] …` line at all and the tool call surfaces as "tool not found") is the same prompt with cause "EPC MCP not configured on this account".
108
+ - `[property-data] … status=http-429` → 4-calls-in-10-seconds throttle. Retry only the throttled tool after the back-off; do not re-run the whole fan-out.
72
109
 
73
110
  ## Out of scope
74
111
 
75
- - Multi-address batch preval (CSV in → many PDFs out) — separate skill if needed.
76
- - Loop CRM auto-publish of the PDF onto a contact's record — separate skill.
77
- - A hero image on the cover via `property-socials` preval cover is text-only by design.
78
- - Caching of MCP responses 12 credits per pack is acceptable; cost optimisation is a Task 144B concern.
112
+ - Multi-address batch preval (CSV in → many PDFs out).
113
+ - Loop CRM auto-publish of the PDF onto a contact's record.
114
+ - Pulling `agent_listings` from Loop CRM automatically operator paste-in for now; CRM wire-up is a separate task.
115
+ - Caching of MCP responses (13 credits per pack is acceptable).
116
+ - A separate cover letter / pitch letter — the cover page *is* the pitch.
117
+ - Compound adjustments (linear sum is the contract; see above).
118
+ - Subject sqft from sources other than EPC and the operator paste-in fallback (e.g. Loop CRM floor plans, Rightmove sqft scrape) — separate task if pursued.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Substitutes <!-- REPLACE: <slot> --> placeholders in template.html with values
4
4
  derived from one inputs.json (shape: template-inputs.schema.json), drives
5
- Chrome headless to produce a 4-page A4 PDF, and asserts page count == 4.
5
+ Chrome headless to produce a 5-page A4 PDF, and asserts page count == 5.
6
6
 
7
7
  Usage:
8
8
  python3 render.py inputs.json
@@ -12,10 +12,25 @@ import html, json, re, shutil, subprocess, sys
12
12
  from pathlib import Path
13
13
 
14
14
 
15
+ M2_TO_SQFT = 10.7639
16
+
17
+
15
18
  def _money(n: int) -> str:
16
19
  return f"£{int(n):,}"
17
20
 
18
21
 
22
+ def _range_money(low: int, high: int) -> str:
23
+ return f"£{int(low):,} – £{int(high):,}"
24
+
25
+
26
+ # ---------------- helpers ----------------
27
+
28
+ def _short_address(address: str) -> str:
29
+ """First two address tokens for the cover meta strip."""
30
+ parts = [p.strip() for p in address.split(",")]
31
+ return ", ".join(parts[:2]) if len(parts) >= 2 else address
32
+
33
+
19
34
  def _sold_comps_rows(rows: list[dict]) -> str:
20
35
  if not rows:
21
36
  return '<tr><td colspan="4" class="muted">No sold transactions in the sample.</td></tr>'
@@ -49,31 +64,6 @@ def _asking_comps_tiles(rows: list[dict]) -> str:
49
64
  return "".join(out)
50
65
 
51
66
 
52
- def _crime_rows(rows: list[dict]) -> str:
53
- if not rows:
54
- return '<div class="muted">No crime data.</div>'
55
- top = sorted(rows, key=lambda r: r["count"], reverse=True)[:6]
56
- cap = max((r["count"] for r in top), default=1) or 1
57
- return "".join(
58
- f'<div class="dist-row">'
59
- f'<div class="name">{html.escape(r["category"])}</div>'
60
- f'<div class="bar"><span{" class=gold" if i == 0 else ""} style="width:{int(100 * r["count"] / cap)}%"></span></div>'
61
- f'<div class="pct">{r["count"]}</div>'
62
- f"</div>"
63
- for i, r in enumerate(top)
64
- )
65
-
66
-
67
- def _band_rows(rows: list[dict]) -> str:
68
- if not rows:
69
- return '<tr><td colspan="2" class="muted">No banding data.</td></tr>'
70
- return "".join(
71
- f"<tr><td>Band {html.escape(r['band'])}</td>"
72
- f"<td class=\"num\">{_money(r['amount'])}</td></tr>"
73
- for r in rows
74
- )
75
-
76
-
77
67
  def _type_dist_rows(rows: list[dict]) -> str:
78
68
  if not rows:
79
69
  return '<div class="muted">No property-type mix data.</div>'
@@ -89,7 +79,7 @@ def _type_dist_rows(rows: list[dict]) -> str:
89
79
 
90
80
 
91
81
  def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
92
- """Two-line sparkline in a 1000x380 viewBox SVG, no external dependencies."""
82
+ """Two-line sparkline in a 1000x380 viewBox SVG, no external deps."""
93
83
  W, H = 1000, 380
94
84
  pad_l, pad_r, pad_t, pad_b = 70, 30, 30, 50
95
85
  iw, ih = W - pad_l - pad_r, H - pad_t - pad_b
@@ -102,9 +92,7 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
102
92
  if ymax == ymin:
103
93
  ymax = ymin + 1
104
94
  n = len(series)
105
- pts = []
106
- circles = []
107
- labels = []
95
+ pts, circles, labels = [], [], []
108
96
  for i, p in enumerate(series):
109
97
  x = pad_l + (i * iw / max(1, n - 1))
110
98
  y = pad_t + ih - ((float(p[key]) - ymin) / (ymax - ymin)) * ih
@@ -115,8 +103,7 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
115
103
  )
116
104
  return (
117
105
  f'<polyline class="{css_pl}" points="{" ".join(pts)}"/>'
118
- + "".join(circles)
119
- + "".join(labels)
106
+ + "".join(circles) + "".join(labels)
120
107
  )
121
108
 
122
109
  axis = (
@@ -128,54 +115,283 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
128
115
  )
129
116
 
130
117
 
118
+ def _summary_paragraphs(paras: list[str]) -> str:
119
+ if not paras:
120
+ return ""
121
+ out = [f'<p class="dropcap">{html.escape(paras[0])}</p>']
122
+ for p in paras[1:]:
123
+ out.append(f"<p>{html.escape(p)}</p>")
124
+ return "".join(out)
125
+
126
+
127
+ def _wordmark(brand: dict, variant: str, out_dir: Path) -> str:
128
+ """variant ∈ {'light', 'dark'}. Mirrors property-market-report's wordmark."""
129
+ key = "logo_light" if variant == "light" else "logo_dark"
130
+ logo_path = brand.get(key)
131
+ if logo_path:
132
+ src = Path(logo_path).expanduser()
133
+ dest_dir = out_dir / "img"
134
+ dest_dir.mkdir(parents=True, exist_ok=True)
135
+ dest = dest_dir / src.name
136
+ if src.resolve() != dest.resolve():
137
+ shutil.copy2(src, dest)
138
+ rel = f"img/{src.name}"
139
+ height = "14mm" if variant == "light" else "9mm"
140
+ return f'<img src="{html.escape(rel)}" alt="{html.escape(brand["name"])}" style="height:{height};width:auto;display:block">'
141
+ cls = "wm light" if variant == "light" else "wm small"
142
+ return (
143
+ f'<div class="{cls}">'
144
+ f'<span class="wm-name">{html.escape(brand["name"])}</span>'
145
+ f'<span class="wm-sub">{html.escape(brand["tagline"])}</span>'
146
+ f"</div>"
147
+ )
148
+
149
+
150
+ def _hero_asset(subject: dict, out_dir: Path) -> str:
151
+ """Copy subject.cover_hero into img/ and return the relative path. URLs pass through."""
152
+ hero = subject["cover_hero"]
153
+ if hero.startswith(("http://", "https://", "data:")):
154
+ return hero
155
+ src = Path(hero).expanduser()
156
+ if not src.exists():
157
+ raise FileNotFoundError(f"subject.cover_hero not found: {src}")
158
+ dest_dir = out_dir / "img"
159
+ dest_dir.mkdir(parents=True, exist_ok=True)
160
+ dest = dest_dir / f"hero{src.suffix.lower()}"
161
+ if src.resolve() != dest.resolve():
162
+ shutil.copy2(src, dest)
163
+ return f"img/{dest.name}"
164
+
165
+
166
+ def _listing_card(p: dict, index: int, out_dir: Path) -> str:
167
+ addr_parts = [x.strip() for x in p["address"].split(",")]
168
+ addr = html.escape(addr_parts[0])
169
+ chips = []
170
+ if p.get("beds"): chips.append(f"{p['beds']} bed")
171
+ if p.get("type"): chips.append(html.escape(p["type"]))
172
+ chips_html = "".join(f"<span>{c}</span>" for c in chips)
173
+ img_src = ""
174
+ if p.get("image"):
175
+ src = Path(p["image"]).expanduser() if not str(p["image"]).startswith(("http://", "https://", "data:")) else None
176
+ if src and src.exists():
177
+ dest_dir = out_dir / "img"
178
+ dest_dir.mkdir(parents=True, exist_ok=True)
179
+ dest = dest_dir / f"agent-{index:02d}-{p['slug']}{src.suffix.lower()}"
180
+ if src.resolve() != dest.resolve():
181
+ shutil.copy2(src, dest)
182
+ img_src = f"img/{dest.name}"
183
+ elif src is None:
184
+ img_src = str(p["image"])
185
+ status = p.get("status", "")
186
+ badge_cls = {"Sold": "sold", "Under offer": "under"}.get(status, "")
187
+ photo = (
188
+ f'<div class="photo">'
189
+ f'{f"<img src=\"{html.escape(img_src)}\" alt=\"{addr}\">" if img_src else ""}'
190
+ f'<div class="badge {badge_cls}">{html.escape(status)}</div>'
191
+ f"</div>"
192
+ )
193
+ meta = (
194
+ f'<div class="meta">'
195
+ f'<div class="addr">{addr}</div>'
196
+ f'<div class="price">{_money(p["price"])}</div>'
197
+ f'<div class="kfs">{chips_html}</div>'
198
+ f"</div>"
199
+ )
200
+ inner = photo + meta
201
+ if p.get("url"):
202
+ return f'<a class="listing" href="{html.escape(p["url"])}" target="_blank" rel="noopener">{inner}</a>'
203
+ return f'<div class="listing">{inner}</div>'
204
+
205
+
206
+ def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
207
+ if not rows:
208
+ return '<div class="muted" style="margin-top:5mm">No recent listings supplied.</div>'
209
+ return "".join(_listing_card(p, i, out_dir) for i, p in enumerate(rows[:6]))
210
+
211
+
212
+ # ---------------- valuation maths ----------------
213
+
214
+ def _valuation_block(subject: dict, valuation: dict) -> str:
215
+ """Build the page-2 valuation panel: headline range, adjustments, final range.
216
+
217
+ Falls back to the 'requires floor area' state when subject.sqft is missing.
218
+ """
219
+ sqft = subject.get("sqft")
220
+ if not sqft:
221
+ return (
222
+ '<div class="val-panel"><div class="val-empty">'
223
+ "Estimate requires floor area.<br>"
224
+ "<span style=\"font-size:10pt;color:var(--text-muted)\">"
225
+ f"Benchmark: sold £/sqft = {_money(valuation['sold_psf'])}, "
226
+ f"asking £/sqft = {_money(valuation['asking_psf'])}. "
227
+ "Paste subject.sqft to compute the range."
228
+ "</span></div></div>"
229
+ )
230
+
231
+ low_psf = valuation["sold_psf_low"]
232
+ mid_psf = valuation["sold_psf"]
233
+ high_psf = valuation["sold_psf_high"]
234
+ ask_psf = valuation["asking_psf"]
235
+
236
+ low_raw = int(sqft * low_psf)
237
+ mid_raw = int(sqft * mid_psf)
238
+ high_raw = int(sqft * high_psf)
239
+ ask_raw = int(sqft * ask_psf)
240
+
241
+ rows = [
242
+ ('<div class="val-row headline">'
243
+ f'<div class="lbl">Headline range<small>{sqft:,} sqft × £{low_psf}–£{high_psf} sold £/sqft (Land Registry 70pc band)</small></div>'
244
+ f'<div class="num">{_range_money(low_raw, high_raw)}</div></div>'),
245
+ ('<div class="val-row">'
246
+ f'<div class="lbl">Cross-check · asking £/sqft<small>{sqft:,} sqft × £{ask_psf} mean asking £/sqft</small></div>'
247
+ f'<div class="num">{_money(ask_raw)}</div></div>'),
248
+ ]
249
+
250
+ adjustments = subject.get("adjustments") or []
251
+ if adjustments:
252
+ rows.append('<div class="val-row"><div class="lbl" style="font-family:var(--sans);font-weight:600;font-size:8pt;letter-spacing:2pt;text-transform:uppercase;color:var(--text-accent)">Adjustments</div><div class="num">&nbsp;</div></div>')
253
+ for adj in adjustments:
254
+ delta = float(adj["delta_pct"])
255
+ cls = "pos" if delta > 0 else ("neg" if delta < 0 else "")
256
+ sign = "+" if delta >= 0 else ""
257
+ rows.append(
258
+ '<div class="val-row adj">'
259
+ f'<div class="lbl">{html.escape(adj["label"])}</div>'
260
+ f'<div class="num {cls}">{sign}{delta:.1f}%</div>'
261
+ "</div>"
262
+ )
263
+
264
+ delta_sum = sum(float(a["delta_pct"]) for a in adjustments)
265
+ factor = 1.0 + delta_sum / 100.0
266
+ low_final = int(low_raw * factor)
267
+ high_final = int(high_raw * factor)
268
+
269
+ final = (
270
+ '<div class="val-final">'
271
+ f'<div class="lbl">Final indicative range</div>'
272
+ f'<div class="num">{_range_money(low_final, high_final)}</div>'
273
+ "</div>"
274
+ )
275
+
276
+ return '<div class="val-panel">' + "".join(rows) + "</div>" + final
277
+
278
+
279
+ def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
280
+ sqft = subject.get("sqft")
281
+ m2 = subject.get("sqft_m2")
282
+ if sqft:
283
+ sub = f"≈ {m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
284
+ return f"{int(sqft):,}", sub
285
+ return "—", "No EPC on file"
286
+
287
+
288
+ def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
289
+ current = subject.get("epc_current")
290
+ potential = subject.get("epc_potential")
291
+ lodgement = subject.get("epc_lodgement")
292
+ if not current:
293
+ return "—", "No EPC on file"
294
+ sub_bits = []
295
+ if potential:
296
+ sub_bits.append(f"potential {potential}")
297
+ if lodgement:
298
+ sub_bits.append(f"lodged {lodgement}")
299
+ return current, " · ".join(sub_bits) or "EPC register"
300
+
301
+
302
+ # ---------------- render ----------------
303
+
131
304
  def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
132
305
  template_path = template_path or (Path(__file__).parent / "template.html")
133
306
  tmpl = template_path.read_text()
134
307
 
135
- v = inputs["valuation"]
136
- a = inputs["area"]
137
- d = inputs["demand_trend"]
138
- r = d["rental"]
308
+ brand = inputs["brand"]
309
+ subject = inputs["subject"]
310
+ v = inputs["valuation"]
311
+ a = inputs["area"]
312
+ d = inputs["demand_trend"]
313
+ ms = inputs["market_summary"]
314
+ r = d["rental"]
315
+
316
+ out_dir.mkdir(parents=True, exist_ok=True)
317
+
318
+ subject_sqft, subject_sqft_sub = _kpi_subject_sqft(subject)
319
+ subject_epc, subject_epc_sub = _kpi_subject_epc(subject)
320
+
321
+ yoy = d.get("yoy_growth_pct")
322
+ if yoy is None and len(d.get("growth_series", [])) >= 2:
323
+ last, prev = d["growth_series"][-1]["price"], d["growth_series"][-2]["price"]
324
+ yoy = (last - prev) / prev * 100.0
325
+ yoy_str = f"{yoy:+.1f}%" if yoy is not None else "—"
326
+
327
+ psf = d.get("sales_per_month")
328
+ if psf is None:
329
+ psf = max(1, round(d["for_sale"] / 7)) # crude fallback only when not provided
139
330
 
140
331
  slots = {
332
+ # branding
333
+ "brand_name": brand["name"],
334
+ "brand_tagline": brand["tagline"],
335
+ "brand_primary": brand["primary"],
336
+ "brand_primary_dark": brand["primary_dark"],
337
+ "brand_accent": brand["accent"],
338
+ "brand_paper": brand["paper"],
339
+ "brand_paper_banded": brand["paper_banded"],
340
+ "brand_rule": brand["rule"],
341
+ "wordmark_light": _wordmark(brand, "light", out_dir),
342
+ "wordmark_dark": _wordmark(brand, "dark", out_dir),
343
+
344
+ # cover + header
345
+ "subject_hero_path": _hero_asset(subject, out_dir),
141
346
  "address": inputs["address"],
347
+ "address_short": _short_address(inputs["address"]),
142
348
  "postcode": inputs["postcode"],
143
349
  "generated": inputs["generated"],
144
350
 
145
- "kpi_avg_asking": _money(v["avg_asking"]),
146
- "kpi_asking_psf": _money(v["asking_psf"]),
147
- "kpi_avg_sold": _money(v["avg_sold"]),
148
- "kpi_sold_psf": _money(v["sold_psf"]),
149
- "sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
150
- "asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
151
-
152
- "area_headline": a["headline"],
153
- "kpi_crime": a["crime_rating"],
154
- "kpi_flood": a["flood_risk"],
155
- "kpi_band_d": _money(a["council_tax_band_d"]),
156
- "kpi_population": f"{int(a['population']):,}",
157
- "crime_rows": _crime_rows(a.get("crime_breakdown", [])),
158
- "band_rows": _band_rows(a.get("council_tax_bands", [])),
159
-
160
- "kpi_demand_rating": d["demand_rating"],
161
- "kpi_dom": str(d["dom"]),
162
- "kpi_turnover": f"{int(d['turnover_pct'])}%",
163
- "kpi_for_sale": str(d["for_sale"]),
164
- "spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
165
-
166
- "rental_rating": r["demand_rating"],
167
- "rental_dom": str(r["dom"]),
168
- "rental_turnover": f"{int(r['turnover_pct'])}%",
169
- "rental_for_rent": str(r["for_rent"]),
170
-
171
- "type_dist_rows": _type_dist_rows(d.get("type_dist", [])),
351
+ # page 2 — subject valuation
352
+ "kpi_subject_sqft": subject_sqft,
353
+ "kpi_subject_sqft_sub": subject_sqft_sub,
354
+ "kpi_subject_beds": str(subject.get("beds") or "—"),
355
+ "kpi_subject_epc": subject_epc,
356
+ "kpi_subject_epc_sub": subject_epc_sub,
357
+ "kpi_subject_type": subject.get("type") or "—",
358
+ "kpi_subject_tenure": subject.get("tenure") or "Tenure unknown",
359
+ "valuation_block": _valuation_block(subject, v),
360
+ "subject_narrative": subject.get("narrative") or "",
361
+
362
+ # page 3 — market context
363
+ "summary_headline": ms["headline"],
364
+ "summary_paragraphs": _summary_paragraphs(ms["paragraphs"]),
365
+ "kpi_avg_asking": _money(v["avg_asking"]),
366
+ "kpi_avg_sold": _money(v["avg_sold"]),
367
+ "kpi_psf": _money(v["asking_psf"]),
368
+ "kpi_yoy": yoy_str,
369
+ "demand_rating": d["demand_rating"],
370
+ "gauge_position": ms["demand_gauge_position"],
371
+ "price_growth_note": ms["price_growth_note"],
372
+ "demand_note": ms["demand_note"],
373
+ "spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
374
+
375
+ # page 4 — comparables
376
+ "sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
377
+ "asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
378
+
379
+ # page 5 — agent listings + sources
380
+ "agent_listings_cards": _agent_listings_cards(inputs["agent_listings"], out_dir),
172
381
  }
173
382
 
174
- # Escape free-form text slots that aren't already HTML fragments.
175
- text_slots = {"address", "postcode", "generated", "area_headline", "kpi_crime", "kpi_flood",
176
- "kpi_demand_rating", "rental_rating"}
383
+ # Escape only the plain-text slots; HTML fragments pass through untouched.
384
+ text_slots = {
385
+ "address", "address_short", "postcode", "generated",
386
+ "brand_name", "brand_tagline",
387
+ "kpi_subject_beds", "kpi_subject_epc", "kpi_subject_epc_sub",
388
+ "kpi_subject_sqft", "kpi_subject_sqft_sub",
389
+ "kpi_subject_type", "kpi_subject_tenure",
390
+ "summary_headline", "demand_rating",
391
+ "price_growth_note", "demand_note", "subject_narrative",
392
+ }
177
393
  for k in text_slots:
178
- slots[k] = html.escape(slots[k])
394
+ slots[k] = html.escape(str(slots[k]))
179
395
 
180
396
  out = tmpl
181
397
  for k, val in slots.items():
@@ -187,7 +403,6 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
187
403
  "template has unfilled placeholders: " + ", ".join(sorted(set(leftovers)))
188
404
  )
189
405
 
190
- out_dir.mkdir(parents=True, exist_ok=True)
191
406
  out_path = out_dir / "index.html"
192
407
  out_path.write_text(out)
193
408
  return out_path
@@ -212,11 +427,10 @@ _PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
212
427
 
213
428
 
214
429
  def _count_pages(pdf_path: Path) -> int:
215
- """Prefer pdfinfo (handles compressed object streams); fall back to a
216
- raw `/Type /Page` byte scan when pdfinfo is not installed. The fallback
217
- is correct for current Chrome `--print-to-pdf` output (no `/ObjStm`)
218
- but would silently undercount if a future Chrome switches to compressed
219
- page-object streams — hence pdfinfo first when available.
430
+ """pdfinfo first (handles /ObjStm-compressed page-object streams), with a
431
+ raw /Type /Page byte scan as fallback. The fallback is correct for current
432
+ Chrome --print-to-pdf output but would undercount if Chrome ever switches
433
+ to compressed page-object streams hence pdfinfo first when present.
220
434
  """
221
435
  pdfinfo = shutil.which("pdfinfo")
222
436
  if pdfinfo:
@@ -231,13 +445,6 @@ def _count_pages(pdf_path: Path) -> int:
231
445
 
232
446
 
233
447
  def assert_page_count(pdf_path: Path, expected: int) -> int:
234
- """Assert PDF page count == expected; raise RuntimeError otherwise.
235
-
236
- Synchronous and reliable — does not depend on Spotlight (`mdls` returns
237
- `(null)` until indexing finishes, which is async). Operators can still
238
- verify externally with `mdls -name kMDItemNumberOfPages <pdf>` once
239
- Spotlight catches up.
240
- """
241
448
  n = _count_pages(pdf_path)
242
449
  if n != expected:
243
450
  raise RuntimeError(
@@ -256,7 +463,7 @@ def main():
256
463
  base = inputs["filename_stem"]
257
464
  render(inputs, out_dir)
258
465
  pdf = render_pdf(out_dir, f"{base}.pdf")
259
- pages = assert_page_count(pdf, 4)
466
+ pages = assert_page_count(pdf, 5)
260
467
  print(f"HTML → {out_dir/'index.html'}")
261
468
  print(f"PDF → {pdf} ({pages} pages)")
262
469