Skip to content

feat: group-chat capture policy with a fail-closed classifier and opt-out knob#969

Draft
gorkem2020 wants to merge 6 commits into
CortexReach:masterfrom
gorkem2020:feat/group-chat-capture-policy
Draft

feat: group-chat capture policy with a fail-closed classifier and opt-out knob#969
gorkem2020 wants to merge 6 commits into
CortexReach:masterfrom
gorkem2020:feat/group-chat-capture-policy

Conversation

@gorkem2020

Copy link
Copy Markdown
Contributor

Problem

In multi-party rooms the host delivers every non-self participant as user-role content. Extraction has no way to attribute speakers, so peer-agent statements become "User believes X" rows, and with captureAssistant=true the misattribution surface widens further. Full group support needs per-sender speaker awareness; until that lands, group sessions need a conservative capture policy.

Change

Fail-closed group-chat policy, keyed on session-key grammar:

  • isDirectConversationSessionKey allowlists direct (1:1) conversations from the host's key builder: the dmScope-collapsed main key, dashboard/webchat sessions, and the explicit :direct: peer forms. Everything else (channels, groups, threads, topics, and any key shape never seen before) counts as a group chat, fail-closed.
  • Group chats force contextTurns=0 (no context tags, no retention) and captureAssistant=false (user-only grounding) regardless of config. Both are threaded per-call into the extractor, so the prompt teaching always matches the transcript actually built for that extraction.
  • New autoCaptureGroupChats opt-out knob: unset follows autoCapture, preserving existing behavior on install with no surprises; explicit false skips group-chat capture entirely. The schema deliberately declares no default so unset stays unset.

Tests

Classifier truth table (direct forms, group forms, unknown shapes failing closed), forced contextTurns=0 on group keys, forced user-only grounding under configured captureAssistant=true, unaffected direct sessions, knob-false skip, and knob-unset passthrough, all in test/pair-window-retention.test.mjs. Manifest checks pin the no-default contract.

Stacked on #968; the diff includes its predecessors until they merge.

…ant misattribution

With "User:"/"Assistant:" line prefixes only the first line of a message
carries a speaker marker, so a multi-paragraph assistant reply sheds its
marker after the first paragraph and the extractor attributes
assistant-authored plans and preferences to the user. Wrap every message
wholly in <user_message>/<assistant_message> blocks built from the hook's
role-tagged message-loop order, neutralize literal speaker tags typed inside
message content, and snap extractMaxChars truncation to a tag boundary so a
sliced transcript never leads with headless text.

The extraction prompt becomes a {system, user} split so the format teaching
and grounding rules ride the system half while the transcript rides the user
half: captureAssistant=false grounds memories exclusively in user blocks
(assistant lines never enter the transcript); captureAssistant=true keeps
assistant blocks as attributable sources with explicit attribution rules.
completeJson gains an optional per-call system prompt to carry the split.
Port the extraction lane's speaker-tagged conversation structure into the
reflection distiller input: session messages render as <user_message>/
<assistant_message> blocks instead of role-colon lines, and the INPUT code
fence is removed (any code block inside the conversation terminated the
fence early and leaked the rest of the transcript out of the input frame).
Clipping now snaps to whole tagged blocks via trimTranscriptToTagBoundary
instead of slicing mid-message, and the prompt teaches the tag grammar up
front.

Stored session-summary rows keep the legacy labeled role-colon shape via an
explicit format switch: a stored row must never carry literal speaker tags
that a later recall could replay into a prompt as fake transcript structure.
…r extraction

In steady state (history-carrying sessions extracting every turn) each
extraction transcript contains only that call's own turns, so the extractor
never has the conversational context to resolve references ("yes exactly,
that one"). Add a rolling pair window: the last N user turns, with their
assistant replies interleaved in true order, stay in the transcript across
extractions. Retained turns are context for the extractor; the watermark
keeps them from re-becoming extraction sources.

N = autoCaptureContextTurns: 0 (the default) disables retention entirely and
preserves stock behavior; 1-10 sets the window size, decoupled from the
extractMinMessages warm-up gate. The window is bounded at set time by
trimTurnsToUserCap (never trimming this call's own unextracted turns out of
their transcript), and dedupePairWindow repairs double-included pairs when a
below-threshold deferral preserves the same exchange on two paths.
…symmetric context tags

With autoCaptureContextTurns > 0 the retained window now carries real
conversational context with mechanically exact source marking:

- Already-processed turns (the retained prior window) render as
  <context_user_message>/<context_assistant_message> blocks, marked at
  retrieval from the rolling buffer: processed-ness needs no new state,
  retained prior-window turns are by definition the already-extracted set.
- Under captureAssistant=false, assistant replies now enter the transcript
  window as context blocks: they join the rolling window for reference
  resolution (pronouns, follow-ups, corrections) while staying out of the
  eligible set: they cannot fire the gate, move the watermark, or ground a
  memory. Self replies always render as <context_assistant_message> (self is
  never a source).
- The NEW delta alone keeps the extractable <user_message> (and, under
  captureAssistant=true, <assistant_message>) tags, so 'Extract ONLY from
  <user_message> blocks' is mechanically exact instead of prose about
  earlier turns.

The prompt teaches exactly the tags that can appear in each mode; context
tags only exist when the window is enabled, and the plain modes are
byte-identical. The spoof neutralizer and tag-boundary trimmer cover the
context tags too.
… raw ingress

Message-tool runs (Slack groups and other non-auto-delivery channels) hand
the capture hook a user message that concatenates runtime scaffolding around
the real inbound content, and their assistant texts are internal monologue
(the real reply left via the message tool). Two complementary hygiene layers
keep that scaffolding out of the extraction transcript:

Deterministic strips (host grammar, fail-closed): the Delivery banner line
is removed (matched on its stable first sentence, both host variants); the
quoted chat-history re-render is removed (exact header, both variants, plus
lines matching the timestamped sender grammar; unmatched lines always pass
through); assistant texts are dropped entirely from banner-carrying payloads;
banner-only payloads normalize to null instead of occupying an extractable
slot.

Raw-ingress anchoring (channel-agnostic): the ingress hook delivers every
inbound message raw, before the host composes the prompt and layers channel
injections above it, and the raw message always ends the composed text.
Recently seen raws (small per-conversation rings plus a global ring for
main-collapsed sessions) become anchors: a composed user text that ends with
a newline-bounded recent raw is sliced to exactly that raw, removing any
channel's injection without parsing its shape, known or unknown. A minimum
anchor length stops trivial raws from truncating multi-line messages, and no
match leaves the text to the deterministic strips as fallback.
… and opt-out knob

In multi-party rooms the host delivers every non-self participant as
user-role content, so extraction has no way to attribute speakers until
per-sender speaker awareness lands. Until then, group-chat sessions get a
conservative, fail-closed capture policy:

- isDirectConversationSessionKey allowlists direct (1:1) conversations by
  the host's session-key grammar: the dmScope-collapsed main key,
  dashboard/webchat sessions, and the explicit :direct: peer forms.
  Everything else: channels, groups, threads, topics, and any key shape
  never seen before: counts as a group chat.
- Group chats force contextTurns=0 (no context tags, no retention) and
  captureAssistant=false (user-only grounding) regardless of config, threaded
  per-call so the prompt teaching always matches the transcript actually
  built for that extraction.
- autoCaptureGroupChats opt-out knob: unset follows autoCapture, preserving
  existing behavior on install with no surprises; explicit false skips
  group-chat capture entirely.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant