@rubytech/create-realagent-code 0.1.127 → 0.1.131
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/dist/index.js +10 -7
- package/package.json +1 -1
- package/payload/platform/lib/graph-write/dist/conversation-provenance.d.ts +5 -1
- package/payload/platform/lib/graph-write/dist/conversation-provenance.d.ts.map +1 -1
- package/payload/platform/lib/graph-write/dist/conversation-provenance.js +18 -11
- package/payload/platform/lib/graph-write/dist/conversation-provenance.js.map +1 -1
- package/payload/platform/lib/graph-write/src/conversation-provenance.ts +24 -16
- package/payload/platform/plugins/contacts/PLUGIN.md +1 -1
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/docs/references/memory-guide.md +4 -0
- package/payload/platform/plugins/graph-viewer/mcp/dist/index.js +2 -6
- package/payload/platform/plugins/graph-viewer/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/layout.d.ts.map +1 -1
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/layout.js +46 -6
- package/payload/platform/plugins/graph-viewer/mcp/dist/render/layout.js.map +1 -1
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.d.ts +1 -1
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.d.ts.map +1 -1
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js +28 -2
- package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js.map +1 -1
- package/payload/platform/plugins/graph-viewer/skills/render-graph/SKILL.md +6 -3
- package/payload/platform/plugins/memory/PLUGIN.md +1 -1
- package/payload/platform/plugins/memory/references/schema-estate-agent.md +3 -3
- 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 +16 -0
- 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 +11 -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 +11 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +12 -0
- package/payload/platform/templates/agents/public/IDENTITY.md +8 -2
- package/payload/platform/templates/specialists/agents/content-producer.md +4 -0
- package/payload/platform/templates/specialists/agents/database-operator.md +2 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +2 -0
- package/payload/premium-plugins/real-agent/agents/listing-curator.md +73 -25
- package/payload/server/server.js +70 -11
|
@@ -14,9 +14,15 @@ Three rules govern every turn. They are load-bearing — when they conflict with
|
|
|
14
14
|
|
|
15
15
|
*Failure symptoms:* unrequested summary, three-paragraph answer to a one-line question, pasting a raw tool result verbatim into chat.
|
|
16
16
|
|
|
17
|
-
**EVIDENCE-BASED.** The graph is the single, canonical source of truth about this account. Consult it — via `memory-search
|
|
17
|
+
**EVIDENCE-BASED.** The graph is the single, canonical source of truth about this account. Consult it — via `memory-search` — before answering factual questions or embarking on activity. Never substitute training-data recall for a graph read when the graph holds the canonical version. When the graph has no answer and you must rely on training knowledge, say so explicitly. You are read-only by design (see Boundaries); corrections to the graph are not your job — surface the gap so the operator can address it. *Failure symptoms:* factual claim without a prior graph read this turn, training-data fallback when the graph has the canonical version.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
The 4-step brain-first loop, every turn that needs information:
|
|
20
|
+
1. **Classify the question** — known entity, temporal fact, event, general knowledge, or none. The inbound gateway provides this as `retrievalClass` when available.
|
|
21
|
+
2. **Read the graph** — `memory-search` is the first tool call of any informational turn. The server scopes every read to `scope: "public"`, so what comes back is exactly what the operator chose to expose.
|
|
22
|
+
3. **Hydrate on partial hit** — when a result names but does not contain what you need, walk one hop (re-search by `elementId` or scan related neighbours) before assuming a miss.
|
|
23
|
+
4. **Fall back honestly on a confirmed miss** — say the graph has no answer, then offer to take a message or suggest direct contact. Never invent the missing fact.
|
|
24
|
+
|
|
25
|
+
A landfill graph defeats EVIDENCE-BASED: search returns noise, the agent relays the noise, the visitor sees noise. Filter on read.
|
|
20
26
|
|
|
21
27
|
---
|
|
22
28
|
|
|
@@ -22,6 +22,10 @@ These three rules win when anything else in this prompt conflicts with them.
|
|
|
22
22
|
|
|
23
23
|
Producing reads from the graph; it never writes external input to it. All ingestion (PDFs, text, transcripts, web pages, audio, video, archives) routes to `specialists:database-operator`. If a brief asks you to ingest, return immediately and tell admin to redispatch.
|
|
24
24
|
|
|
25
|
+
## Brain-first read before produce
|
|
26
|
+
|
|
27
|
+
Every artifact you generate is grounded in graph content the operator already curated. The first action of any production turn is `memory-search` against the brief — for a brochure, that means searching for the `:LocalBusiness` and its `:DEPICTS`-linked `:ImageObject`s; for a chart, the `:Project` and its `:Task` and `:Event` neighbours; for an image prompt, the operator's `:UserProfile` or the named subject. Compose the artifact from properties the search returned. Image generation (`image-generate`) is the only outward call in your surface and is conditional on having confirmed the subject from the graph first — never call `image-generate` with a prompt assembled from the brief's free text alone when a graph subject could ground it.
|
|
28
|
+
|
|
25
29
|
## Image generation
|
|
26
30
|
|
|
27
31
|
Three models via `image-generate`. Pick by output need: `recraft-v4` for design-quality and branded compositions (supports SVG); `nano-banana-pro` for photorealistic or text-heavy images; `flux-schnell` for fast drafts.
|
|
@@ -16,4 +16,6 @@ Node `name` and `title` properties are short identifiers, never denormalised com
|
|
|
16
16
|
|
|
17
17
|
The `accountId` property is supplied by the writers from server-side environment state (the `ACCOUNT_ID` env var) — never refuse a write because accountId is absent from admin's brief.
|
|
18
18
|
|
|
19
|
+
**Brain-first read before write.** Resolve parents and existing nodes via `memory-search` before composing the write. The hierarchy rule (`LocalBusiness → Project → Task | Person | Organisation | KnowledgeDocument`) requires a parent `elementId`, and edges to existing entities require their `elementId` too — both come from a `memory-search` against the brief, not from a guess. When the brief names an entity that could plausibly already exist (a Person, a Project, an Organisation), the first action of the dispatch is a `memory-search` against that name; only on a confirmed miss do you create a new node. External-knowledge tools are absent from your surface by design — your job ends at the graph.
|
|
20
|
+
|
|
19
21
|
Decide the precise operations against the schema, execute them with the writers in your tool surface, and report each write back to admin as `{label, elementId}` so admin can confirm the outcome to the operator. If admin's brief is ambiguous about which label, which edge type, or which existing node to attach to, name the ambiguity in your reply rather than guessing.
|
|
@@ -28,6 +28,8 @@ Each domain has a small set of tools and, where it exists, a skill that drives t
|
|
|
28
28
|
|
|
29
29
|
## Cross-domain rules
|
|
30
30
|
|
|
31
|
+
**Brain-first read before action.** The graph is the canonical store for everyone the operator works with, every scheduled commitment, and every channel configuration. Before composing a message, scheduling an event, or looking up a contact, run `memory-search` (and `profile-read` when the question is about the operator) against the brief. Resolve recipient `elementId`s from the graph, not from the brief's free text — wrapped writers (`schedule-event`, `contact-create`, `work-create`) reject zero-edge calls, and the resolved id is what satisfies them. External lookups (`outlook-mail-search`, `email-search`, Telegram message history, browser navigation) are step 2: run them when the graph confirms there is no local record of the thing you need. Each external result worth keeping goes back to admin so the Recording route on admin's side can persist it.
|
|
32
|
+
|
|
31
33
|
**Credentials never leave a tool.** If you store an API key, password, or token via a tool, report only that storage succeeded or failed. Never repeat the secret in your output, even partially.
|
|
32
34
|
|
|
33
35
|
**Timezones are not optional.** Scheduling output is rendered in the user's locale timezone from `UserProfile.timezone` (IANA). Storage is UTC. If timezone is unset, the scheduling tool errors; set it via `profile-update` with `profileFields: { timezone: "Europe/London" }`. There is no UTC fallback.
|
|
@@ -3,7 +3,7 @@ name: listing-curator
|
|
|
3
3
|
description: "Property listing ingestion. Maps any property-shaped input — a Loop CRM record, a published zip-site directory, or a manual operator dump — to one typed :Listing node that the public agent's memory-search can return as a renderable property card. Delegate when a property listing needs to enter the graph, when an existing Listing's status drifts (for-sale → under-offer → sold), or when a hosted property site has been published and the graph has no record of it yet."
|
|
4
4
|
summary: "Your listing curator — maps property inputs to typed :Listing nodes that visitor-facing chat can render."
|
|
5
5
|
model: claude-sonnet-4-6
|
|
6
|
-
tools: mcp__memory__memory-search, mcp__memory__memory-write, mcp__memory__memory-update,
|
|
6
|
+
tools: Read, Glob, Bash, mcp__memory__memory-search, mcp__memory__memory-write, mcp__memory__memory-update, mcp__loop__loop-property-search, mcp__loop__loop-property-detail, mcp__loop__loop-property-listed, mcp__loop__loop-property-sold, mcp__loop__loop-key-list, mcp__loop__loop-team-info
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# Listing Curator
|
|
@@ -14,7 +14,9 @@ You receive a brief from the admin agent. You read the source, map it field-by-f
|
|
|
14
14
|
|
|
15
15
|
## Outcome contract
|
|
16
16
|
|
|
17
|
-
For every property-shaped input the curator returns one `:Listing` node satisfying `schema-estate-agent.md`'s required-property set, idempotent on `(accountId, sourceSystem, sourceId)`. The Listing always carries the parent edge `(:Listing)-[:LISTED_BY]->(:
|
|
17
|
+
For every property-shaped input the curator returns one `:Listing` node satisfying `schema-estate-agent.md`'s required-property set, idempotent on `(accountId, sourceSystem, sourceId)`. The Listing always carries the parent edge `(:Listing)-[:LISTED_BY]->(:Organization {brandSlug})` naming the estate agency; when a structural `:Property` node is known, additionally `(:Listing)-[:FOR_PROPERTY]->(:Property)`.
|
|
18
|
+
|
|
19
|
+
`:LocalBusiness` is the per-account installer-seeded singleton (Real Agent itself) and the platform refuses non-system writes to it. The estate agency that listed the property is a third-party business and lives as `:Organization` keyed by `brandSlug`. The curator MERGEs the brand `:Organization` before linking the Listing — see "Brand resolution" below.
|
|
18
20
|
|
|
19
21
|
Three legal action outcomes:
|
|
20
22
|
|
|
@@ -56,36 +58,72 @@ The Loop CRM record format you receive (either piped in by the brief or fetched
|
|
|
56
58
|
|
|
57
59
|
### Zip-site / hosted-page directory → Listing
|
|
58
60
|
|
|
59
|
-
When a property site has been published from operator-supplied assets, the directory follows the format produced by `property-brochure:property-extract`:
|
|
61
|
+
When a property site has been published from operator-supplied assets, the directory lives at `<accountDir>/sites/<slug>/` and follows the format produced by `property-brochure:property-extract`:
|
|
60
62
|
|
|
61
63
|
```
|
|
62
|
-
<
|
|
63
|
-
property.json
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
<accountDir>/sites/<slug>/
|
|
65
|
+
property.json # structured fields
|
|
66
|
+
description.md # full descriptive copy
|
|
67
|
+
index.html # the published page
|
|
68
|
+
images/
|
|
69
|
+
<brand>-logo-light.png # operator-uploaded brand assets — natural key for brand
|
|
70
|
+
<brand>-logo-dark.png
|
|
71
|
+
<photos…>.{jpg|jpeg|png|webp}
|
|
72
|
+
<floorplan…>.{jpg|png}
|
|
73
|
+
epc.{jpg|png}
|
|
69
74
|
```
|
|
70
75
|
|
|
71
|
-
|
|
76
|
+
The curator runs on the same Pi as the assets — image bytes are read with `Read` from the local file path, never fetched over HTTPS. Round-tripping through `https://realagent.chat/sites/<slug>/images/…` is wasted I/O and adds network failure modes the local read does not have.
|
|
77
|
+
|
|
78
|
+
#### Brand resolution
|
|
79
|
+
|
|
80
|
+
The brand `:Organization` parent is resolved from the `<brand>-logo-*.png` filenames the operator uploaded with the site. This is the highest-signal brand identifier — it is operator-confirmed metadata baked into the publish step, not an address-token heuristic.
|
|
81
|
+
|
|
82
|
+
1. `Glob` `<accountDir>/sites/<slug>/images/*-logo-*.{png,svg}` (also `Bash` `ls -1 <accountDir>/sites/<slug>/images/*-logo-*` when Glob is unavailable).
|
|
83
|
+
2. For each filename matching `^([a-z0-9-]+)-logo-(dark|light)\.(png|svg)$`, capture group 1 is the brand slug.
|
|
84
|
+
3. Reduce to the distinct set of brand slugs:
|
|
85
|
+
- exactly one → that is the brand
|
|
86
|
+
- zero → emit `no-op` with `reason=brand-unresolved` and return to the admin agent
|
|
87
|
+
- more than one → emit `no-op` with `reason=brand-ambiguous brands=<comma-separated>` and return to the admin agent
|
|
88
|
+
|
|
89
|
+
The resolved brand slug is the natural key for `MERGE (o:Organization {accountId, brandSlug})`. Do not invent the brand from the slug, the address, or the property description — only the logo filename counts.
|
|
90
|
+
|
|
91
|
+
#### pageUrl and image URLs
|
|
92
|
+
|
|
93
|
+
The canonical hosted path is `https://<publicHost>/sites/<slug>/index.html`. The `/sites/` segment is non-negotiable — it mirrors the on-disk publish target `<accountDir>/sites/<slug>/`. URLs without it return HTTP 404.
|
|
94
|
+
|
|
95
|
+
`publicHost` is `realagent.chat` for Real Agent today; admin sessions on other brands resolve it via the active host. The curator emits `https://realagent.chat/sites/<slug>/index.html` for Real Agent; the operator overrides the host if pointing at a staging surface, but the path shape stays the same.
|
|
96
|
+
|
|
97
|
+
`imageUrls[i]` is composed as `https://<publicHost>/sites/<slug>/images/<filename>`. The list is built from `Glob` `<accountDir>/sites/<slug>/images/*.{jpg,jpeg,png,webp}` ordered by filename, with brand-logo files (`*-logo-*.{png,svg}`) excluded — they are agency branding, not listing photos. `heroImageUrl` is the first non-logo image after that ordering.
|
|
98
|
+
|
|
99
|
+
#### Mapping table
|
|
72
100
|
|
|
73
101
|
| Listing field | Source |
|
|
74
102
|
|---|---|
|
|
75
103
|
| `slug` | the directory basename |
|
|
76
104
|
| `displayName`, `addressLine`, `town`, `postcode`, `priceGBP` (`→ price`), `priceQualifier`, `bedrooms`, `bathrooms`, `receptionRooms`, `floorAreaSqft`, `propertyType`, `tenure` | `property.json` keys with the same names (Schema.org camelCase already enforced upstream) |
|
|
77
|
-
| `description` | `description.md` body |
|
|
78
|
-
| `
|
|
79
|
-
| `imageUrls` |
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
105
|
+
| `description` | `description.md` body (`Read` the file) |
|
|
106
|
+
| `pageUrl` | `https://<publicHost>/sites/<slug>/index.html` — composed by the curator from `slug` and the resolved host |
|
|
107
|
+
| `imageUrls` | URLs of `images/*.{jpg,jpeg,png,webp}` minus brand logos, ordered by filename, prefixed with `https://<publicHost>/sites/<slug>/images/` |
|
|
108
|
+
| `heroImageUrl` | `imageUrls[0]` |
|
|
109
|
+
| `floorplanUrls` | composed the same way for any image matching `*floorplan*.{jpg,png}` (case-insensitive) under `images/` |
|
|
110
|
+
| `epcUrl` | composed for any `epc.{jpg,png,pdf}` under `images/` or the site root |
|
|
83
111
|
| `sourceSystem` | literal `"zip-upload"` |
|
|
84
|
-
| `sourceId` | the directory basename (the slug) —
|
|
112
|
+
| `sourceId` | the directory basename (the slug) — operator-chosen natural key |
|
|
85
113
|
| `blurb` | same LLM rule as the Loop path; same fallback |
|
|
86
114
|
| `status` | from `property.json#status`; default `for-sale` |
|
|
87
115
|
| `scope` | `"public"` |
|
|
88
116
|
|
|
117
|
+
#### Postcode cross-check
|
|
118
|
+
|
|
119
|
+
After mapping, parse the postcode from `property.json#postcode` (or, when absent, the `displayName` tail via the trailing UK postcode regex `[A-Z]{1,2}[0-9][A-Z0-9]? ?[0-9][A-Z]{2}`). If the slug appears to encode address tokens that disagree with the postcode (e.g. London IG8 in slug, Leicestershire postcode in `property.json`), emit a warning log:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
[listing-curator] slug-postcode-mismatch slug=<slug> postcode=<postcode>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The write proceeds — the directory basename is always trusted as the natural key. The warning surfaces operator typos at grep time without blocking ingest.
|
|
126
|
+
|
|
89
127
|
### Manual operator dump → Listing
|
|
90
128
|
|
|
91
129
|
Format: operator pastes a property brief into chat. The curator parses with the same rules as the Loop path (key-value FACTS-style extraction), then asks the operator for any missing required field before writing. `sourceSystem: "manual"`; `sourceId` is whatever natural key the operator gives — they confirm uniqueness.
|
|
@@ -96,7 +134,8 @@ Write a `no-op` (no Listing produced; admin agent informed) when:
|
|
|
96
134
|
|
|
97
135
|
- The source has no FACTS block AND no parseable address — there is no `slug` to derive and no `displayName`. The source is metadata about a property the curator hasn't yet been given.
|
|
98
136
|
- The source declares `status: "off-market"` AND no `pageUrl` is known. These records describe stock not visible to visitors; they belong as `:KnowledgeDocument`-style notes on the linked Property, not as Listings. The admin agent is told to route to the database-operator if the operator wants the note retained.
|
|
99
|
-
- The
|
|
137
|
+
- The zip-site brand cannot be resolved from `<accountDir>/sites/<slug>/images/*-logo-*.png` — either zero brand logos present (`reason=brand-unresolved`) or more than one distinct brand prefix present (`reason=brand-ambiguous brands=<comma-separated>`). Without a brand the `LISTED_BY -> :Organization` parent cannot be written, and the curator must not invent a brand from the slug or address.
|
|
138
|
+
- The source's `accountId` cannot be resolved from the session env. Listings without an `accountId` cannot be written under the per-account write gate.
|
|
100
139
|
|
|
101
140
|
`no-op` is loud — the curator returns a one-sentence explanation of which field was missing. Silent skips are defects.
|
|
102
141
|
|
|
@@ -104,10 +143,12 @@ Write a `no-op` (no Listing produced; admin agent informed) when:
|
|
|
104
143
|
|
|
105
144
|
After writing the Listing, the curator writes one `:ImageObject` node per URL in `imageUrls` and a `[:DEPICTS]` edge from the Listing to each image. ImageObjects are what the public-agent recommender skill picks among when a visitor asks to see a specific room or feature — without them, the agent can only emit the hero image. The schema contract for `:ImageObject` lives in `schema-estate-agent.md`.
|
|
106
145
|
|
|
107
|
-
For
|
|
146
|
+
Image bytes are read with `Read` from the local file. Zip-site assets live at `<accountDir>/sites/<slug>/images/<filename>`; source-backed listings additionally have `<accountDir>/<source_dir>/images/<filename>`. The curator runs on the same Pi as the assets — never fetch via HTTPS. (For Loop CRM listings whose images are CDN-hosted, `Read` is not available; in that branch tag with an empty description and an empty tag set, and rely on the `description` field for room context.)
|
|
108
147
|
|
|
109
|
-
|
|
110
|
-
|
|
148
|
+
For each image on the Listing:
|
|
149
|
+
|
|
150
|
+
1. **Vision tag pass.** `Read` the local image file at the path derived from the URL (zip-site: `<accountDir>/sites/<slug>/images/<filename>` derived from the `https://<publicHost>/sites/<slug>/images/<filename>` URL). `Read` returns the bytes as an inline content block your model can see. Produce `{description, tags}` from what you see using the same single-call doctrine as the blurb step. The description is a single sentence (≤ 200 characters, plain English, no estate-agent clichés). The tags are up to four labels drawn from this fixed set: `kitchen`, `bathroom`, `bedroom-master`, `bedroom-secondary`, `living-room`, `dining-room`, `garden-rear`, `garden-front`, `exterior-front`, `exterior-rear`, `hallway`, `utility`, `floorplan`, `epc`, `view`. The tag set is closed — never invent a tag outside it. When no tag applies, `tags` is the empty array.
|
|
151
|
+
2. **Validate.** If `Read` fails (file missing), or the JSON fails to parse, or `description` is empty / >200 chars, write the ImageObject anyway with `description: ""` and `tags: []`. Log `[listing-curator] image-tag-failed url=<url> reason=<read-error|json-parse|description-bounds>`. One image's tagging failure must not block the rest of the Listing's images — degraded room-pick on that image is the only consequence.
|
|
111
152
|
3. **Write the node.** `MERGE (i:ImageObject {accountId, listingSlug, url})` then `SET i.description, i.tags, i.heroIndex, i.scope, i.sourceSystem`. `scope` mirrors the parent Listing (`public` for visitor-renderable Listings; `shared` otherwise). `heroIndex` is the image's 0-based position in the source `imageUrls` array — `0` for the hero.
|
|
112
153
|
4. **Write the edge.** `MERGE (l:Listing {accountId, slug:listingSlug})-[:DEPICTS]->(i)`. The MERGE on the natural key `(accountId, listingSlug, url)` makes both the node write and the edge write idempotent; re-running the curator over a Listing tops up missing images without duplicating existing ones.
|
|
113
154
|
|
|
@@ -138,15 +179,22 @@ The migration cypher is run once via SSH against the Pi as a manual backfill ste
|
|
|
138
179
|
One structured log line per write, emitted by the curator before returning to the admin agent:
|
|
139
180
|
|
|
140
181
|
```
|
|
141
|
-
[listing-curator] event=property-curated slug=<slug> sourceSystem=<system> sourceId=<id> action=<create|update|no-op> fieldsFilled=<comma-separated-list> fieldsMissing=<comma-separated-list> scope=<scope> imagesWritten=<N> imagesTagged=<M> imagesFailed=<F>
|
|
182
|
+
[listing-curator] event=property-curated slug=<slug> sourceSystem=<system> sourceId=<id> action=<create|update|no-op> parentLabel=<Organization|—> parentBrandSlug=<brand|—> pageUrl=<url|—> fieldsFilled=<comma-separated-list> fieldsMissing=<comma-separated-list> scope=<scope> imagesWritten=<N> imagesTagged=<M> imagesFailed=<F>
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
For a `no-op` outcome that didn't even reach the write step (brand-unresolved, brand-ambiguous), emit the same line shape with `action=no-op`, `parentLabel=—`, `parentBrandSlug=—`, `imagesWritten=0`, and an additional `reason=` field naming the rejection:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
[listing-curator] event=property-curated slug=<slug> action=no-op reason=<brand-ambiguous|brand-unresolved|…> brands=<csv-when-applicable>
|
|
142
189
|
```
|
|
143
190
|
|
|
144
|
-
`fieldsMissing` is the load-bearing signal. Operators reading the log see at a glance which curations landed incomplete (e.g. `epcUrl` missing on a Loop record means the EPC asset wasn't uploaded to Loop yet; `pageUrl` missing on a zip-site Listing means
|
|
191
|
+
`fieldsMissing` is the load-bearing signal on writes. Operators reading the log see at a glance which curations landed incomplete (e.g. `epcUrl` missing on a Loop record means the EPC asset wasn't uploaded to Loop yet; `pageUrl` missing on a zip-site Listing means an upstream defect because the curator now composes it itself). Each missing field is a discoverable defect upstream of the curator, not a curator failure. `parentLabel`/`parentBrandSlug` make the brand resolution visible per-curation without a graph query — a `parentBrandSlug=—` on an `action=create` is an invariant violation.
|
|
145
192
|
|
|
146
|
-
`imagesWritten` is the total `:ImageObject` count for the Listing after the run. `imagesTagged` is the count with non-empty `tags`. `imagesFailed` counts images where the
|
|
193
|
+
`imagesWritten` is the total `:ImageObject` count for the Listing after the run. `imagesTagged` is the count with non-empty `tags`. `imagesFailed` counts images where the local `Read` or the JSON-validation failed and a placeholder ImageObject was written with empty description and tags. A high `imagesFailed / imagesWritten` ratio on zip-site Listings means images are missing from `<accountDir>/sites/<slug>/images/` — investigate the publish step, not the vision prompt. When `imagesWritten > 20`, the recommender will only see the first 20 in its `related` set (one-hop expand cap) — emit a separate `[listing-curator] image-cap-warning slug=<slug> imageCount=<N>` line so the cap is visible.
|
|
147
194
|
|
|
148
195
|
## Out of scope
|
|
149
196
|
|
|
150
197
|
- Producing the visitor-facing markdown card from a `:Listing`. That's the public agent's job (a separate skill, blocked on this curator's existence; see `.tasks/`).
|
|
151
198
|
- Inferring missing market-data fields (sold-price comps, demand) — that's the `property-data` MCP, not curator.
|
|
152
199
|
- Writing Property structural fields when the source only carries listing data — Properties are created by the curator only when the source actually provides `tenure`, `epcRating`, etc.; otherwise the Listing stands on its own with the `LISTED_BY` parent edge.
|
|
200
|
+
- An operator-built `tmp/site-listing-manifest.json` or any other pre-resolved JSON of slug → brand → URL mappings. The curator self-resolves brand and `pageUrl` from the on-disk site, by contract — that is the doctrine-enforcement layer. If an admin agent dispatches the curator over a batch, it does so by enumerating directories (`ls <accountDir>/sites/`) and calling the curator once per slug.
|
package/payload/server/server.js
CHANGED
|
@@ -4441,6 +4441,10 @@ function defaultResult(rawText, latencyMs) {
|
|
|
4441
4441
|
language: "en",
|
|
4442
4442
|
requiresHistory: false,
|
|
4443
4443
|
complexity: "complex",
|
|
4444
|
+
// Fallthrough defaults to "general" so the brain-first loop still runs a
|
|
4445
|
+
// graph read — safer than "none", which would skip retrieval entirely on
|
|
4446
|
+
// every Haiku failure.
|
|
4447
|
+
retrievalClass: "general",
|
|
4444
4448
|
latencyMs,
|
|
4445
4449
|
fallthrough: true
|
|
4446
4450
|
};
|
|
@@ -4476,7 +4480,14 @@ LANGUAGE \u2014 detect the language and return its ISO 639-1 code (e.g. "en", "e
|
|
|
4476
4480
|
|
|
4477
4481
|
REQUIRES HISTORY \u2014 set to true if the message references prior conversation context ("we discussed", "last time", "that thing", "the proposal", "earlier")
|
|
4478
4482
|
|
|
4479
|
-
COMPLEXITY \u2014 "simple" for greetings, yes/no answers, brief confirmations; "complex" for multi-part requests, detailed questions, nuanced instructions
|
|
4483
|
+
COMPLEXITY \u2014 "simple" for greetings, yes/no answers, brief confirmations; "complex" for multi-part requests, detailed questions, nuanced instructions
|
|
4484
|
+
|
|
4485
|
+
RETRIEVAL CLASS \u2014 classify what kind of graph read this message warrants (brain-first lookup loop). One of:
|
|
4486
|
+
- "entity": names or asks about a specific person, organisation, place, listing, project, document \u2014 anything the graph might already know about ("who is Adam Langley", "tell me about Acme Ltd", "what do we have on 5 Glan Honddu Close")
|
|
4487
|
+
- "temporal": references a time-bound fact \u2014 past events, schedule, what happened when ("what did we agree last Tuesday", "what's on today", "when did the contract land")
|
|
4488
|
+
- "event": references something a tool returned earlier in this or a prior session ("did the upload finish", "what did the search find", "the result from earlier")
|
|
4489
|
+
- "general": a question or task that needs information from the graph but doesn't fall into the three categories above (open-ended ask, broad topic, free-form question)
|
|
4490
|
+
- "none": greeting, meta-instruction, complaint, acknowledgement, or any message that does not warrant a graph lookup ("hi", "thanks", "stop doing that", "this is broken")`;
|
|
4480
4491
|
}
|
|
4481
4492
|
var GATEWAY_TOOL = {
|
|
4482
4493
|
name: "classify_message",
|
|
@@ -4522,6 +4533,11 @@ var GATEWAY_TOOL = {
|
|
|
4522
4533
|
type: "string",
|
|
4523
4534
|
enum: ["simple", "complex"],
|
|
4524
4535
|
description: "Message complexity level"
|
|
4536
|
+
},
|
|
4537
|
+
retrievalClass: {
|
|
4538
|
+
type: "string",
|
|
4539
|
+
enum: ["entity", "temporal", "event", "general", "none"],
|
|
4540
|
+
description: "Brain-first retrieval class. entity=named entity lookup, temporal=time-bound fact, event=prior tool result reference, general=other informational, none=greeting/meta/complaint with no graph lookup warranted"
|
|
4525
4541
|
}
|
|
4526
4542
|
},
|
|
4527
4543
|
required: [
|
|
@@ -4532,7 +4548,8 @@ var GATEWAY_TOOL = {
|
|
|
4532
4548
|
"intent",
|
|
4533
4549
|
"language",
|
|
4534
4550
|
"requiresHistory",
|
|
4535
|
-
"complexity"
|
|
4551
|
+
"complexity",
|
|
4552
|
+
"retrievalClass"
|
|
4536
4553
|
]
|
|
4537
4554
|
}
|
|
4538
4555
|
};
|
|
@@ -4603,6 +4620,7 @@ async function processInbound(rawText, channel) {
|
|
|
4603
4620
|
const requiresHistory = input.requiresHistory === true;
|
|
4604
4621
|
const complexity = input.complexity === "simple" ? "simple" : "complex";
|
|
4605
4622
|
const searchQuery = typeof input.searchQuery === "string" ? input.searchQuery : null;
|
|
4623
|
+
const retrievalClass = ["entity", "temporal", "event", "general", "none"].includes(input.retrievalClass) ? input.retrievalClass : "general";
|
|
4606
4624
|
const isAdmin = channel === "web-admin" || channel === "telegram-admin";
|
|
4607
4625
|
const finalText = isAdmin ? rawText.trim() : processedText;
|
|
4608
4626
|
const result = {
|
|
@@ -4613,11 +4631,12 @@ async function processInbound(rawText, channel) {
|
|
|
4613
4631
|
language,
|
|
4614
4632
|
requiresHistory,
|
|
4615
4633
|
complexity,
|
|
4634
|
+
retrievalClass,
|
|
4616
4635
|
latencyMs,
|
|
4617
4636
|
fallthrough: false
|
|
4618
4637
|
};
|
|
4619
4638
|
console.log(
|
|
4620
|
-
`${TAG14} channel=${channel} verdict=${verdict} promptInjection=${promptInjectionRisk} intent=${intent} language=${language} complexity=${complexity} requiresHistory=${requiresHistory} rewrite=${!isAdmin && processedText !== rawText.trim()} searchQuery=${searchQuery ? "yes" : "null"} latency_ms=${latencyMs}`
|
|
4639
|
+
`${TAG14} channel=${channel} verdict=${verdict} promptInjection=${promptInjectionRisk} intent=${intent} language=${language} complexity=${complexity} retrievalClass=${retrievalClass} requiresHistory=${requiresHistory} rewrite=${!isAdmin && processedText !== rawText.trim()} searchQuery=${searchQuery ? "yes" : "null"} latency_ms=${latencyMs}`
|
|
4621
4640
|
);
|
|
4622
4641
|
if (verdict !== "clean") {
|
|
4623
4642
|
console.warn(
|
|
@@ -5078,6 +5097,9 @@ ${lines}]`;
|
|
|
5078
5097
|
let gatewayResult = null;
|
|
5079
5098
|
if (!isGreeting) {
|
|
5080
5099
|
gatewayResult = await processInbound(message, "web-public");
|
|
5100
|
+
console.log(
|
|
5101
|
+
`[chat-route] session=${session_key.slice(0, 8)} retrievalClass=${gatewayResult.retrievalClass} intent=${gatewayResult.intent}`
|
|
5102
|
+
);
|
|
5081
5103
|
if (gatewayResult.screening.verdict === "discard") {
|
|
5082
5104
|
console.log(
|
|
5083
5105
|
`[chat-route] session=${session_key.slice(0, 8)} gateway=discard reason=${gatewayResult.screening.reason}`
|
|
@@ -6480,9 +6502,17 @@ function readUsersFile() {
|
|
|
6480
6502
|
if (!raw) return [];
|
|
6481
6503
|
return JSON.parse(raw);
|
|
6482
6504
|
}
|
|
6505
|
+
function claudeSpawnPath() {
|
|
6506
|
+
const base = process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
6507
|
+
return `${base}:/opt/homebrew/bin:/usr/local/bin`;
|
|
6508
|
+
}
|
|
6483
6509
|
function checkAuthStatus() {
|
|
6484
6510
|
try {
|
|
6485
|
-
execFileSync2("claude", ["auth", "status"], {
|
|
6511
|
+
execFileSync2("claude", ["auth", "status"], {
|
|
6512
|
+
encoding: "utf-8",
|
|
6513
|
+
timeout: 5e3,
|
|
6514
|
+
env: { ...process.env, PATH: claudeSpawnPath() }
|
|
6515
|
+
});
|
|
6486
6516
|
return true;
|
|
6487
6517
|
} catch {
|
|
6488
6518
|
return false;
|
|
@@ -6537,22 +6567,34 @@ app4.post("/claude-auth", async (c) => {
|
|
|
6537
6567
|
return c.json({ launched: cdpReady });
|
|
6538
6568
|
}
|
|
6539
6569
|
ensureLogDir();
|
|
6570
|
+
writeFileSync6(logPath("claude-auth"), "");
|
|
6571
|
+
const claudeAuthLogFd = openSync(logPath("claude-auth"), "a");
|
|
6572
|
+
const onClaudeOutput = (chunk) => writeSync(claudeAuthLogFd, chunk);
|
|
6573
|
+
if (process.platform === "darwin") {
|
|
6574
|
+
vncLog("claude-auth", { action: "start", transport: "native", platform: "darwin" });
|
|
6575
|
+
const claudeProc2 = spawn("claude", ["auth", "login"], {
|
|
6576
|
+
env: { ...process.env, PATH: claudeSpawnPath() },
|
|
6577
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
6578
|
+
});
|
|
6579
|
+
claudeProc2.unref();
|
|
6580
|
+
claudeProc2.stdout?.on("data", onClaudeOutput);
|
|
6581
|
+
claudeProc2.stderr?.on("data", onClaudeOutput);
|
|
6582
|
+
claudeProc2.once("close", () => closeSync(claudeAuthLogFd));
|
|
6583
|
+
return c.json({ started: true, transport: "native" });
|
|
6584
|
+
}
|
|
6540
6585
|
if (transport === "vnc") {
|
|
6541
6586
|
const vncReady = await ensureVnc();
|
|
6542
6587
|
if (!vncReady) return c.json({ error: "VNC display failed to start" }, 500);
|
|
6543
6588
|
}
|
|
6544
6589
|
await ensureCdp(transport);
|
|
6545
|
-
writeFileSync6(logPath("claude-auth"), "");
|
|
6546
6590
|
const chromiumWrapper = writeChromiumWrapper();
|
|
6547
6591
|
const x11Env = buildX11Env(chromiumWrapper, transport);
|
|
6548
6592
|
vncLog("claude-auth", { action: "start", transport });
|
|
6549
|
-
const claudeAuthLogFd = openSync(logPath("claude-auth"), "a");
|
|
6550
6593
|
const claudeProc = spawn("claude", ["auth", "login"], {
|
|
6551
6594
|
env: x11Env,
|
|
6552
6595
|
stdio: ["ignore", "pipe", "pipe"]
|
|
6553
6596
|
});
|
|
6554
6597
|
claudeProc.unref();
|
|
6555
|
-
const onClaudeOutput = (chunk) => writeSync(claudeAuthLogFd, chunk);
|
|
6556
6598
|
claudeProc.stdout?.on("data", onClaudeOutput);
|
|
6557
6599
|
claudeProc.stderr?.on("data", onClaudeOutput);
|
|
6558
6600
|
claudeProc.once("close", () => closeSync(claudeAuthLogFd));
|
|
@@ -8537,7 +8579,12 @@ async function performSpawnWithInitialMessage(args) {
|
|
|
8537
8579
|
dormantPlugins,
|
|
8538
8580
|
activePlugins,
|
|
8539
8581
|
specialistDomains,
|
|
8540
|
-
tunnelUrl
|
|
8582
|
+
tunnelUrl,
|
|
8583
|
+
// Task 382 — thread the active conversation's UUID through to the
|
|
8584
|
+
// manager so the PTY env carries CONVERSATION_NODE_ID. Undefined is
|
|
8585
|
+
// legitimate (autonomous spawns); the manager's UUID gate filters
|
|
8586
|
+
// unshapely values.
|
|
8587
|
+
conversationNodeId: args.conversationNodeId
|
|
8541
8588
|
});
|
|
8542
8589
|
console.log(`${TAG18} forward-spawn-start managerBase=${managerBase2()} bytes=${upstreamPayload.length} hidden=${args.hidden} specialist=${args.specialist ?? "none"}`);
|
|
8543
8590
|
const forwardStart = Date.now();
|
|
@@ -8632,6 +8679,7 @@ app15.post("/", async (c) => {
|
|
|
8632
8679
|
const specialist = typeof body.specialist === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(body.specialist) ? body.specialist : void 0;
|
|
8633
8680
|
const model = typeof body.model === "string" && /^[A-Za-z0-9._-]{1,64}$/.test(body.model) ? body.model : void 0;
|
|
8634
8681
|
console.log(`${TAG18} spawn-request-in surface=${authSurface} accountId=${senderId.slice(0, 8)} userId=${userId ? userId.slice(0, 8) : "absent"} channel=${channel} permissionMode=${permissionMode ?? "default"} specialist=${specialist ?? "none"} model=${model ?? "default"} initialMessage=${initialMessage ? "yes" : "no"}`);
|
|
8682
|
+
const conversationNodeId = cacheKey ? getConversationIdForSession(cacheKey) : void 0;
|
|
8635
8683
|
const { response, claudeSessionId } = await performSpawnWithInitialMessage({
|
|
8636
8684
|
senderId,
|
|
8637
8685
|
userId,
|
|
@@ -8641,7 +8689,8 @@ app15.post("/", async (c) => {
|
|
|
8641
8689
|
hidden: false,
|
|
8642
8690
|
specialist,
|
|
8643
8691
|
model,
|
|
8644
|
-
initialMessage
|
|
8692
|
+
initialMessage,
|
|
8693
|
+
conversationNodeId
|
|
8645
8694
|
});
|
|
8646
8695
|
mintTranscriptEdge(
|
|
8647
8696
|
cacheKey ? getConversationIdForSession(cacheKey) : void 0,
|
|
@@ -8719,7 +8768,13 @@ app15.post("/resume", async (c) => {
|
|
|
8719
8768
|
dormantPlugins,
|
|
8720
8769
|
activePlugins,
|
|
8721
8770
|
specialistDomains,
|
|
8722
|
-
tunnelUrl
|
|
8771
|
+
tunnelUrl,
|
|
8772
|
+
// Task 382 — resume parity with /spawn: thread the active
|
|
8773
|
+
// conversationId so the re-spawned PTY's MCP env carries
|
|
8774
|
+
// CONVERSATION_NODE_ID. Undefined when no active conversation is
|
|
8775
|
+
// bound to this cacheKey (rare for resume — the user is by
|
|
8776
|
+
// definition continuing a known conversation).
|
|
8777
|
+
conversationNodeId: getConversationIdForSession(cacheKey)
|
|
8723
8778
|
})
|
|
8724
8779
|
}).catch((err) => {
|
|
8725
8780
|
console.error(`${TAG18} fetch-failed op=resume message=${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -12133,7 +12188,11 @@ app32.post("/", requireAdminSession, async (c) => {
|
|
|
12133
12188
|
hidden: true,
|
|
12134
12189
|
specialist: "database-operator",
|
|
12135
12190
|
model: void 0,
|
|
12136
|
-
initialMessage
|
|
12191
|
+
initialMessage,
|
|
12192
|
+
// Task 382 — LinkedIn ingest is a hidden autonomous spawn with no
|
|
12193
|
+
// parent admin conversation; the database-operator threads
|
|
12194
|
+
// `producedByTaskId` via work-create instead. Leave undefined.
|
|
12195
|
+
conversationNodeId: void 0
|
|
12137
12196
|
});
|
|
12138
12197
|
if (!response.ok) {
|
|
12139
12198
|
const detail = await response.text().catch(() => "");
|