Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Changelog

### Batch: entity-resolver-relationship-stats-543 (Issue #543)

#### Added
- **Pronouns + relationship stats in turn-context entity injection** (nova-mind#543) — the turn-context entity injection (`memory/plugins/turn-context/src/entity-resolver.ts`'s `formatEntityContext()`) previously showed only a 7-key allowlist of facts. It now also renders:
- `entities.pronouns` in the `👤 **Talking with:**` header, e.g. `👤 **Talking with:** Tabatha Janell Wilson (she/her)`.
- An optional trust suffix from `entities.trust_level` (free-text, e.g. `— trust: friend`) — suppressed when the value is `'unknown'` (the column default) or NULL, since that conveys no information.
- A new `📊 Known contact:` stats line with unfiltered fact count, first-seen date (`entities.created_at`), and last message timestamp + `provider:external_chat_id` ref (from `channel_transcripts`/`channel_sessions`) — falling back to `entities.last_seen` when no transcript row exists.
- `relationships/lib/entity-resolver/resolver.ts`'s `getEntityProfile()` return type changed from a bare `EntityFacts` map to `EntityProfile` (`{ facts, stats }`) — a breaking signature change for any direct consumer. `resolver.ts` also gains a shared `mapDbEntity()` helper so `pronouns`/`trust_level`/`last_seen`/`created_at` ride on the existing cheap identifier-resolution query (`resolveEntity()`/`resolveEntityByIdentifiers()`) rather than a second round trip — this means pronouns/trust/last-seen survive a `getEntityProfile()` stats-query timeout, since they're already on the resolved `Entity` before the stats race even begins.
- The stats query itself (unfiltered fact count + most recent transcript, via a single aggregate query with two `LEFT JOIN LATERAL`s) stays inside the existing 1s `Promise.race` timeout in `resolveEntityContext()`; on timeout or error it degrades to `{ facts: {}, stats: { factCount: 0, lastMessage: null } }`, matching the library's existing fail-closed contract.
- Honorific guard path (`resolveEntityForGuard()`) is unchanged — it never triggers the stats query.
- `database/schema.sql`'s `entities.trust_level` column comment updated from an enum-style description to reflect that it is free-text, not enforced by a CHECK constraint.

#### Fixed
- **`entities.last_seen` timezone-shift bug** (nova-mind#543) — `resolveEntity()`/`resolveEntityByIdentifiers()` originally rendered the naive (no-tz) `entities.last_seen` timestamp with `AT TIME ZONE 'UTC'`, which Postgres interprets as "convert from the session's timezone to UTC" — silently shifting the displayed value under any non-UTC session (e.g. `America/Chicago`). Fixed to render directly via `to_char(e.last_seen, 'YYYY-MM-DD HH24:MI "UTC"')` with no timezone conversion, matching how `created_at` is already rendered. See the RS-062 regression test in `relationships/lib/entity-resolver/test.ts` (run under `SET timezone='America/Chicago'`).

#### Tests
- `memory/plugins/turn-context/src/entity-resolver.test.ts` (nova-mind#543) — RS-001 through RS-062: full formatting matrix for `formatEntityContext()` (pronouns, trust suffix incl. `'unknown'`/NULL suppression, stats-line permutations, zero-fact/new-contact rendering, group-channel cache non-contamination), plus stats-query timeout/error-degradation behavior.
- `relationships/lib/entity-resolver/test.ts` (nova-mind#543) — integration scaffold plus RS-062 (timezone regression under `SET timezone='America/Chicago'`).

#### Issues Closed
- #543 — Pronouns + relationship stats in turn-context entity injection

See `relationships/CHANGELOG.md` for the entity-resolver library's own changelog entry.

### Batch: pg-notify-alert-sender-508 (Issue #508)

#### Fixed
Expand Down
278 changes: 278 additions & 0 deletions TEST-DESIGN-543-entity-resolver-relationship-stats.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,7 @@ COMMENT ON COLUMN entities.collaborate IS 'If true, collaborate with this entity
COMMENT ON COLUMN entities.collaboration_scope IS 'full | domain-specific | supervised - determines collaboration breadth';


COMMENT ON COLUMN entities.trust_level IS 'Trust level for confidence scoring: owner, admin, user, unknown, untrusted';
COMMENT ON COLUMN entities.trust_level IS 'Free-text trust/relationship descriptor for the entity (e.g. owner, admin, friend). Values are not enforced by a CHECK constraint; consumers should treat unexpected values as valid arbitrary strings.';


COMMENT ON COLUMN entities.introduction_context IS 'How/why we connected with this entity, relationship context';
Expand Down
8 changes: 7 additions & 1 deletion memory/docs/semantic-recall.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,14 @@ Integrates with the entity-resolver library for **channel-aware** sender identif

Uses `resolveEntityByIdentifiers()` with **conflict detection** — if identifiers match different entities, entity injection is skipped to avoid incorrect context.

As of nova-mind#543, the injected block also renders pronouns, an optional trust suffix, and a
relationship-stats line when available — see `memory/plugins/turn-context/README.md`'s
[Entity Context Formatting](../plugins/turn-context/README.md#entity-context-formatting) section
for the full rendering rules:

```
👤 **Talking with:** I)ruid
👤 **Talking with:** I)ruid (he/him)
📊 Known contact: 214 facts · first seen 2025-02-08 · last message 2026-07-28 03:10 UTC (webchat:session-abc123)
• **Timezone:** America/Chicago
• **Communication Style:** Direct, technical
```
Expand Down
44 changes: 43 additions & 1 deletion memory/plugins/turn-context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ before_prompt_build:
|-----------|------|-------------|
| **Classifier** | `classifier.ts` | Classifies messages into `info_request`, `action`, `conversation`, `continuation`, `command`. Rule-based first pass (~60-70%), Ollama LLM fallback for ambiguous cases. |
| **Domain Identifier** | `domain-identifier.ts` | Matches messages to subject-matter domains via keyword matching + embedding similarity against `agent_domains` table. Returns top 1-3 domains with assigned agents (via JOIN, not hardcoded). Tolerates a missing `agent_domains.keywords` column on drifted schemas — see [Schema-Drift Tolerance](#schema-drift-tolerance-domain-identifier) below. |
| **Entity Resolver** | `entity-resolver.ts` | Resolves sender identity via the entity-resolver library. Cache keyed by `sessionKey:senderId` (not just sessionKey) to support group channels. Returns both formatted text and numeric `entityId`. Also exports `resolveEntityForGuard()`, a lightweight resolution (id + display name only, no facts lookup) used by the honorific guard when `entity_resolver` is gated off. |
| **Entity Resolver** | `entity-resolver.ts` | Resolves sender identity via the entity-resolver library. Cache keyed by `sessionKey:senderId` (not just sessionKey) to support group channels. Returns both formatted text and numeric `entityId`. Formatted text (`formatEntityContext()`) includes pronouns and an optional trust suffix in the `👤 **Talking with:**` header, plus an optional `📊 Known contact:` relationship-stats line — see [Entity Context Formatting](#entity-context-formatting) below (#543). Also exports `resolveEntityForGuard()`, a lightweight resolution (id + display name only, no facts lookup) used by the honorific guard when `entity_resolver` is gated off. |
| **Semantic Recall** | `semantic-recall.ts` | Spawns `proactive-recall.py` for memory retrieval. Supports tiered recall (domain-scoped first, full fallback) and visibility filtering (group channels → public facts only). |
| **Turn Reminders** | `turn-reminders.ts` | Queries `agent_turn_context` table for per-turn reminder text. **Always fires regardless of message type** — not gated by prompt_helper_config. |
| **Honorific Guard** | `honorific-guard.ts` | Deterministic (non-LLM) instruction appended after the base system prompt, enforcing the "Sir" honorific policy based on the resolved sender entity and the responding agent. **Always fires regardless of message type or `entity_resolver` gating** — see [Honorific Guard](#honorific-guard) below. |
Expand All @@ -45,6 +45,48 @@ Per-agent overrides: insert rows with `agent_name` set to override defaults for

On classifier failure, defaults to `info_request` (full pipeline) — safe fallback.

## Entity Context Formatting

`formatEntityContext()` (in `entity-resolver.ts`) is the pure function that renders the resolved
entity and its `getEntityProfile()` result into the injected `👤 **Talking with:**` block. As of
nova-mind#543 it renders up to three lines:

1. **Header (always present):** `👤 **Talking with:** {displayName}`, where `displayName` is
`entity.fullName || entity.name`.
- If `entity.pronouns` is set, it's appended in parentheses: `(she/her)`.
- If `entity.trustLevel` is set **and is not `'unknown'`**, a trust suffix is appended:
` — trust: {trustLevel}`. `'unknown'` is the `entities.trust_level` column default and is
suppressed as noise — every never-assessed entity would otherwise show `trust: unknown`.
`trustLevel` is free-text (see `database/schema.sql`'s column comment) — any non-empty,
non-`'unknown'` string renders as-is; there is no enforced value set.
2. **Relationship stats line (optional):** `📊 Known contact: {factCount} fact(s) · first seen
{createdAt} · last message {timestamp} ({ref})`. Rendered only when there is something
meaningful to show — i.e. `factCount > 0`, or `entity.createdAt`, or a resolved last message,
or `entity.lastSeen` is present. A lone `factCount: 0` with no other metadata (a genuinely new,
never-before-seen contact) suppresses the whole line rather than showing `0 facts` on its own.
- The `last message` segment comes from `profile.stats.lastMessage` (most recent
`channel_transcripts` row, via `getEntityProfile()`). If absent, falls back to
`entity.lastSeen` labeled `last seen {lastSeen}` instead — never both.
- Segments are joined with ` · ` and any missing segment (no last message/last seen, no
createdAt) is cleanly omitted rather than leaving a stray separator.
3. **Fact bullet list (unchanged from pre-#543 behavior):** one `• **Label:** value` line per
entry in `profile.facts` (the existing 7-key allowlist), rendered below the stats line.

### Data source and timeout interaction (#543)

`pronouns`, `trustLevel`, `lastSeen`, and `createdAt` are fetched as part of the **same,
already-cheap identifier-resolution query** used to find the entity in the first place
(`resolveEntityByIdentifiers()` in the entity-resolver library) — not as part of the heavier
`getEntityProfile()` stats/facts query. This means they are already on the `Entity` object before
`resolveEntityContext()` even starts the `getEntityProfile()` 1s `Promise.race`, so **pronouns and
trust survive a stats-query timeout**: on timeout, `profile` degrades to
`{ facts: {}, stats: { factCount: 0, lastMessage: null } }`, but the header line above it still
renders normally with pronouns/trust/lastSeen intact. Only the `📊 Known contact:` stats line and
the fact bullet list are affected by a stats-query timeout.

The honorific guard (`resolveEntityForGuard()`) never triggers the `getEntityProfile()` stats
query at all — it only needs entity id + display name.

## Honorific Guard

Deterministic, non-LLM guard that appends a short instruction to `appendSystemContext` (Step 2.6
Expand Down
Loading
Loading