@rubytech/create-maxy-code 0.1.60 → 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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
- package/payload/platform/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
- package/payload/platform/plugins/docs/references/admin-session.md +2 -2
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/epc/.claude-plugin/plugin.json +17 -0
- package/payload/platform/plugins/epc/PLUGIN.md +85 -0
- package/payload/platform/plugins/epc/mcp/dist/index.d.ts +2 -0
- package/payload/platform/plugins/epc/mcp/dist/index.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/index.js +117 -0
- package/payload/platform/plugins/epc/mcp/dist/index.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js +72 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js +130 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js +10 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js +36 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js +30 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/package-lock.json +2566 -0
- package/payload/platform/plugins/epc/mcp/package.json +21 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
- package/payload/platform/plugins/epc/mcp/src/index.ts +156 -0
- package/payload/platform/plugins/epc/mcp/src/lib/crypto.ts +79 -0
- package/payload/platform/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
- package/payload/platform/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/platform/plugins/epc/mcp/src/lib/key-store.ts +172 -0
- package/payload/platform/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-list.ts +10 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-register.ts +44 -0
- package/payload/platform/plugins/epc/mcp/src/tools/lookup.ts +43 -0
- package/payload/platform/plugins/epc/mcp/tsconfig.json +20 -0
- package/payload/platform/plugins/epc/mcp/vitest.config.ts +8 -0
- package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/preval/PLUGIN.md +23 -17
- package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +78 -38
- package/payload/platform/plugins/preval/skills/property-preval/references/render.py +288 -81
- package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
- package/payload/platform/plugins/preval/skills/property-preval/references/template.html +184 -124
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +6 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/agents/admin/SOUL.md +1 -1
- package/payload/premium-plugins/real-agent/BUNDLE.md +4 -2
- package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/epc/.claude-plugin/plugin.json +17 -0
- package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +85 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts +2 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js +117 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js +72 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js +130 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js +36 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js +30 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/package-lock.json +2566 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/package.json +21 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/index.ts +156 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/crypto.ts +79 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/key-store.ts +172 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-list.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-register.ts +44 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/lookup.ts +43 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/tsconfig.json +20 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/vitest.config.ts +8 -0
- package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +23 -17
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +78 -38
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +288 -81
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +184 -124
- package/payload/server/public/assets/{admin-Bp-BjBCX.js → admin-DhN3G8W7.js} +1 -1
- package/payload/server/public/assets/{data-BGUAGVkV.js → data-B2ZVXOcE.js} +1 -1
- package/payload/server/public/assets/{graph-MvYxZOBF.js → graph-BAMGPHrK.js} +1 -1
- package/payload/server/public/assets/{graph-labels-D865qb3K.js → graph-labels-D9eBbvxo.js} +1 -1
- package/payload/server/public/assets/{page-C2b1nlOc.js → page-CV27Al6Z.js} +1 -1
- package/payload/server/public/assets/{page--hOVRrgN.js → page-DjdVMWCz.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +3 -16
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { getSession } from "./neo4j.js";
|
|
5
|
+
import { encrypt, decrypt } from "./crypto.js";
|
|
6
|
+
import { encryptWith, decryptWith } from "./file-crypto.js";
|
|
7
|
+
|
|
8
|
+
// Storage abstraction so the same MCP body runs against Neo4j (platform mode)
|
|
9
|
+
// or an on-disk encrypted file (standalone mode, e.g. spawned by Claude Code
|
|
10
|
+
// from ~/.claude/). Selected by the EPC_STANDALONE env flag at boot. Mirrors
|
|
11
|
+
// the property-data key-store with one node per account: EPC keys are
|
|
12
|
+
// account-scoped and the gov.uk EPC API has no team concept.
|
|
13
|
+
|
|
14
|
+
export interface StoredKey {
|
|
15
|
+
apiKey: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface KeyStore {
|
|
21
|
+
readonly backendName: string;
|
|
22
|
+
readonly backendPath: string;
|
|
23
|
+
load(accountId: string): Promise<StoredKey | null>;
|
|
24
|
+
save(accountId: string, apiKey: string): Promise<void>;
|
|
25
|
+
delete(accountId: string): Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class Neo4jKeyStore implements KeyStore {
|
|
29
|
+
readonly backendName = "neo4j";
|
|
30
|
+
readonly backendPath = process.env.NEO4J_URI ?? "<unset>";
|
|
31
|
+
|
|
32
|
+
async load(accountId: string): Promise<StoredKey | null> {
|
|
33
|
+
const session = getSession();
|
|
34
|
+
try {
|
|
35
|
+
const result = await session.run(
|
|
36
|
+
`MATCH (k:EpcApiKey {accountId: $accountId})
|
|
37
|
+
RETURN k.encryptedKey AS encryptedKey,
|
|
38
|
+
k.createdAt AS createdAt,
|
|
39
|
+
k.updatedAt AS updatedAt`,
|
|
40
|
+
{ accountId },
|
|
41
|
+
);
|
|
42
|
+
if (result.records.length === 0) return null;
|
|
43
|
+
const r = result.records[0];
|
|
44
|
+
return {
|
|
45
|
+
apiKey: decrypt(r.get("encryptedKey")),
|
|
46
|
+
createdAt: r.get("createdAt") ?? "",
|
|
47
|
+
updatedAt: r.get("updatedAt") ?? "",
|
|
48
|
+
};
|
|
49
|
+
} finally {
|
|
50
|
+
await session.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async save(accountId: string, apiKey: string): Promise<void> {
|
|
55
|
+
const encryptedKey = encrypt(apiKey);
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
const session = getSession();
|
|
58
|
+
try {
|
|
59
|
+
await session.run(
|
|
60
|
+
`CREATE INDEX epc_api_key_account IF NOT EXISTS
|
|
61
|
+
FOR (k:EpcApiKey) ON (k.accountId)`,
|
|
62
|
+
{},
|
|
63
|
+
);
|
|
64
|
+
await session.run(
|
|
65
|
+
`MERGE (k:EpcApiKey {accountId: $accountId})
|
|
66
|
+
ON CREATE SET k.encryptedKey = $encryptedKey, k.createdAt = $now, k.updatedAt = $now
|
|
67
|
+
ON MATCH SET k.encryptedKey = $encryptedKey, k.updatedAt = $now`,
|
|
68
|
+
{ accountId, encryptedKey, now },
|
|
69
|
+
);
|
|
70
|
+
} finally {
|
|
71
|
+
await session.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async delete(accountId: string): Promise<boolean> {
|
|
76
|
+
const session = getSession();
|
|
77
|
+
try {
|
|
78
|
+
const result = await session.run(
|
|
79
|
+
`MATCH (k:EpcApiKey {accountId: $accountId})
|
|
80
|
+
DELETE k
|
|
81
|
+
RETURN count(k) AS deleted`,
|
|
82
|
+
{ accountId },
|
|
83
|
+
);
|
|
84
|
+
const deleted = result.records[0]?.get("deleted")?.toNumber?.() ?? 0;
|
|
85
|
+
return deleted > 0;
|
|
86
|
+
} finally {
|
|
87
|
+
await session.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface FileEnvelope {
|
|
93
|
+
[accountId: string]: { encryptedKey: string; createdAt: string; updatedAt: string };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const LABEL = "epc";
|
|
97
|
+
|
|
98
|
+
class FileKeyStore implements KeyStore {
|
|
99
|
+
readonly backendName = "file";
|
|
100
|
+
readonly backendPath: string;
|
|
101
|
+
private readonly storePath: string;
|
|
102
|
+
private readonly keyFilePath: string;
|
|
103
|
+
|
|
104
|
+
constructor() {
|
|
105
|
+
const home = homedir();
|
|
106
|
+
this.storePath = resolve(home, ".claude/.realagent-epc-key.enc");
|
|
107
|
+
this.keyFilePath = resolve(home, ".claude/.realagent-epc-encryption-key");
|
|
108
|
+
this.backendPath = this.storePath;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private ensureDir(): void {
|
|
112
|
+
mkdirSync(dirname(this.storePath), { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private readAll(): FileEnvelope {
|
|
116
|
+
if (!existsSync(this.storePath)) return {};
|
|
117
|
+
const blob = readFileSync(this.storePath, "utf-8").trim();
|
|
118
|
+
if (!blob) return {};
|
|
119
|
+
const json = decryptWith(blob, this.keyFilePath, LABEL);
|
|
120
|
+
return JSON.parse(json) as FileEnvelope;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private writeAll(env: FileEnvelope): void {
|
|
124
|
+
this.ensureDir();
|
|
125
|
+
const blob = encryptWith(JSON.stringify(env), this.keyFilePath, LABEL);
|
|
126
|
+
writeFileSync(this.storePath, blob, { mode: 0o600 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async load(accountId: string): Promise<StoredKey | null> {
|
|
130
|
+
const env = this.readAll();
|
|
131
|
+
const entry = env[accountId];
|
|
132
|
+
if (!entry) return null;
|
|
133
|
+
return {
|
|
134
|
+
apiKey: decryptWith(entry.encryptedKey, this.keyFilePath, LABEL),
|
|
135
|
+
createdAt: entry.createdAt,
|
|
136
|
+
updatedAt: entry.updatedAt,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async save(accountId: string, apiKey: string): Promise<void> {
|
|
141
|
+
const env = this.readAll();
|
|
142
|
+
const now = new Date().toISOString();
|
|
143
|
+
const encryptedKey = encryptWith(apiKey, this.keyFilePath, LABEL);
|
|
144
|
+
const prev = env[accountId];
|
|
145
|
+
env[accountId] = {
|
|
146
|
+
encryptedKey,
|
|
147
|
+
createdAt: prev?.createdAt ?? now,
|
|
148
|
+
updatedAt: now,
|
|
149
|
+
};
|
|
150
|
+
this.writeAll(env);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async delete(accountId: string): Promise<boolean> {
|
|
154
|
+
const env = this.readAll();
|
|
155
|
+
if (!(accountId in env)) return false;
|
|
156
|
+
delete env[accountId];
|
|
157
|
+
this.writeAll(env);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let cached: KeyStore | null = null;
|
|
163
|
+
|
|
164
|
+
export function selectKeyStore(): KeyStore {
|
|
165
|
+
if (cached) return cached;
|
|
166
|
+
cached = process.env.EPC_STANDALONE === "1" ? new FileKeyStore() : new Neo4jKeyStore();
|
|
167
|
+
return cached;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function _resetKeyStoreForTests(): void {
|
|
171
|
+
cached = null;
|
|
172
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import neo4j, { Driver, Session } from "neo4j-driver";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
let driver: Driver | null = null;
|
|
6
|
+
|
|
7
|
+
function readPassword(): string {
|
|
8
|
+
if (process.env.NEO4J_PASSWORD) return process.env.NEO4J_PASSWORD;
|
|
9
|
+
const passwordFile = resolve(
|
|
10
|
+
process.env.PLATFORM_ROOT ?? resolve(import.meta.dirname, "../../../.."),
|
|
11
|
+
"config/.neo4j-password",
|
|
12
|
+
);
|
|
13
|
+
try {
|
|
14
|
+
return readFileSync(passwordFile, "utf-8").trim();
|
|
15
|
+
} catch {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Neo4j password not found. Expected at ${passwordFile} or in NEO4J_PASSWORD env var.`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getDriver(): Driver {
|
|
23
|
+
if (!driver) {
|
|
24
|
+
const uri = process.env.NEO4J_URI;
|
|
25
|
+
if (!uri) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"[epc] NEO4J_URI unset — refusing to default to bolt://localhost:7687",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const user = process.env.NEO4J_USER ?? "neo4j";
|
|
31
|
+
const password = readPassword();
|
|
32
|
+
console.error(`[epc] resolved neo4j_uri=${uri}`);
|
|
33
|
+
driver = neo4j.driver(uri, neo4j.auth.basic(user, password));
|
|
34
|
+
}
|
|
35
|
+
return driver;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getSession(): Session {
|
|
39
|
+
return getDriver().session();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function closeDriver(): Promise<void> {
|
|
43
|
+
if (driver) {
|
|
44
|
+
await driver.close();
|
|
45
|
+
driver = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { selectKeyStore } from "../lib/key-store.js";
|
|
2
|
+
|
|
3
|
+
export async function keyDeregister(params: { accountId: string }): Promise<void> {
|
|
4
|
+
const deleted = await selectKeyStore().delete(params.accountId);
|
|
5
|
+
if (!deleted) {
|
|
6
|
+
throw new Error("No EPC API key is registered for this account.");
|
|
7
|
+
}
|
|
8
|
+
console.error(`[epc] key-op op=deregister account=${params.accountId}`);
|
|
9
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { loadAccountKey } from "../lib/epc-api.js";
|
|
2
|
+
|
|
3
|
+
export async function keyList(params: { accountId: string }): Promise<string> {
|
|
4
|
+
const key = await loadAccountKey(params.accountId);
|
|
5
|
+
console.error(`[epc] key-op op=list account=${params.accountId}`);
|
|
6
|
+
if (!key) {
|
|
7
|
+
return "No EPC API key registered. Use epc-key-register to add one.";
|
|
8
|
+
}
|
|
9
|
+
return `EPC API key registered (created: ${key.createdAt}, updated: ${key.updatedAt}). Key value is encrypted and cannot be retrieved.`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { selectKeyStore } from "../lib/key-store.js";
|
|
2
|
+
import { BASE_URL, REQUEST_TIMEOUT_MS, EpcError } from "../lib/epc-api.js";
|
|
3
|
+
|
|
4
|
+
// Probe the EPC register with the supplied credential against
|
|
5
|
+
// /domestic/search constrained to a tiny page so we do not pull a real
|
|
6
|
+
// payload. A valid credential returns HTTP 200 (rows may be empty for a
|
|
7
|
+
// nonsense postcode, which is fine — authentication is what we test).
|
|
8
|
+
// An invalid credential returns HTTP 401 Unauthorized.
|
|
9
|
+
|
|
10
|
+
async function validateApiKey(credential: string): Promise<void> {
|
|
11
|
+
const url = `${BASE_URL}/domestic/search?postcode=SW1A1AA&size=1`;
|
|
12
|
+
const response = await fetch(url, {
|
|
13
|
+
headers: {
|
|
14
|
+
Accept: "application/json",
|
|
15
|
+
Authorization: `Basic ${Buffer.from(credential, "utf-8").toString("base64")}`,
|
|
16
|
+
},
|
|
17
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
18
|
+
});
|
|
19
|
+
if (response.status === 401 || response.status === 403) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`EPC register rejected the credential (HTTP ${response.status}). Expected format is email:api-key.`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
// 200, 404 (no rows for that postcode), and 200-with-rows all prove the
|
|
25
|
+
// credential was accepted. Anything else surfaces as the upstream code.
|
|
26
|
+
if (response.status === 200 || response.status === 404) return;
|
|
27
|
+
const body = await response.text().catch(() => "");
|
|
28
|
+
throw new EpcError(response.status, "domestic/search", body.slice(0, 200));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function keyRegister(params: {
|
|
32
|
+
apiKey: string;
|
|
33
|
+
accountId: string;
|
|
34
|
+
}): Promise<void> {
|
|
35
|
+
const { apiKey, accountId } = params;
|
|
36
|
+
if (!apiKey.includes(":")) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"EPC credential must be in the form email:api-key (email of the account that generated the key, then a colon, then the API key).",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
await validateApiKey(apiKey);
|
|
42
|
+
await selectKeyStore().save(accountId, apiKey);
|
|
43
|
+
console.error(`[epc] key-op op=register account=${accountId}`);
|
|
44
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { epcSearch, groupAndPickMostRecent, EpcMatch } from "../lib/epc-api.js";
|
|
2
|
+
|
|
3
|
+
interface LookupResult {
|
|
4
|
+
status: "ok";
|
|
5
|
+
matches: EpcMatch[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function epcLookupByAddress(args: {
|
|
9
|
+
accountId: string;
|
|
10
|
+
address: string;
|
|
11
|
+
postcode?: string;
|
|
12
|
+
}): Promise<string> {
|
|
13
|
+
const { accountId, address, postcode } = args;
|
|
14
|
+
const { rows } = await epcSearch({
|
|
15
|
+
toolName: "epc-lookup-by-address",
|
|
16
|
+
accountId,
|
|
17
|
+
identifier: address,
|
|
18
|
+
params: { address, postcode, size: 25 },
|
|
19
|
+
});
|
|
20
|
+
const result: LookupResult = {
|
|
21
|
+
status: "ok",
|
|
22
|
+
matches: groupAndPickMostRecent(rows),
|
|
23
|
+
};
|
|
24
|
+
return JSON.stringify(result, null, 2);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function epcLookupByUprn(args: {
|
|
28
|
+
accountId: string;
|
|
29
|
+
uprn: string;
|
|
30
|
+
}): Promise<string> {
|
|
31
|
+
const { accountId, uprn } = args;
|
|
32
|
+
const { rows } = await epcSearch({
|
|
33
|
+
toolName: "epc-lookup-by-uprn",
|
|
34
|
+
accountId,
|
|
35
|
+
identifier: uprn,
|
|
36
|
+
params: { uprn, size: 25 },
|
|
37
|
+
});
|
|
38
|
+
const result: LookupResult = {
|
|
39
|
+
status: "ok",
|
|
40
|
+
matches: groupAndPickMostRecent(rows),
|
|
41
|
+
};
|
|
42
|
+
return JSON.stringify(result, null, 2);
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"declarationMap": true,
|
|
16
|
+
"sourceMap": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src"],
|
|
19
|
+
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts"]
|
|
20
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "real-agent-preval",
|
|
3
|
-
"description": "Single-address pre-valuation pack. One full UK address in,
|
|
3
|
+
"description": "Single-address pre-valuation pitch pack. One full UK address in, 5-page A4 PDF out (cover · subject valuation · market context · comparables · recently with <brand>). Consumes the real-agent-property-data and real-agent-epc MCPs — no MCP tools of its own.",
|
|
4
4
|
"version": "0.1.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Rubytech LLC"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: real-agent-preval
|
|
3
|
-
description: "Single-address pre-valuation pack. One full UK address in,
|
|
3
|
+
description: "Single-address pre-valuation pitch pack. One full UK address in, 5-page A4 PDF out (cover · subject valuation · market context · comparables · recently with <brand>). Consumes the real-agent-property-data and real-agent-epc MCPs — no MCP tools of its own."
|
|
4
4
|
skills:
|
|
5
5
|
- skills/property-preval/SKILL.md
|
|
6
6
|
always: false
|
|
@@ -9,31 +9,37 @@ metadata: {"platform":{"optional":true,"embed":["admin"]}}
|
|
|
9
9
|
|
|
10
10
|
# Real Agent — Preval
|
|
11
11
|
|
|
12
|
-
Consumer sub-plugin. No MCP server, no new tools — only a skill (`property-preval`) that takes one full UK address and renders a
|
|
12
|
+
Consumer sub-plugin. No MCP server, no new tools — only a skill (`property-preval`) that takes one full UK address plus operator-supplied brand tokens, subject details, agent listings, and a market summary, and renders a **5-page A4 pre-valuation PDF** designed as a sales pitch for the listing.
|
|
13
13
|
|
|
14
14
|
## What it produces
|
|
15
15
|
|
|
16
|
-
A
|
|
16
|
+
A 5-page A4 PDF and the source HTML, written to `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/`:
|
|
17
17
|
|
|
18
|
-
- **Page 1 — Cover.** Full
|
|
19
|
-
- **Page 2 —
|
|
20
|
-
- **Page 3 —
|
|
21
|
-
- **Page 4 —
|
|
18
|
+
- **Page 1 — Cover.** Full-bleed subject hero photo with gradient scrim. Light brand wordmark top-left. Address in display-italic. Postcode chip. Four-up meta strip (Report · Address · Postcode · Generated). Lifted from the `property-market-report` cover contract.
|
|
19
|
+
- **Page 2 — Subject valuation.** KPI strip (floor area, bedrooms, EPC rating, type). Valuation panel: headline range = `subject.sqft × sold £/sqft 70pc band` (Land Registry), asking £/sqft cross-check, operator adjustments (`subject.adjustments[]`) shown as a stacked list with colour-coded percentages, final indicative range pill. Falls back to "Estimate requires floor area" benchmark-only mode when sqft is missing.
|
|
20
|
+
- **Page 3 — Market context.** Market summary headline + paragraphs with drop cap. KPI strip (avg asking £, avg sold £, £/sqft). Price-growth panel (YoY) + demand pill with gauge. 7-year sparkline of £ and £/sqft. Same patterns as `property-market-report`.
|
|
21
|
+
- **Page 4 — Comparables.** Full-width Land Registry sold-transactions table (≤ 6 rows), stacked above a full-width live-asking-comparables tile grid (≤ 4 tiles). Never side-by-side — corrects the cramped two-column layout in v1 preval.
|
|
22
|
+
- **Page 5 — Recently with `<brand>` + sources.** Operator-curated `agent_listings[]` rendered as a clickable tile grid with status badges (Sold / Under offer / For sale), then the sources & methodology block. The vendor reads this page and knows who to call.
|
|
22
23
|
|
|
23
|
-
##
|
|
24
|
+
## Tool dependencies
|
|
24
25
|
|
|
25
|
-
The skill
|
|
26
|
+
The skill fans out to **13** MCP calls in parallel:
|
|
26
27
|
|
|
27
|
-
`prices`, `prices-per-sqf`, `sold-prices`, `sold-prices-per-sqf`, `growth`, `growth-psf`, `demand`, `demand-rent`, `property-types`, `crime`, `flood-risk`, `council-tax`.
|
|
28
|
+
- **12 PropertyData** tools (postcode-keyed): `prices`, `prices-per-sqf`, `sold-prices`, `sold-prices-per-sqf`, `growth`, `growth-psf`, `demand`, `demand-rent`, `property-types`, `crime`, `flood-risk`, `council-tax`.
|
|
29
|
+
- **1 EPC** tool (address-keyed): `epc-lookup-by-address` for subject floor area + energy rating + lodgement date.
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
`property-data-address-match-uprn` and `property-data-planning-applications` are deliberately not called (CRM hygiene and dev-precedent reports, respectively).
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
- `property-data-planning-applications` — planning is a separate dev-precedent report.
|
|
33
|
+
## Failure handling
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
- **PropertyData key-not-registered** → abort with the `property-data-key-register` prompt. Market data is essential; the pack cannot render without it.
|
|
36
|
+
- **EPC unavailable** — any of `key-not-registered`, `http-404 (no record)`, `http-429`, `http-500`, network failure, or EPC MCP not configured — is treated as a single class. The skill prompts the operator with the specific cause and offers two paths:
|
|
37
|
+
1. Paste `subject.sqft` directly → pack renders with EPC KPI cells showing "—" + cause sub-label, but the valuation maths still runs on the pasted sqft.
|
|
38
|
+
2. Decline → valuation panel switches to "Estimate requires floor area" benchmark-only mode; the pack still renders all five pages.
|
|
39
|
+
- **PropertyData http-429** → back off and retry only the throttled tool (4 calls per 10 seconds upstream throttle).
|
|
35
40
|
|
|
36
|
-
##
|
|
41
|
+
## External dependencies
|
|
37
42
|
|
|
38
|
-
- Requires `real-agent-property-data` enabled on the account with a registered PropertyData API key (`property-data-key-register`).
|
|
39
|
-
- Requires
|
|
43
|
+
- Requires `real-agent-property-data` enabled on the account with a registered PropertyData API key (`property-data-key-register`).
|
|
44
|
+
- Requires `real-agent-epc` enabled with a registered EPC API key (`epc-key-register`) — failure modes above; not strictly blocking thanks to the paste-sqft fallback.
|
|
45
|
+
- Requires Google Chrome at `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` for headless `--print-to-pdf`. Same Chrome dependency as `property-market-report`.
|
|
@@ -1,78 +1,118 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: property-preval
|
|
3
|
-
description: Use when an operator
|
|
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
|
-
|
|
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
|
-
##
|
|
10
|
+
## Typography is fixed; only colours and logos come from the agent
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Same typographic lock as `property-market-report`:
|
|
13
13
|
|
|
14
|
-
-
|
|
15
|
-
-
|
|
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
|
-
|
|
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
|
-
##
|
|
20
|
-
|
|
21
|
-
A directory under `~/Documents/Maxy/preval/<postcode-compact>-<YYYY-MM-DD>/` containing:
|
|
20
|
+
## Inputs (never invented)
|
|
22
21
|
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
33
|
+
## Tools the skill calls
|
|
29
34
|
|
|
30
|
-
|
|
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
|
|
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
|
|
49
|
+
12. `property-data-council-tax` — band D + per-property bands (full postcode only).
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
**EPC (1 call — free, rate-limited):**
|
|
46
52
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
55
|
-
2. Run the
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
##
|
|
91
|
+
## Adjustments — linear vs compound
|
|
61
92
|
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
-
|
|
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)
|
|
76
|
-
- Loop CRM auto-publish of the PDF onto a contact's record
|
|
77
|
-
-
|
|
78
|
-
- Caching of MCP responses
|
|
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.
|