@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.
Files changed (36) hide show
  1. package/dist/index.js +10 -7
  2. package/package.json +1 -1
  3. package/payload/platform/lib/graph-write/dist/conversation-provenance.d.ts +5 -1
  4. package/payload/platform/lib/graph-write/dist/conversation-provenance.d.ts.map +1 -1
  5. package/payload/platform/lib/graph-write/dist/conversation-provenance.js +18 -11
  6. package/payload/platform/lib/graph-write/dist/conversation-provenance.js.map +1 -1
  7. package/payload/platform/lib/graph-write/src/conversation-provenance.ts +24 -16
  8. package/payload/platform/plugins/contacts/PLUGIN.md +1 -1
  9. package/payload/platform/plugins/docs/references/internals.md +1 -1
  10. package/payload/platform/plugins/docs/references/memory-guide.md +4 -0
  11. package/payload/platform/plugins/graph-viewer/mcp/dist/index.js +2 -6
  12. package/payload/platform/plugins/graph-viewer/mcp/dist/index.js.map +1 -1
  13. package/payload/platform/plugins/graph-viewer/mcp/dist/render/layout.d.ts.map +1 -1
  14. package/payload/platform/plugins/graph-viewer/mcp/dist/render/layout.js +46 -6
  15. package/payload/platform/plugins/graph-viewer/mcp/dist/render/layout.js.map +1 -1
  16. package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.d.ts +1 -1
  17. package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.d.ts.map +1 -1
  18. package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js +28 -2
  19. package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js.map +1 -1
  20. package/payload/platform/plugins/graph-viewer/skills/render-graph/SKILL.md +6 -3
  21. package/payload/platform/plugins/memory/PLUGIN.md +1 -1
  22. package/payload/platform/plugins/memory/references/schema-estate-agent.md +3 -3
  23. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  24. package/payload/platform/services/claude-session-manager/dist/http-server.js +16 -0
  25. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  26. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +11 -0
  27. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  28. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +11 -0
  29. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  30. package/payload/platform/templates/agents/admin/IDENTITY.md +12 -0
  31. package/payload/platform/templates/agents/public/IDENTITY.md +8 -2
  32. package/payload/platform/templates/specialists/agents/content-producer.md +4 -0
  33. package/payload/platform/templates/specialists/agents/database-operator.md +2 -0
  34. package/payload/platform/templates/specialists/agents/personal-assistant.md +2 -0
  35. package/payload/premium-plugins/real-agent/agents/listing-curator.md +73 -25
  36. 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`, `memory-read`, or `profile-read` — before answering factual questions or embarking on activity. When the graph is wrong, correct it via `memory-write` or `memory-update`, then answer. 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. *Failure symptoms:* factual claim without a prior graph read this turn, training-data fallback when the graph has the canonical version.
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
- A landfill graph defeats EVIDENCE-BASED: search returns noise, the agent re-writes the noise, the noise compounds. Compress on write; filter on read.
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, mcp__memory__image-fetch, 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
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]->(:LocalBusiness)` naming the estate agency; when a structural `:Property` node is known, additionally `(:Listing)-[:FOR_PROPERTY]->(:Property)`.
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
- <property-slug>/
63
- property.json # structured fields
64
- hero.jpg | hero.png # primary image
65
- photos/ # full image set
66
- floorplans/ # floorplan images
67
- epc.pdf | epc.png # EPC document
68
- description.md # full descriptive copy
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
- Map directly:
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
- | `heroImageUrl` | the published URL of `hero.jpg` (passed in by the hosting agent, never invented) |
79
- | `imageUrls` | published URLs of `photos/*` in directory order `heroImageUrl` is included at index 0 |
80
- | `floorplanUrls` | published URLs of `floorplans/*` |
81
- | `epcUrl` | published URL of `epc.pdf` |
82
- | `pageUrl` | the live URL the hosting agent emits for `<property-slug>/index.html` |
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) — directory basename is the operator-chosen natural key |
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 source's `accountId` cannot be resolved (no LocalBusiness in session env). Listings without a `LISTED_BY` parent cannot be written.
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 each image URL on the Listing:
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
- 1. **Vision tag pass.** Call `mcp__memory__image-fetch` with the image URL; the tool returns the image bytes as an inline content block your model can see. Then 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. When `image-fetch` returns `isError`, write the ImageObject anyway with empty `description` and `tags` and log `[listing-curator] image-tag-failed url=<url> reason=<short>` — the validate step below handles that branch.
110
- 2. **Validate.** If the response fails to parse as JSON, or `description` is empty / >200 chars, write the ImageObject anyway with `description: ""` and `tags: []`. Log `[listing-curator] image-tag-failed url=<url> reason=<short>`. 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.
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 the hosting agent didn't pass the published URL through to the curator). Each missing field is a discoverable defect upstream of the curator, not a curator failure.
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 vision pass failed JSON-validation and a placeholder ImageObject was written with empty description and tags. A high `imagesFailed / imagesWritten` ratio means the vision pass is degrading; investigate the prompt or the source images. 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.
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.
@@ -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"], { encoding: "utf-8", timeout: 5e3 });
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(() => "");