Skip to content

popoto.recipes.subconscious_memory

popoto.recipes.subconscious_memory

SubconsciousMemory -- Automatic memory injection and extraction around LLM turns.

Wraps an existing chat flow with: - Pre-turn: assemble relevant memories, inject as system context - Post-turn: extract facts/observations from LLM response, save as Memory - Outcome: report how injected memories were used

Architecture::

User message
    |
    v
[Pre-turn hook: ContextAssembler.assemble() -> inject into messages]
    |
    v
[LLM inference]
    |
    v
[Post-turn hook: extract observations from response -> save as Memory records]
    |
    v
[Outcome hook: report acted/dismissed/contradicted via ObservationProtocol]
    |
    v
Agent response

The recipe is framework-agnostic -- it works with plain list[dict] messages, so it drops into the OpenAI SDK, an agent harness, or a hand-rolled loop without any framework dependency.

Dependencies

ContextAssembler (from popoto.recipes) ObservationProtocol (from popoto.fields.observation) A Popoto Model class with at least Level 1 fields (DecayingSortedField). Omit model_class to use popoto.recipes.DefaultMemory.

Example

from popoto.recipes import SubconsciousMemory

sm = SubconsciousMemory(agent_id="agent-1")

Pre-turn: inject context

messages, assembly_result = sm.inject_context(messages)

... call LLM with messages ...

Post-turn: extract and save memories

new_memories = sm.extract_memories(response_text, importance=0.6)

Outcome: report usage

sm.report_outcomes(assembly_result)

DEFAULT_EXTRACTION_MIN_LENGTH = 10 module-attribute

Minimum sentence length (chars) to be considered a fact worth saving.

DEFAULT_SYSTEM_PREAMBLE = 'You are a helpful assistant.' module-attribute

Default system message preamble when no system message exists.

DEFAULT_SCORE_WEIGHTS = {'relevance': 1.0} module-attribute

Composite-path score weights used when the caller passes none.

The benchmarked configuration, not the {"relevance": 0.6, "confidence": 0.3} pair the guides used to show. Source: tests/benchmarks/results/sweep_20260326_125145.jsonconstants.score_weights.best_value, over the coding_assistant / research_agent / support_agent scenarios (18/18 points OK). Full transparency on the strength of that evidence: all six swept configurations tied at nDCG@5 = 1.0 on those scenarios, so this is the selected best_value and the simplest single-index vector, not a configuration measured to beat the alternatives. It also matches what the hybrid/lexical suites use throughout, and in those modes score_weights is ignored for the pull path anyway.

Copied per instance -- never share this dict across constructions.

DEFAULT_OUTPUT_FORMAT = 'content' module-attribute

Injected-context format: memory text only, as a "- " bullet list.

Issue #513 measured the previous "structured" JSON default at ~2.8x the character count of the content it wrapped, spending the difference on memory_id UUIDs, the agent_id the caller already knows, and relevance as a bare epoch float that no model can interpret. Pass output_format="structured" to restore the pre-#513 payload verbatim.

SubconsciousMemory

Automatic memory injection and extraction around LLM turns.

Wraps an existing chat flow with: - Pre-turn: assemble relevant memories, inject as system context - Post-turn: extract facts/observations from LLM response, save as Memory - Outcome: report how injected memories were used

The only required argument is agent_id::

sm = SubconsciousMemory(agent_id="agent-1")

That uses :class:popoto.recipes.DefaultMemory, which declares a BM25Field -- so retrieval_mode='auto' resolves to the query-sensitive lexical mode rather than the query-blind composite path a hand-rolled Level 1 model falls into.

Parameters:

Name Type Description Default
model_class

Popoto Model class (any level from the quickstart guide). Default None → :class:popoto.recipes.DefaultMemory, the batteries-included model. When left at None, that model's confidence and associations fields are also wired up automatically (see confidence_field and co_occurrence_field below); passing an explicit model_class keeps those at None exactly as before.

None
agent_id

Identifier for the agent whose memories to query/save. Required -- it is the partition key, so omitting it would mix every agent's memories into one pool.

None
score_weights

Dict mapping field names to weights for ContextAssembler. Default NoneDEFAULT_SCORE_WEIGHTS ({"relevance": 1.0}, the benchmarked vector; see that constant for the sweep provenance). Ignored by the pull path in lexical/hybrid modes.

None
output_format

Format of the injected context block. "content" (default) emits memory text only. "structured" restores the pre-#513 JSON payload; "xml" and "natural" are also accepted. See DEFAULT_OUTPUT_FORMAT.

DEFAULT_OUTPUT_FORMAT
max_items

Maximum memory records to inject per turn. Default 10.

10
max_tokens

Soft token budget for injected context. Default 4000.

4000
extraction_min_length

Minimum characters for a sentence to be extracted as a memory. Default 10.

DEFAULT_EXTRACTION_MIN_LENGTH
system_preamble

System message prefix used when injecting context. Default "You are a helpful assistant."

DEFAULT_SYSTEM_PREAMBLE
content_field

Name of the field on model_class that stores the text content. Default "content".

'content'
importance_field

Name of the field on model_class that stores importance score. Default "importance".

'importance'
agent_id_field

Name of the KeyField for agent partitioning. Default "agent_id".

'agent_id'
extraction_provider

An AbstractExtractionProvider instance (see popoto.extraction) used to turn LLM response text into ExtractedFact records in extract_memories(). Default None, which resolves to HeuristicExtractionProvider(min_length=extraction_min_length) -- the historical sentence-splitting behavior, preserved byte-identical when no new kwargs are passed. Pass a ClaudeExtractionProvider (popoto.extraction.claude) for LLM-based extraction with entities/importance/confidence.

None
confidence_field

Name of a ConfidenceField on model_class to seed from each extracted fact's confidence opinion, or None to skip confidence seeding entirely. Default None, except when model_class is left unset — the default model then wires its own "confidence" field.

None
co_occurrence_field

Name of a CoOccurrenceField on model_class to link co-mentioned entities in, or None to skip association seeding entirely. Default None, except when model_class is left unset — the default model then wires its own "associations" field.

None

Raises:

Type Description
ValueError

If agent_id is not given.

Source code in src/popoto/recipes/subconscious_memory.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
class SubconsciousMemory:
    """Automatic memory injection and extraction around LLM turns.

    Wraps an existing chat flow with:
    - Pre-turn: assemble relevant memories, inject as system context
    - Post-turn: extract facts/observations from LLM response, save as Memory
    - Outcome: report how injected memories were used

    The only required argument is ``agent_id``::

        sm = SubconsciousMemory(agent_id="agent-1")

    That uses :class:`popoto.recipes.DefaultMemory`, which declares a
    ``BM25Field`` -- so ``retrieval_mode='auto'`` resolves to the
    query-sensitive ``lexical`` mode rather than the query-blind
    ``composite`` path a hand-rolled Level 1 model falls into.

    Args:
        model_class: Popoto Model class (any level from the quickstart
            guide). Default ``None`` → :class:`popoto.recipes.DefaultMemory`,
            the batteries-included model. When left at ``None``, that
            model's ``confidence`` and ``associations`` fields are also
            wired up automatically (see ``confidence_field`` and
            ``co_occurrence_field`` below); passing an explicit
            ``model_class`` keeps those at ``None`` exactly as before.
        agent_id: Identifier for the agent whose memories to query/save.
            Required -- it is the partition key, so omitting it would mix
            every agent's memories into one pool.
        score_weights: Dict mapping field names to weights for
            ContextAssembler. Default ``None`` → ``DEFAULT_SCORE_WEIGHTS``
            (``{"relevance": 1.0}``, the benchmarked vector; see that
            constant for the sweep provenance). Ignored by the pull path in
            lexical/hybrid modes.
        output_format: Format of the injected context block.
            ``"content"`` (default) emits memory text only.
            ``"structured"`` restores the pre-#513 JSON payload;
            ``"xml"`` and ``"natural"`` are also accepted. See
            ``DEFAULT_OUTPUT_FORMAT``.
        max_items: Maximum memory records to inject per turn. Default 10.
        max_tokens: Soft token budget for injected context. Default 4000.
        extraction_min_length: Minimum characters for a sentence to be
            extracted as a memory. Default 10.
        system_preamble: System message prefix used when injecting context.
            Default "You are a helpful assistant."
        content_field: Name of the field on model_class that stores the
            text content. Default "content".
        importance_field: Name of the field on model_class that stores
            importance score. Default "importance".
        agent_id_field: Name of the KeyField for agent partitioning.
            Default "agent_id".
        extraction_provider: An ``AbstractExtractionProvider`` instance
            (see ``popoto.extraction``) used to turn LLM response text
            into ``ExtractedFact`` records in ``extract_memories()``.
            Default ``None``, which resolves to
            ``HeuristicExtractionProvider(min_length=extraction_min_length)``
            -- the historical sentence-splitting behavior, preserved
            byte-identical when no new kwargs are passed. Pass a
            ``ClaudeExtractionProvider`` (``popoto.extraction.claude``)
            for LLM-based extraction with entities/importance/confidence.
        confidence_field: Name of a ``ConfidenceField`` on model_class to
            seed from each extracted fact's ``confidence`` opinion, or
            ``None`` to skip confidence seeding entirely. Default ``None``,
            except when ``model_class`` is left unset — the default model
            then wires its own ``"confidence"`` field.
        co_occurrence_field: Name of a ``CoOccurrenceField`` on
            model_class to link co-mentioned entities in, or ``None`` to
            skip association seeding entirely. Default ``None``, except
            when ``model_class`` is left unset — the default model then
            wires its own ``"associations"`` field.

    Raises:
        ValueError: If ``agent_id`` is not given.
    """

    def __init__(
        self,
        model_class=None,
        agent_id=None,
        score_weights=None,
        max_items=10,
        max_tokens=4000,
        extraction_min_length=DEFAULT_EXTRACTION_MIN_LENGTH,
        system_preamble=DEFAULT_SYSTEM_PREAMBLE,
        content_field="content",
        importance_field="importance",
        agent_id_field="agent_id",
        extraction_provider=None,
        confidence_field=None,
        co_occurrence_field=None,
        output_format=DEFAULT_OUTPUT_FORMAT,
    ):
        if agent_id is None:
            raise ValueError(
                "SubconsciousMemory requires agent_id — it partitions every "
                "index on the model, so without it all agents share one "
                "memory pool. Example: SubconsciousMemory(agent_id='agent-1')"
            )

        # Batteries-included path: no model_class means DefaultMemory, whose
        # optional fields are wired for the caller. Only applied when the
        # caller supplied neither the model nor the field name, so an
        # explicit model_class keeps the historical None defaults.
        if model_class is None:
            model_class = DefaultMemory
            if confidence_field is None:
                confidence_field = "confidence"
            if co_occurrence_field is None:
                co_occurrence_field = "associations"

        self.model_class = model_class
        self.agent_id = agent_id
        # dict(...) so the module-level default is never handed out by
        # reference — one instance mutating score_weights must not reach
        # another.
        self.score_weights = (
            dict(score_weights)
            if score_weights is not None
            else dict(DEFAULT_SCORE_WEIGHTS)
        )
        self.max_items = max_items
        self.max_tokens = max_tokens
        self.extraction_min_length = extraction_min_length
        self.system_preamble = system_preamble
        self.content_field = content_field
        self.importance_field = importance_field
        self.agent_id_field = agent_id_field
        self.output_format = output_format

        self._extractor = extraction_provider or HeuristicExtractionProvider(
            min_length=extraction_min_length
        )
        self.confidence_field = confidence_field
        self.co_occurrence_field = co_occurrence_field

        self._assembler = ContextAssembler(
            model_class=model_class,
            score_weights=self.score_weights,
            max_items=max_items,
            max_tokens=max_tokens,
            output_format=output_format,
            content_field=content_field,
        )

    def inject_context(self, messages):
        """Pre-turn: assemble memories and inject into the messages array.

        Finds or creates a system message at index 0 and appends assembled
        memory context to it. Returns the modified messages list and the
        AssemblyResult for later outcome reporting.

        If no memories are found, the messages are returned unchanged.

        Args:
            messages: List of message dicts with "role" and "content" keys.

        Returns:
            Tuple of (modified_messages, AssemblyResult). The messages list
            is modified in-place for convenience but also returned.
        """
        if not messages:
            return messages, AssemblyResult()

        # Extract user query cues from the last user message
        query_cues = {}
        for msg in reversed(messages):
            if msg.get("role") == "user" and msg.get("content"):
                query_cues["topic"] = msg["content"]
                break

        try:
            result = self._assembler.assemble(
                query_cues=query_cues if query_cues else None,
                agent_id=self.agent_id,
            )
        except Exception as e:
            logger.warning("Context assembly failed: %s", e)
            return messages, AssemblyResult()

        if not result.records:
            return messages, result

        # Inject into system message
        context_block = f"\n\nRelevant context:\n{result.formatted}"

        if messages and messages[0].get("role") == "system":
            messages[0] = dict(messages[0])
            messages[0]["content"] = messages[0].get("content", "") + context_block
        else:
            system_msg = {
                "role": "system",
                "content": self.system_preamble + context_block,
            }
            messages = [system_msg] + list(messages)

        return messages, result

    def extract_memories(self, response_text, importance=0.5):
        """Post-turn: extract facts from LLM response and save as Memory records.

        Delegates to ``self._extractor`` (an ``AbstractExtractionProvider``,
        see ``popoto.extraction``) to turn ``response_text`` into
        ``ExtractedFact`` records, then saves each as a Memory record. By
        default ``self._extractor`` is a ``HeuristicExtractionProvider``,
        which splits the response into sentences and filters by minimum
        length -- this reproduces the original sentence-splitting behavior
        of this method byte-for-byte when no new constructor kwargs are
        passed. Pass ``extraction_provider=ClaudeExtractionProvider(...)``
        (see ``popoto.extraction.claude``) for LLM-based extraction with
        entities, importance, and confidence opinions.

        Importance-on-write nuance: each ``ExtractedFact.importance`` is
        used verbatim when the provider has an opinion (not ``None``);
        otherwise the ``importance`` argument passed to this call is used
        as the fallback. The heuristic provider never has an opinion, so
        its output always uses the caller-supplied ``importance``.

        If ``co_occurrence_field`` is configured and a fact names two or
        more distinct entities, every unordered pair is linked in that
        field's co-occurrence graph (see ``_seed_associations``). If
        ``confidence_field`` is configured and a fact has a confidence
        opinion, that field is seeded via ``_seed_confidence`` -- note
        ``ConfidenceField.update_confidence()`` blends the signal with the
        field's fixed ``initial_confidence`` rather than storing it
        verbatim; see ``_seed_confidence`` for the exact formula.

        Args:
            response_text: The LLM's response text.
            importance: Fallback importance score used for any extracted
                fact that has no importance opinion of its own (i.e.
                ``ExtractedFact.importance is None``). Float between 0.0
                and 1.0. Default 0.5.

        Returns:
            List of saved model instances. Empty list if response_text
            is empty or contains no extractable facts.
        """
        if not response_text or not response_text.strip():
            return []

        facts = self._extractor.extract(response_text)
        saved = []

        for fact in facts:
            eff_importance = (
                fact.importance if fact.importance is not None else importance
            )
            kwargs = {
                self.agent_id_field: self.agent_id,
                self.content_field: fact.text,
                self.importance_field: eff_importance,
            }
            try:
                instance = self.model_class(**kwargs)
                if instance.save() is not False:
                    saved.append(instance)
                    self._seed_associations(instance, fact)
                    self._seed_confidence(instance, fact)
            except Exception as e:
                logger.warning("Failed to save extracted memory: %s", e)

        return saved

    def _seed_associations(self, instance, fact):
        """Link co-mentioned entities in ``self.co_occurrence_field``.

        No-op unless ``self.co_occurrence_field`` is set, the named field
        exists on ``self.model_class``, and ``fact.entities`` contains at
        least two distinct (case-sensitive, whitespace-trimmed) names.

        Entity name strings -- not the saved ``instance``'s PK -- are used
        as the graph nodes: co-mention within one fact is treated as an
        association between entities, which is the natural unit for a
        write-time extraction graph (there is no record PK for an
        abstract entity). This is a deliberate departure from the field's
        usual record-PK-as-node convention; entity-name nodes and record
        PK nodes coexist in the same keyspace under
        ``$CoOcF:{ClassName}:{field}:``.

        Entities are deduplicated (order-stable) before pairing, since
        ``CoOccurrenceField.link()`` rejects self-pairs. Each pair is
        linked independently with its own try/except so one failing pair
        (e.g. a residual self-pair, or a transient Redis error) never
        drops the remaining valid pairs or the already-saved memory
        record.

        The deduped entity list is truncated to
        ``Defaults.EXTRACTION_MAX_ENTITIES_PER_FACT`` before pairing:
        combination count grows O(n^2), so an extraction result with an
        unusually large entity set (malformed provider output, or an
        adversarial input) can't blow up into a burst of co-occurrence
        writes for a single fact.

        Args:
            instance: The already-saved model instance for this fact.
            fact: The ``ExtractedFact`` that produced ``instance``.
        """
        if not self.co_occurrence_field:
            return
        if self.model_class._meta.fields.get(self.co_occurrence_field) is None:
            return

        seen = set()
        norm_entities = []
        for e in fact.entities:
            e = (e or "").strip()
            if e and e not in seen:
                seen.add(e)
                norm_entities.append(e)

        if len(norm_entities) < 2:
            return

        if len(norm_entities) > Defaults.EXTRACTION_MAX_ENTITIES_PER_FACT:
            norm_entities = norm_entities[: Defaults.EXTRACTION_MAX_ENTITIES_PER_FACT]

        field = getattr(self.model_class, self.co_occurrence_field)
        for a, b in itertools.combinations(norm_entities, 2):
            try:
                field.link(
                    self.model_class,
                    a,
                    b,
                    initial_weight=Defaults.EXTRACTION_ENTITY_PAIR_LINK_WEIGHT,
                )
            except Exception as exc:
                logger.warning("entity link %r<->%r failed: %s", a, b, exc)

    def _seed_confidence(self, instance, fact):
        """Seed ``self.confidence_field`` from ``fact.confidence``.

        No-op unless ``self.confidence_field`` is set, the named field
        exists on ``self.model_class``, and ``fact.confidence is not
        None``.

        Note: ``ConfidenceField`` has no per-instance "set initial value"
        API. The companion hash is seeded in ``on_save`` with the field's
        fixed ``initial_confidence`` (pseudo-count 1); this method's call
        to ``update_confidence()`` is the *first evidence update* against
        that prior, not a hard override. Concretely, seeding with signal
        ``s`` yields a stored confidence of ``(initial_confidence + s) /
        2`` -- not ``s`` verbatim. Callers should not expect
        ``fact.confidence`` to equal the stored value after this call.

        Args:
            instance: The already-saved model instance for this fact.
            fact: The ``ExtractedFact`` that produced ``instance``.
        """
        if not self.confidence_field:
            return
        if self.model_class._meta.fields.get(self.confidence_field) is None:
            return
        if fact.confidence is None:
            return

        try:
            ConfidenceField.update_confidence(
                instance, self.confidence_field, signal=fact.confidence
            )
        except Exception as exc:
            logger.warning(
                "confidence seed for %r failed: %s", self.confidence_field, exc
            )

    def report_outcomes(self, assembly_result, outcome="acted"):
        """Outcome hook: report how injected memories were used.

        Calls ObservationProtocol.on_context_used() for all records in the
        assembly result with the specified outcome.

        Args:
            assembly_result: AssemblyResult from inject_context().
            outcome: How the agent used the memories. One of "acted",
                "dismissed", "contradicted", "deferred". Default "acted".
        """
        if not assembly_result or not assembly_result.records:
            return

        try:
            outcome_map = {}
            for record in assembly_result.records:
                try:
                    key = record.db_key.redis_key
                    outcome_map[key] = outcome
                except Exception:
                    continue

            if outcome_map:
                ObservationProtocol.on_context_used(
                    assembly_result.records, outcome_map
                )
        except Exception as e:
            logger.warning("Failed to report outcomes: %s", e)

    @staticmethod
    def _split_sentences(text):
        """Split text into sentences using a simple regex heuristic.

        Delegates to ``HeuristicExtractionProvider._split_sentences`` --
        kept here (rather than removed) for backward compatibility with
        callers/tests that reference this staticmethod directly.
        ``extract_memories()`` no longer calls this method itself; it
        delegates to ``self._extractor`` instead (see
        ``popoto.extraction.HeuristicExtractionProvider``, which contains
        the canonical implementation).

        Args:
            text: Input text to split.

        Returns:
            List of sentence strings.
        """
        return HeuristicExtractionProvider._split_sentences(text)

inject_context(messages)

Pre-turn: assemble memories and inject into the messages array.

Finds or creates a system message at index 0 and appends assembled memory context to it. Returns the modified messages list and the AssemblyResult for later outcome reporting.

If no memories are found, the messages are returned unchanged.

Parameters:

Name Type Description Default
messages

List of message dicts with "role" and "content" keys.

required

Returns:

Type Description

Tuple of (modified_messages, AssemblyResult). The messages list

is modified in-place for convenience but also returned.

Source code in src/popoto/recipes/subconscious_memory.py
def inject_context(self, messages):
    """Pre-turn: assemble memories and inject into the messages array.

    Finds or creates a system message at index 0 and appends assembled
    memory context to it. Returns the modified messages list and the
    AssemblyResult for later outcome reporting.

    If no memories are found, the messages are returned unchanged.

    Args:
        messages: List of message dicts with "role" and "content" keys.

    Returns:
        Tuple of (modified_messages, AssemblyResult). The messages list
        is modified in-place for convenience but also returned.
    """
    if not messages:
        return messages, AssemblyResult()

    # Extract user query cues from the last user message
    query_cues = {}
    for msg in reversed(messages):
        if msg.get("role") == "user" and msg.get("content"):
            query_cues["topic"] = msg["content"]
            break

    try:
        result = self._assembler.assemble(
            query_cues=query_cues if query_cues else None,
            agent_id=self.agent_id,
        )
    except Exception as e:
        logger.warning("Context assembly failed: %s", e)
        return messages, AssemblyResult()

    if not result.records:
        return messages, result

    # Inject into system message
    context_block = f"\n\nRelevant context:\n{result.formatted}"

    if messages and messages[0].get("role") == "system":
        messages[0] = dict(messages[0])
        messages[0]["content"] = messages[0].get("content", "") + context_block
    else:
        system_msg = {
            "role": "system",
            "content": self.system_preamble + context_block,
        }
        messages = [system_msg] + list(messages)

    return messages, result

extract_memories(response_text, importance=0.5)

Post-turn: extract facts from LLM response and save as Memory records.

Delegates to self._extractor (an AbstractExtractionProvider, see popoto.extraction) to turn response_text into ExtractedFact records, then saves each as a Memory record. By default self._extractor is a HeuristicExtractionProvider, which splits the response into sentences and filters by minimum length -- this reproduces the original sentence-splitting behavior of this method byte-for-byte when no new constructor kwargs are passed. Pass extraction_provider=ClaudeExtractionProvider(...) (see popoto.extraction.claude) for LLM-based extraction with entities, importance, and confidence opinions.

Importance-on-write nuance: each ExtractedFact.importance is used verbatim when the provider has an opinion (not None); otherwise the importance argument passed to this call is used as the fallback. The heuristic provider never has an opinion, so its output always uses the caller-supplied importance.

If co_occurrence_field is configured and a fact names two or more distinct entities, every unordered pair is linked in that field's co-occurrence graph (see _seed_associations). If confidence_field is configured and a fact has a confidence opinion, that field is seeded via _seed_confidence -- note ConfidenceField.update_confidence() blends the signal with the field's fixed initial_confidence rather than storing it verbatim; see _seed_confidence for the exact formula.

Parameters:

Name Type Description Default
response_text

The LLM's response text.

required
importance

Fallback importance score used for any extracted fact that has no importance opinion of its own (i.e. ExtractedFact.importance is None). Float between 0.0 and 1.0. Default 0.5.

0.5

Returns:

Type Description

List of saved model instances. Empty list if response_text

is empty or contains no extractable facts.

Source code in src/popoto/recipes/subconscious_memory.py
def extract_memories(self, response_text, importance=0.5):
    """Post-turn: extract facts from LLM response and save as Memory records.

    Delegates to ``self._extractor`` (an ``AbstractExtractionProvider``,
    see ``popoto.extraction``) to turn ``response_text`` into
    ``ExtractedFact`` records, then saves each as a Memory record. By
    default ``self._extractor`` is a ``HeuristicExtractionProvider``,
    which splits the response into sentences and filters by minimum
    length -- this reproduces the original sentence-splitting behavior
    of this method byte-for-byte when no new constructor kwargs are
    passed. Pass ``extraction_provider=ClaudeExtractionProvider(...)``
    (see ``popoto.extraction.claude``) for LLM-based extraction with
    entities, importance, and confidence opinions.

    Importance-on-write nuance: each ``ExtractedFact.importance`` is
    used verbatim when the provider has an opinion (not ``None``);
    otherwise the ``importance`` argument passed to this call is used
    as the fallback. The heuristic provider never has an opinion, so
    its output always uses the caller-supplied ``importance``.

    If ``co_occurrence_field`` is configured and a fact names two or
    more distinct entities, every unordered pair is linked in that
    field's co-occurrence graph (see ``_seed_associations``). If
    ``confidence_field`` is configured and a fact has a confidence
    opinion, that field is seeded via ``_seed_confidence`` -- note
    ``ConfidenceField.update_confidence()`` blends the signal with the
    field's fixed ``initial_confidence`` rather than storing it
    verbatim; see ``_seed_confidence`` for the exact formula.

    Args:
        response_text: The LLM's response text.
        importance: Fallback importance score used for any extracted
            fact that has no importance opinion of its own (i.e.
            ``ExtractedFact.importance is None``). Float between 0.0
            and 1.0. Default 0.5.

    Returns:
        List of saved model instances. Empty list if response_text
        is empty or contains no extractable facts.
    """
    if not response_text or not response_text.strip():
        return []

    facts = self._extractor.extract(response_text)
    saved = []

    for fact in facts:
        eff_importance = (
            fact.importance if fact.importance is not None else importance
        )
        kwargs = {
            self.agent_id_field: self.agent_id,
            self.content_field: fact.text,
            self.importance_field: eff_importance,
        }
        try:
            instance = self.model_class(**kwargs)
            if instance.save() is not False:
                saved.append(instance)
                self._seed_associations(instance, fact)
                self._seed_confidence(instance, fact)
        except Exception as e:
            logger.warning("Failed to save extracted memory: %s", e)

    return saved

report_outcomes(assembly_result, outcome='acted')

Outcome hook: report how injected memories were used.

Calls ObservationProtocol.on_context_used() for all records in the assembly result with the specified outcome.

Parameters:

Name Type Description Default
assembly_result

AssemblyResult from inject_context().

required
outcome

How the agent used the memories. One of "acted", "dismissed", "contradicted", "deferred". Default "acted".

'acted'
Source code in src/popoto/recipes/subconscious_memory.py
def report_outcomes(self, assembly_result, outcome="acted"):
    """Outcome hook: report how injected memories were used.

    Calls ObservationProtocol.on_context_used() for all records in the
    assembly result with the specified outcome.

    Args:
        assembly_result: AssemblyResult from inject_context().
        outcome: How the agent used the memories. One of "acted",
            "dismissed", "contradicted", "deferred". Default "acted".
    """
    if not assembly_result or not assembly_result.records:
        return

    try:
        outcome_map = {}
        for record in assembly_result.records:
            try:
                key = record.db_key.redis_key
                outcome_map[key] = outcome
            except Exception:
                continue

        if outcome_map:
            ObservationProtocol.on_context_used(
                assembly_result.records, outcome_map
            )
    except Exception as e:
        logger.warning("Failed to report outcomes: %s", e)