221 Commits

Author SHA1 Message Date
Joseph Doherty 51a12afbec merge: T102 phase 4 documentation update 2026-04-27 04:09:09 -04:00
Joseph Doherty fc3020a0ee merge: T101 phase 4 cross-feature integration tests 2026-04-27 04:09:09 -04:00
Joseph Doherty 228f9abb19 test: phase 4 cross-feature integration coverage (T101) 2026-04-27 04:08:25 -04:00
Joseph Doherty b6119879e5 docs: phase 4 status, behavioral defaults, deferred items (T102) 2026-04-27 03:56:45 -04:00
Joseph Doherty 3b4c7b9cef merge: T100 cross-chat search UX (top-bar + results page) 2026-04-27 03:48:06 -04:00
Joseph Doherty 36d75fa6e7 merge: T99 snapshot UX (manual trigger + list + restore + preview) 2026-04-27 03:48:06 -04:00
Joseph Doherty 0a2c5924f9 feat: cross-chat search UX (top-bar + results page) (T100)
Wires T93's `search_all_memories` service into a small read-only HTML
surface so users can find a memory across every chat in the database.

* `chat/web/search.py` (new): GET `/search?q=...` runs the FTS service
  with k=50, hydrates each row with bot name + scene timestamp, and
  renders `search.html`. Empty `q` short-circuits to no results so the
  top-bar form can submit even with an empty input.
* `chat/templates/search.html` (new): empty-state placeholder, results
  list with chat-level "Open chat" links (`/chats/{chat_id}` — memories
  don't carry an event_id today, so no per-turn anchor).
* `chat/templates/layout.html`: append a small `<form>` to the rail
  nav, additive only.
* `chat/app.py`: register `search_router` (additive import + include).
* `tests/test_search_ux.py`: 3 tests — multi-chat results, empty-query
  placeholder, chat link.
2026-04-27 03:46:52 -04:00
Joseph Doherty a5f0e69d44 feat: snapshot UX (manual trigger + list + restore + preview) (T99) 2026-04-27 03:46:49 -04:00
Joseph Doherty 3dbe1a01ff merge: T98 drawer Phase 4 bundle (branching + sig review + hide + delete + remaining edits) 2026-04-27 03:38:15 -04:00
Joseph Doherty 4546bc0d9c feat: drawer remaining v1 field edits (T98.5)
Audit of chat/state/manual_edit.py target_kind dispatch found two §6.4
fields without drawer affordances despite being already-projected text
columns: chat_state.narrative_anchor and chat_state.weather. Both land
via new manual_edit branches (target_kind chat_narrative_anchor and
chat_weather) plus paired drawer routes and Scene-section text inputs.

The container properties_json blob is intentionally deferred — bounded
JSON edits aren't wired through manual_edit and the drawer never
surfaces multiple containers at once, so v1 leaves it out.
2026-04-27 03:35:54 -04:00
Joseph Doherty c4fa11fe78 feat: drawer surgical delete with cascade preview (T98.4) 2026-04-27 03:29:07 -04:00
Joseph Doherty 461d441078 feat: drawer hide-from-view toggle + turn_hidden manual_edit branch (T98.3) 2026-04-27 03:27:59 -04:00
Joseph Doherty b25007eb44 feat: drawer significance review panel (T98.2) 2026-04-27 03:25:40 -04:00
Joseph Doherty d39d31479d feat: drawer branching UI (T98.1) 2026-04-27 03:24:02 -04:00
Joseph Doherty 7899c50b6c merge: T97 memory write hook + embedding worker + backfill + call-site wiring 2026-04-27 03:09:14 -04:00
Joseph Doherty 177e39d59c feat: wire embedding worker call sites in turns/meanwhile/skip/regenerate (T97.5) 2026-04-27 03:08:36 -04:00
Joseph Doherty d85ed8aaa6 feat: backfill_embeddings script for existing memories (T97.4) 2026-04-27 02:51:48 -04:00
Joseph Doherty 9c63d6b24c feat: app lifespan starts/stops EmbeddingWorker (T97.3) 2026-04-27 02:51:44 -04:00
Joseph Doherty 64a07aa87f feat: memory_write enqueues embedding job after each memory_written (T97.2) 2026-04-27 02:51:40 -04:00
Joseph Doherty 6674f9475c feat: embedding worker drains queue and emits embedding_indexed events (T97.1) 2026-04-27 02:51:36 -04:00
Joseph Doherty 50448b72f8 merge: T96 combined FTS + vector retrieval ranking via RRF 2026-04-27 02:44:03 -04:00
Joseph Doherty b8b4aed6d9 feat: combined FTS + vector retrieval ranking via RRF (T96) 2026-04-27 02:42:38 -04:00
Joseph Doherty 5ff107574c merge: T95 delete-impact computation service 2026-04-27 02:37:28 -04:00
Joseph Doherty 915d625d7f merge: T94 branching service 2026-04-27 02:37:28 -04:00
Joseph Doherty 28e13d416f feat: delete-impact computation service (preview without mutation) (T95) 2026-04-27 02:36:30 -04:00
Joseph Doherty 296e8fdddd feat: branching service (branch_from_event + switch + metadata) (T94) 2026-04-27 02:35:58 -04:00
Joseph Doherty 013b563f21 merge: T93 cross-chat search service 2026-04-27 02:32:53 -04:00
Joseph Doherty 62d5cdd826 merge: T92 pure-Python cosine vector search service 2026-04-27 02:32:53 -04:00
Joseph Doherty a25c166174 merge: T91 embedding generation service (pseudo-embedding) 2026-04-27 02:32:53 -04:00
Joseph Doherty 8f66e1123a feat: cross-chat search service (T93) 2026-04-27 02:31:31 -04:00
Joseph Doherty caa17b4174 feat: embedding generation service (Phase 4 pseudo-embedding) (T91) 2026-04-27 02:31:07 -04:00
Joseph Doherty c7cb0eb01e feat: pure-Python cosine vector search service (T92) 2026-04-27 02:31:06 -04:00
Joseph Doherty 1d6768e980 test: bump schema_version assertion to 13 (0012 embeddings + 0013 branches) 2026-04-27 02:28:11 -04:00
Joseph Doherty 8b086d4bb8 merge: T90 phase 3.6 carry-overs trio 2026-04-27 02:27:48 -04:00
Joseph Doherty 6c7ac8f69f merge: T89 branches table + projector handlers 2026-04-27 02:27:48 -04:00
Joseph Doherty fe34d4f4c0 merge: T88 embeddings table + projector handlers 2026-04-27 02:27:48 -04:00
Joseph Doherty 0d76a6b2d6 refactor: consolidate legacy record_turn_memory into unified API (T90.3)
The Phase 1 single-bot ``record_turn_memory`` lingered next to the
unified ``record_turn_memory_for_present`` introduced in T84. Only test
fixtures still called the legacy entry point.

- Remove ``record_turn_memory`` from ``chat/services/memory_write.py``.
- Update the two test_memory_write.py callers to use
  ``record_turn_memory_for_present(..., guest_bot_id=None)``, which
  produces the same ``[you=1, host=1, guest=0]`` witness mask.

The unified API returns ``dict[bot_id, (event_id, memory_id)]``; tests
extract the host entry. No production callers were affected.
2026-04-27 02:25:07 -04:00
Joseph Doherty cc71fb4d01 chore: clarify regenerate lifecycle warning wording (T90.2)
The warning said "lifecycle transitions from superseded turn ARE NOT
being rolled back". When regenerating an OLDER turn, the listed
transitions can include intervening-turn ones that legitimately stand
on their own — they weren't authored by the superseded turn itself.

Reword to "lifecycle transitions at-or-after turn <id>" so operators
reading logs aren't misled into thinking every listed event id was
emitted by the target turn. Cosmetic change to a single log message.

Test: extends test_regenerate_with_prior_lifecycle_logs_warning to
assert the new phrasing is present and the old phrasing is gone.
2026-04-27 02:23:55 -04:00
Joseph Doherty c06a32767b perf: read_recent_dialogue pushes chat-id filter into SQL (T90.1)
The previous implementation pulled the last N rows in SQL across all
chats and dropped foreign-chat rows in Python. With LIMIT N this could
return far fewer than N relevant rows when other chats had recent
activity. Push the chat_id filter into SQL via json_extract so LIMIT N
always returns N rows scoped to the requested chat.

Test: seeds two chats with 60 turns each interleaved; queries chat_a
with limit=50; asserts exactly 50 chat_a rows returned (was 0 prior to
the fix because chat_b's rows dominated the global tail).
2026-04-27 02:23:15 -04:00
Joseph Doherty 0ba374b790 feat: embeddings table + projector handlers (pure-Python cosine, T88) 2026-04-27 02:22:32 -04:00
Joseph Doherty 77f1636086 feat: branches table + projector handlers (T89) 2026-04-27 02:22:27 -04:00
Joseph Doherty bffd9a2f38 docs: add Phase 4 implementation plan (vector retrieval + branching + polish)
15 tasks across 8 waves landing the Phase 4 deliverables per
requirements doc §13 + §14:

- Vector retrieval via sqlite-vec (new external dependency)
- Branching UI (event log forks)
- Drawer-edit on every field (significance review, hide-from-view,
  surgical delete with cascade preview, branching affordances)
- Backup tooling (snapshot UX surface)
- Cross-chat search

Plus the 3 Phase 3.6 carry-over fixes (T90 bundle).

Wave structure:
- W1 (parallel 3-way): schema foundation + carry-overs
- W2 (parallel 3-way): embedding/search services
- W3 (parallel 2-way): branching + delete services
- W4 (single): combined retrieval ranking
- W5 (single): memory write hook + backfill
- W6 (single): drawer Phase 4 bundle (5 sub-features)
- W7 (parallel 2-way): snapshot UX + cross-chat search UX
- W8 (parallel 2-way): integration tests + docs

External dependency: sqlite-vec must be installed BEFORE Wave 1.
Embedding model choice (384-dim default) pinned in T91 before dispatch
since the migration hardcodes the dimension.

Schema baseline: 11 -> 13 (adds 0012_embeddings.sql + 0013_branches.sql).
Task ids T88-T102 to avoid collision with prior phases.
2026-04-27 02:03:08 -04:00
dohertj2 1b66a2821c Merge pull request 'Phase 3.5 cleanup: 17-item backlog burndown' (#5) from phase-3.5 into main 2026-04-27 01:56:28 -04:00
Joseph Doherty 74bb42397d merge: T87 phase 3.5 docs sweep — prune shipped backlog, capture phase 3.6 residuals 2026-04-26 22:46:26 -04:00
Joseph Doherty 3be8ed8915 docs: phase 3.5 status, prune shipped backlog items, capture phase 3.6 follow-ups (T87) 2026-04-26 22:45:59 -04:00
Joseph Doherty 097073ede5 merge: T86 frontend turn_html_replace SSE handler + event_id stamping 2026-04-26 22:42:40 -04:00
Joseph Doherty 4a2617565b merge: T85 JSON-build audit + meanwhile cancel route-level test 2026-04-26 22:42:40 -04:00
Joseph Doherty 73625c0ac4 merge: T84 unified record_turn_memory API with you_present kwarg 2026-04-26 22:42:40 -04:00
Joseph Doherty aea20a2c83 feat: frontend turn_html_replace SSE handler for regenerate live-swap (T86) 2026-04-26 22:41:35 -04:00
Joseph Doherty 9493d24a53 test: meanwhile cancel route + JSON-build audit (T85)
T85.1 — JSON-build audit (chat/state, chat/services, chat/eventlog):
no findings. Every JSON column write in those modules already uses
``json.dumps`` (chat/state/events.py, world.py, edges.py, group_node.py,
meanwhile.py, manual_edit.py, entities.py, chat/services/snapshot.py,
chat/eventlog/log.py); chat/state/meanwhile.py:48-49 even carries an
explicit comment about the ``json.dumps`` choice for safety against
quote/backslash injection. No production changes.

T85.2 — meanwhile cancel route-level coverage:

* ``test_meanwhile_turn_cancellation_via_route`` — pins the
  end-to-end shape produced when /turns/cancel fires mid-meanwhile-
  beat: assistant_turn lands with truncated=True (and the right
  meanwhile_scene_id + speaker_id), no memory_written events fire, no
  post-turn edge_update events fire, and _in_flight_tasks is empty
  post-flight. Drives the cancel by hijacking client.stream to raise
  CancelledError on first iteration — same pattern proven by
  test_cancelled_turn_still_closes_scene_when_user_prose_signals_close
  in tests/test_turn_flow.py. The synchronous TestClient can't issue
  a second POST mid-stream from the same thread, and driving via
  task.cancel() trips GeneratorExit-on-dependency that prevents the
  conn from committing the partial; the inline-raise mirrors what
  cancel_turn produces (CancelledError delivered on next await) and
  is the standard idiom in this codebase. Combined with the existing
  test_meanwhile_turn_registered_in_in_flight_tasks (registration
  pin), the full Stop-button lifecycle for meanwhile beats is now
  covered.
* ``test_meanwhile_cancel_route_no_op_after_turn_completes`` — runs
  a meanwhile turn to completion, then POSTs /turns/cancel; asserts
  204 no-op, no error, registry stays empty. Pins the cancel
  endpoint's robustness against the racy "Stop just after stream
  finished" sequence.

Suite: 334 -> 336 passing.
2026-04-26 22:33:52 -04:00
Joseph Doherty da7aa88b8e refactor: unified record_turn_memory API with you_present kwarg (T84)
Extends record_turn_memory_for_present with a you_present: bool = True
kwarg so a single entry-point covers both you-scenes (witness_you=1)
and meanwhile scenes (witness_you=0). Validates that meanwhile callers
provide a guest_bot_id.

record_meanwhile_memory becomes a thin backward-compat wrapper that
delegates with you_present=False, preserving the call site in
chat/web/meanwhile.py without churn.
2026-04-26 22:24:57 -04:00
Joseph Doherty 82701d3c18 merge: T83 regenerate.py polish bundle (cancel + DRY + scoped query + warning + ordering) 2026-04-26 22:22:26 -04:00
Joseph Doherty 0de4d1252c refactor: regenerate event-detection ordering mirrors post_turn (T83.5)
Cosmetic-only renumbering of the event-lifecycle detection block in
``regenerate_assistant_turn`` from ``# 10.`` to ``# 9a.`` — mirrors the
``# 8a.`` shape in ``chat.web.turns.post_turn``. The block was already
in the correct structural position (immediately after the interjection
branch); only the numbering and comment reflected an earlier draft
where it read as a final step rather than the post-interjection /
pre-(absent)-scene-close slot.

No behavioural change. All 9 regenerate tests + 18 turn_flow tests
pass without modification.
2026-04-26 22:19:27 -04:00
Joseph Doherty b667a21c99 chore: document regenerate lifecycle-rollback limitation with warning log (T83.4)
When a regenerate replaces an assistant_turn that already produced
lifecycle transitions (``event_started`` / ``event_completed`` /
``event_cancelled``), those transitions are NOT rolled back before
``detect_event_transitions`` re-runs against the new text. A
regenerate-after-completion can therefore double-emit promotion
artifacts.

Phase 3.5 first cut (per the task plan): documentation + WARNING log
naming the affected event_log ids. The actual undo pass is invasive
(re-projection / inverse-handler dispatch) and is deferred to Phase 4.

Implementation:
- TODO docstring block at the top of ``regenerate_assistant_turn``.
- Module-level ``_log = logging.getLogger(__name__)``.
- Scan immediately after the original assistant_turn row is located:
  joins event_log lifecycle rows to the events table on event_id so we
  can scope by chat (lifecycle payloads carry only ``event_id``, not
  ``chat_id``). Filter ``id > original_assistant_event_id`` as the
  positional linkage to "transitions emitted as part of (or after)
  this turn's processing."

Decision (asked in the brief): the scan uses the ``id > original``
heuristic rather than scanning for explicit references. Lifecycle
event payloads do not carry a back-pointer to the assistant_turn that
triggered them — the linkage is positional in the event log. A tighter
linkage would require either adding a payload field on lifecycle
events (cross-cutting schema change) or threading the just-appended
assistant_turn id into ``detect_event_transitions``'s emit calls
(narrow but still cross-cutting). The positional heuristic is loose
but conservative: a turn that emits no lifecycle events produces no
warning, and the warning's purpose is operator-visible breadcrumbs
not an exact rollback set.

Test: test_regenerate_with_prior_lifecycle_logs_warning seeds a turn
that produced ``event_started`` + ``event_completed`` rows and asserts
the WARNING fires with the expected ids.
2026-04-26 22:18:23 -04:00
Joseph Doherty a1e2d9a24d perf: scope regenerate sibling-lookup to chat_id (T83.3)
The sibling assistant_turn lookup in ``regenerate_assistant_turn``
previously scanned every non-superseded ``assistant_turn`` row across
the whole database and filtered in Python. With many chats in the log
this is O(total_assistant_turns) per regenerate.

Push the chat_id filter into SQL via ``json_extract(payload_json,
'$.chat_id') = ?`` and add ``ORDER BY id DESC LIMIT 50`` so worst-case
work is bounded even within a single chat. Mirrors the SQL pattern in
``chat.web.meanwhile._last_meanwhile_speaker``.

Test added: test_regenerate_sibling_lookup_scoped_to_chat seeds two
chats — the second has an interjection whose ``interjection_of`` value
collides with the first chat's primary speaker. Regenerating chat A
must leave chat B's rows untouched and the regenerated chat A
interjection's ``regenerated_from`` must point at chat A's original
(not chat B's). Pre-T83.3 a global query could in principle latch
onto cross-chat rows.
2026-04-26 22:16:23 -04:00
Joseph Doherty d833bbc3e7 refactor: extract turn_common helpers from regenerate + turns (T83.2)
The recent-dialogue read and the directed-pair edge gather were
duplicated between ``chat.services.regenerate`` and ``chat.web.turns``.
Extracted into ``chat.services.turn_common`` with two helpers:

- ``read_recent_dialogue(conn, chat_id, *, limit, exclude_event_id)``:
  oldest-first ``[{speaker, text}]`` over user_turn / user_turn_edit /
  assistant_turn rows, with the standard ``superseded_by IS NULL AND
  hidden = 0`` filter. ``exclude_event_id`` covers regenerate's need to
  drop the original assistant_turn before its supersede UPDATE lands.
- ``gather_prior_edges(conn, present_ids)``: ``{(src, tgt): edge}`` over
  every directed pair across ``present_ids``, with the schema default
  50/50 baseline for missing rows.

``chat.web.turns._read_recent_dialogue`` becomes a thin delegate so the
chat-detail template and other in-module callers keep their import
shape; ``_gather_state_update_inputs`` now calls into the shared edge
gather. ``regenerate_assistant_turn`` calls both helpers in three call
sites (primary + post-interjection edges, primary + interjection
recent reads), still post-processing speaker ids to display names for
its prompts.

Decision: ``chat.services.scene_summarize._read_recent_dialogue`` is
left in place — it has a ``since_event_id`` clamp (T80.2) and excludes
``user_turn_edit`` deliberately. Folding it into the shared helper
would either silently change its read shape or require a second flag,
both more invasive than the duplication. Documented in the new module
docstring.

Tests: tests/test_turn_common.py covers chronological ordering,
supersede / other-chat / exclude_event_id filtering, and prior-edge
default-fallback. Existing 6 regenerate + 18 turn_flow tests pass
unchanged.
2026-04-26 22:14:59 -04:00
Joseph Doherty f2fd30c5a9 feat: regenerate registers stream task in _in_flight_tasks (T83.1)
Both the primary and the interjection sub-stream in
``regenerate_assistant_turn`` are now wrapped in ``asyncio.create_task``
and registered in the chat-keyed ``_in_flight_tasks`` registry that the
``/turns/cancel`` route reads. Without this, hitting Stop during a
mid-regenerate stream was a no-op.

Mirrors the meanwhile registration pattern in chat/web/meanwhile.py
(snapshot-tested by tests/test_meanwhile_turn_flow.py).

Test added: test_regenerate_registers_task_in_in_flight_tasks captures
``"chat_bot_a" in _in_flight_tasks`` at the first stream yield via a
custom MockLLMClient subclass and asserts post-flight cleanup.
2026-04-26 22:11:23 -04:00
Joseph Doherty 9e7c16de40 merge: T82 turns.py wiring (consume meanwhile digests + skip runs scene close) 2026-04-26 22:07:46 -04:00
Joseph Doherty 71245fb85a fix: natural-language skip runs scene close detection (T82.2)
The natural-language skip dispatch in chat.web.turns.post_turn
(intent="skip_elision") previously bypassed scene close detection
entirely. User prose like "fade out, skip an hour" carries both a
close signal and a skip directive — the close summary must capture
the closing scene's final beat (and promote per-POV memories) before
the time advances.

Insert detect_scene_close + apply_scene_close_summary BEFORE the skip
controller invocation in the skip_elision branch. Order: scene close
-> skip narration -> time advance. When there's no active scene or
the prose carries no close signal, detect_scene_close returns the
safe should_close=False default and the flow drops straight to the
skip controller — same behavior as today.
2026-04-26 22:06:24 -04:00
Joseph Doherty be92691f9a fix: post_turn consumes pending meanwhile digests (T82.1)
Wire chat.services.prompt.consume_pending_meanwhile_digests into
chat.web.turns.post_turn at the END of the handler, after scene-close
detection and before the response broadcast. Without this call digests
created by a meanwhile close stay pending forever — they surface in the
next you-turn's prompt (via T65) but are never marked consumed, so they
re-render on every subsequent turn.

Idempotent: re-calling the helper produces zero events when nothing's
pending. The T66 cross-feature note is updated to reflect the new
wiring; the existing direct-helper test in test_phase3_integration.py
is preserved as defensive coverage of the helper contract in isolation.
2026-04-26 22:02:25 -04:00
Joseph Doherty 6f50ce5b7a merge: T81 ChatNotFoundError typed exception for skip routes 2026-04-26 21:57:01 -04:00
Joseph Doherty f816d44438 fix: typed ChatNotFoundError replaces string-prefix sniff in skip routes (T81) 2026-04-26 21:55:53 -04:00
Joseph Doherty 6f0716201f merge: T80 scene_summarize.py polish bundle (T58 follow-ups) 2026-04-26 21:52:44 -04:00
Joseph Doherty 0d3bbf4272 test: T58 coverage gaps (truncation, update/close paths) (T80.5)
Three gaps left by T58's initial test coverage:

* test_key_quote_truncation_at_200_chars — exercises the 200-char hard
  slice in _build_key_quotes_suffix so any future change to the
  truncation strategy (ellipsis, word boundary, etc) trips the test.
* test_thread_detection_update_candidate_emits_thread_updated —
  pins the ``update`` action emission shape (thread_id, summary,
  last_referenced_scene_id).
* test_thread_detection_close_candidate_emits_thread_closed — pins
  the ``close`` action emission shape (thread_id, closed_at).

No production change; pure coverage add.
2026-04-26 21:50:55 -04:00
Joseph Doherty b91a5e9293 fix: thread_closed uses chat-clock time, not wall clock (T80.4)
T58 stamped emitted ``thread_closed`` events with
``datetime.now(timezone.utc).isoformat()``. The rest of the close
pipeline (memories.chat_clock_at, scene_closed.ended_at, edge writes)
uses the chat's in-world clock. Threads must agree so timeline
reconstruction stays consistent under time skips and replay.

Read ``chat["time"]`` (already loaded for the per-POV path) and pass
it through as ``closed_at``. Falls back to UTC now only when chat_state
has no clock yet — defensive; chat_created always seeds it.

Adds test_thread_closed_uses_chat_clock_time.
2026-04-26 21:50:04 -04:00
Joseph Doherty 9d06eaf57a fix: log swallowed exceptions in detect_threads try/except (T80.3)
The broad ``except Exception`` around detect_threads silently dropped
programmer errors (wrong kwargs, import-time failures, etc), making
diagnostics painful. Log at DEBUG with full exc_info so the failure
surfaces in local logs without breaking the close pipeline's
failure-tolerant contract.

Adds test_detect_threads_failure_is_logged using caplog.
2026-04-26 21:49:17 -04:00
Joseph Doherty dae481eb92 fix: scope thread detection transcript to closing scene (T80.2)
apply_scene_close_summary fed detect_threads the chat-wide last-50
turns. When a chat has accumulated multiple scenes' worth of dialogue,
that bleeds prior-scene turns into the second close's classifier prompt
and risks mis-attributing threads (closing one that opened earlier,
re-opening one that already closed).

Add an optional ``since_event_id`` kwarg to ``_read_recent_dialogue``
that lower-bounds by event_log id, plus a ``_scene_opened_event_id``
helper that resolves the scene-open event for a given scene_id. Wire
both into the thread-detection call site so its scene_transcript
holds only the closing scene's turns. The per-POV summarizer keeps the
chat-wide approximation it had before — that's intentional.

Adds test_thread_detection_uses_scene_scoped_transcript.
2026-04-26 21:48:44 -04:00
Joseph Doherty d123684f9a fix: guard scene close key-quote suffix against re-close bloat (T80.1)
Re-running apply_scene_close_summary on the same scene previously caused
recursive bloat: _build_key_quotes_suffix sourced quote text from
memories.pov_summary, which after the first close already carried a
"Key quotes:" suffix. The next close would then quote the quotes,
nesting deeper each time.

Strip any existing suffix from candidate text before truncating to
200 chars in the suffix builder, and from the fresh classifier output
before composing the new value in _summarize_and_apply_for_witness so
the rewrite replaces rather than stacks.

Adds test_scene_close_re_run_does_not_double_suffix.
2026-04-26 21:46:20 -04:00
Joseph Doherty 29e6f346ef merge: T79 _witness_role_for defensive None handling 2026-04-26 21:42:24 -04:00
Joseph Doherty cb570a5adc merge: T78 search_memories docstring SQL-bias note 2026-04-26 21:42:24 -04:00
Joseph Doherty ce4f56adfa merge: T77 AddresseeDecision.confidence as Literal 2026-04-26 21:42:24 -04:00
Joseph Doherty bdc93b4b67 merge: T76 narrate_skip timeout_s plumbed through 2026-04-26 21:42:24 -04:00
Joseph Doherty 9c9d71eb31 fix: _witness_role_for defensive None handling (T79) 2026-04-26 21:41:15 -04:00
Joseph Doherty 4199038b8b fix: AddresseeDecision.confidence as Literal[high|medium|low] (T77) 2026-04-26 21:40:47 -04:00
Joseph Doherty e3dfe18811 docs: search_memories docstring mentions SQL-side significance bias (T78) 2026-04-26 21:40:40 -04:00
Joseph Doherty d759b90aa1 fix: plumb narrate_skip timeout_s through to client.generate (T76) 2026-04-26 21:40:29 -04:00
Joseph Doherty fb7e97260b docs: add Phase 3.5 cleanup plan (Phase 2.6/3 + 3.5/4 backlog burn-down)
12 tasks across 7 waves consolidating the 17-item backlog tracked in
CLAUDE.md (7 from Phase 2.6/3 + 10 from Phase 3.5/4). Items are
grouped by file ownership so each wave stays file-disjoint:

- Wave 1 (parallel 4-way): trivial single-line/single-file fixes
  (timeout_s plumbing, Literal type, docstring, defensive None)
- Wave 2 (single): scene_summarize.py polish bundle (5 T58 items)
- Wave 3 (single): typed ChatNotFoundError for skip routes
- Wave 4 (single): turns.py wiring (consume_pending_meanwhile_digests
  + natural-language skip runs scene close detection)
- Wave 5 (single): regenerate.py polish (cancel hook + DRY +
  sibling query + lifecycle rollback documentation + ordering)
- Wave 6 (parallel 3-way): unified record_turn_memory API + JSON
  audit + frontend turn_html_replace SSE handler
- Wave 7 (single): docs sweep

No schema migrations. Bundled tasks split into per-item sub-commits
for clean review bisection. Uses task ids T76-T87 to avoid collision
with prior phases (Phase 3 used T49-T67, Phase 2.5 used T68-T75).
2026-04-26 21:33:16 -04:00
dohertj2 753cec327f Merge pull request 'Phase 3: events, time skips, threads, meanwhile scenes' (#4) from phase-3 into main 2026-04-26 21:26:49 -04:00
Joseph Doherty 70a5ad3ecc docs: add T66-discovered consume_pending_meanwhile_digests backlog item 2026-04-26 21:19:11 -04:00
Joseph Doherty 6709cf46a7 merge: T67 phase 3 documentation update 2026-04-26 21:18:38 -04:00
Joseph Doherty c3947bbb68 merge: T66 phase 3 cross-feature integration coverage 2026-04-26 21:18:38 -04:00
Joseph Doherty f865ac2ee2 test: phase 3 cross-feature integration coverage (T66) 2026-04-26 21:16:30 -04:00
Joseph Doherty af6c54dd05 docs: phase 3 status, behavioral defaults, deferred items (T67) 2026-04-26 21:10:49 -04:00
Joseph Doherty dc35833534 test: feed meanwhile digest canned response after Wave 6b cross-feature merge 2026-04-26 21:07:44 -04:00
Joseph Doherty 0cd41636b3 merge: T65 meanwhile summary digest surfaces to next you-scene 2026-04-26 21:06:10 -04:00
Joseph Doherty 2c7aa68af9 merge: T64 meanwhile turn flow (host+guest, no you) 2026-04-26 21:06:10 -04:00
Joseph Doherty cf43ba0993 feat: meanwhile turn flow (host+guest, no you) (T64) 2026-04-26 21:05:40 -04:00
Joseph Doherty a781732ee6 feat: meanwhile summary digest surfaces to next you-scene (T65) 2026-04-26 20:59:35 -04:00
Joseph Doherty c9d58b8229 merge: T63 meanwhile scene schema + state 2026-04-26 20:52:51 -04:00
Joseph Doherty c463dc70b2 feat: meanwhile scene schema + state (T63) 2026-04-26 20:52:45 -04:00
Joseph Doherty 819803da84 merge: T62 natural-language skip command flow + shared skip controllers 2026-04-26 20:47:07 -04:00
Joseph Doherty a7eedb8037 feat: natural-language skip detection + skip command flow (T62)
Extend ParsedTurn with intent/landing_state_hint so the classifier can
flag skip-elision and skip-jump prose. The post_turn handler short-
circuits the regular narrative path when intent != "narrative":
elision runs through the shared controller in chat/web/skip.py;
jump returns 422 directing the user to the drawer's structured form
(simpler Phase 3 path — natural-language fiction-time delta parsing
is too fragile for v1 without a structured surface).

Extract the elision/jump logic that previously lived in drawer.py
into chat/web/skip.py so both the drawer T59 routes and the new
natural-language path share one canonical implementation. The drawer
routes become thin HTTP wrappers that translate ValueError to 400
and refresh the drawer partial; the existing drawer skip tests pass
unchanged.

The new natural-language elision derives ``new_time`` by bumping the
chat clock by 1 hour (Phase 3 stub) — the drawer's structured form
remains the path for picking a specific landing time.
2026-04-26 20:45:05 -04:00
Joseph Doherty e236bcadcd merge: T61 per-turn event-lifecycle detection + completion promotion 2026-04-26 20:37:21 -04:00
Joseph Doherty 3678bcaca6 merge: T60 prompt assembly active events + open threads 2026-04-26 20:37:21 -04:00
Joseph Doherty b582567521 feat: per-turn event-lifecycle detection + completion promotion (T61) 2026-04-26 20:35:34 -04:00
Joseph Doherty 21c4ffa63c feat: prompt assembly renders active events + open threads (T60) 2026-04-26 20:34:26 -04:00
Joseph Doherty 83f94a4325 merge: T59 drawer events / threads / skip controls 2026-04-26 20:29:40 -04:00
Joseph Doherty 2d14197553 feat: drawer events / threads / skip controls (T59) 2026-04-26 20:27:47 -04:00
Joseph Doherty 8efbcdf6c3 merge: T58 scene compression + thread emission on close 2026-04-26 20:21:01 -04:00
Joseph Doherty 8aeadfd0e4 merge: T57 significance-aware retrieval ranking 2026-04-26 20:21:01 -04:00
Joseph Doherty 88350d7d2e merge: T56 event-completion promotion service 2026-04-26 20:21:00 -04:00
Joseph Doherty 343f305587 feat: significance-driven quote retention + thread emission on close (T58) 2026-04-26 20:18:34 -04:00
Joseph Doherty 021587b3df feat: event-completion promotion service (T56) 2026-04-26 20:15:51 -04:00
Joseph Doherty 5e6b29e0c5 feat: significance-aware retrieval ranking (T57) 2026-04-26 20:15:19 -04:00
Joseph Doherty a34931375c merge: T55 thread-detection service 2026-04-26 20:12:12 -04:00
Joseph Doherty 959fe11410 merge: T54 synthesized-memories service 2026-04-26 20:12:12 -04:00
Joseph Doherty 2959e1ac2a merge: T53 skip narration service 2026-04-26 20:12:12 -04:00
Joseph Doherty afe940259a merge: T52 event-lifecycle detection service 2026-04-26 20:12:12 -04:00
Joseph Doherty c2144cd9df feat: skip narration service (T53) 2026-04-26 20:10:42 -04:00
Joseph Doherty 7857da4112 feat: thread-detection service (T55) 2026-04-26 20:10:36 -04:00
Joseph Doherty adbbd32873 feat: synthesized-memories service for jump skips (T54) 2026-04-26 20:10:05 -04:00
Joseph Doherty 98250644ad feat: event-lifecycle detection service (T52) 2026-04-26 20:09:13 -04:00
Joseph Doherty da1f67fb6a test: bump schema_version assertion to 10 (0009 events + 0010 threads) 2026-04-26 20:07:08 -04:00
Joseph Doherty 03ba34272b merge: T51 threads table + projector handlers 2026-04-26 20:06:45 -04:00
Joseph Doherty e26885b011 merge: T50 time_skip event handlers 2026-04-26 20:06:45 -04:00
Joseph Doherty 5b7a195cf5 merge: T49 events table + lifecycle handlers 2026-04-26 20:06:45 -04:00
Joseph Doherty 25bcbac055 feat: threads table + projector handlers (T51) 2026-04-26 20:05:09 -04:00
Joseph Doherty ab2b494c21 feat: time_skip event handlers (T50) 2026-04-26 20:04:46 -04:00
Joseph Doherty b6888ff36a feat: events table + lifecycle handlers (T49) 2026-04-26 20:04:36 -04:00
dohertj2 e4fd888b53 Merge pull request 'Phase 2.5 cleanup: 15-item backlog burndown' (#3) from phase-2.5 into main 2026-04-26 20:00:38 -04:00
dohertj2 079774dce5 Merge pull request 'Phase 2: multi-entity scene support (you + host + guest)' (#2) from phase-2 into main 2026-04-26 20:00:16 -04:00
dohertj2 3be7920f41 Merge pull request 'Phase 1: v1 single-bot roleplay engine' (#1) from phase-1 into main 2026-04-26 19:59:29 -04:00
Joseph Doherty e61bd9cb08 merge: T75 phase 2.5 docs sweep + phase 2.6 backlog 2026-04-26 17:47:01 -04:00
Joseph Doherty c6e0130e59 docs: phase 2.5 status, prune shipped backlog items, capture phase 2.6 follow-ups (T75) 2026-04-26 17:46:50 -04:00
Joseph Doherty 67d6f3fe68 merge: T74 turn-flow polish + addressee service 2026-04-26 17:43:04 -04:00
Joseph Doherty dbc9690358 merge: T73 regenerate.py polish (turn_html SSE + interjection regenerate + stale-guest cleanup) 2026-04-26 17:43:04 -04:00
Joseph Doherty 6d98728a2e chore: remove defensive stale-guest degrade in turns.py (T74.4)
T44 carried a defensive degrade-to-1:1 block in post_turn for the
case where chat.guest_bot_id pointed at a deleted bot. T47 then
fixed the root cause by adding a bot_reset cascade that clears
guest_bot_id from any chat that referenced the deleted bot, so the
post_turn defensive block was rendered dead.

Remove the orphan-clear branch and replace it with a comment
documenting that get_bot now returns a real row when guest_bot_id
is non-None. The cascade behavior is pinned by
test_reset_clears_guest_reference_in_other_chats in tests/test_reset.py.
2026-04-26 17:40:46 -04:00
Joseph Doherty bfb2ffb6f6 chore: pin scene-close-on-cancel behavior + comment rationale (T74.3)
Phase 2 T44 review noted that scene close still runs when a primary
turn is cancelled mid-stream and asked the implementer to review.

Review finding: the existing behavior is correct, not a bug. The
close-detection branch in post_turn consumes ONLY the user's prose
(fully appended to the event_log BEFORE streaming starts) and the
current container name. It does NOT consume the bot's output. A user
who types "we're done here, fade out" and then hits Stop mid-stream
still meant to close — the cancelled bot beat doesn't invalidate
that intent.

- Document the rationale with an inline comment near the
  close-detection branch in chat/web/turns.py.
- Add regression test
  test_cancelled_turn_still_closes_scene_when_user_prose_signals_close
  that drives a stream raising CancelledError on first iteration and
  asserts the scene_closed event still lands.
2026-04-26 17:40:12 -04:00
Joseph Doherty bd13b64959 chore: remove defensive stale-guest degrade in regenerate.py (T73.3)
Phase 2 T44 added a defensive degrade-to-1:1 here when
`chat.guest_bot_id` pointed at a deleted bot. T47 fixed the root cause:
`bot_reset` cascade-clears the column when the referenced bot is purged
(verified by tests/test_reset.py), so the guard was dead code.

No corresponding stale-guest test existed in tests/test_regenerate.py
to remove. The bot_reset cascade test in tests/test_reset.py already
covers the root-cause behavior.
2026-04-26 17:40:07 -04:00
Joseph Doherty f2a57005e5 feat: regenerate covers interjection turns (T73.2)
Phase 2 T44 deferred interjection regenerate — when the original turn
group included a follow-on interjection beat we left it untouched. Now
regenerate redoes BOTH halves:

- Detect a sibling interjection by looking up assistant_turn events
  pinned to the same user_turn_id with `interjection_of` set.
- After streaming the new primary, run `detect_interjection` against
  the new primary text.
- If True: stream a new interjection from the silent witness, append
  with `interjection_of=<new primary speaker_id>`, supersede the
  original interjection, and re-run memory + state-update for the new
  beat.
- If False: supersede the original interjection without a replacement
  (back-pointer goes to the new primary so the row stays consistently
  hidden).

Also broadcast a `turn_html_replace` event for the new interjection so
the front-end can swap the prior interjection node in place (mirrors
T73.1's primary swap).

Tests:
- `test_regenerate_with_interjection_redoes_both_turns`: classifier
  returns True; assert two new assistant_turns land for the same
  user_turn, second carries `interjection_of`, originals superseded.
- `test_regenerate_drops_interjection_when_classifier_returns_false`:
  classifier returns False; assert one new assistant_turn (primary
  only) and the original interjection is superseded with no
  replacement.

`interjection_of` carries the primary's *speaker_id* (matching the
existing convention in chat/web/turns.py) rather than the event_id.
2026-04-26 17:39:31 -04:00
Joseph Doherty 88fae33152 fix: enqueue significance for interjection memories (T74.2)
T44's interjection branch wrote interjection memories via
record_turn_memory_for_present but never enqueued a SignificanceJob,
so the interjection beat could land in memory but never be scored —
which meant it could never auto-pin even when it carried a pivotal
moment.

- Capture the host-POV memory id from the interjection's memory write
  result and enqueue a SignificanceJob mirroring the primary turn's
  pattern. One enqueue per beat (host id; guest POV piggybacks on the
  same score since the prose is identical for v2 — per-POV rewrite
  happens at scene close in T45).
- New test test_interjection_enqueues_significance_job pins the
  contract by intercepting worker.enqueue and asserting two distinct
  jobs land per 3-entity turn that fires an interjection.
2026-04-26 17:38:30 -04:00
Joseph Doherty c874883a84 feat: classifier-based addressee detection (T74.1)
Replace the substring _detect_addressee_id helper with a classifier
call for the multi-entity case. The substring helper is kept as a
fast-path for the no-guest case (no LLM round-trip needed when only
one bot is present, preserves throughput).

- New service chat/services/addressee.py wrapping the existing
  classifier wrapper. AddresseeDecision carries addressee_id +
  confidence + reason; classifier failure falls back to the host with
  reason="fallback" (graceful-degradation, matches the relationship_seed
  / interjection pattern).
- chat/web/turns.py post_turn now calls detect_addressee in the
  multi-entity branch; 1:1 keeps the substring path.
- tests/test_addressee.py: 3 new tests (guest pick, host pick,
  classifier-failure fallback).
- tests/test_turn_flow.py: existing multi-entity tests now feed a
  canned addressee response in the queue. The addressee-routing test
  is updated to assert classifier-driven routing rather than substring.
2026-04-26 17:37:26 -04:00
Joseph Doherty 6f22e86f54 feat: regenerate broadcasts turn_html over SSE (T73.1)
After the new assistant_turn lands, publish a `turn_html_replace` SSE
event carrying the rendered HTML, the new turn_id, and the original
assistant_turn id as `supersedes_id` so connected tabs can swap the
prior DOM node in-place. Phase 1 T29 deferred this — page had to refresh
to see the regenerated turn.

Uses a new event name (not the existing `turn_html`) because the HTMX
`sse-swap="turn_html"` consumer expects raw HTML and an *append*
semantic; regenerate is a *replace*. The new event ships as JSON
(supersedes_id forces sse.py's JSON branch) so the front-end JS can
read the swap target from the payload.

Test: `test_regenerate_broadcasts_turn_html_over_sse` patches the
`publish` reference inside the regenerate module and asserts the
event shape.
2026-04-26 17:36:16 -04:00
Joseph Doherty e632a6247d merge: T72 drawer polish (deferred edits + first-meeting gate + witness flag editing) 2026-04-26 17:32:02 -04:00
Joseph Doherty 607d0971c4 feat: drawer witness flag inline-edit (T72.3)
Memories grow per-flag witness checkboxes (you / host / guest) that
auto-submit on change via HTMX. The new POST route emits a manual_edit
event with target_kind=memory_witness and a {flag, value} payload;
prior_value mirrors the same shape so an inverse edit restores the
flag. The drawer's recent-memories query now selects the three
witness columns alongside the existing fields so the template can
render checkbox state without a second query per row.
2026-04-26 17:28:25 -04:00
Joseph Doherty c265e4ce0f feat: first-meeting gate on drawer Add-guest form (T72.2)
When a host->candidate edge already exists from a prior chat, the
Add-guest form renders the prose textarea disabled with an "already
know each other" note. Submission without the explicit "re-seed
anyway" toggle skips seed_inter_bot_edges so existing edge content
(affinity, trust, knowledge, summaries) survives — guest_added and
group_node_initialized still fire. A small inline script enables /
disables the textarea per-option based on a pre-computed
existing_guest_edges dict surfaced by the GET handler.
2026-04-26 17:26:31 -04:00
Joseph Doherty 21404a373b feat: drawer edits for edge_trust / edge_summary / memory_pov_summary / knowledge_facts (T72.1)
Adds the four POST routes whose state-layer support was already
dispatched by the manual_edit projector (edge_trust, edge_summary,
memory_pov_summary) plus a new edge_knowledge_fact dispatch branch for
add/remove fact list manipulation. Drawer template gains editable
textareas, sliders, and add/remove fact controls. Remove semantics on
knowledge_fact match by string (not index) so concurrent edge_update
events appending facts between drawer renders don't desync the form.
2026-04-26 17:24:24 -04:00
Joseph Doherty 789b9bd042 merge: T71 prompt.py polish (witness role + ACTIVITIES + NICE trim docs) 2026-04-26 17:18:02 -04:00
Joseph Doherty 73bb8c1f17 chore: document NICE trim order rationale (T71.3)
T18 review (Phase 1) noted the NICE-tier trim drops previous-scene
FIRST while §6.3 spec lists previous-scene LAST in the NICE tier
group. Decision: keep the existing greedy order (previous-scene
first), and document why.

Rationale (now in code at the trim ladder):
  1. Cheapest-impact-first — a per-POV previous-scene summary loses
     less narrative continuity than the older dialogue turns or
     memory hits it competes with.
  2. Greedy lookahead is more expensive than the marginal narrative
     loss. Dropping previous-scene typically clears the soft-budget
     slack in one step.

Test added: test_nice_trim_order_documented pins the observed order
(previous-scene -> memories -> dialogue) so a future refactor can't
silently invert it. Sized so that all-NICE config overflows soft but
dropping just previous-scene fits — proves memories and older
dialogue turns survive while previous-scene is the FIRST drop.
2026-04-26 17:16:02 -04:00
Joseph Doherty afd1a50958 refactor: single ACTIVITIES: block with bullet-level trim (T71.2)
Phase 2 T43 added a SECOND ACTIVITIES: block to render guest activity
separately from you+speaker. Two consecutive ACTIVITIES: headers can
read as a duplicate-section bug to the LLM and bloat the prompt.

Consolidate to a single ACTIVITIES: block whose body is composed from
up to three bullets (you, speaker, guest). The block itself is
MUST-tier (always renders); bullet-level trim drops bullets in the
order guest -> group node -> you -> other edges, with the speaker
bullet as the MUST-tier floor (the speaker's own current activity is
the load-bearing slice).

Implementation chose Option B from the polish plan: pre-truncate the
bullets list at trim time before _build_activity_block runs, rather
than introduce a granular tier mode in the trim machinery. Rationale
documented in code; the existing block-level trim ladder gains a
single new toggle (include_you_activity) and the SHOULD-tier
guest_activity_block is gone.

Tests:
- test_single_activities_block_with_three_bullets_when_3_entities:
  exactly one ACTIVITIES: header with all three entity bullets.
- test_tight_budget_drops_guest_activity_bullet_first: speaker bullet
  survives, guest bullet absent under tight budget.
- Existing test_assemble_with_tight_budget_drops_guest_activity_first
  still passes (asserts on bullet absence, not block-header absence).
2026-04-26 17:13:24 -04:00
Joseph Doherty 428438b223 fix: witness role parametric in prompt assembly (T71.1)
Phase 2 T46 pinned the witness mask contract on search_memories with a
witness_role parameter (host/guest/you). The prompt-assembly call site
in assemble_narrative_prompt was hardcoded to "host", which silently
returned the wrong rows when the speaker was the guest bot.

Derive the witness role from chat membership via a new private helper
_witness_role_for(speaker_bot_id, host_bot_id), and apply it at the
search_memories call. Behaviour is identical when the speaker is the
host (or when no guest is present); the fix is load-bearing only when
the guest bot is the speaker — exactly the scenario Phase 2 T43 added
support for.

Tests: pin both directions (host-as-speaker and guest-as-speaker) by
patching the imported search_memories reference and asserting the
witness_role argument the call site emits.
2026-04-26 17:11:20 -04:00
Joseph Doherty b13f3b4e47 merge: T70 LLM-merged group meta-summary 2026-04-26 17:09:16 -04:00
Joseph Doherty f701f9d7dd merge: T69 bot_reset purges orphaned 'you' activity rows 2026-04-26 17:09:16 -04:00
Joseph Doherty 1b9144442a merge: T68 open_db with check_same_thread parameter 2026-04-26 17:09:16 -04:00
Joseph Doherty 13c23fd898 feat: LLM-merged group meta-summary (T70) 2026-04-26 17:07:12 -04:00
Joseph Doherty c1e419e012 fix: bot_reset purges orphaned 'you' activity rows (T69) 2026-04-26 17:06:21 -04:00
Joseph Doherty 994728b5ed refactor: open_db with check_same_thread parameter (T68) 2026-04-26 17:05:29 -04:00
Joseph Doherty e05f28e9d5 docs: add Phase 2.5 cleanup plan (Phase 1.5 + 2.5/3 backlog)
8 tasks across 5 waves consolidating the 15-item backlog tracked in
CLAUDE.md (5 from Phase 1.5 cleanup + 10 from Phase 2.5/3). Items are
grouped by file ownership so each wave stays file-disjoint:

- Wave 1 (parallel): open_db refactor, bot_reset orphan cleanup,
  LLM-merged group meta-summary
- Wave 2 (single): prompt.py polish — witness role parametric, single
  ACTIVITIES block, NICE trim documented
- Wave 3 (single): drawer polish — deferred v1 edits, first-meeting
  gate, witness flag editing
- Wave 4 (parallel): regenerate.py polish (SSE + interjection
  regenerate + stale-guest cleanup); turn-flow polish + new addressee
  service (classifier addressee + significance for interjection +
  scene-close-on-cancel pinned + stale-guest cleanup)
- Wave 5 (single): docs sweep

No schema migrations. Bundled tasks split into per-item sub-commits
for clean review bisection. Uses task ids T68-T75 to avoid collision
with Phase 3 plan (T49-T67) regardless of merge order.
2026-04-26 17:02:46 -04:00
Joseph Doherty 379054755a docs: add Phase 3 implementation plan with parallel-safe waves
19 tasks across 8 waves covering events with lifecycles, time skips
(elision + jump), active threads, significance/retrieval refinements,
and meanwhile scenes (host+guest with no 'you'). Mirrors the Phase 2
plan structure: pre-flight, parallel-execution strategy with worktree
isolation, file-disjointness analysis per wave, and per-task TDD spec
with commit messages.

Phase 3 schema: adds 0009_events.sql, 0010_threads.sql,
0011_meanwhile_scenes.sql (final version 11). Builds on Phase 2's
3-entity scene support and event-sourced architecture.
2026-04-26 16:55:50 -04:00
Joseph Doherty bb87fcbd4a merge: T48 Phase 2 documentation update 2026-04-26 16:28:46 -04:00
Joseph Doherty f6b75b25eb merge: T47 bot_reset cascades to guest references 2026-04-26 16:28:46 -04:00
Joseph Doherty 9f35669936 merge: T46 witness filter coverage for multi-entity scenarios 2026-04-26 16:28:46 -04:00
Joseph Doherty 321810fa54 docs: phase 2 status, behavioral defaults, deferred items 2026-04-26 16:28:14 -04:00
Joseph Doherty fb17ba0657 fix: bot_reset cascades to guest references in other chats 2026-04-26 16:25:37 -04:00
Joseph Doherty d40313063c test: witness filter coverage for multi-entity scenarios 2026-04-26 16:25:03 -04:00
Joseph Doherty 60ac33a787 merge: T44 multi-entity turn flow with interjection support 2026-04-26 16:22:11 -04:00
Joseph Doherty c86b0df411 feat: T44 multi-entity turn flow with interjection support
Rewrites post_turn for the multi-entity world:

- Addressee detection via case-insensitive whole-word match against the
  guest name; defaults to host on no-match or both-match.
- Multi-entity prompt assembly: forwards guest_id so the prompt sees
  the third party's activity / edges / group-node.
- Multi-witness memory write: record_turn_memory_for_present writes one
  memory per present bot witness when a guest is in the room.
- Multi-pair state-update: compute_state_updates_for_present emits one
  edge_update per directed pair (6 with a guest, 2 without).
- Interjection branch (T39): when a guest is present and the primary
  beat completes, the silent witness may follow on. detect_interjection
  decides; on True we stream a second narrative as the witness, append a
  second assistant_turn linked to the same user_turn_id, and re-run the
  multi-pair state update + memory write for the follow-on beat. Cancel
  collapses both halves; a cancelled interjection skips its downstream
  passes so we don't classifier-spam against a half-formed beat.
- Scene-close runs after both beats so apply_scene_close_summary sees
  the full closing scene; T45's guest-aware summarizer handles per-POV
  rewrites for each present witness.

regenerate.py mirrors the prompt / memory / state-update changes for
1:1 and multi-entity scenes. Per the Phase 2 spec, interjection
regeneration is deferred to Phase 2.5 — regenerate only re-streams the
addressee turn for v2.

Tests: adds 5 cases to tests/test_turn_flow.py covering the no-guest
regression, multi-bot without interjection, multi-bot with interjection,
scene-close per-POV rewrites, and addressee routing on a named-bot
prose. Each test pins its own canned MockLLMClient queue with the call
shape documented in the docstring.
2026-04-26 16:18:38 -04:00
Joseph Doherty 44c8735b27 merge: T45 per-POV summaries on close for each present witness 2026-04-26 16:08:54 -04:00
Joseph Doherty 9b601650fb merge: T43 multi-entity prompt assembly 2026-04-26 16:08:54 -04:00
Joseph Doherty fcb111310a feat: multi-entity prompt assembly with guest activity, edges, group node 2026-04-26 16:07:15 -04:00
Joseph Doherty 4e240347b4 feat: per-POV summaries on close for each present witness 2026-04-26 16:06:05 -04:00
Joseph Doherty a90647dddb merge: T42 drawer guest add/remove + render 2026-04-26 16:01:17 -04:00
Joseph Doherty bb83d97088 feat: drawer guest add/remove + render 2026-04-26 15:59:48 -04:00
Joseph Doherty f24ffb8e4f merge: T41 multi-witness memory write helper 2026-04-26 15:54:25 -04:00
Joseph Doherty 9d80b9ae2b merge: T40 multi-entity state-update coordinator 2026-04-26 15:54:25 -04:00
Joseph Doherty 77b42f1ea5 merge: T39 interjection classifier service 2026-04-26 15:54:25 -04:00
Joseph Doherty e7793f2441 feat: multi-witness memory write helper 2026-04-26 15:52:48 -04:00
Joseph Doherty 4ec56dd475 feat: multi-entity state-update coordinator 2026-04-26 15:51:58 -04:00
Joseph Doherty 6a92253ae7 feat: interjection classifier service 2026-04-26 15:51:29 -04:00
Joseph Doherty 22db9f3554 test: bump schema_version assertion to 8 after 0008_group_node migration 2026-04-26 15:49:25 -04:00
Joseph Doherty 6b726b2a4a merge: T38 relationship-seed service 2026-04-26 15:49:03 -04:00
Joseph Doherty e58cdbd527 merge: T37 guest_added/guest_removed event handlers 2026-04-26 15:49:03 -04:00
Joseph Doherty b69a74e69b merge: T36 group_node schema + projector handlers 2026-04-26 15:49:03 -04:00
Joseph Doherty c6b3531c64 feat: relationship-seed service for first-co-appearance prompt 2026-04-26 15:47:12 -04:00
Joseph Doherty a0d7debce5 feat: group_node schema + projector handlers 2026-04-26 15:46:16 -04:00
Joseph Doherty a1b4e251c5 feat: guest_added / guest_removed event handlers 2026-04-26 15:46:09 -04:00
Joseph Doherty b8335895e1 docs: add Phase 2 implementation plan with parallel-safe waves
13 tasks across 6 waves (1, 2, 3, 4a, 4b, 5). Designed for parallel
subagent execution where file-disjointness allows.

Waves 1, 2, 4a, and 5 each contain 2-3 tasks that touch disjoint files
and can be dispatched concurrently via the Agent tool with
isolation: "worktree". Waves 3 (drawer guest support) and 4b (multi-
entity turn flow) are single-task because they touch hot files
(_drawer.html, turns.py) that cannot be safely co-modified.

Plan covers:
- T36: group_node schema + handlers (new migration 0008)
- T37: guest_added / guest_removed event handlers (modifies world.py)
- T38: relationship-seed service ("have they met?")
- T39: interjection classifier service
- T40: multi-entity state-update coordinator (6 directed pairs)
- T41: multi-witness memory write helper
- T42: drawer guest add/remove UI + render
- T43: multi-entity prompt assembly (extends T18)
- T44: multi-entity turn flow (rewrites post_turn)
- T45: multi-entity per-POV summaries on scene close
- T46: witness filter cross-coverage tests
- T47: bot_reset cascades to guest references
- T48: Phase 2 documentation update

Plan also documents:
- Worktree-per-subagent dispatch pattern using Agent isolation flag
- Merge ordering per wave (file-disjointness = conflict-free merges)
- Failure recovery (cancel failed parallel task, re-dispatch as solo)
- Conflict prevention checklist (verify Files sections disjoint per wave)

Tasks file (.tasks.json) carries dependency DAG with `blockedBy` and
`parallelGroup` so a future executing-plans run can dispatch correctly.

NOT EXECUTING. Plan only.
2026-04-26 15:37:07 -04:00
Joseph Doherty d161e7b8e9 feat: cap narrative response length + tune sampling
Bot replies were running long (4 paragraphs of action+dialogue beats
per turn) because we never set max_tokens on the narrative call. Three
tunable knobs now in Settings (set in data/config.toml to override):

- narrative_max_tokens: int = 400
  Hard cap on each generated response. ~400 tokens ≈ 1–2 short
  paragraphs. Drop to 200 for terse banter, bump to 800+ for longer
  scenes.

- narrative_temperature: float = 0.85
  Sampling temperature. 0.7 = grounded/consistent (slightly stiff),
  0.85 = creative-but-in-character (default), 1.0 = wide variety,
  >1.0 = often off-the-rails.

- prompt closing instruction now nudges: "Keep your response to a
  single beat — one or two short paragraphs at most. Don't monologue;
  leave room for the other person to react."

Both turns.py (post_turn) and regenerate.py forward the params to
client.stream(). FeatherlessClient already passes **params through to
the OpenAI-compat endpoint.

Note: temperature doesn't control length — that was a common
misconception. max_tokens is the actual length cap. Lower temperature
makes word choice more predictable (slightly stiffer voice), not
shorter. Both knobs are useful for different goals.
2026-04-26 15:28:08 -04:00
Joseph Doherty f0742dd4f9 fix: use readOnly (not disabled) to lock textarea during stream
The form-submit handler in chat.html was setting
``textarea.disabled = true`` synchronously before the browser actually
serialized the form. Disabled form fields are excluded from
submission, so the request body contained ``prose=""`` even when the
user had typed text — which the server (correctly) rejected with the
new empty-prose 400. Net effect: typing "hello" + Send gave a "prose
cannot be empty" error.

Switched to ``readOnly``: same UX (user can't edit while streaming)
but the field IS submitted. The unlock path now also clears the
textarea and refocuses for the next turn.
2026-04-26 15:23:06 -04:00
Joseph Doherty 52555e0455 fix: reject empty prose on turn submit
Empty submission was producing a blank user_turn event in the log and
firing the LLM stream anyway — the bot would invent a response from the
kickoff context alone, producing a monologue with no user input. Two-
layer fix:

- Browser: add `required` to the prose textarea in chat.html so the
  form refuses to submit empty.
- Server: 400 in post_turn when prose.strip() is empty. Defense in
  depth — if a client bypasses the textarea attribute (custom UI,
  curl, etc.), the server still rejects.

Verified live: POST with empty body returns 400; POST with whitespace-
only returns 400; chat shell renders the textarea with required.
Full suite: 168 passed.
2026-04-26 15:20:02 -04:00
Joseph Doherty 5c039c8e56 fix: classifier timeout + Featherless concurrency cap
Two related issues blocking real-world use of the kickoff parse:

1. Classifier calls take ~12s end-to-end on Featherless for the
   complex KickoffParse schema (Hermes-3-8B generating ~1.3KB of
   structured JSON). The 10s timeout was firing on most attempts,
   causing all 3 retries to time out and the empty-fallback to render
   with blank form values. Bumping the default
   classifier_timeout_s 10 → 30s gives generous headroom; measured
   p99 is ~13s, so 30s is comfortable.

2. Featherless caps concurrent connections per account (2 on free /
   lower paid tiers). Each turn flow can fire 4–5 calls (parse,
   scene-close detect, narrative stream, two state-update passes)
   plus the background significance worker. Without a gate, we'd
   exceed the cap and fail.

   Added a class-level ``asyncio.Semaphore`` to FeatherlessClient,
   shared across all instances, configured once in lifespan from
   ``Settings.featherless_max_concurrent`` (default 2). Both
   ``generate`` and ``stream`` acquire the semaphore for the duration
   of the call; the stream holds it until the async generator
   completes, so token streaming is correctly accounted for.

Verified live: 4/4 sequential kickoff parses for the same bot all
succeed with real parsed values (previously ~50% blank-fallback).
Full suite: 168 passed.
2026-04-26 15:15:14 -04:00
Joseph Doherty 5aab98e4d7 fix: classifier robustness — schema in prompt, retries, kickoff fallback
The kickoff parse-and-confirm route was 500-ing intermittently because
Hermes-3 + Featherless's response_format={"type":"json_object"} only
guarantees JSON output, NOT a particular schema. The model was inventing
its own field names (sceneTime, entities, settingDetails) instead of
the KickoffParse fields, causing Pydantic validation to fail on both
classify() retries.

Three changes:

1. Include the Pydantic JSON schema in the system prompt so the model
   knows exactly which keys to produce. Affects every classify() call
   (kickoff parse, turn parse, scene-close detect, significance,
   state-update, scene summarize). Strip ```json fences if the model
   wraps its output. Bump retries 2 → 3 (model is stochastic; one extra
   attempt closes most of the remaining gap).

2. parse_kickoff() now passes a default empty KickoffParse so the
   route degrades to a fillable form instead of 500 when the classifier
   ultimately fails. The confirm form is the human-in-the-loop; an
   empty form is strictly better UX than a stack trace.

3. Tests updated: bumped canned-failure arrays from 2 → 3 entries to
   match the new attempt count; renamed kickoff test from
   "raises_when_classifier_fails_twice" to
   "falls_back_to_empty_when_classifier_fails" reflecting the new
   degraded-but-usable behavior.

Verified live with all 3 sample bots (maya/eli/sam) — kickoff route
returns 200 across multiple attempts. Full suite: 168 passed.
2026-04-26 15:03:13 -04:00
Joseph Doherty 12502d6ec7 chore: add scripts/seed_sample_bots.py
Idempotent seeder for three sample bots (Maya — coworker slow-burn,
Eli — live-in partner, Sam — bartender / new connection). Each is a
distinct relational archetype to exercise the system from different
angles. Run from repo root:

    .venv/bin/python scripts/seed_sample_bots.py

Re-running skips ids that already exist. After seeding, walk each bot
through kickoff parse-and-confirm at /bots/<id>/kickoff.
2026-04-26 14:50:06 -04:00
Joseph Doherty 365dacc0d0 chore: post-Phase-1 cleanup — gitignore, packaging, backlog
- .gitignore: add *.egg-info/ so editable installs don't show in git status.
- pyproject.toml: add [build-system] and [tool.setuptools.packages.find]
  scoped to chat*, fixing pip install -e . which was failing on data/
  auto-discovery.
- CLAUDE.md: add Phase 1.5 cleanup backlog section under Phase 1 status,
  capturing the small follow-ups surfaced in implementer reviews
  (open_db refactor, regenerate SSE broadcast, you-activity purge,
  drawer edits for deferred fields, NICE trim order).
2026-04-26 14:39:10 -04:00
Joseph Doherty a302ed427a feat: error banners and first-run navigation flow 2026-04-26 14:33:28 -04:00
Joseph Doherty 0353d592cd feat: streaming UX with Stop, disconnect handling, send-lock 2026-04-26 14:27:39 -04:00
Joseph Doherty 330077afcf feat: transcript display formatting with markdown and OOC styling 2026-04-26 14:22:43 -04:00
Joseph Doherty 8390703b73 feat: nightly DB backups with 14-day retention 2026-04-26 14:18:57 -04:00
Joseph Doherty b9644fad31 feat: periodic snapshots with retention and cold-load fast-path 2026-04-26 14:15:17 -04:00
Joseph Doherty 82be8b3f51 feat: bot reset with hard confirm and event-driven purge 2026-04-26 14:07:56 -04:00
Joseph Doherty 46062973c2 feat: regenerate with edit-then-regenerate inline UX 2026-04-26 14:04:02 -04:00
Joseph Doherty aa0563b4fa feat: rewind with impact preview, pre-rewind snapshot, undo toast 2026-04-26 13:58:20 -04:00
Joseph Doherty b5175aefaa feat: per-POV summary and edge summary update on scene close 2026-04-26 13:53:12 -04:00
Joseph Doherty 0997562e75 feat: scene close on hard signals with manual override 2026-04-26 13:46:14 -04:00
Joseph Doherty db3005fc17 feat: drawer edits with manual_edit event capture 2026-04-26 13:40:40 -04:00
Joseph Doherty 5fc5b8ac23 feat: read-only drawer with scene, activity, edges, memories 2026-04-26 13:35:47 -04:00
Joseph Doherty 3995a8671b feat: FTS5 memory retrieval with witness filter and ranking boosts 2026-04-26 13:30:40 -04:00
Joseph Doherty eb4cdf9cbb feat: async significance pass with auto-pin on score 3 2026-04-26 13:27:25 -04:00
Joseph Doherty a45dabb6ae feat: per-turn memory writes with witness flags 2026-04-26 13:20:43 -04:00
Joseph Doherty e8d24a0875 feat: post-turn state-update pass per present entity 2026-04-26 13:17:07 -04:00
Joseph Doherty 9b45710cb1 feat: narrative streaming via SSE with assistant_turn event 2026-04-26 13:09:31 -04:00
Joseph Doherty 73d8b0c092 feat: prompt assembly with must/should/nice trim tiers 2026-04-26 13:00:00 -04:00
Joseph Doherty a0f5e818ec feat: turn input parser via classifier 2026-04-26 12:53:34 -04:00
Joseph Doherty 656c2558cb feat: per-chat SSE channel and pub/sub 2026-04-26 12:49:41 -04:00
Joseph Doherty e79f4d8d22 feat: chat shell page rendering 2026-04-26 12:39:15 -04:00
Joseph Doherty 0c08745194 feat: top-level nav and chat list view 2026-04-26 12:36:20 -04:00
Joseph Doherty fbb16c86b3 feat: kickoff parse-and-confirm flow with chat creation 2026-04-26 12:28:05 -04:00
Joseph Doherty e44e2bf93f feat: settings page with you-entity authoring 2026-04-26 12:22:00 -04:00
Joseph Doherty 44ea627a8a feat: bot authoring form with bot_authored event 2026-04-26 12:17:06 -04:00
Joseph Doherty a5339fc1d2 feat: kickoff prose parser via classifier 2026-04-26 12:09:17 -04:00
Joseph Doherty ec344064f1 feat: chats, chat_state, containers, scenes, activity tables 2026-04-26 12:03:26 -04:00
Joseph Doherty 30e6648122 feat: memory schema with witness flags and FTS5 index 2026-04-26 11:56:32 -04:00
Joseph Doherty bc97d425ef feat: directed edges with per-turn delta projector 2026-04-26 11:51:15 -04:00
Joseph Doherty 7e6c2985dd docs: fix Task 6 plan snippet: PRAGMA table_info name index is c[1] not c[0] 2026-04-26 11:48:30 -04:00
Joseph Doherty 5e6bbb586c feat: bot and you entity schemas with projector handlers 2026-04-26 11:46:19 -04:00
Joseph Doherty 517fe49aef feat: append-only event log with projector skeleton 2026-04-26 11:42:49 -04:00
Joseph Doherty c2aceffda1 feat: classifier wrapper with retry, timeout, schema-default fallback 2026-04-26 11:38:48 -04:00
Joseph Doherty e627356168 feat: LLMClient protocol with Featherless and mock implementations 2026-04-26 11:35:57 -04:00
Joseph Doherty 67517926aa feat: sqlite migration runner with meta version table 2026-04-26 11:32:32 -04:00
Joseph Doherty 01e6975d20 feat: config loader with toml + env override 2026-04-26 11:28:40 -04:00
Joseph Doherty 4a60171035 feat: project skeleton with health endpoint 2026-04-26 11:23:38 -04:00
191 changed files with 38975 additions and 1 deletions
+7
View File
@@ -2,3 +2,10 @@
# v1 runtime data (DB, backups, snapshots, exports, config with secrets)
data/
# Python
.venv/
__pycache__/
*.pyc
.pytest_cache/
*.egg-info/
+1
View File
@@ -0,0 +1 @@
3.12
+223
View File
@@ -50,6 +50,10 @@ The 3-entity cap is load-bearing: it makes the relationship graph fully enumerab
- **Snapshots**: periodic every 100 events / 30 min; pre-rewind always. 5 periodic retained; pre-rewind retained 14 days.
- **Streaming**: Stop button on streaming row; mid-stream disconnect commits partial with `truncated: true`; Send disabled mid-stream; multi-tab streaming via per-chat SSE channel.
- **Display**: lightweight markdown; `*action*` italic; OOC `((parens))` shown dimmed/italic, never sent to bot.
- **Multi-entity defaults (Phase 2)**: when `chat.guest_bot_id is None`, behavior matches Phase 1 single-bot 1:1. With a guest, all 3 entities are present in the prompt, witness writes, and state-update fan-out (6 directed pairs).
- **Addressee detection**: simple substring match (whole-word, case-insensitive) over the user turn's body. If both bot names match or neither does, the host gets the floor.
- **Interjection**: classifier-driven, conservative bias (default false on classifier failure / refusal / parse error). When the classifier returns true, the addressee speaks first, then the non-addressee may interject in a follow-up turn.
- **Per-POV summaries (multi-entity)**: each present witness with a memory store gets their own per-POV summary on scene close. The summary differs per bot based on persona + their edge to "you". The group node summary is updated alongside.
## Core concepts (vocabulary)
@@ -149,3 +153,222 @@ Don't jump phases. Phase 1 must work end-to-end before Phase 2 lands.
- Inference hosting (start with a cloud API, re-evaluate later)
- Character template format (during Phase 1)
- Multi-session / multi-character casts: **out of scope for v1**. Leave cheap schema hooks only.
## Phase 1 status
Phase 1 shipped end-to-end across **35 tasks** (T0T35). The single-bot core loop is functional: event log + projector, schema + migrations, settings/bot authoring, kickoff confirm, streaming turns, drawer rendering, regenerate/rewind, scene close + per-POV summaries, significance classifier, snapshots/backups, first-run navigation, and friendly 404/500 pages. **168 tests passing.**
Deferred to Phase 2: second bot, group node, scene configurations, witness filtering across multi-entity scenes, activity/containers, scene-transition compression. Phase 3: event queue + triggers, time skips, active threads. Phase 4: vector retrieval, branching, surgical delete + regenerate, impact-preview UI.
### Known v1 limitations (read before extending)
- **Drawer edits scope**: only affinity, significance, and pin can be hand-edited from the drawer. Other v1 fields (knowledge, summary text, traits) are deferred to Phase 1.5.
- **Cold-load snapshot path** is wired and unit-tested but rarely exercised in dev — long-running sessions are the only realistic trigger.
- **WAL sidecar files** (`-wal`, `-shm`) are not captured in nightly backups; the nightly snapshot is a fresh `.backup()` so this is fine for restore but worth knowing if you copy the db file by hand.
- **HTMX SSE event names** may need a version check if you bump the htmx CDN URL in `base.html` — the swap targets are name-coupled.
- **"You" activity rows** can linger after `bot_reset` (the reset purges the bot's chats and the bot's own activity row but not the "you" row that was associated with those chats). Cosmetic, fixed in Phase 1.5.
- **Projector replay is non-idempotent** for plain `INSERT` events. After appending, call `apply_event(conn, event)` for the new row only — calling `project(conn)` re-runs every handler from scratch and will trip uniqueness or duplicate inserts.
- **8-pin auto-cap eviction** is FIFO over the auto-pinned set only. Manual pins survive the eviction; this is by design (manual intent > auto-pin signal).
- **Regenerate (T29) does not broadcast `turn_html` over SSE** — the page must refresh to show the regenerated turn. Acceptable for v1 single-tab usage; Phase 1.5 should wire the SSE event.
- **First-run middleware** fires only on bare `/` and `/chats`. Sub-paths like `/chats/<id>` and `/chats/<id>/drawer` pass through (correct: HTMX partials should not page-redirect, and a deep-link to a missing chat should 404, not redirect mid-setup).
### Phase 1.5 cleanup backlog
All items shipped — see Phase 2.5 status below.
## Phase 2 status
Phase 2 shipped end-to-end across **13 tasks** (T36T48 wave). The multi-entity surface is functional: chats can host a guest bot, the prompt assembly is guest-aware, post-turn fans out across all directed pairs, and scene close writes a per-POV summary per present witness plus a group_node summary.
- **Multi-entity scene support**: chats can now have a guest bot (you + host + guest). The 3-entity cap holds. New event kinds: `guest_added`, `guest_removed`, `group_node_initialized`, `group_node_updated`. New table: `group_node` (members, summary, dynamic, threads).
- **Drawer guest UX**: add/remove guest from the drawer side panel. The "have they met?" prose seed is parsed by the `relationship_seed` classifier into inter-bot directed edges (host↔guest).
- **Multi-entity turn flow**: `post_turn` assembles narrative with the guest-aware prompt; writes memories for **all** present bot witnesses; runs state updates for **all** directed pairs (6 with 3 entities); detects interjections via classifier (default false; the addressee gets the floor first).
- **Per-POV scene close summaries**: each present witness with a memory store gets their own per-POV summary on close; `group_node` summary updated alongside.
- **Bot reset cascade**: resetting a bot now also clears `chats.guest_bot_id` references in other chats (root-cause fix for stale-guest references after T47).
### Phase 2.5 / 3 backlog
All items shipped — see Phase 2.5 status below.
## Phase 2.5 status
Phase 2.5 cleanup shipped end-to-end across 8 tasks (T68T75). Two CLAUDE.md backlogs (Phase 1.5 cleanup, Phase 2.5/3) are now empty; deferred follow-ups discovered during execution are tracked in a new "Phase 2.6 / 3 backlog" section below.
- **`open_db` with check_same_thread parameter (T68)**: refactored `chat/db/connection.py` so `chat/web/bots.py:get_conn` no longer duplicates the PRAGMA setup. Default behavior preserved.
- **`bot_reset` cross-chat cleanup (T69)**: now purges orphaned "you" activity rows. Note: this also fixed a latent FK constraint crash that was lurking in the projector — `activity.container_id` is FK-referenced and the prior code would have crashed on any reset of a bot whose chat had a non-NULL `container_id` "you" activity row. The bug was masked because no prior test seeded such a row.
- **LLM-merged group meta-summary (T70)**: replaces Phase 2 T45's naive concat with a classifier merge call. Falls back to the naive concat on classifier failure.
- **`prompt.py` polish (T71)**: witness role parametric (`host` vs `guest` derived from chat membership); single `ACTIVITIES:` block with bullet-level trim; NICE trim order kept with documented rationale (greedy cheapest-impact-first beats spec-listing order in practice).
- **Drawer polish (T72)**: deferred v1 edits (edge_trust slider, edge_summary textarea, memory pov_summary textarea, knowledge_facts add/remove) + first-meeting gate (Add-guest form disables prose textarea when host→guest edge already exists; "re-seed anyway" toggle re-enables) + witness flag inline-edit (per-memory checkboxes for [you, host, guest] flags). Two new `manual_edit` projector branches: `edge_knowledge_fact` and `memory_witness`.
- **Regenerate polish (T73)**: regenerate now broadcasts `turn_html_replace` over SSE (NEW event distinct from `turn_html` to avoid breaking the existing append-semantic consumer); regenerate covers interjection turns (re-detects + re-streams or supersedes); defensive stale-guest degrade removed.
- **Turn-flow polish + addressee service (T74)**: classifier-based addressee detection (substring helper kept as no-guest fast path); SignificanceJob enqueued for interjection memories; scene-close-on-cancel pinned with comment + regression test (close detection is genuinely user-prose-only); defensive stale-guest degrade removed.
### Phase 2.6 / 3 backlog
All items shipped — see Phase 3.5 status below.
## Phase 3 status
Phase 3 shipped end-to-end across 19 tasks (T49T67). Events with full lifecycle, time skips, active threads, significance refinements, and meanwhile scenes are functional. Schema baseline is now version 11 (migrations 0009 events, 0010 threads, 0011 meanwhile_scenes). Test count grew from ~247 (Phase 2) to ~315 (+68 new tests across the wave).
- **Wave 1 — schema + lifecycle handlers (parallel)**:
- **T49** `events` table + lifecycle handlers (`event_planned`, `event_started`, `event_completed`, `event_cancelled`, `event_expired`).
- **T50** `time_skip` event handlers (elision and jump variants).
- **T51** `threads` table + handlers (`thread_opened`, `thread_updated`, `thread_closed`).
- **Wave 2 — detection / narration services (parallel)**:
- **T52** event-lifecycle detection service (planned→active→completed transitions inferred from narration).
- **T53** skip narration service (elision + jump prose).
- **T54** synthesized-memories service for jump skips (LLM-summarized intervening time).
- **T55** thread-detection service (open/update/close inferred from recent dialogue).
- **Wave 3 — promotion + ranking (parallel)**:
- **T56** event-completion promotion service (objects → inventory, knowledge → edge knowledge, relationship deltas → edge summary; everything else stays in the closed event).
- **T57** significance-aware retrieval ranking — SQL-side `SIGNIFICANCE_RANK_BIAS` plus the existing Python composite re-rank.
- **T58** scene compression keeps key quotes when significance ≥ 2; thread emission piggybacks on scene close.
- **Wave 4 — drawer UX (single)**:
- **T59** drawer additions: events panel, threads panel, skip controls.
- **Wave 5a — prompt + turn flow integration (parallel)**:
- **T60** prompt assembly includes active events + open threads in the speaker's prompt.
- **T61** turn flow invokes event-detection + completion promotion alongside existing post-turn fan-out.
- **Wave 5b — natural-language skip surface (single)**:
- **T62** classifier-driven skip command at the user-input layer; shared skip controllers extracted into `chat/web/skip.py`.
- **Wave 6a — meanwhile schema (single)**:
- **T63** meanwhile-scene schema + state (scene config 4: host+guest, no "you").
- **Wave 6b — meanwhile turn flow (parallel)**:
- **T64** meanwhile turn flow (host+guest, no "you" in the prompt or witness writes).
- **T65** meanwhile summary digest surfaces to the next "you"-present scene.
- **Wave 7 — integration + docs (parallel)**:
- **T66** cross-feature integration tests covering events × skips × threads × meanwhile.
- **T67** documentation (this section).
### Phase 3.5 / 4 backlog
All items shipped — see Phase 3.5 status below.
## Phase 3.5 status
Phase 3.5 cleanup shipped end-to-end across 12 tasks (T76T87). Two CLAUDE.md backlogs (Phase 2.6/3, Phase 3.5/4) are now empty; deferred follow-ups discovered during execution are tracked in a new "Phase 3.6 / 4 backlog" section below. Test count grew from 315 (Phase 3) to 343 (+28 new tests).
- **Wave 1 — trivial polish (parallel)**:
- **T76** `narrate_skip` `timeout_s` plumbed through to `client.generate`.
- **T77** `AddresseeDecision.confidence` typed as `Literal["high","medium","low"]`.
- **T78** `search_memories` docstring notes SQL-side significance bias (`SIGNIFICANCE_RANK_BIAS`).
- **T79** `_witness_role_for` defensive `host_bot_id is None` handling (returns `"host"` for Phase-1 chats).
- **Wave 2 — scene_summarize polish (single)**:
- **T80** five T58 follow-ups: re-close suffix bloat guard, transcript scoping by scene, swallowed-exception logging in `detect_threads`, chat-clock `closed_at`, and three new tests covering T58 gaps (200-char truncation, `thread_updated`/`thread_closed` candidate paths, try/except fallback).
- **Wave 3 — typed exception (single)**:
- **T81** `ChatNotFoundError` replaces string-prefix sniff in skip routes; mapped to 404 (vs 400 for other `ValueError` cases).
- **Wave 4 — turn-flow wiring (single)**:
- **T82** `consume_pending_meanwhile_digests` wired into `post_turn` (closes T66 gap; meanwhile digests no longer pile up); natural-language skip dispatch now runs scene close detection first.
- **Wave 5 — regenerate polish (single)**:
- **T83** five sub-fixes — cancel/stop hook (regenerate registers stream task in `_in_flight_tasks`); DRY extraction of `read_recent_dialogue` and `gather_prior_edges` into `chat/services/turn_common.py`; chat-scoped sibling-assistant-turn lookup; lifecycle-rollback warning log on regenerate; ordering-symmetry comment between post_turn and regenerate event-detection paths.
- **Wave 6 — final polish (parallel)**:
- **T84** unified `record_turn_memory` API with `you_present` kwarg; `record_meanwhile_memory` becomes a thin wrapper.
- **T85** JSON-build audit (no findings) + meanwhile cancel route-level test.
- **T86** frontend `turn_html_replace` SSE handler + turn_id stamping on rendered HTML so the in-place swap actually works.
### Phase 3.6 / 4 backlog
New follow-ups discovered during Phase 3.5 reviews and execution. None are blocking; pick up at any time.
#### From T80 review
- **`read_recent_dialogue` chat-id pushdown**: helper filters `chat_id` post-fetch in Python. Could push the `json_extract(payload_json, '$.chat_id') = ?` predicate into SQL (matching T83.3's pattern) for tighter LIMIT semantics. Currently a chat-with-many-other-chats can have its 50-row LIMIT consumed by foreign rows.
- **Lifecycle warning wording in regenerate**: T83.4's warning log lists ALL lifecycle event ids that exist after the original `assistant_turn` id, not just ones produced by the superseded turn. For the typical "regenerate the most recent" flow these are identical, but if a user regenerates an OLDER turn, the warning will list intervening-turn lifecycle events that legitimately stand. Tighten warning wording to "lifecycle transitions at-or-after turn X" (operator-friendly); a code-level fix would require a schema change to add explicit back-reference from lifecycle events to their producing turn.
#### From T84 review
- **`record_turn_memory` legacy single-bot function** still exists alongside the unified `record_turn_memory_for_present`. Could be consolidated in a follow-up.
#### From T86 fix-up
- **Test fixtures + `tests/test_phase3_integration.py`** that seed turns directly via `append_event`+`project` may need updating once any new test asserts the rendered HTML carries the new turn ids end-to-end. Existing tests pass because they don't read the stamped attribute, but they're brittle if the contract evolves.
#### Deferred items (carry-over)
- **Scene-close-on-cancel UX revisit** (Phase 2.5 carry-over): T74.3 pinned the existing behavior; revisit if real play-testing surfaces a regression.
- **Cross-feature canned-queue brittleness**: meanwhile-scene close test required a canned response for T65's digest call after T64+T65 merge. Future close-path additions will keep extending the queue. Consider a structured fixture builder rather than positional canned arrays. NOT addressed in Phase 3.5.
- **Lifecycle-transition rollback in regenerate**: T83.4 added a warning log; actual rollback (with proper schema linkage from lifecycle event back to producing turn) is Phase 4 work.
## Phase 4 status
Phase 4 polish shipped end-to-end across 15 tasks (T88T102). Vector retrieval is functional via pure-Python cosine over a JSON-blob embeddings table (sqlite-vec deferred — host Python lacks loadable extensions). Branching is data-model + drawer UI. Surgical delete with cascade preview, hide-from-view soft delete, significance review panel, snapshot UX, and cross-chat search all surface from the drawer or top-bar. Test count grew from 343 (Phase 3.5) to ~413 (+70 new tests).
- **Wave 1 — schema + Phase 3.6 carry-overs (parallel)**:
- **T88** `embeddings` table + projector handlers (pure-Python cosine, JSON-blob storage; sqlite-vec deferred).
- **T89** `branches` table + handlers (main bootstrapped; `is_active` flag; partial unique index).
- **T90** Phase 3.6 carry-overs trio — `read_recent_dialogue` chat-id SQL pushdown, lifecycle warning wording tightening, legacy `record_turn_memory` removed.
- **Wave 2 — services (parallel)**:
- **T91** embedding generation service (Phase 4 ships a deterministic SHA-256-derived pseudo-embedding; real model swap is Phase 4.5+).
- **T92** vector search service via pure-Python cosine.
- **T93** cross-chat search service (FTS5 across all owners, no witness filter — admin-style).
- **Wave 3 — services (parallel)**:
- **T94** branching service (`branch_from_event`, `switch_active_branch`, `list_branches_with_metadata`).
- **T95** delete-impact computation service (cascade preview, no DB mutation).
- **Wave 4 — combined retrieval (single)**:
- **T96** combined FTS + vector retrieval ranking via reciprocal-rank fusion (RRF, `RRF_CONST=60`); existing significance/recency boost applied as final pass.
- **Wave 5 — memory write hook + backfill (single)**:
- **T97** `EmbeddingWorker` drains queue and emits `embedding_indexed` events; `memory_write` enqueues per `memory_written`; `backfill_embeddings` script for existing memories; ALL 4 production call sites wired (turns, regenerate, meanwhile, drawer).
- **Wave 6 — drawer Phase 4 bundle (single, 5 sub-features)**:
- **T98.1** branching UI (Branches panel + 3 routes).
- **T98.2** significance review panel (distribution bar chart + per-memory edit).
- **T98.3** hide-from-view toggle + `turn_hidden` `manual_edit` branch.
- **T98.4** surgical delete with cascade preview (reuses existing rewind path; pre-rewind snapshot preserved).
- **T98.5** remaining v1 edits — `narrative_anchor` + weather drawer affordances + 2 new `manual_edit` branches.
- **Wave 7 — UX surfaces (parallel)**:
- **T99** snapshot UX (manual trigger, list, restore with hard-confirm, preview).
- **T100** cross-chat search UX (top-bar form + results page).
- **Wave 8 — polish (parallel)**:
- **T101** cross-feature integration tests (5 multi-feature scenarios).
- **T102** documentation (this section).
### Phase 4.5 / 5 backlog
New follow-ups discovered during Phase 4 reviews and execution. None are blocking; pick up at any time.
#### From T88 review
- **`embeddings` FK lacks `ON DELETE CASCADE`**: deindex events are the only deletion path; if memories ever get deleted directly (raw SQL), embedding rows orphan. Defensible since projector model uses explicit deindex events, but worth a comment or `ON DELETE CASCADE` addition.
#### From T89 review
- **`list_branches(chat_id=...)` filter leaks global branches** (`chat_id IS NULL`) into every chat scope. Intentional? Document.
- **Branch-switch to nonexistent silently leaves zero active branches** — log a warning when this would happen.
#### From T91 review
- **Real embedding model swap**: Phase 4 ships pseudo-embedding (deterministic SHA-256 hash). Phase 4.5+ should swap to a real model (Featherless `bge-small-en-v1.5` if available; or local `sentence-transformers/all-MiniLM-L6-v2`). The 384-dim is hardcoded in `0012_embeddings.sql`; if dim changes, migrate first.
- **`timeout_s` unused on pseudo path** — fine, but log when non-default model falls through to fallback so misconfigured callers don't silently degrade.
#### From T96 review
- **Duplicate `MAX(id)` lookup** between `_composite_rerank` and the fused-path tail — DRY follow-up.
- **`fts_rank=None` for vector-only rows** — document downstream contract.
#### From T98 review
- **`event_id <= 0` guard in `delete_turn`** — currently silently rewinds everything if `event_id` is 0. Add `if event_id <= 0: 400`.
- **`html.escape()` on `compute_delete_impact` output rendered into the modal** — defense in depth (currently model-controlled strings, but if event payload fields ever appear in descriptions, autoescape needed).
- **Extract delete-impact modal HTML to a Jinja partial** — testability + autoescape inheritance.
#### From T99 review
- **Hoist `datetime`/`timezone` imports to module level** in `chat/web/snapshots.py`.
- **`kind` defaulting in restore/preview** — reject missing `kind` rather than silent 404.
- **`created_at` from file mtime** vs filename-encoded timestamp — small drift if files copied; document.
#### From T100 review
- **Hardcoded `k=50`** — extract to module constant.
- **N+1 lookups (`get_bot`/`get_chat`/`get_scene` per row)** — fine at `k=50`, revisit if `k` grows.
- **FTS highlighting via `snippet()`** — Phase 4 skipped this; UX nice-to-have.
- **Result links chat-level only** — `memories` table has no `event_id` column; deep-linking to specific turn requires schema addition.
#### Deferred items
- **sqlite-vec swap** when host Python supports `enable_load_extension`.
- **Real embedding model** with proper semantic similarity.
- **Branching read-side filter**: T89 ships data-model + UI but event readers don't yet consult `is_active`. Each branch is metadata-only labeled ranges. Consult-on-read is Phase 4.5+ work.
- **Bulk significance re-rate** in drawer (T98.2 deferred — only per-memory edit shipped).
- **Vector index optimization** (HNSW) — only relevant if memory counts grow past pure-Python feasibility.
- **`scene-close-on-cancel` UX revisit** (Phase 2.5 carry-over).
- **Cross-feature canned-queue brittleness fixture builder** (Phase 3 carry-over).
- **Full lifecycle-rollback in regenerate** — Phase 3.5 T83.4 shipped a warning log; proper rollback needs schema-level back-references (`triggered_by_assistant_turn_id` payload field).
View File
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException as StarletteHTTPException
from chat.config import load_settings
from chat.db.connection import open_db
from chat.db.migrate import apply_migrations
from chat.eventlog.log import read_events
from chat.eventlog.projector import apply_event
from chat.services.background import BackgroundWorker
from chat.services.embedding_worker import EmbeddingWorker
from chat.services.snapshot import latest_snapshot_path, restore_from_snapshot
# Trigger handler registration:
import chat.state.entities # noqa: F401
import chat.state.edges # noqa: F401
import chat.state.manual_edit # noqa: F401
import chat.state.memory # noqa: F401
import chat.state.world # noqa: F401
from chat.web.bots import router as bots_router
from chat.web.chat import router as chat_router
from chat.web.drawer import router as drawer_router
from chat.web.kickoff import router as kickoff_router
from chat.web.middleware import FirstRunRedirectMiddleware
from chat.web.nav import router as nav_router
from chat.web.search import router as search_router
from chat.web.settings import router as settings_router
from chat.web.snapshots import router as snapshots_router
from chat.web.sse import router as sse_router
from chat.web.turns import router as turns_router
log = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = load_settings()
settings.db_path.parent.mkdir(parents=True, exist_ok=True)
apply_migrations(settings.db_path)
# T31 cold-load fast-path: if a periodic snapshot exists, restore
# projected tables from it and replay only events past its
# ``last_event_id``. Migrations already ran above, so any new tables
# introduced after the snapshot was taken are present and empty —
# the replay-forward step refills them from the event log.
snapshot_path = latest_snapshot_path(settings.data_dir, kind="periodic")
if snapshot_path is not None:
with open_db(settings.db_path) as conn:
last_event_id = restore_from_snapshot(conn, snapshot_path)
for event in read_events(
conn, branch_id=1, after_id=last_event_id
):
apply_event(conn, event)
log.info(
"cold-load restored from %s, replayed events past id %d",
snapshot_path,
last_event_id,
)
app.state.settings = settings
# Cap concurrent Featherless connections to the account's limit
# (free / lower paid tiers cap at 2). Shared across all
# FeatherlessClient instances in the process.
from chat.llm.featherless import FeatherlessClient
FeatherlessClient.configure_concurrency(settings.featherless_max_concurrent)
# Background worker for the async significance pass (T22). Each job
# constructs a fresh FeatherlessClient via the factory; tests can
# disable enqueue by toggling ``app.state.background_worker.enabled``.
def _factory():
return FeatherlessClient(
api_key=settings.featherless_api_key,
base_url=settings.featherless_base_url,
)
worker = BackgroundWorker(settings, llm_client_factory=_factory)
await worker.start()
app.state.background_worker = worker
# T97: separate worker for the async embedding pass. Each
# ``memory_written`` enqueues an EmbeddingJob; the worker drains the
# queue, calls ``generate_embedding``, and emits ``embedding_indexed``.
# Phase 4's pseudo-embedding path is local so the worker doesn't need
# an LLM client; we still pass one so the Phase 4.5 swap to a real
# model is a one-line change.
embedding_worker = EmbeddingWorker(
conn_factory=lambda: open_db(settings.db_path),
client=_factory(),
)
await embedding_worker.start()
app.state.embedding_worker = embedding_worker
try:
yield
finally:
await embedding_worker.stop()
await worker.stop()
app = FastAPI(title="chat", lifespan=lifespan)
app.add_middleware(FirstRunRedirectMiddleware)
STATIC_DIR = Path(__file__).resolve().parent / "static"
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
ERROR_TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent / "templates")
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
"""Render a friendly HTML page for 404/500; JSON for everything else."""
if exc.status_code in (404, 500):
return ERROR_TEMPLATES.TemplateResponse(
request,
"errors.html",
{
"status_code": exc.status_code,
"detail": exc.detail or "Something went wrong.",
"active_nav": "chats",
},
status_code=exc.status_code,
)
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
app.include_router(bots_router)
app.include_router(kickoff_router)
app.include_router(settings_router)
app.include_router(snapshots_router)
app.include_router(nav_router)
app.include_router(chat_router)
app.include_router(drawer_router)
app.include_router(search_router)
app.include_router(sse_router)
app.include_router(turns_router)
@app.get("/health")
def health():
return {"status": "ok"}
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import os
import tomllib
from pathlib import Path
from pydantic import BaseModel, Field
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG = REPO_ROOT / "data" / "config.toml"
DEFAULT_DB = REPO_ROOT / "data" / "chat.db"
class Settings(BaseModel):
featherless_api_key: str
featherless_base_url: str = "https://api.featherless.ai/v1"
narrative_model: str = "dphn/Dolphin-Mistral-24B-Venice-Edition"
classifier_model: str = "NousResearch/Hermes-3-Llama-3.1-8B"
classifier_fallbacks: list[str] = Field(
default_factory=lambda: [
"cognitivecomputations/dolphin-2.9.4-llama3-8b",
"mlabonne/Meta-Llama-3.1-8B-Instruct-abliterated",
]
)
ooc_marker: str = "(("
retrieval_k: int = 4
narrative_budget_hard: int = 8000
narrative_budget_soft: int = 6000
# Cap on each generated bot response. ~400 tokens ≈ 12 short paragraphs.
# Bump if you want longer scenes; drop to 200 for terse banter.
narrative_max_tokens: int = 400
# Sampling temperature for narrative generation. 0.7 = grounded /
# consistent; 0.85 = creative-but-in-character (default); 1.0 = wide
# variety, can drift; >1.0 = often off-the-rails.
narrative_temperature: float = 0.85
classifier_budget_hard: int = 4000
classifier_timeout_s: float = 30.0
# Featherless free tier and lower paid tiers cap concurrent connections.
# Set this to your account's max-concurrent-connections limit.
featherless_max_concurrent: int = 2
db_path: Path = DEFAULT_DB
data_dir: Path = REPO_ROOT / "data"
bind_host: str = "127.0.0.1"
bind_port: int = 8000
def load_settings() -> Settings:
config_path = Path(os.environ.get("CHAT_CONFIG_PATH", DEFAULT_CONFIG))
raw: dict = {}
if config_path.exists():
raw = tomllib.loads(config_path.read_text())
if "CHAT_DB_PATH" in os.environ:
raw["db_path"] = Path(os.environ["CHAT_DB_PATH"])
if "CHAT_DATA_DIR" in os.environ:
raw["data_dir"] = Path(os.environ["CHAT_DATA_DIR"])
elif "data_dir" not in raw and "db_path" in raw:
# T31: when ``CHAT_DB_PATH`` is overridden (typical in tests) but
# ``data_dir`` isn't, derive ``data_dir`` from the db's parent so
# snapshot/auxiliary files stay alongside the test db rather than
# leaking into the real repo data dir.
raw["data_dir"] = Path(raw["db_path"]).parent
return Settings(**raw)
View File
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
@contextmanager
def open_db(path: Path, *, check_same_thread: bool = True):
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, check_same_thread=check_same_thread)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
conn.commit()
finally:
conn.close()
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from pathlib import Path
from chat.db.connection import open_db
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
def apply_migrations(db_path: Path) -> None:
with open_db(db_path) as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)"
)
cur = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'")
row = cur.fetchone()
current = int(row[0]) if row else 0
for path in sorted(MIGRATIONS_DIR.glob("*.sql")):
version = int(path.stem.split("_", 1)[0])
if version <= current:
continue
sql = path.read_text()
conn.executescript(sql)
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
(str(version),),
)
+2
View File
@@ -0,0 +1,2 @@
-- meta table is created by the migrate runner; this migration is a marker.
SELECT 1;
@@ -0,0 +1,8 @@
CREATE TABLE classifier_failures (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
model TEXT NOT NULL,
raw_text TEXT,
attempt_count INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE event_log (
id INTEGER PRIMARY KEY,
branch_id INTEGER NOT NULL DEFAULT 1,
ts TEXT NOT NULL DEFAULT (datetime('now')),
kind TEXT NOT NULL,
payload_json TEXT NOT NULL,
superseded_by INTEGER REFERENCES event_log(id),
hidden INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_event_log_branch_kind ON event_log(branch_id, kind);
+18
View File
@@ -0,0 +1,18 @@
CREATE TABLE bots (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
persona TEXT NOT NULL,
voice_samples_json TEXT NOT NULL DEFAULT '[]',
traits_json TEXT NOT NULL DEFAULT '[]',
backstory TEXT NOT NULL DEFAULT '',
initial_relationship_to_you TEXT NOT NULL DEFAULT '',
kickoff_prose TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE you_entity (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL,
pronouns TEXT NOT NULL DEFAULT '',
persona TEXT NOT NULL DEFAULT ''
);
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE edges (
id INTEGER PRIMARY KEY,
chat_id TEXT,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
affinity INTEGER NOT NULL DEFAULT 50,
trust INTEGER NOT NULL DEFAULT 50,
summary TEXT NOT NULL DEFAULT '',
knowledge_json TEXT NOT NULL DEFAULT '[]',
last_interaction_chat_id TEXT,
last_interaction_at TEXT,
UNIQUE (source_id, target_id)
);
+35
View File
@@ -0,0 +1,35 @@
CREATE TABLE memories (
id INTEGER PRIMARY KEY,
owner_id TEXT NOT NULL,
chat_id TEXT NOT NULL,
scene_id INTEGER,
pov_summary TEXT NOT NULL,
witness_you INTEGER NOT NULL,
witness_host INTEGER NOT NULL,
witness_guest INTEGER NOT NULL,
chat_clock_at TEXT,
source TEXT,
reliability REAL NOT NULL DEFAULT 1.0,
significance INTEGER NOT NULL DEFAULT 1,
pinned INTEGER NOT NULL DEFAULT 0,
auto_pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_memories_owner ON memories(owner_id);
CREATE VIRTUAL TABLE memories_fts USING fts5(
pov_summary, content='memories', content_rowid='id'
);
CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, pov_summary) VALUES (new.id, new.pov_summary);
END;
CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, pov_summary)
VALUES('delete', old.id, old.pov_summary);
INSERT INTO memories_fts(rowid, pov_summary) VALUES (new.id, new.pov_summary);
END;
CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, pov_summary)
VALUES('delete', old.id, old.pov_summary);
END;
+45
View File
@@ -0,0 +1,45 @@
CREATE TABLE chats (
id TEXT PRIMARY KEY,
host_bot_id TEXT NOT NULL,
guest_bot_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE chat_state (
chat_id TEXT PRIMARY KEY,
time TEXT NOT NULL,
weather TEXT NOT NULL DEFAULT '',
active_scene_id INTEGER,
narrative_anchor TEXT
);
CREATE TABLE containers (
id INTEGER PRIMARY KEY,
chat_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
properties_json TEXT NOT NULL DEFAULT '{}',
parent_id INTEGER REFERENCES containers(id)
);
CREATE TABLE scenes (
id INTEGER PRIMARY KEY,
chat_id TEXT NOT NULL,
container_id INTEGER REFERENCES containers(id),
started_at TEXT NOT NULL,
ended_at TEXT,
significance INTEGER NOT NULL DEFAULT 0,
participants_json TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE activity (
entity_id TEXT PRIMARY KEY,
container_id INTEGER REFERENCES containers(id),
slot TEXT,
posture TEXT NOT NULL DEFAULT '',
action_json TEXT NOT NULL DEFAULT '{}',
attention TEXT NOT NULL DEFAULT '',
holding_json TEXT NOT NULL DEFAULT '[]',
status_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE group_node (
chat_id TEXT PRIMARY KEY,
members_json TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
dynamic TEXT NOT NULL DEFAULT '',
threads_json TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE events (
id INTEGER PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE,
chat_id TEXT NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'planned',
props_json TEXT NOT NULL DEFAULT '{}',
planned_for TEXT,
started_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX events_chat_idx ON events(chat_id, status);
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE threads (
id INTEGER PRIMARY KEY,
thread_id TEXT NOT NULL UNIQUE,
chat_id TEXT NOT NULL,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
closed_at TEXT,
last_referenced_scene_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX threads_chat_status_idx ON threads(chat_id, status);
@@ -0,0 +1,27 @@
-- T63: Meanwhile scene support — extend scenes with a present-set discriminator
-- and a parent link (you-scene -> meanwhile child), plus a pending-digest queue.
--
-- Existing scenes table (0007) has columns:
-- id, chat_id, container_id, started_at, ended_at, significance,
-- participants_json
-- It has no `status` / `closed_at` columns. We treat `ended_at IS NULL` as
-- "active" and `ended_at IS NOT NULL` as "closed" — consistent with the
-- existing scene_opened/scene_closed handlers.
ALTER TABLE scenes ADD COLUMN present_set_kind TEXT NOT NULL DEFAULT 'you_host';
ALTER TABLE scenes ADD COLUMN parent_scene_id INTEGER;
CREATE INDEX scenes_present_set_idx
ON scenes(chat_id, present_set_kind, ended_at);
CREATE TABLE meanwhile_digest_pending (
id INTEGER PRIMARY KEY,
scene_id INTEGER NOT NULL,
chat_id TEXT NOT NULL,
summary TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
consumed_at TEXT
);
CREATE INDEX meanwhile_digest_chat_idx
ON meanwhile_digest_pending(chat_id) WHERE consumed_at IS NULL;
+14
View File
@@ -0,0 +1,14 @@
-- Embeddings stored as JSON arrays (pure-Python cosine at query time).
-- Phase 4.5+ may swap to sqlite-vec when the host Python supports
-- loadable extensions; the schema is intentionally simple to make that
-- migration straightforward.
CREATE TABLE embeddings (
memory_id INTEGER PRIMARY KEY,
vector_json TEXT NOT NULL, -- JSON array of floats, length = dim
model TEXT NOT NULL,
dim INTEGER NOT NULL,
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (memory_id) REFERENCES memories(id)
);
CREATE INDEX embeddings_model_idx ON embeddings(model);
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE branches (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
origin_event_id INTEGER NOT NULL,
head_event_id INTEGER NOT NULL,
chat_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
is_active INTEGER NOT NULL DEFAULT 0
);
-- Exactly one row may have is_active = 1 at any time.
CREATE UNIQUE INDEX branches_active_idx ON branches(is_active) WHERE is_active = 1;
-- Bootstrap the main branch. origin_event_id=0 + head_event_id=0 are
-- placeholder seeds; the orchestrator updates head as new events land.
INSERT INTO branches (name, origin_event_id, head_event_id, is_active)
VALUES ('main', 0, 0, 1);
View File
+77
View File
@@ -0,0 +1,77 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Iterator
from sqlite3 import Connection
@dataclass
class Event:
id: int
branch_id: int
ts: str
kind: str
payload: dict[str, Any]
superseded_by: int | None
hidden: bool
def append_event(conn: Connection, *, kind: str, payload: dict[str, Any], branch_id: int = 1) -> int:
cur = conn.execute(
"INSERT INTO event_log (branch_id, kind, payload_json) VALUES (?, ?, ?)",
(branch_id, kind, json.dumps(payload)),
)
return cur.lastrowid
def append_and_apply(
conn: Connection,
*,
kind: str,
payload: dict[str, Any],
branch_id: int = 1,
) -> int:
"""Append an event AND immediately apply just that event's handler.
Calling :func:`chat.eventlog.projector.project` after an append
re-runs every prior event, which is fine for idempotent inserts but
catastrophic for delta-shaped events like ``edge_update`` whose
handler is *not* replay-safe (each pass would re-add the same
``affinity_delta``). This helper runs only the brand-new event
through the registered handler, leaving prior state untouched.
No-ops cleanly when ``kind`` has no registered handler — useful for
transcript-only events like ``user_turn`` / ``assistant_turn`` where
callers may swap ``append_event`` for ``append_and_apply`` without
side effects.
"""
# Local import to avoid a circular dependency at module import: the
# projector imports from .log to define ``Event``.
from chat.eventlog.projector import apply_event
eid = append_event(conn, kind=kind, payload=payload, branch_id=branch_id)
event = Event(
id=eid,
branch_id=branch_id,
ts="",
kind=kind,
payload=payload,
superseded_by=None,
hidden=False,
)
apply_event(conn, event)
return eid
def read_events(conn: Connection, branch_id: int = 1, after_id: int = 0) -> Iterator[Event]:
cur = conn.execute(
"SELECT id, branch_id, ts, kind, payload_json, superseded_by, hidden "
"FROM event_log WHERE branch_id = ? AND id > ? AND hidden = 0 "
"AND superseded_by IS NULL ORDER BY id",
(branch_id, after_id),
)
for row in cur:
yield Event(
id=row[0], branch_id=row[1], ts=row[2], kind=row[3],
payload=json.loads(row[4]), superseded_by=row[5], hidden=bool(row[6]),
)
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from collections.abc import Callable
from sqlite3 import Connection
from .log import Event, read_events
Handler = Callable[[Connection, Event], None]
_REGISTRY: dict[str, Handler] = {}
def on(kind: str):
def deco(fn: Handler) -> Handler:
_REGISTRY[kind] = fn
return fn
return deco
def project(conn: Connection, branch_id: int = 1) -> None:
for event in read_events(conn, branch_id=branch_id):
h = _REGISTRY.get(event.kind)
if h:
h(conn, event)
def apply_event(conn: Connection, event: Event) -> None:
h = _REGISTRY.get(event.kind)
if h:
h(conn, event)
View File
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import json
import asyncio
from typing import TypeVar
from pydantic import BaseModel, ValidationError
from .client import LLMClient, Message
T = TypeVar("T", bound=BaseModel)
REFUSAL_PATTERNS = ("i can't", "i cannot", "i'm sorry, but", "as an ai")
def _strip_json_fences(text: str) -> str:
"""Strip ```json ... ``` markdown fences if the model wraps its JSON output."""
s = text.strip()
if s.startswith("```"):
# Drop the first fence line (which may be ``` or ```json)
s = s.split("\n", 1)[1] if "\n" in s else s[3:]
# Drop the trailing fence
if s.rstrip().endswith("```"):
s = s.rstrip()[:-3]
return s.strip()
async def classify(
client: LLMClient,
*,
model: str,
system: str,
user: str,
schema: type[T],
default: T | None = None,
timeout_s: float = 10.0,
) -> T:
schema_json = json.dumps(schema.model_json_schema(), indent=2)
schema_block = (
f"\n\nRespond with a single JSON object matching this exact schema. "
f"Use these field names exactly; do not invent your own keys:\n```json\n{schema_json}\n```"
)
msgs = [
Message(role="system", content=system + schema_block),
Message(role="user", content=user),
]
for attempt in range(3):
try:
text = await asyncio.wait_for(
client.generate(msgs, model=model, response_format={"type": "json_object"}),
timeout=timeout_s,
)
cleaned = _strip_json_fences(text)
if any(p in cleaned.lower()[:80] for p in REFUSAL_PATTERNS) and not cleaned.lstrip().startswith("{"):
raise ValueError("refusal-shaped response")
return schema.model_validate_json(cleaned)
except (ValidationError, ValueError, json.JSONDecodeError, asyncio.TimeoutError):
msgs[0] = Message(
role="system",
content=system + schema_block + "\n\nRespond with valid JSON ONLY. No prose, no markdown fences.",
)
continue
if default is None:
raise RuntimeError(f"classify failed for schema {schema.__name__} with no default")
return default
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, AsyncIterator, Sequence
@dataclass
class Message:
role: str # "system" | "user" | "assistant"
content: str
class LLMClient(Protocol):
async def generate(self, messages: Sequence[Message], *, model: str, **params) -> str: ...
def stream(self, messages: Sequence[Message], *, model: str, **params) -> AsyncIterator[str]: ...
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import asyncio
from typing import AsyncIterator, Sequence
from openai import AsyncOpenAI
from .client import Message
class FeatherlessClient:
"""Client for Featherless's OpenAI-compatible API.
Featherless caps concurrent connections per account (2 on free / lower
paid tiers). A class-level semaphore gates every ``generate`` and
``stream`` call so the orchestrator never exceeds the configured cap,
regardless of how many ``FeatherlessClient`` instances are alive.
Configure once at app startup via :meth:`configure_concurrency`. The
default is 2.
"""
_semaphore: asyncio.Semaphore | None = None
@classmethod
def configure_concurrency(cls, max_concurrent: int) -> None:
cls._semaphore = asyncio.Semaphore(max(1, int(max_concurrent)))
@classmethod
def _sem(cls) -> asyncio.Semaphore:
if cls._semaphore is None:
cls._semaphore = asyncio.Semaphore(2)
return cls._semaphore
def __init__(self, api_key: str, base_url: str = "https://api.featherless.ai/v1"):
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url)
async def generate(self, messages: Sequence[Message], *, model: str, **params) -> str:
async with self._sem():
resp = await self._client.chat.completions.create(
model=model,
messages=[{"role": m.role, "content": m.content} for m in messages],
**params,
)
return resp.choices[0].message.content or ""
async def stream(self, messages: Sequence[Message], *, model: str, **params) -> AsyncIterator[str]:
async with self._sem():
stream = await self._client.chat.completions.create(
model=model,
messages=[{"role": m.role, "content": m.content} for m in messages],
stream=True,
**params,
)
async for chunk in stream:
delta = chunk.choices[0].delta.content or ""
if delta:
yield delta
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from typing import AsyncIterator, Sequence
from .client import Message
class MockLLMClient:
def __init__(self, canned: list[str]):
self._canned = list(canned)
async def generate(self, messages: Sequence[Message], *, model: str, **params) -> str:
return self._canned.pop(0)
async def stream(self, messages: Sequence[Message], *, model: str, **params) -> AsyncIterator[str]:
text = self._canned.pop(0)
for ch in text:
yield ch
View File
+110
View File
@@ -0,0 +1,110 @@
"""Addressee classifier service (T74.1).
Phase 2 (T44) detected the addressee — host vs. guest — with a simple
case-insensitive whole-word substring match against the bots' names.
That worked for the obvious case ("BotB, what do you think?") but lost
the long tail: pronouns, paraphrases, indirect address, narrative
focus on a particular party. T74.1 swaps the substring helper for a
classifier call that reads the prose holistically.
The substring helper in :mod:`chat.web.turns` is kept as a fast-path
for the no-guest case (only one bot present means there is nothing to
classify) and as a non-breaking fallback for the regenerate path. The
multi-entity branch in :func:`chat.web.turns.post_turn` calls
:func:`detect_addressee` from this module.
Failure mode: classifier flake or low-confidence response degrades to
the host (the default speaker per Phase 2's host-keeps-the-floor
bias). The decision carries ``confidence`` and ``reason`` so callers
that want to log degraded decisions can distinguish a real "host" call
from a fallback.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class AddresseeDecision(BaseModel):
"""Which present bot the user is addressing.
``addressee_id`` is the chosen bot's id. ``confidence`` is one of
``"high"`` / ``"medium"`` / ``"low"`` — callers may treat ``"low"``
as a soft fallback to the host. ``reason`` is a short free-form
string. The classifier-failure fallback uses ``reason="fallback"``
so it's distinguishable from a real low-confidence call.
"""
addressee_id: str
confidence: Literal["high", "medium", "low"] = "medium"
reason: str = ""
_SYSTEM = (
"Given a user's turn prose and the names of present bots, decide "
"which bot the user is addressing. If the user is speaking to no "
"specific bot (descriptive narration, action without dialogue), "
"default to the host. Output strict JSON matching the schema. "
"The addressee_id MUST be one of the ids supplied in the user "
"message — do not invent ids."
)
async def detect_addressee(
client: LLMClient,
*,
classifier_model: str,
user_prose: str,
host_id: str,
host_name: str,
guest_id: str | None,
guest_name: str | None,
timeout_s: float = 30.0,
) -> AddresseeDecision:
"""Classify which present bot the user is addressing.
Defaults to host on classifier failure or when the classifier picks
an id that isn't one of the supplied ids. The caller is expected to
only invoke this in the multi-entity case (a guest is present);
when no guest is present the substring fast-path in
:mod:`chat.web.turns` is used instead and this function is not
called.
"""
fallback = AddresseeDecision(
addressee_id=host_id, confidence="low", reason="fallback"
)
user = (
f"Host: {host_name} (id={host_id})\n"
+ (
f"Guest: {guest_name} (id={guest_id})\n"
if guest_id is not None
else ""
)
+ f"\nUser prose:\n{user_prose}"
)
decision = await classify(
client,
model=classifier_model,
system=_SYSTEM,
user=user,
schema=AddresseeDecision,
default=fallback,
timeout_s=timeout_s,
)
# Defensive: if the classifier returned an id outside the supplied
# set, treat it as a fallback to the host. This catches pathological
# outputs that pass schema validation but pick a phantom id.
valid_ids = {host_id}
if guest_id is not None:
valid_ids.add(guest_id)
if decision.addressee_id not in valid_ids:
return fallback
return decision
__all__ = ["AddresseeDecision", "detect_addressee"]
+262
View File
@@ -0,0 +1,262 @@
"""Async background worker for post-turn jobs (T22).
The turn flow records a ``memory_written`` event synchronously on the
request path so the timeline updates immediately. Significance scoring is
a separate classifier round-trip that we don't want to block on, so the
turn handler enqueues a :class:`SignificanceJob` here and the worker
drains the queue out-of-band.
A single :class:`BackgroundWorker` is started/stopped via FastAPI lifespan
in :mod:`chat.app`. The worker owns its own ``asyncio.Queue`` and runs
exactly one task that pulls jobs off the queue, calls
:func:`chat.services.significance.compute_significance`, and writes
``memory_significance_set`` (and on score 3, ``memory_pin_changed``)
events. Each job opens its own DB connection — workers and request
handlers don't share connections.
Failures inside ``_process`` are logged and swallowed: a flaky classifier
shouldn't take down the worker. Tests can disable enqueue() by setting
``BackgroundWorker.enabled = False`` (e.g. in the existing turn-flow
fixture, which doesn't have a usable LLM key for the lifespan-managed
factory).
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from typing import Callable
from chat.config import Settings
from chat.db.connection import open_db
from chat.eventlog.log import append_and_apply
from chat.llm.client import LLMClient
from chat.services.backup import (
prune_backups,
should_take_backup,
take_backup,
)
from chat.services.significance import compute_significance
from chat.services.snapshot import (
prune_periodic_snapshots,
should_take_periodic_snapshot,
take_snapshot,
)
# T32: tick-loop wake interval. 60s gives a single backup window per
# target hour with plenty of slack: should_take_backup's 23h freshness
# guard prevents back-to-back runs.
BACKUP_TICK_INTERVAL_SECONDS = 60.0
log = logging.getLogger(__name__)
@dataclass
class SignificanceJob:
"""One unit of work for the background worker.
``host_bot_id`` is the memory's owner — used both for the auto-pin
soft cap query and as the eventual scope for the soft-cap eviction.
"""
memory_id: int
narrative_text: str
prior_dialogue: list[dict]
host_bot_id: str
class BackgroundWorker:
"""asyncio.Queue-backed single-worker task.
Started on app startup; ``stop()`` enqueues a sentinel and awaits the
task so any in-flight job has a chance to finish. Pending jobs after
the sentinel are dropped on shutdown — Phase 1 simplification.
"""
def __init__(
self,
settings: Settings,
llm_client_factory: Callable[[], LLMClient],
*,
enabled: bool = True,
) -> None:
self._settings = settings
self._llm_client_factory = llm_client_factory
self._queue: asyncio.Queue[SignificanceJob | None] = asyncio.Queue()
self._task: asyncio.Task | None = None
# T32: nightly-backup tick loop runs alongside the job loop. The
# event is set by stop() to wake the loop early so shutdown is
# snappy even mid-tick.
self._tick_task: asyncio.Task | None = None
self._tick_stop: asyncio.Event = asyncio.Event()
self.enabled = enabled
async def start(self) -> None:
if self._task is not None:
return
self._task = asyncio.create_task(self._run())
self._tick_task = asyncio.create_task(self._tick_loop())
async def stop(self) -> None:
# Stop the tick loop first — it has no in-flight work to drain,
# so signalling early lets it exit while the job loop is still
# finishing its sentinel handoff.
self._tick_stop.set()
if self._tick_task is not None:
await self._tick_task
self._tick_task = None
if self._task is None:
return
await self._queue.put(None) # sentinel
await self._task
self._task = None
def enqueue(self, job: SignificanceJob) -> None:
if not self.enabled:
return
self._queue.put_nowait(job)
async def _run(self) -> None:
while True:
job = await self._queue.get()
if job is None:
return
try:
await self._process(job)
except Exception as exc: # noqa: BLE001 — worker must not die
log.exception("significance job failed: %s", exc)
async def _tick_loop(self) -> None:
"""Periodic-operations loop (T32 nightly backup).
Wakes every :data:`BACKUP_TICK_INTERVAL_SECONDS` seconds and
asks :func:`should_take_backup` whether a backup is due. The
scheduling decision lives in the backup module so we don't
duplicate the "is it 03:00?" logic here. Failures are caught
and logged so a flaky disk doesn't kill the loop — the next
tick will retry.
Wait uses :func:`asyncio.wait_for` on ``_tick_stop`` so that
:meth:`stop` can interrupt a sleeping tick instead of having to
wait the full interval.
"""
while not self._tick_stop.is_set():
try:
if should_take_backup(self._settings.data_dir):
take_backup(
db_path=self._settings.db_path,
data_dir=self._settings.data_dir,
)
prune_backups(self._settings.data_dir, keep=14)
log.info("nightly backup taken")
except Exception as exc: # noqa: BLE001 — never break the loop
log.exception("backup tick failed: %s", exc)
try:
await asyncio.wait_for(
self._tick_stop.wait(),
timeout=BACKUP_TICK_INTERVAL_SECONDS,
)
except asyncio.TimeoutError:
# Normal path: timed out waiting for stop, run another tick.
pass
async def _process(self, job: SignificanceJob) -> None:
client = self._llm_client_factory()
score = await compute_significance(
client,
model=self._settings.classifier_model,
narrative_text=job.narrative_text,
prior_dialogue=job.prior_dialogue,
)
with open_db(self._settings.db_path) as conn:
append_and_apply(
conn,
kind="memory_significance_set",
payload={
"memory_id": job.memory_id,
"significance": score,
},
)
if score >= 3:
_auto_pin_with_cap(
conn,
owner_id=job.host_bot_id,
memory_id=job.memory_id,
)
# T31: piggy-back the periodic snapshot check on the background
# worker so we don't need a separate timer task. The classifier
# pass already runs out-of-band, so snapshot I/O on the same
# worker is a natural fit. Each snapshot opens its own
# connection so we don't conflate the snapshot's read-only view
# with the significance-write transaction above. Failures are
# caught and logged: a flaky disk shouldn't take down the
# significance pipeline.
try:
with open_db(self._settings.db_path) as conn:
if should_take_periodic_snapshot(
conn, self._settings.data_dir
):
snapshot_path = take_snapshot(
conn,
data_dir=self._settings.data_dir,
kind="periodic",
)
prune_periodic_snapshots(
self._settings.data_dir, keep=5
)
log.info(
"periodic snapshot taken: %s", snapshot_path
)
except Exception as exc: # noqa: BLE001 — never break the worker
log.exception("periodic snapshot failed: %s", exc)
def _auto_pin_with_cap(
conn,
*,
owner_id: str,
memory_id: int,
cap: int = 8,
) -> None:
"""Auto-pin ``memory_id`` and evict the oldest auto-pin if over ``cap``.
Per §8.5: pivotal turns are auto-pinned, with a soft cap of 8 pins per
bot. When the cap is exceeded the oldest auto-pin is unpinned (manual
pins are never auto-evicted — we filter on ``auto_pinned = 1``).
"""
append_and_apply(
conn,
kind="memory_pin_changed",
payload={
"memory_id": memory_id,
"pinned": 1,
"auto_pinned": 1,
},
)
cur = conn.execute(
"SELECT COUNT(*) FROM memories WHERE owner_id = ? AND pinned = 1",
(owner_id,),
)
count = cur.fetchone()[0]
if count <= cap:
return
cur = conn.execute(
"SELECT id FROM memories "
"WHERE owner_id = ? AND pinned = 1 AND auto_pinned = 1 AND id != ? "
"ORDER BY created_at ASC, id ASC LIMIT 1",
(owner_id, memory_id),
)
row = cur.fetchone()
if row is None:
return
append_and_apply(
conn,
kind="memory_pin_changed",
payload={
"memory_id": row[0],
"pinned": 0,
"auto_pinned": 0,
},
)
+106
View File
@@ -0,0 +1,106 @@
"""Nightly DB backup service (T32, Requirements §12).
A simple in-process scheduler: at 03:00 local time daily, copy
``chat.db`` to ``data/backups/chat-<utc-timestamp>.db`` and prune to the
14 most recent. The BackgroundWorker tick loop calls
:func:`should_take_backup` every 60 seconds; when it returns True the
worker calls :func:`take_backup` then :func:`prune_backups`.
The launchd plist suggested in §12 can replace this later by invoking a
small script that calls :func:`take_backup` directly. For v1 the
in-process loop is enough — the daemon already runs continuously to
serve requests, so there's no extra moving part to install.
Backups capture the live ``.db`` file via :func:`shutil.copy2`. SQLite's
WAL mode means an in-flight transaction's pages might live in the
``-wal`` sidecar rather than the main file, but our codebase commits
every write transaction synchronously, so the .db alone is sufficient
for v1. A truly safe online backup would use
``sqlite3.Connection.backup()``; deferred.
"""
from __future__ import annotations
import shutil
from datetime import datetime, timezone
from pathlib import Path
# 03:00 local time per Requirements §12. Hardcoded for v1 — making this
# configurable via Settings is straightforward but not needed yet.
DEFAULT_BACKUP_HOUR = 3
# Retention window per Requirements §12 ("Last 14 retained").
DEFAULT_KEEP = 14
# Wake interval for should_take_backup's freshness check. We wake the
# tick loop every 60s, so a backup taken in the previous tick within the
# same target hour must NOT trigger another. 23h gives us a generous
# safety margin against scheduling jitter while still allowing a single
# backup per day.
FRESHNESS_HOURS = 23
def take_backup(*, db_path: Path, data_dir: Path) -> Path:
"""Copy ``db_path`` to ``data_dir/backups/chat-<utc-timestamp>.db``.
Returns the new file path. Creates the backup directory if missing.
Uses :func:`shutil.copy2` so the destination's mtime is preserved —
:func:`should_take_backup` reads mtime to gate fresh backups.
"""
backup_dir = data_dir / "backups"
backup_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
backup_path = backup_dir / f"chat-{timestamp}.db"
shutil.copy2(db_path, backup_path)
return backup_path
def prune_backups(data_dir: Path, *, keep: int = DEFAULT_KEEP) -> int:
"""Remove all but the most recent ``keep`` backup files.
Returns the number of files removed. Safe when the directory is
missing (returns 0). Sorting is by filename, which is the UTC
timestamp embedded in the name — lexicographic order matches
chronological order.
"""
backup_dir = data_dir / "backups"
if not backup_dir.exists():
return 0
files = sorted(backup_dir.glob("chat-*.db"))
to_remove = files[:-keep] if len(files) > keep else []
for f in to_remove:
f.unlink()
return len(to_remove)
def should_take_backup(
data_dir: Path, *, target_hour: int = DEFAULT_BACKUP_HOUR
) -> bool:
"""Decide whether a nightly backup is due.
Two conditions must hold:
* The current local hour matches ``target_hour``.
* No backup file in ``data_dir/backups/`` has an mtime within the
last :data:`FRESHNESS_HOURS` (23h). The 23h window prevents a
double-backup within the same target hour while still allowing
the next day's run to fire on time.
Local time (not UTC) is used for the hour comparison per the
requirements ("03:00 local time"). The filename embeds a UTC stamp
so file ordering remains unambiguous across DST transitions.
"""
now = datetime.now()
if now.hour != target_hour:
return False
backup_dir = data_dir / "backups"
if not backup_dir.exists():
return True
files = list(backup_dir.glob("chat-*.db"))
if not files:
return True
most_recent = max(files, key=lambda f: f.stat().st_mtime)
age_hours = (
datetime.now().timestamp() - most_recent.stat().st_mtime
) / 3600
return age_hours >= FRESHNESS_HOURS
+107
View File
@@ -0,0 +1,107 @@
"""Branching service (T94, Phase 4).
Wraps branches state with validation + event emission. Phase 4 ships
the data model and creation/switching APIs; the read-side filter
(event readers consulting is_active) is a Phase 4.5+ follow-up — for
now branches are metadata-only and the existing event readers remain
branch-agnostic. The drawer UI (T98) drives create/switch via these
helpers.
"""
from __future__ import annotations
from sqlite3 import Connection
from chat.eventlog.log import append_and_apply
from chat.state.branches import get_branch, list_branches, active_branch # noqa: F401
def branch_from_event(
conn: Connection,
*,
name: str,
origin_event_id: int,
chat_id: str | None = None,
) -> int:
"""Create a new named branch forking from origin_event_id.
Emits a branch_created event. Returns the new branch's row id.
Raises ValueError if name already exists or origin_event_id doesn't
correspond to a real event."""
if not name or not name.strip():
raise ValueError("branch name must be non-empty")
if get_branch(conn, name) is not None:
raise ValueError(f"branch {name!r} already exists")
# Validate origin_event_id is a real event id (or 0 for the bootstrap case
# which only main uses).
if origin_event_id < 0:
raise ValueError(f"origin_event_id must be >= 0, got {origin_event_id}")
if origin_event_id > 0:
row = conn.execute(
"SELECT 1 FROM event_log WHERE id = ?", (origin_event_id,)
).fetchone()
if row is None:
raise ValueError(
f"origin_event_id {origin_event_id} does not exist in event_log"
)
append_and_apply(
conn,
kind="branch_created",
payload={
"name": name,
"origin_event_id": origin_event_id,
"head_event_id": origin_event_id, # head starts at origin
"chat_id": chat_id,
},
)
branch = get_branch(conn, name)
if branch is None:
# Should be unreachable if append_and_apply worked.
raise RuntimeError(f"branch {name!r} not found after creation")
return branch["id"]
def switch_active_branch(conn: Connection, *, name: str) -> None:
"""Make the named branch active. Emits branch_switched."""
if get_branch(conn, name) is None:
raise ValueError(f"branch {name!r} does not exist")
append_and_apply(
conn,
kind="branch_switched",
payload={"name": name},
)
def list_branches_with_metadata(
conn: Connection, chat_id: str | None = None
) -> list[dict]:
"""List branches with computed event_count metadata.
event_count = head_event_id - origin_event_id + 1 (when both are set)
OR head_event_id (when origin is 0, e.g., main branch)
OR 0 (when head <= origin, which is the bootstrap state)
"""
branches = list_branches(conn, chat_id)
enriched = []
for b in branches:
origin = b["origin_event_id"]
head = b["head_event_id"]
if head < origin:
event_count = 0
elif origin == 0:
event_count = head
else:
event_count = head - origin + 1
enriched.append({**b, "event_count": event_count})
return enriched
__all__ = [
"branch_from_event",
"switch_active_branch",
"list_branches_with_metadata",
]
+75
View File
@@ -0,0 +1,75 @@
"""Cross-chat search service (T93, Phase 4).
FTS5-based search across ALL owners and ALL chats. Used by the
top-bar search UX (T100) for "where did I last see this character
mention X?" queries. NO witness filter -- this is intentionally a
power-user surface that surfaces memories across POVs.
Mirrors the FTS5 access pattern of ``chat.state.memory.search_memories``
but drops both the ``owner_id = ?`` and the per-witness predicates so a
single query can sweep every chat in the database. The composite
re-rank is also dropped: callers want raw BM25 ordering for the
"highest match strength wins" semantics expected of a global search box.
"""
from __future__ import annotations
from sqlite3 import Connection
def search_all_memories(
conn: Connection,
*,
query: str,
k: int = 20,
) -> list[dict]:
"""Search FTS5 across all owners and chats.
Returns rows with ``{memory_id, owner_id, chat_id, scene_id,
pov_summary, significance, ts, fts_rank}``, sorted by FTS5 BM25
rank ascending (lower rank = stronger match, surfaced first).
The ``memories`` table has no ``ts`` column; we expose ``created_at``
(the projector-side row insertion timestamp) under that key so the
UI does not have to know the storage name.
An empty / whitespace-only ``query`` short-circuits to ``[]`` to
avoid an FTS5 ``MATCH ''`` syntax error and to keep the top-bar
"no input yet" state from triggering a full-table scan.
"""
if not query or not query.strip():
return []
# FTS5 MATCH against the same ``memories_fts`` virtual table that
# backs ``state.memory.search_memories``; the JOIN pulls metadata
# from the content table because the FTS index only stores
# ``pov_summary``. ORDER BY rank ASC because BM25 in FTS5 returns
# negative scores where lower is better.
rows = conn.execute(
"SELECT m.id, m.owner_id, m.chat_id, m.scene_id, "
" m.pov_summary, m.significance, m.created_at, "
" memories_fts.rank "
"FROM memories_fts "
"JOIN memories m ON m.id = memories_fts.rowid "
"WHERE memories_fts MATCH ? "
"ORDER BY memories_fts.rank ASC "
"LIMIT ?",
(query.strip(), k),
).fetchall()
return [
{
"memory_id": r[0],
"owner_id": r[1],
"chat_id": r[2],
"scene_id": r[3],
"pov_summary": r[4],
"significance": r[5],
"ts": r[6],
"fts_rank": r[7],
}
for r in rows
]
__all__ = ["search_all_memories"]
+147
View File
@@ -0,0 +1,147 @@
"""Delete-impact computation service (T95, Phase 4).
Walks event_log forward from a target event_id and produces an ImpactReport
describing what would be removed if rewind-to-target were invoked. Pure
computation — does NOT mutate the database. Used by T98's drawer surgical-
delete UI to render an 'are you sure?' modal before invoking the actual
rewind path (chat/services/rewind.py).
"""
from __future__ import annotations
import json
from sqlite3 import Connection
from pydantic import BaseModel, Field
class DeletedItem(BaseModel):
kind: str
description: str
target_id: int | str | None = None
class ImpactReport(BaseModel):
target_event_id: int
cascading: list[DeletedItem] = Field(default_factory=list)
notes: list[str] = Field(default_factory=list)
def _excerpt(text: str, n: int = 60) -> str:
text = (text or "").strip().replace("\n", " ")
return text if len(text) <= n else text[: n - 1] + ""
def compute_delete_impact(
conn: Connection,
*,
target_event_id: int,
) -> ImpactReport:
"""Compute the cascading impact of rewinding to target_event_id."""
# Verify target exists.
target_row = conn.execute(
"SELECT id, kind, payload_json FROM event_log WHERE id = ?",
(target_event_id,),
).fetchone()
if target_row is None:
return ImpactReport(
target_event_id=target_event_id,
cascading=[],
notes=[f"target event_id {target_event_id} not found"],
)
# Walk forward: every event with id >= target_event_id is in scope.
rows = conn.execute(
"SELECT id, kind, payload_json FROM event_log "
"WHERE id >= ? ORDER BY id ASC",
(target_event_id,),
).fetchall()
cascading: list[DeletedItem] = []
notes: list[str] = []
scene_close_present = False
regenerated_from = None
for row_id, kind, payload_json in rows:
try:
payload = json.loads(payload_json) if payload_json else {}
except (json.JSONDecodeError, TypeError):
payload = {}
if kind == "memory_written":
cascading.append(
DeletedItem(
kind=kind,
description=f"memory: {_excerpt(payload.get('pov_summary', ''))}",
target_id=payload.get("memory_id"),
)
)
elif kind == "edge_update":
src = payload.get("source_id", "?")
tgt = payload.get("target_id", "?")
cascading.append(
DeletedItem(
kind=kind,
description=f"edge update: {src} -> {tgt}",
target_id=f"{src}->{tgt}",
)
)
elif kind == "scene_closed":
scene_close_present = True
cascading.append(
DeletedItem(
kind=kind,
description=f"scene close at {payload.get('closed_at', '?')}",
target_id=payload.get("scene_id"),
)
)
elif kind in ("user_turn", "user_turn_edit", "assistant_turn"):
speaker = payload.get("speaker_id") or ("you" if kind.startswith("user") else "?")
prose = payload.get("prose") or payload.get("text") or ""
cascading.append(
DeletedItem(
kind=kind,
description=f"turn {row_id} ({speaker}: {_excerpt(prose, 50)})",
target_id=row_id,
)
)
if regenerated_from is None and payload.get("regenerated_from"):
regenerated_from = payload["regenerated_from"]
elif kind == "manual_edit":
target_kind = payload.get("target_kind", "?")
cascading.append(
DeletedItem(
kind=kind,
description=f"manual edit: {target_kind}",
target_id=payload.get("target_id"),
)
)
else:
cascading.append(
DeletedItem(
kind=kind,
description=f"{kind} event",
target_id=row_id,
)
)
# Notes / warnings.
notes.append(f"{len(rows)} events would be discarded total")
if scene_close_present:
notes.append(
"scene close events are in scope — closing-scene per-POV summaries "
"and group_node updates will be reverted"
)
if regenerated_from is not None:
notes.append(
f"target turn was regenerated from event_id {regenerated_from}; "
f"the original turn remains intact"
)
return ImpactReport(
target_event_id=target_event_id,
cascading=cascading,
notes=notes,
)
__all__ = ["DeletedItem", "ImpactReport", "compute_delete_impact"]
+137
View File
@@ -0,0 +1,137 @@
"""Embedding worker (T97, Phase 4).
Drains a queue of embedding jobs. Each job carries a memory id and the
narrative text to embed; the worker calls
:func:`chat.services.embeddings.generate_embedding` and emits an
``embedding_indexed`` event so the projector lands the vector in the
``embeddings`` table.
Mirrors the :class:`chat.services.background.BackgroundWorker` pattern:
single asyncio task, sentinel-based shutdown, exceptions are caught and
logged so a flaky embedding call doesn't take down the worker. Each job
opens its own SQLite connection via ``conn_factory`` — the request path
and the worker do not share connections.
Featherless concurrency (the 2-conn cap) is respected by virtue of the
single-task design: jobs run strictly serially. Phase 4's pseudo-embedding
path is local and synchronous so this is largely moot, but the pattern
is in place for the Phase 4.5+ real-embedding swap.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
from sqlite3 import Connection
from typing import Callable
from chat.eventlog.log import append_and_apply
from chat.services.embeddings import (
DEFAULT_EMBEDDING_DIM,
DEFAULT_EMBEDDING_MODEL,
FALLBACK_EMBEDDING_MODEL,
generate_embedding,
)
log = logging.getLogger(__name__)
@dataclass
class EmbeddingJob:
"""One unit of work for the embedding worker.
``memory_id`` is the row to attach the vector to; ``text`` is the
narrative text to embed (typically ``memories.pov_summary``).
"""
memory_id: int
text: str
class EmbeddingWorker:
"""asyncio.Queue-backed single-worker task for embedding generation.
Started on app startup; ``stop()`` enqueues a sentinel and awaits
the task so any in-flight job has a chance to finish. Pending jobs
after the sentinel are dropped on shutdown.
"""
def __init__(
self,
*,
conn_factory: Callable[[], Connection],
client, # LLMClient | None — unused on the pseudo path.
model: str = DEFAULT_EMBEDDING_MODEL,
dim: int = DEFAULT_EMBEDDING_DIM,
enabled: bool = True,
) -> None:
self._queue: asyncio.Queue[EmbeddingJob | None] = asyncio.Queue()
self._conn_factory = conn_factory
self._client = client
self._model = model
self._dim = dim
self._task: asyncio.Task | None = None
self.enabled = enabled
def enqueue(self, job: EmbeddingJob) -> None:
if not self.enabled:
return
self._queue.put_nowait(job)
async def start(self) -> None:
if self._task is None:
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
if self._task is None:
return
self._queue.put_nowait(None) # sentinel
await self._task
self._task = None
async def _run(self) -> None:
while True:
job = await self._queue.get()
if job is None:
return
try:
await self._process(job)
except Exception as exc: # noqa: BLE001 — worker must not die
log.warning(
"embedding worker failed for memory_id=%s: %s",
job.memory_id,
exc,
exc_info=True,
)
async def _process(self, job: EmbeddingJob) -> None:
result = await generate_embedding(
self._client,
text=job.text,
model=self._model,
dim=self._dim,
)
if result.model == FALLBACK_EMBEDDING_MODEL:
# Don't index a fallback (zero) vector — the backfill script
# can retry later once a real embedding is available.
log.debug(
"embedding worker skipping fallback result for memory_id=%s",
job.memory_id,
)
return
with self._conn_factory() as conn:
append_and_apply(
conn,
kind="embedding_indexed",
payload={
"memory_id": job.memory_id,
"model": result.model,
"dim": result.dim,
"vector": result.vector,
},
)
__all__ = ["EmbeddingJob", "EmbeddingWorker"]
+108
View File
@@ -0,0 +1,108 @@
"""Embedding generation service (T91, Phase 4).
Wraps the embedding API call. For Phase 4's first cut we ship a
deterministic local pseudo-embedding (hash-derived) so the vector
retrieval pipeline can land without an external embedding endpoint
or heavy local dependency. Phase 4.5+ swaps to a real model — the
EmbeddingResult shape stays the same, only the generator changes.
"""
from __future__ import annotations
import hashlib
import math
import struct
from pydantic import BaseModel
from chat.llm.client import LLMClient
DEFAULT_EMBEDDING_DIM = 384
DEFAULT_EMBEDDING_MODEL = "pseudo-sha256-384"
FALLBACK_EMBEDDING_MODEL = "fallback"
class EmbeddingResult(BaseModel):
vector: list[float]
model: str
dim: int
def _pseudo_embed(text: str, dim: int = DEFAULT_EMBEDDING_DIM) -> list[float]:
"""Deterministic pseudo-embedding for Phase 4 first cut.
Hashes the text with SHA-256, then expands by re-hashing each
successive block with the previous block + a counter — this gives
``dim * 4`` bytes of fresh entropy per input rather than naively
repeating the 32-byte digest (which would collapse the vector onto
only 8 unique floats and make distinct inputs cosine-similar).
Bytes are unpacked as little-endian int32s and rescaled to [-1, 1]
so we sidestep the float32 NaN/denormal values that ``struct.unpack
'f'`` would otherwise produce on raw hash bytes. The result is
unit-normalized so cosine similarity reduces to a dot product.
NOT semantically meaningful — just consistent for testing the
pipeline. Phase 4.5 should swap to a real embedding model.
"""
needed = dim * 4 # 4 bytes per int32
seed = text.encode("utf-8")
chunks: list[bytes] = []
counter = 0
while sum(len(c) for c in chunks) < needed:
block = hashlib.sha256(seed + counter.to_bytes(4, "big")).digest()
chunks.append(block)
counter += 1
full = b"".join(chunks)[:needed]
ints = struct.unpack(f"<{dim}i", full)
# Map int32 to roughly [-1, 1] — exact bound doesn't matter since we
# normalize, but keeps values numerically tame.
raw = [x / 2147483648.0 for x in ints]
norm = math.sqrt(sum(x * x for x in raw)) or 1.0
return [x / norm for x in raw]
async def generate_embedding(
client: LLMClient,
*,
text: str,
model: str = DEFAULT_EMBEDDING_MODEL,
dim: int = DEFAULT_EMBEDDING_DIM,
timeout_s: float = 30.0,
) -> EmbeddingResult:
"""Generate an embedding for the given text.
Phase 4 default uses a deterministic local pseudo-embedding. If
the LLMClient grows an ``embed(...)`` method in Phase 4.5, this
wrapper will route to it when ``model != "pseudo-sha256-384"``.
Falls back to a zero vector with ``model="fallback"`` on any
failure (callers detect the sentinel and skip indexing). For the
pseudo path, failure is structurally impossible — it's pure local
computation.
"""
if not text or not text.strip():
# Empty input — return fallback so caller doesn't index empty rows.
return EmbeddingResult(
vector=[0.0] * dim, model=FALLBACK_EMBEDDING_MODEL, dim=dim
)
if model == DEFAULT_EMBEDDING_MODEL:
# Pure-local pseudo path — no LLMClient call.
return EmbeddingResult(vector=_pseudo_embed(text, dim), model=model, dim=dim)
# Future: real embedding via client.embed(...). Phase 4.5 work.
# For Phase 4, any non-default model falls through to fallback.
return EmbeddingResult(
vector=[0.0] * dim, model=FALLBACK_EMBEDDING_MODEL, dim=dim
)
__all__ = [
"DEFAULT_EMBEDDING_DIM",
"DEFAULT_EMBEDDING_MODEL",
"FALLBACK_EMBEDDING_MODEL",
"EmbeddingResult",
"generate_embedding",
]
+72
View File
@@ -0,0 +1,72 @@
"""Event-lifecycle detection (T52).
After each turn, classify whether any active events transitioned
(started, completed, cancelled). Conservative bias — most turns
return empty. T61 turn flow appends one event_started/completed/
cancelled per transition via append_and_apply.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class EventTransition(BaseModel):
event_id: str
new_status: str # "active" | "completed" | "cancelled"
reason: str = ""
class EventLifecycleDecision(BaseModel):
transitions: list[EventTransition] = Field(default_factory=list)
_SYSTEM = (
"You decide whether any active events transitioned this turn. "
"STRONGLY default to empty transitions — most turns do NOT resolve "
"or start a known event. Output only transitions that the narrative "
"text clearly resolves or starts. Each transition MUST reference an "
"event_id from the active_events list. new_status is one of "
"'active' (planned -> active), 'completed', or 'cancelled'. "
"Output strict JSON matching the schema."
)
async def detect_event_transitions(
client: LLMClient,
*,
classifier_model: str,
narrative_text: str,
active_events: list[dict], # [{event_id, kind, status, props}, ...]
timeout_s: float = 30.0,
) -> EventLifecycleDecision:
"""Classify event transitions for the latest turn. Empty active_events
short-circuits without an LLM call."""
if not active_events:
return EventLifecycleDecision()
user_lines = ["Active events:"]
for ev in active_events:
user_lines.append(
f"- event_id={ev['event_id']} kind={ev['kind']} "
f"status={ev['status']} props={ev.get('props', {})}"
)
user_lines.append("")
user_lines.append("Latest narrative:")
user_lines.append(narrative_text.strip())
user = "\n".join(user_lines)
return await classify(
client,
model=classifier_model,
system=_SYSTEM,
user=user,
schema=EventLifecycleDecision,
default=EventLifecycleDecision(),
timeout_s=timeout_s,
)
__all__ = ["EventTransition", "EventLifecycleDecision", "detect_event_transitions"]
+149
View File
@@ -0,0 +1,149 @@
"""Event-completion promotion (T56).
When an event reaches ``status='completed'``, read its ``props_json``
and emit promotion events into the appropriate state stores.
Synchronous, no LLM. Skips when the event status is not ``completed``
(cancelled / expired terminate the event without promoting).
Props recognized:
- ``acquired_objects: list[str]`` — emits a ``manual_edit`` with
``target_kind="memory_pov_summary"`` per object on the host's memory
row, recording the acquisition. Phase 3 is a stub: it requires both
``host_bot_id`` and ``host_memory_id`` (an existing memories.id) to
be present in props; missing either skips that object cleanly.
Phase 4 will introduce a real inventory schema.
- ``knowledge_facts: list[{owner_id, target_id, fact}]`` — emits an
``edge_update`` event on the directed ``owner_id -> target_id`` edge
with the fact appended to ``knowledge_facts``. The ``edge_update``
projector accepts ``knowledge_facts`` as a list and extends the
edge's stored ``knowledge_json``.
- ``relationship_change: {summary, source_id, target_id}`` — emits a
``manual_edit`` with ``target_kind="edge_summary"`` overwriting the
edge's ``summary`` field on the directed pair.
Anything else stays in the closed event record (the projector kept
the row; no further promotion).
"""
from __future__ import annotations
from sqlite3 import Connection
from chat.eventlog.log import append_and_apply
from chat.state.events import get_event
def promote_completed_event(
conn: Connection,
*,
event_id: str,
chat_id: str,
chat_clock_at: str | None,
) -> dict:
"""Read the completed event's props and emit promotion events.
Returns a dict of counts keyed by promoted artifact:
``{"acquired_objects", "knowledge_facts", "relationship_change"}``.
Skips silently if the event row is missing or its status is not
``completed`` — cancelled / expired events terminate without any
promotion.
"""
counts = {
"acquired_objects": 0,
"knowledge_facts": 0,
"relationship_change": 0,
}
event = get_event(conn, event_id)
if event is None or event["status"] != "completed":
return counts
props = event.get("props") or {}
# acquired_objects: each becomes a memory_pov_summary edit (Phase 3
# stub). The manual_edit projector requires a valid memory rowid as
# ``target_id`` (it does ``int(target_id)``), so skip cleanly when
# neither a host_bot_id nor a host_memory_id is supplied.
host_bot_id = props.get("host_bot_id")
host_memory_id = props.get("host_memory_id")
for obj in props.get("acquired_objects", []) or []:
if host_bot_id is None or host_memory_id is None:
continue
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "memory_pov_summary",
"target_id": host_memory_id,
"owner_id": host_bot_id,
"chat_id": chat_id,
"prior_value": "",
"new_value": f"Acquired: {obj}",
"source": "event_promotion",
"event_id": event_id,
"chat_clock_at": chat_clock_at,
},
)
counts["acquired_objects"] += 1
# knowledge_facts: each becomes an edge_update appending the fact.
for fact_entry in props.get("knowledge_facts", []) or []:
owner_id = fact_entry.get("owner_id")
target_id = fact_entry.get("target_id")
fact = fact_entry.get("fact", "")
if not owner_id or not target_id or not fact:
continue
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": owner_id,
"target_id": target_id,
"chat_id": chat_id,
"affinity_delta": 0,
"trust_delta": 0,
"knowledge_facts": [fact],
"last_interaction_at": chat_clock_at,
"last_interaction_chat_id": chat_id,
"source": "event_promotion",
"event_id": event_id,
},
)
counts["knowledge_facts"] += 1
# relationship_change: edge_summary manual_edit on the directed pair.
# The manual_edit projector for ``edge_summary`` keys on a
# ``target_id`` dict ``{source_id, target_id}`` (see
# chat/state/manual_edit.py); we shape the payload to match.
rc = props.get("relationship_change") or {}
if rc:
source_id = rc.get("source_id")
rc_target_id = rc.get("target_id")
summary = rc.get("summary", "")
if source_id and rc_target_id and summary:
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "edge_summary",
"target_id": {
"source_id": source_id,
"target_id": rc_target_id,
},
"chat_id": chat_id,
"prior_value": "",
"new_value": summary,
"source": "event_promotion",
"event_id": event_id,
"chat_clock_at": chat_clock_at,
},
)
counts["relationship_change"] += 1
return counts
__all__ = ["promote_completed_event"]
+100
View File
@@ -0,0 +1,100 @@
"""Interjection classifier service (T39).
Per Requirements §6.2, when a guest is present and the addressee bot has
just spoken, the *non-addressee* bot may follow on with a brief
interjection beat. This service decides whether that interjection
fires. Conservative bias: most turns return ``should_interject=False``
— the addressee has the floor and an interjection is the exception.
Trigger ``True`` only when the silent witness's character, given their
persona and edges, would plausibly speak up: jealousy, surprise, strong
agreement worth voicing, correcting a factual falsehood, urgency.
T44 (turn flow) calls this and, on ``True``, generates the brief
follow-on response as the silent witness. Classifier failure falls back
to ``should_interject=False`` with ``reason="fallback"`` so the chat
keeps moving (§3.3 graceful-degradation rule); callers that care can
distinguish a real "no" from a degraded "no" by the reason string.
"""
from __future__ import annotations
from pydantic import BaseModel
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class InterjectionDecision(BaseModel):
"""Whether the silent witness interjects, plus a short reason.
Defaults are a deliberate no-op: ``should_interject=False`` with an
empty reason. The classifier-failure fallback uses
``reason="fallback"`` so it's distinguishable from a real "no".
"""
should_interject: bool = False
reason: str = ""
_SYSTEM = (
"You decide whether a silent witness character interjects after the "
"addressee character finishes speaking. STRONGLY default to false — "
"the addressee has the floor and most turns should NOT have an "
"interjection. Only return true when the silent witness's character, "
"given their persona and edges, would plausibly speak up: jealousy, "
"surprise, strong agreement worth voicing, correcting a factual "
"falsehood, urgency. Output strict JSON matching the schema."
)
async def detect_interjection(
client: LLMClient,
*,
classifier_model: str,
addressee_name: str,
addressee_just_said: str,
silent_witness_name: str,
silent_witness_persona: str,
silent_witness_edge_to_addressee: dict, # {affinity, trust, summary}
silent_witness_edge_to_you: dict,
you_just_said: str,
timeout_s: float = 30.0,
) -> InterjectionDecision:
"""Decide whether the silent witness bot interjects after the addressee
finishes speaking.
The two ``silent_witness_edge_*`` dicts carry the silent witness's
directed edges toward the addressee and toward the user ("you"),
each shaped ``{affinity: int, trust: int, summary: str}``. Missing
keys fall back to a 50/50 baseline with an empty summary so this
function tolerates partially-populated edge state without raising.
"""
user = (
f"You said: {you_just_said}\n\n"
f"{addressee_name} just said: {addressee_just_said}\n\n"
f"Silent witness: {silent_witness_name}\n"
f"Persona: {silent_witness_persona}\n"
f"Edge {silent_witness_name} -> {addressee_name}: "
f"affinity={silent_witness_edge_to_addressee.get('affinity', 50)}, "
f"trust={silent_witness_edge_to_addressee.get('trust', 50)}, "
f"summary={silent_witness_edge_to_addressee.get('summary', '')}\n"
f"Edge {silent_witness_name} -> you: "
f"affinity={silent_witness_edge_to_you.get('affinity', 50)}, "
f"trust={silent_witness_edge_to_you.get('trust', 50)}, "
f"summary={silent_witness_edge_to_you.get('summary', '')}\n\n"
f"Should {silent_witness_name} interject?"
)
return await classify(
client,
model=classifier_model,
system=_SYSTEM,
user=user,
schema=InterjectionDecision,
default=InterjectionDecision(
should_interject=False, reason="fallback"
),
timeout_s=timeout_s,
)
__all__ = ["InterjectionDecision", "detect_interjection"]
+150
View File
@@ -0,0 +1,150 @@
"""Kickoff prose parser.
Service-layer function that converts a bot's authored kickoff prose into a
structured ``KickoffParse`` for the kickoff confirm-and-edit step (T13 will
wire this into the UI flow).
The classifier prompt includes only the bot context that's load-bearing for
parsing the opening scene: name, persona, the authored
``initial_relationship_to_you`` blurb, the ``you`` entity name, and the
kickoff prose itself. Other identity fields (traits, backstory, ...) are
intentionally left out — they would be noise for this extraction.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class ActivityShape(BaseModel):
"""Per-entity activity at scene start.
Maps onto Requirements §6.5: ``current_action.{verb,interruptible,
required_attention,expected_duration}`` plus posture, attention, holding.
``action_required_attention`` is left as a free-form string ("low" /
"medium" / "high" expected) rather than a Literal so the classifier has
room to vary phrasing in v1.
"""
posture: str
action_verb: str
action_interruptible: bool
action_required_attention: str # low | medium | high
action_expected_duration: str
attention: str = ""
holding: list[str] = Field(default_factory=list)
class KickoffParse(BaseModel):
"""Structured opening-scene state extracted from kickoff prose.
``container_properties`` is loose ``dict``: the classifier may emit
``moving`` / ``public`` / ``audible_range`` keys, but downstream
consumers (T13's confirm form) handle missing keys gracefully.
``initial_time_iso`` is stored as text — not validated as a datetime
here; ``chat_state.time`` stores it as a plain string.
"""
container_name: str
container_type: str
container_properties: dict
you_activity: ActivityShape
bot_activity: ActivityShape
initial_time_iso: str
edge_seed_summary: str
edge_seed_knowledge_facts: list[str]
_SYSTEM_PROMPT = (
"You are extracting structured scene state from a roleplay kickoff "
"scene description. The user provides bot context and a prose "
"description of the opening scene; you output JSON conforming to the "
"schema. Be concrete: pick a single container, single activity per "
"entity, and a sensible initial in-fiction time. Anything not stated "
"explicitly should be inferred reasonably from the prose."
)
def _build_user_prompt(
*,
bot_name: str,
bot_persona: str,
initial_relationship_to_you: str,
kickoff_prose: str,
you_name: str,
) -> str:
return (
f"BOT NAME: {bot_name}\n"
f"BOT PERSONA: {bot_persona}\n"
f"INITIAL RELATIONSHIP TO {you_name}: {initial_relationship_to_you}\n"
f"YOU NAME: {you_name}\n"
f"KICKOFF PROSE:\n{kickoff_prose}"
)
def _empty_activity() -> ActivityShape:
return ActivityShape(
posture="",
action_verb="",
action_interruptible=True,
action_required_attention="low",
action_expected_duration="brief",
)
def _empty_kickoff_parse() -> KickoffParse:
"""Default returned when the classifier can't produce a valid parse.
The user gets a mostly-empty confirm form they can fill in by hand
instead of a 500. ``initial_time_iso`` is left as the current UTC.
"""
from datetime import datetime, timezone
return KickoffParse(
container_name="",
container_type="",
container_properties={},
you_activity=_empty_activity(),
bot_activity=_empty_activity(),
initial_time_iso=datetime.now(timezone.utc).isoformat(timespec="seconds"),
edge_seed_summary="",
edge_seed_knowledge_facts=[],
)
async def parse_kickoff(
client: LLMClient,
*,
model: str,
bot_name: str,
bot_persona: str,
initial_relationship_to_you: str,
kickoff_prose: str,
you_name: str,
timeout_s: float = 10.0,
) -> KickoffParse:
"""Parse authored kickoff prose into a structured ``KickoffParse``.
Falls back to a mostly-empty default if the classifier fails — the
confirm-and-edit form is the human-in-the-loop, so a degraded form
that the user can fill in is preferable to a 500.
"""
user_prompt = _build_user_prompt(
bot_name=bot_name,
bot_persona=bot_persona,
initial_relationship_to_you=initial_relationship_to_you,
kickoff_prose=kickoff_prose,
you_name=you_name,
)
return await classify(
client,
model=model,
system=_SYSTEM_PROMPT,
user=user_prompt,
schema=KickoffParse,
default=_empty_kickoff_parse(),
timeout_s=timeout_s,
)
+213
View File
@@ -0,0 +1,213 @@
"""Per-turn memory writes (T21).
After ``assistant_turn`` lands, the turn flow records a ``memory_written``
event for each present POV owner. Phase 1 single-bot turns only have the
host bot as a memory-store owner — ``you`` doesn't have a memory store in
v1 — so we write exactly one row per turn.
Phase 1 simplifications (per plan §11.1, T27 will refine):
- ``pov_summary`` is the assistant's raw narrative text. T27 rewrites at
scene close into per-POV summary form.
- ``significance`` defaults to ``1`` (Notable). T22's async significance
pass overwrites via a follow-up event.
- Witness flags are hard-coded ``[you=1, host=1, guest=0]``. Phase 2 will
derive them from ``chat.guest_bot_id`` once a guest can be present.
T97 (Phase 4): each successful memory write also enqueues an
:class:`~chat.services.embedding_worker.EmbeddingJob` on the
lifespan-managed embedding worker, so the just-written memory gets a
vector indexed out-of-band. The hook is opt-in via the ``app`` kwarg —
callers without a FastAPI app handle (e.g. one-off scripts, isolated
unit tests) simply don't enqueue, and the backfill script can pick up
those rows later.
"""
from __future__ import annotations
from sqlite3 import Connection
from chat.eventlog.log import append_and_apply
from chat.services.embedding_worker import EmbeddingJob
def _write_one_memory(
conn: Connection,
*,
owner_id: str,
chat_id: str,
narrative_text: str,
witness_you: int,
witness_host: int,
witness_guest: int,
scene_id: int | None,
chat_clock_at: str | None,
source: str,
significance: int,
app=None,
) -> tuple[int, int | None]:
"""Append a single ``memory_written`` event for ``owner_id`` and return
``(event_id, memory_id)`` for the projected row.
When ``app`` is provided and ``app.state.embedding_worker`` exists,
enqueue an :class:`EmbeddingJob` for the freshly-projected memory id
(T97). Skipped silently if the worker is absent or the projected row
can't be located — the backfill script handles missing-vector rows.
"""
payload: dict = {
"owner_id": owner_id,
"chat_id": chat_id,
"pov_summary": narrative_text,
"witness_you": witness_you,
"witness_host": witness_host,
"witness_guest": witness_guest,
"source": source,
"reliability": 1.0,
"significance": significance,
"pinned": 0,
"auto_pinned": 0,
}
if scene_id is not None:
payload["scene_id"] = scene_id
if chat_clock_at is not None:
payload["chat_clock_at"] = chat_clock_at
event_id = append_and_apply(conn, kind="memory_written", payload=payload)
row = conn.execute(
"SELECT id FROM memories "
"WHERE owner_id = ? AND chat_id = ? "
"ORDER BY id DESC LIMIT 1",
(owner_id, chat_id),
).fetchone()
memory_id = row[0] if row else None
# T97: enqueue an embedding job for the just-written memory. The
# worker drains the queue out-of-band and emits an
# ``embedding_indexed`` event when the vector is ready. ``getattr``
# keeps this a no-op for callers without a wired-up app (scripts,
# tests) — the backfill script handles those rows.
if memory_id is not None and narrative_text and narrative_text.strip():
worker = (
getattr(app.state, "embedding_worker", None)
if app is not None
else None
)
if worker is not None:
worker.enqueue(
EmbeddingJob(memory_id=memory_id, text=narrative_text)
)
return event_id, memory_id
def record_turn_memory_for_present(
conn: Connection,
*,
chat_id: str,
host_bot_id: str,
guest_bot_id: str | None,
narrative_text: str,
scene_id: int | None = None,
chat_clock_at: str | None = None,
source: str = "direct",
significance: int = 1,
you_present: bool = True,
app=None,
) -> dict[str, tuple[int, int | None]]:
"""Single entry-point for per-turn memory writes (T84).
Writes one ``memory_written`` event per present bot witness. Host is
always written. Guest is written iff ``guest_bot_id is not None``.
Witness flags depend on ``you_present``:
- ``you_present=True`` (default — Phase 1/2/3 you-scenes): the user
is a witness. Mask is ``[you=1, host=1, guest=1]`` when a guest is
present, ``[you=1, host=1, guest=0]`` otherwise.
- ``you_present=False`` (Phase 3 meanwhile scenes): the user is
absent. Mask is ``[you=0, host=1, guest=1]`` for both bots. Both
``host_bot_id`` and ``guest_bot_id`` are required — a meanwhile
scene by definition has both bots, so passing ``guest_bot_id=None``
with ``you_present=False`` is a programming error and raises
:class:`ValueError`.
When ``app`` is provided, each per-witness write also enqueues an
:class:`EmbeddingJob` on ``app.state.embedding_worker`` (T97).
Returns a mapping ``{bot_id: (event_id, memory_id)}`` so callers can
look up the freshly-projected memory id per owner without re-querying
the database.
"""
if not you_present and guest_bot_id is None:
raise ValueError("you_present=False requires guest_bot_id")
witness_you = 1 if you_present else 0
witness_host = 1
witness_guest = 1 if guest_bot_id is not None else 0
result: dict[str, tuple[int, int | None]] = {}
result[host_bot_id] = _write_one_memory(
conn,
owner_id=host_bot_id,
chat_id=chat_id,
narrative_text=narrative_text,
witness_you=witness_you,
witness_host=witness_host,
witness_guest=witness_guest,
scene_id=scene_id,
chat_clock_at=chat_clock_at,
source=source,
significance=significance,
app=app,
)
if guest_bot_id is not None:
result[guest_bot_id] = _write_one_memory(
conn,
owner_id=guest_bot_id,
chat_id=chat_id,
narrative_text=narrative_text,
witness_you=witness_you,
witness_host=witness_host,
witness_guest=1,
scene_id=scene_id,
chat_clock_at=chat_clock_at,
source=source,
significance=significance,
app=app,
)
return result
def record_meanwhile_memory(
conn: Connection,
*,
chat_id: str,
host_bot_id: str,
guest_bot_id: str,
narrative_text: str,
scene_id: int | None = None,
chat_clock_at: str | None = None,
source: str = "direct",
significance: int = 1,
app=None,
) -> dict[str, tuple[int, int | None]]:
"""Backward-compat thin wrapper for meanwhile memory writes (T64, T84).
Equivalent to calling :func:`record_turn_memory_for_present` with
``you_present=False``. Kept so existing call sites in
:mod:`chat.web.meanwhile` continue to work without churn. New code
should prefer the unified entry-point directly.
"""
return record_turn_memory_for_present(
conn,
chat_id=chat_id,
host_bot_id=host_bot_id,
guest_bot_id=guest_bot_id,
narrative_text=narrative_text,
scene_id=scene_id,
chat_clock_at=chat_clock_at,
source=source,
significance=significance,
you_present=False,
app=app,
)
+62
View File
@@ -0,0 +1,62 @@
"""Multi-entity state-update coordinator (T40).
Wraps single-pair compute_state_update to run state updates for ALL
directed pairs of present entities. With 3 present entities (you, host,
guest) that's 6 directed pairs. With 2 present (you, host) it's 2 pairs.
Calls run sequentially to respect Featherless's 2-connection cap (the
client-level semaphore would serialize them anyway, but doing it here
keeps the failure surface clean — a hung pair doesn't queue behind
itself).
"""
from __future__ import annotations
from chat.llm.client import LLMClient
from chat.services.state_update import StateUpdate, compute_state_update
async def compute_state_updates_for_present(
client: LLMClient,
*,
classifier_model: str,
present_ids: list[str],
present_names: dict[str, str],
personas: dict[str, str],
prior_edges: dict[tuple[str, str], dict],
recent_dialogue: list[dict],
timeout_s: float = 30.0,
) -> list[tuple[str, str, StateUpdate]]:
"""Run compute_state_update for every directed pair (src != tgt) over
``present_ids``. Returns list of ``(source_id, target_id, update)``
tuples in the natural iteration order over ``present_ids x present_ids``.
A single failing pair falls back to the schema-default StateUpdate
(zero deltas, empty facts) inside ``compute_state_update``; the batch
keeps going.
"""
out: list[tuple[str, str, StateUpdate]] = []
for src in present_ids:
for tgt in present_ids:
if src == tgt:
continue
edge = prior_edges.get((src, tgt), {})
update = await compute_state_update(
client,
model=classifier_model,
source_id=src,
target_id=tgt,
source_name=present_names.get(src, src),
source_persona=personas.get(src, "") or "",
target_name=present_names.get(tgt, tgt),
prior_affinity=int(edge.get("affinity", 50)),
prior_trust=int(edge.get("trust", 50)),
prior_summary=edge.get("summary", "") or "",
recent_dialogue=recent_dialogue,
timeout_s=timeout_s,
)
out.append((src, tgt, update))
return out
__all__ = ["compute_state_updates_for_present"]
+998
View File
@@ -0,0 +1,998 @@
"""Narrative-prompt assembly with must/should/nice trim tiers.
Implements Task 18 (Phase 1D). See Requirements §3.2 (token budgets and
trim tiers) and §6.3 (speaker prompt assembly order). The function
:func:`assemble_narrative_prompt` returns a list of
:class:`chat.llm.client.Message` objects ready to feed to
``LLMClient.generate``.
Trim policy when the assembled prompt exceeds the soft target:
- **MUST-include** (never trimmed): system / speaker identity, the
speaker→addressee edge, the activity snapshot for all present
entities, the current scene description, and the last 4 turns of
dialogue.
- **SHOULD-include** (trim when over budget): other edges of the
speaker. (Group nodes, active threads, and active events / props are
Phase 3 — skipped here.)
- **NICE-include** (trim first): retrieved memories beyond top-2,
dialogue turns beyond the last 4 (replaced with a one-line elision
placeholder), per-POV summary of the previous scene.
Token counting uses ``tiktoken.get_encoding("cl100k_base")`` per the
requirements. Mistral / Llama tokenizers diverge ~5%; we accept the
drift.
The function is intentionally deterministic (no LLM call) so it is
testable with synthetic state and so T29's regenerate flow can rebuild
prompts without re-running classifiers.
"""
from __future__ import annotations
from sqlite3 import Connection
import tiktoken
from chat.llm.client import Message
from chat.state.edges import get_edge, list_edges_for
from chat.state.entities import get_bot, get_you
from chat.state.events import list_active_events
from chat.state.group_node import get_group_node
from chat.state.meanwhile import list_pending_meanwhile_digests
from chat.state.memory import search_memories
from chat.state.threads import list_open_threads
from chat.state.world import (
active_scene,
get_activity,
get_chat,
get_container,
get_scene,
)
# Cache the encoder once at import-time. tiktoken's encoder load is
# non-trivial (~tens of ms) and the encoding is process-wide stable.
_ENCODER = tiktoken.get_encoding("cl100k_base")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _count_tokens(text: str, encoding=_ENCODER) -> int:
"""Return the cl100k_base token count for ``text`` (0 for falsy)."""
if not text:
return 0
return len(encoding.encode(text))
def _build_speaker_identity(bot: dict) -> str:
"""Render the bot identity block. Skips empty optional fields."""
lines = [f"You are {bot['name']}."]
if bot.get("persona"):
lines.append("")
lines.append("PERSONA:")
lines.append(bot["persona"])
voice_samples = bot.get("voice_samples") or []
if voice_samples:
lines.append("")
lines.append("VOICE REFERENCE:")
lines.append("\n---\n".join(voice_samples))
traits = bot.get("traits") or []
if traits:
lines.append("")
lines.append(f"TRAITS: {', '.join(traits)}")
if bot.get("backstory"):
lines.append("")
lines.append("BACKSTORY:")
lines.append(bot["backstory"])
return "\n".join(lines)
def _build_edge_block(edge: dict | None, addressee_name: str) -> str | None:
"""Render the speaker → addressee edge. Returns None when no edge exists."""
if edge is None:
return None
lines = [f"YOUR EDGE TO {addressee_name}:"]
lines.append(f"- Affinity: {edge.get('affinity', 50)}/100")
lines.append(f"- Trust: {edge.get('trust', 50)}/100")
summary = edge.get("summary") or ""
if summary:
lines.append(f"- Summary: {summary}")
knowledge = edge.get("knowledge") or []
if knowledge:
lines.append(f"- What you know about {addressee_name}:")
for fact in knowledge:
lines.append(f" * {fact}")
return "\n".join(lines)
def _build_activity_block(activities: list[dict]) -> str | None:
"""Render the activity snapshot for all present entities."""
rendered: list[str] = []
for a in activities:
if a is None:
continue
label = a.get("_display_name") or a.get("entity_id", "?")
parts: list[str] = []
posture = a.get("posture") or ""
if posture:
parts.append(posture)
action = a.get("action") or {}
verb = action.get("verb") if isinstance(action, dict) else None
if verb:
parts.append(verb)
attention = a.get("attention") or ""
if attention:
parts.append(f"attention: {attention}")
holding = a.get("holding") or []
if holding:
parts.append(f"holding: {', '.join(holding)}")
if parts:
rendered.append(f"- {label}: " + ", ".join(parts))
else:
rendered.append(f"- {label}: (no activity recorded)")
if not rendered:
return None
return "ACTIVITIES:\n" + "\n".join(rendered)
def _build_scene_block(chat: dict, container: dict | None, scene: dict | None) -> str | None:
"""Render the current-scene block. Always present when chat exists."""
lines = ["CURRENT SCENE:"]
if container is not None:
lines.append(f"- Container: {container['name']} ({container['type']})")
chat_time = chat.get("time") if chat else None
if chat_time:
lines.append(f"- Time: {chat_time}")
if scene is not None and scene.get("started_at"):
lines.append(f"- Active scene started: {scene['started_at']}")
if len(lines) == 1:
return None
return "\n".join(lines)
def _format_dialogue_turn(turn: dict) -> str:
speaker = turn.get("speaker") or "?"
text = turn.get("text") or ""
return f"{speaker}: {text}"
def _build_dialogue_block(
recent: list[dict],
earlier_summary: str | None,
) -> str | None:
"""Render the recent-dialogue block. The ``recent`` list is the
*kept* tail of the dialogue (already trimmed to the last-N turns).
``earlier_summary``, when non-None, is rendered as the first line as
``earlier: <text>`` to flag elided context.
"""
if not recent and not earlier_summary:
return None
lines = ["RECENT DIALOGUE:"]
if earlier_summary:
lines.append(f"earlier: {earlier_summary}")
for turn in recent:
lines.append(_format_dialogue_turn(turn))
return "\n".join(lines)
def _build_memories_block(memory_summaries: list[str]) -> str | None:
if not memory_summaries:
return None
lines = ["RELEVANT MEMORIES:"]
for m in memory_summaries:
lines.append(f"- {m}")
return "\n".join(lines)
def _build_other_edges_block(edges: list[dict]) -> str | None:
"""Render edges to entities other than the addressee."""
if not edges:
return None
lines = ["OTHER EDGES:"]
for e in edges:
target = e.get("_display_name") or e.get("target_id", "?")
affinity = e.get("affinity", 50)
trust = e.get("trust", 50)
lines.append(f"- {target}: affinity {affinity}/100, trust {trust}/100")
summary = e.get("summary") or ""
if summary:
lines.append(f" summary: {summary}")
return "\n".join(lines)
def _build_previous_scene_block(pov_summary: str | None) -> str | None:
if not pov_summary:
return None
return "PREVIOUS SCENE SUMMARY:\n" + pov_summary
def _build_group_node_block(group_node: dict | None) -> str | None:
"""Render the group-node summary + dynamic as a SHOULD-tier block.
Used only in 3-entity scenes (you + host + guest). Returns None when
the row is missing or both summary and dynamic are empty.
"""
if not group_node:
return None
summary = (group_node.get("summary") or "").strip()
dynamic = (group_node.get("dynamic") or "").strip()
if not summary and not dynamic:
return None
lines = ["Group dynamic:"]
if summary:
lines.append(f"- Summary: {summary}")
if dynamic:
lines.append(f"- Dynamic: {dynamic}")
return "\n".join(lines)
def _props_excerpt(props: dict | None, limit: int = 80) -> str:
"""Return a one-line excerpt of an event's ``props`` dict.
Renders ``key=value`` pairs separated by ", " (deterministic by dict
insertion order) and truncates to ~``limit`` characters with a
trailing ellipsis. Returns empty string for falsy/empty props so the
caller can omit the line entirely.
"""
if not props:
return ""
pieces: list[str] = []
for k, v in props.items():
pieces.append(f"{k}={v}")
rendered = ", ".join(pieces)
if len(rendered) > limit:
# Reserve 1 char for the ellipsis so the total never exceeds limit.
rendered = rendered[: max(0, limit - 1)] + ""
return rendered
def _build_active_events_block(events: list[dict]) -> str | None:
"""Render the ``Active events:`` block for Phase 3 Task 60.
One bullet per event. The sub-label depends on status:
- ``planned`` → ``(planned for {planned_for})``
- ``active`` → ``(active, started_at={started_at})``
A second indented line carries a one-line excerpt of the event's
``props`` (truncated ~80 chars) when non-empty. Returns ``None`` when
there are no active events so the caller can omit the entire block.
"""
if not events:
return None
lines = ["Active events:"]
for ev in events:
kind = ev.get("kind") or "?"
status = ev.get("status") or ""
if status == "active":
started = ev.get("started_at") or ""
lines.append(f"- {kind} (active, started_at={started})")
else:
planned = ev.get("planned_for") or ""
lines.append(f"- {kind} (planned for {planned})")
excerpt = _props_excerpt(ev.get("props"))
if excerpt:
lines.append(f" {excerpt}")
return "\n".join(lines)
def _build_meanwhile_digests_block(digests: list[dict]) -> str | None:
"""Render the ``Meanwhile while you were away:`` block for T65.
One bullet per pending digest, formatted as ``- {summary}`` with the
summary truncated to ~200 characters per spec. Returns ``None`` when
there are no pending digests so the caller can omit the entire block.
The block is rendered ONLY when the prompt is for a regular you-scene
(``present_set_kind != "host_guest"``); the caller filters before
constructing the digests list.
"""
if not digests:
return None
lines = ["Meanwhile while you were away:"]
for d in digests:
summary = d.get("summary") or ""
if len(summary) > 200:
summary = summary[:199] + ""
if summary:
lines.append(f"- {summary}")
if len(lines) == 1:
return None
return "\n".join(lines)
def _build_open_threads_block(threads: list[dict]) -> str | None:
"""Render the ``Open threads:`` block for Phase 3 Task 60.
One bullet per thread, formatted as ``- {title}: {summary}`` with the
summary truncated to ~120 characters. Returns ``None`` when there are
no open threads so the caller can omit the entire block.
"""
if not threads:
return None
lines = ["Open threads:"]
for t in threads:
title = t.get("title") or "?"
summary = t.get("summary") or ""
if len(summary) > 120:
summary = summary[:119] + ""
if summary:
lines.append(f"- {title}: {summary}")
else:
lines.append(f"- {title}")
return "\n".join(lines)
def _closing_instruction(speaker_name: str, addressee_name: str) -> str:
return (
f"Continue the scene as {speaker_name}, in their voice, responding "
"naturally. Use *asterisks* for actions and quotes for dialogue. "
f"Stay in character. Do not narrate {addressee_name}'s actions or "
"thoughts. "
"Keep your response to a single beat — one or two short paragraphs "
"at most. Don't monologue; leave room for the other person to react."
)
def _join_blocks(blocks: list[str | None]) -> str:
"""Join non-empty blocks with double newlines."""
return "\n\n".join(b for b in blocks if b)
def _earlier_summary_placeholder(elided_count: int) -> str:
"""Phase 1 placeholder. Real summarization is a downstream concern."""
plural = "turn" if elided_count == 1 else "turns"
return f"{elided_count} earlier {plural} elided for brevity"
def _resolve_previous_scene_summary(
conn: Connection, chat_id: str, speaker_bot_id: str
) -> str | None:
"""Return ``pov_summary`` of the most recent ended scene, owned by
the speaker. None if no closed scene exists or no matching memory.
"""
row = conn.execute(
"SELECT id FROM scenes WHERE chat_id = ? AND ended_at IS NOT NULL "
"ORDER BY ended_at DESC LIMIT 1",
(chat_id,),
).fetchone()
if not row:
return None
scene_id = row[0]
mem = conn.execute(
"SELECT pov_summary FROM memories WHERE scene_id = ? AND owner_id = ? "
"ORDER BY id DESC LIMIT 1",
(scene_id, speaker_bot_id),
).fetchone()
if not mem:
return None
return mem[0]
def _witness_role_for(speaker_bot_id: str, host_bot_id: str | None) -> str:
"""Return the witness POV role for the speaker's memory query.
The host bot of a chat queries memories with ``witness_role="host"``;
the guest bot queries with ``witness_role="guest"``. Phase 2 T46
pinned the contract on ``search_memories``; this helper applies it
at the call site so a guest-as-speaker doesn't silently retrieve
memories under the wrong POV mask.
When ``host_bot_id`` is ``None`` (degenerate case from a half-seeded
chat or Phase-1 path), the speaker is treated as the host so the
query falls back to the host POV mask rather than silently masking
the speaker's own memories as a guest.
"""
if host_bot_id is None or speaker_bot_id == host_bot_id:
return "host"
return "guest"
def _resolve_addressee(
conn: Connection, addressee: str, you: dict | None
) -> tuple[str, str]:
"""Return ``(addressee_id, addressee_display_name)``.
The function is permissive: ``addressee="you"`` resolves to the
you-entity (display name is its authored name, falling back to
"you" if no entity exists yet). Other ids resolve as bot ids.
"""
if addressee == "you":
name = (you or {}).get("name") or "you"
return "you", name
bot = get_bot(conn, addressee)
if bot is not None:
return addressee, bot["name"]
return addressee, addressee
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def assemble_narrative_prompt(
conn: Connection,
*,
chat_id: str,
speaker_bot_id: str,
addressee: str = "you",
user_turn_prose: str | None = None,
recent_dialogue: list[dict] | None = None,
retrieved_memory_summaries: list[str] | None = None,
budget_soft: int = 6000,
budget_hard: int = 8000,
encoding_name: str = "cl100k_base",
guest_id: str | None = None,
present_set_kind: str = "you_host",
) -> list[Message]:
"""Assemble the narrative prompt for ``speaker_bot_id`` to respond.
Returns a list of :class:`Message` objects: one ``system`` message
carrying the assembled context, optionally followed by a single
``user`` message containing ``user_turn_prose`` (when provided).
Trimming proceeds in tiers (NICE → SHOULD) once the total token
count exceeds ``budget_soft``; the function refuses to exceed
``budget_hard``. If the MUST-include block alone is already over
``budget_hard``, :class:`ValueError` is raised — the caller should
surface the failure rather than ship a malformed prompt.
"""
encoding = (
_ENCODER if encoding_name == "cl100k_base"
else tiktoken.get_encoding(encoding_name)
)
bot = get_bot(conn, speaker_bot_id)
if bot is None:
raise ValueError(f"speaker_bot_id {speaker_bot_id!r} not found")
chat = get_chat(conn, chat_id)
if chat is None:
raise ValueError(f"chat_id {chat_id!r} not found")
# Auto-detect guest from chat state when caller didn't pass one.
# Phase 1 chats have ``guest_bot_id is None``; the auto-detect is a
# no-op there and the function behaves exactly as before.
if guest_id is None:
guest_id = chat.get("guest_bot_id")
# A speaker addressing themself as guest doesn't add a third party.
if guest_id is not None and guest_id == speaker_bot_id:
guest_id = None
you = get_you(conn)
addressee_id, addressee_name = _resolve_addressee(conn, addressee, you)
# T64: meanwhile-mode marker. When present_set_kind == "host_guest"
# the user ("you") is NOT a witness in the scene — bots speak only to
# each other. The local flag below is consumed by the activity-block
# builder (skip the "you" bullet entirely) and the other-edges filter
# (drop any speaker -> "you" rendering). Default "you_host" preserves
# the Phase 1/2/3 behavior for normal turns.
_exclude_you = present_set_kind == "host_guest"
# ---- Build all components as text strings ------------------------------
speaker_identity = _build_speaker_identity(bot)
edge_to_addressee = _build_edge_block(
get_edge(conn, speaker_bot_id, addressee_id),
addressee_name,
)
# Activity for present entities — single ACTIVITIES: block with up
# to three bullets (you, speaker, guest). The block itself is
# MUST-tier and survives all trims, but bullet-level trim drops
# bullets in the order guest -> you, keeping the speaker bullet
# (the speaker's own current activity is the load-bearing slice).
#
# T71.2 chose Option B from the polish plan: pre-truncate the
# bullets list at trim time before _build_activity_block runs,
# rather than introducing a granular tier mode in the trim
# machinery. The single-block render avoids the dual-ACTIVITIES:
# header that Phase 2 T43 introduced (read by some LLMs as a
# duplicate-section bug).
you_activity: dict | None = None
if not _exclude_you:
you_act = get_activity(conn, "you")
if you_act is not None:
you_activity = dict(you_act)
you_activity["_display_name"] = (you or {}).get("name") or "you"
speaker_activity: dict | None = None
bot_act = get_activity(conn, speaker_bot_id)
if bot_act is not None:
speaker_activity = dict(bot_act)
speaker_activity["_display_name"] = bot["name"]
guest_activity: dict | None = None
if guest_id is not None:
guest_act = get_activity(conn, guest_id)
if guest_act is not None:
guest_activity = dict(guest_act)
guest_bot = get_bot(conn, guest_id)
guest_activity["_display_name"] = (
guest_bot["name"] if guest_bot else guest_id
)
def _activity_block_for(
*, include_you: bool, include_guest: bool
) -> str | None:
"""Render the single ACTIVITIES: block with the requested bullets.
Speaker bullet is always included (it's the MUST-tier baseline);
``you`` and ``guest`` bullets are toggled by the caller during
trim. Returns None when no bullets remain.
"""
bullets: list[dict] = []
if include_you and you_activity is not None:
bullets.append(you_activity)
if speaker_activity is not None:
bullets.append(speaker_activity)
if include_guest and guest_activity is not None:
bullets.append(guest_activity)
return _build_activity_block(bullets)
# SHOULD-tier group-node block (Phase 2 / Task 43): rendered only
# when the group_node row is present AND it covers all three of
# you + host + guest (per the Task 43 spec).
group_node_block: str | None = None
if guest_id is not None:
gn = get_group_node(conn, chat_id)
if gn is not None:
members = set(gn.get("members") or [])
host_id = chat.get("host_bot_id")
required = {"you"}
if host_id is not None:
required.add(host_id)
required.add(guest_id)
if required.issubset(members):
group_node_block = _build_group_node_block(gn)
# SHOULD-tier active events + open threads (Phase 3 / Task 60).
# Auto-detect both from the chat_id per the Phase 2 T43 precedent —
# no new function parameter. Both blocks are omit-when-empty so a
# Phase 1 chat with no events/threads renders identically to before.
active_events_block = _build_active_events_block(
list_active_events(conn, chat_id)
)
open_threads_block = _build_open_threads_block(
list_open_threads(conn, chat_id)
)
# SHOULD-tier meanwhile digest (Phase 3 / Task 65). Only surfaces
# when the prompt is for a regular you-scene (NOT for a meanwhile
# child scene — the absent player is the audience, not the bots
# currently mid-meanwhile). We distinguish via the chat's active
# scene's ``present_set_kind``; a missing scene row defaults to a
# you-scene render so the block can still surface during the
# post-meanwhile-close transition before the next scene opens.
#
# Consumption is INTENTIONALLY left to the post_turn flow (a
# ``consume_pending_meanwhile_digests`` helper, see below) rather
# than emitted inline here. Surfacing has no side-effects; the
# caller appends ``meanwhile_digest_consumed`` after the response
# streams. This keeps prompt assembly pure and deterministic — the
# Phase 1 invariant T29's regenerate flow relies on.
meanwhile_digests_block: str | None = None
active_scene_kind: str | None = None
if chat.get("active_scene_id"):
active_sc = get_scene(conn, chat["active_scene_id"])
if active_sc:
active_scene_kind = active_sc.get("present_set_kind")
if active_scene_kind != "host_guest":
pending_digests = list_pending_meanwhile_digests(conn, chat_id)
meanwhile_digests_block = _build_meanwhile_digests_block(
pending_digests
)
container = None
if chat.get("active_scene_id"):
scene = get_scene(conn, chat["active_scene_id"])
if scene and scene.get("container_id"):
container = get_container(conn, scene["container_id"])
else:
scene = active_scene(conn, chat_id)
if container is None and scene and scene.get("container_id"):
container = get_container(conn, scene["container_id"])
scene_block = _build_scene_block(chat, container, scene)
# Other edges: speaker → non-addressee. In meanwhile mode (host_guest)
# the speaker -> "you" edge is filtered out as well — "you" isn't
# part of the present set, so surfacing the speaker's relationship
# to the user inside a private bot-to-bot beat would leak context
# the bots aren't supposed to be drawing on right now.
all_outgoing = list_edges_for(conn, speaker_bot_id)
other_edges_raw = [e for e in all_outgoing if e.get("target_id") != addressee_id]
if _exclude_you:
other_edges_raw = [e for e in other_edges_raw if e.get("target_id") != "you"]
for e in other_edges_raw:
tid = e.get("target_id")
if tid == "you":
e["_display_name"] = (you or {}).get("name") or "you"
else:
tb = get_bot(conn, tid) if tid else None
e["_display_name"] = tb["name"] if tb else (tid or "?")
other_edges_block = _build_other_edges_block(other_edges_raw)
# Memories: caller override wins; otherwise FTS5 search keyed on the
# scene's container/posture as a coarse query proxy.
if retrieved_memory_summaries is not None:
memory_summaries = list(retrieved_memory_summaries)
else:
query = (container or {}).get("name") or chat.get("narrative_anchor") or ""
memory_summaries = []
if query:
try:
witness_role = _witness_role_for(
speaker_bot_id, chat.get("host_bot_id")
)
hits = search_memories(
conn, speaker_bot_id, witness_role, query, k=4
)
memory_summaries = [h["pov_summary"] for h in hits]
except Exception:
memory_summaries = []
# Dialogue: caller override only (no event_log read in Phase 1).
dialogue_full = list(recent_dialogue or [])
previous_scene_summary = _resolve_previous_scene_summary(
conn, chat_id, speaker_bot_id
)
closing = _closing_instruction(bot["name"], addressee_name)
# ---- Build the MUST core ----------------------------------------------
last4 = dialogue_full[-4:] if dialogue_full else []
must_dialogue_block = _build_dialogue_block(last4, earlier_summary=None)
# MUST-tier ACTIVITIES floor: the speaker bullet alone (you and
# guest bullets are dropped first under bullet-level trim before
# the block bottoms out at speaker-only).
must_activity_block = _activity_block_for(
include_you=False, include_guest=False
)
must_blocks: list[str | None] = [
speaker_identity,
edge_to_addressee,
scene_block,
must_activity_block,
must_dialogue_block,
closing,
]
must_text = _join_blocks(must_blocks)
must_tokens = _count_tokens(must_text, encoding)
if must_tokens > budget_hard:
raise ValueError(
f"MUST-include block ({must_tokens} tokens) exceeds budget_hard "
f"({budget_hard}). Cannot assemble prompt."
)
# ---- Stage SHOULD additions, then NICE additions -----------------------
# We carry a running "components" list and rebuild the body as we go
# so token accounting reflects join-overhead. Order in the final
# prompt follows §6.3: identity → edge → other edges → scene →
# activities → previous scene summary → memories → dialogue → close.
def assemble(
*,
include_other_edges: bool,
include_previous_scene: bool,
include_memories_top_k: int,
dialogue_keep: int,
include_you_activity: bool = True,
include_guest_activity: bool = True,
include_group_node: bool = True,
include_active_events: bool = True,
include_open_threads: bool = True,
include_meanwhile_digests: bool = True,
) -> tuple[str, int, list[dict]]:
# dialogue: keep the last `dialogue_keep` turns verbatim; older
# turns become an "earlier:" placeholder line.
kept_dialogue = (
dialogue_full[-dialogue_keep:] if dialogue_keep > 0 else []
)
elided = max(0, len(dialogue_full) - len(kept_dialogue))
earlier_summary = (
_earlier_summary_placeholder(elided) if elided > 0 else None
)
dialogue_block = _build_dialogue_block(kept_dialogue, earlier_summary)
memories_subset = memory_summaries[:include_memories_top_k]
memories_block = _build_memories_block(memories_subset)
prev_block = (
_build_previous_scene_block(previous_scene_summary)
if include_previous_scene else None
)
# Single ACTIVITIES: block, bullet-level trim (T71.2). Guest
# bullet drops first, then the you bullet; speaker bullet is the
# MUST-tier floor and always present when an activity row exists.
activity_block = _activity_block_for(
include_you=include_you_activity,
include_guest=include_guest_activity,
)
body = _join_blocks([
speaker_identity,
edge_to_addressee,
other_edges_block if include_other_edges else None,
scene_block,
activity_block,
group_node_block if include_group_node else None,
active_events_block if include_active_events else None,
open_threads_block if include_open_threads else None,
(
meanwhile_digests_block
if include_meanwhile_digests else None
),
prev_block,
memories_block,
dialogue_block,
closing,
])
return body, _count_tokens(body, encoding), kept_dialogue
# Start with the MUST baseline: last 4 turns of dialogue, no
# SHOULD/NICE extras.
baseline_keep = min(4, len(dialogue_full))
# Try the most generous configuration first; trim greedily.
nice_dialogue_keep = len(dialogue_full) # all turns, no elision
nice_memories_k = min(4, len(memory_summaries))
include_prev = previous_scene_summary is not None
include_other = other_edges_block is not None
include_you_activity = you_activity is not None
include_guest_activity = guest_activity is not None
include_group_node = group_node_block is not None
include_active_events = active_events_block is not None
include_open_threads = open_threads_block is not None
include_meanwhile_digests = meanwhile_digests_block is not None
def _build(*, prev: bool, mem_k: int, dlg: int, other: bool,
you_act: bool, guest_act: bool, group: bool,
events: bool, threads: bool,
digests: bool) -> tuple[str, int]:
body, total, _ = assemble(
include_other_edges=other,
include_previous_scene=prev,
include_memories_top_k=mem_k,
dialogue_keep=dlg,
include_you_activity=you_act,
include_guest_activity=guest_act,
include_group_node=group,
include_active_events=events,
include_open_threads=threads,
include_meanwhile_digests=digests,
)
return body, total
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
# If under soft, we're done.
if total <= budget_soft:
return _emit(body, user_turn_prose)
# Drop NICE in order: previous scene → memories beyond top-2 →
# older dialogue turns (collapse to 4).
#
# T71.3 — order rationale: the §6.3 spec lists NICE-tier members
# with previous-scene LAST, which read as a literal trim order
# during T18 review. We deliberately keep the greedy order shown
# here (previous-scene FIRST) for two reasons:
#
# 1. Cheapest-impact-first: a per-POV previous-scene summary is
# a single short paragraph that loses very little narrative
# continuity when dropped, while the older dialogue turns it
# is competing with carry the speaker's last few beats — those
# ground the next response far more concretely.
# 2. Greedy lookahead is more expensive than the marginal
# narrative loss. Dropping previous-scene typically clears
# the soft-budget slack in one step; trying memories or
# dialogue first would routinely require multiple recompute
# passes through the assembler.
#
# The pin test test_nice_trim_order_documented locks this order so
# a future refactor can't quietly invert it without surfacing the
# decision.
if include_prev:
include_prev = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
if total <= budget_soft:
return _emit(body, user_turn_prose)
if nice_memories_k > 2:
nice_memories_k = 2
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
if total <= budget_soft:
return _emit(body, user_turn_prose)
if nice_dialogue_keep > baseline_keep:
nice_dialogue_keep = baseline_keep
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
if total <= budget_soft:
return _emit(body, user_turn_prose)
# Drop more NICE until we're under hard: memories all the way to 0.
while nice_memories_k > 0 and total > budget_hard:
nice_memories_k = max(0, nice_memories_k - 1)
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
# Drop SHOULD-tier extras in order:
# 1. meanwhile digests block (T65: SHOULD-tier; refers to a past
# meanwhile scene — least critical to the speaker's immediate
# voice, so dropped first among SHOULD)
# 2. open threads block (T60: SHOULD-tier; least critical to the
# speaker's immediate voice — drop next among SHOULD)
# 3. active events block (T60: same tier, drops next)
# 4. guest activity bullet (T71.2: bullet-level trim within the
# single ACTIVITIES: block — guest goes first per Task 43 spec)
# 5. group node block
# 6. you activity bullet (still SHOULD-tier; speaker bullet is the
# MUST-tier floor and never dropped)
# 7. other edges
if include_meanwhile_digests and total > budget_hard:
include_meanwhile_digests = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
if include_open_threads and total > budget_hard:
include_open_threads = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
if include_active_events and total > budget_hard:
include_active_events = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
if include_guest_activity and total > budget_hard:
include_guest_activity = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
if include_group_node and total > budget_hard:
include_group_node = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
if include_you_activity and total > budget_hard:
include_you_activity = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
if include_other and total > budget_hard:
include_other = False
body, total = _build(
prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep,
other=include_other, you_act=include_you_activity,
guest_act=include_guest_activity, group=include_group_node,
events=include_active_events, threads=include_open_threads,
digests=include_meanwhile_digests,
)
if total > budget_hard:
# We've stripped everything optional and we still overflow.
# MUST alone fits (we checked at the top), so this means our
# last-4 dialogue + must blocks together exceed hard. Fall back
# to the bare MUST core.
body = must_text
total = must_tokens
if total > budget_hard:
raise ValueError(
f"Prompt cannot fit budget_hard={budget_hard}; MUST core "
f"is {total} tokens"
)
return _emit(body, user_turn_prose)
def _emit(system_body: str, user_turn_prose: str | None) -> list[Message]:
msgs: list[Message] = [Message(role="system", content=system_body)]
if user_turn_prose is not None:
msgs.append(Message(role="user", content=user_turn_prose))
return msgs
def consume_pending_meanwhile_digests(conn: Connection, chat_id: str) -> int:
"""Mark every pending meanwhile digest for ``chat_id`` as consumed.
Called by the post_turn flow AFTER the assistant response streams,
once for the first you-turn that surfaced any pending digests. We
keep this side-effect out of :func:`assemble_narrative_prompt` so
prompt assembly stays pure (T29's regenerate flow rebuilds prompts
repeatedly without state mutation).
Returns the number of digests consumed (0 when none were pending).
"""
from datetime import datetime, timezone
from chat.eventlog.log import append_and_apply
pending = list_pending_meanwhile_digests(conn, chat_id)
if not pending:
return 0
now = datetime.now(timezone.utc).isoformat()
for d in pending:
append_and_apply(
conn,
kind="meanwhile_digest_consumed",
payload={
"digest_id": d["id"],
"consumed_at": now,
},
)
return len(pending)
__all__ = [
"assemble_narrative_prompt",
"consume_pending_meanwhile_digests",
]
+771
View File
@@ -0,0 +1,771 @@
"""Regenerate flow (T29).
The user clicks "Regenerate" on the latest ``assistant_turn``. The UI
puts the prior ``user_turn`` into inline edit mode and submits to
:func:`regenerate_assistant_turn` either:
- with **no edit** — we re-run the narrative against the original user
prose and append a fresh ``assistant_turn`` superseding the old one;
- with **edited prose** — we additionally append a ``user_turn_edit``
event capturing the new prose, mark the original ``user_turn`` as
superseded by the edit, then run the narrative against the edited
prose.
Per Requirements §10.2 superseded events are *kept in the log* — the
display layer hides them. This is what makes rewinding to before a
regenerate cheap: we just clear ``superseded_by`` on the old row.
The supersede update is one of the rare "direct DB write" exceptions
documented in the plan: we manipulate metadata fields on the canonical
event_log row itself rather than projecting through a handler.
Phase 1 simplifications (per the plan's "bound it" guidance):
- Significance pass is *not* re-run on regenerate. The original score
remains attached to the prior memory. The state-update pass *is* re-run
so affinity/trust/knowledge reflect the new output.
- The route does not broadcast a fresh ``turn_html`` SSE event; T34
polishes UI swaps. The user refreshes the page to see the new turn.
*(T73.1 closed this gap — see Phase 2.5 changes below.)*
Phase 2 changes (T44):
- Multi-entity prompt assembly: ``guest_id`` is forwarded to the
prompt assembler so the regenerated narrative sees the same
guest-aware context the original turn did.
- Multi-witness memory write: ``record_turn_memory_for_present`` fans
out one ``memory_written`` event per witness when a guest is present.
- Multi-pair state-update: ``compute_state_updates_for_present`` emits
one ``edge_update`` per directed pair across present entities. With
three present that's six edges instead of two.
- Interjection regeneration is **deferred to Phase 2.5**. Regenerate
only re-streams the addressee turn for v2; ``detect_interjection``
is not invoked here. If the prior turn fired an interjection it
remains attached to the original assistant_turn (which is superseded
alongside the regenerated turn) — Phase 2.5 will revisit.
Phase 2.5 changes:
- T73.1: After the new ``assistant_turn`` lands we publish a
``turn_html_replace`` SSE event carrying the rendered HTML for the
regenerated turn plus the original assistant_turn's event_id as
``supersedes_id`` so connected tabs can swap the prior DOM node
in-place. We use a NEW event name (rather than re-using ``turn_html``)
because the existing HTMX ``sse-swap="turn_html"`` consumer expects a
raw-HTML body and an *append* semantic; ``turn_html_replace`` is a
JSON payload (sse.py auto-serialises when extra keys accompany
``data``) so the front-end JS can read ``supersedes_id`` and replace
the right node.
- T73.2: Interjection regeneration. When the original assistant_turn
group included an interjection beat we redo BOTH the primary and the
interjection — re-running ``detect_interjection`` against the new
primary text. If the classifier returns False this time we supersede
the original interjection without appending a replacement.
- T73.3: The defensive degrade-to-1:1 for stale ``guest_bot_id``
references was removed — Phase 2 T47 fixed the root cause (resets
clear the reference) so the guard is dead code.
"""
from __future__ import annotations
import asyncio
import json
import logging
from sqlite3 import Connection
from chat.config import Settings
from chat.eventlog.log import append_and_apply, append_event
from chat.services.event_lifecycle import detect_event_transitions
from chat.services.event_promotion import promote_completed_event
from chat.services.interjection import detect_interjection
from chat.services.memory_write import record_turn_memory_for_present
from chat.services.multi_state_update import compute_state_updates_for_present
from chat.services.prompt import assemble_narrative_prompt
from chat.services.turn_common import (
gather_prior_edges,
read_recent_dialogue,
)
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you
from chat.state.events import list_active_events
from chat.state.world import active_scene, get_chat
from chat.web.pubsub import publish
from chat.web.render import render_turn_html
_log = logging.getLogger(__name__)
async def regenerate_assistant_turn(
conn: Connection,
client,
*,
settings: Settings,
chat_id: str,
original_assistant_event_id: int,
edited_user_prose: str | None = None,
app=None,
) -> str:
"""Regenerate the assistant turn linked to ``original_assistant_event_id``.
When ``edited_user_prose`` is provided the original user_turn is also
superseded by a fresh ``user_turn_edit`` event capturing the new
prose. Returns the new assistant text.
Raises :class:`ValueError` when the chat or the assistant_turn event
cannot be found — the FastAPI route translates this to 404.
.. note::
**Lifecycle-rollback limitation (T83.4, Phase 4 follow-up).**
When the superseded turn already produced lifecycle transitions
(``event_started`` / ``event_completed`` / ``event_cancelled``),
this function does NOT roll those rows back before re-running
``detect_event_transitions`` against the regenerated text. A
regenerate-after-completion can therefore double-emit promotion
artifacts if the new text re-completes the same event. Phase 3.5
only documents the gap and emits a WARNING log naming the
affected event_log ids; the actual undo pass is invasive
(re-projection / inverse-handler dispatch) and is deferred to
Phase 4. See the ``# T83.4`` block below for the warning emit.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise ValueError("chat not found")
host_bot_id = chat["host_bot_id"]
host_bot = get_bot(conn, host_bot_id) or {
"id": host_bot_id,
"name": "bot",
"persona": "",
}
# Phase 2: surface the guest (if any) so the prompt assembler and
# downstream multi-entity passes see the same shape post_turn does.
# Phase 2 T47 made bot_reset cascade-clear ``chat.guest_bot_id`` when
# the referenced bot is purged (verified by tests/test_reset.py), so
# we trust the column here: it's either a valid bot id or NULL.
guest_bot_id = chat.get("guest_bot_id")
guest_bot: dict | None = (
get_bot(conn, guest_bot_id) if guest_bot_id is not None else None
)
# 1. Locate the original assistant_turn event.
row = conn.execute(
"SELECT payload_json FROM event_log "
"WHERE id = ? AND kind = 'assistant_turn'",
(original_assistant_event_id,),
).fetchone()
if row is None:
raise ValueError("assistant_turn event not found")
original_assistant_payload = json.loads(row[0])
original_user_turn_id = original_assistant_payload.get("user_turn_id")
# T83.4: scan for downstream lifecycle transitions emitted by the
# superseded turn — they're not being rolled back (see method
# docstring). Heuristic: any ``event_started`` / ``event_completed``
# / ``event_cancelled`` event_log row with id strictly greater than
# the original assistant_turn's id was emitted as part of (or after)
# that turn's processing. Lifecycle events don't carry ``chat_id``
# in their payload (their payload references an ``event_id`` FK to
# the ``events`` table, which holds chat_id), so we join through
# ``events`` to scope to this chat.
#
# A WARNING log surfaces the affected event ids so operators can
# spot double-emit cases until the Phase 4 rollback pass lands.
unrolled_lifecycle = conn.execute(
"SELECT el.id, el.kind FROM event_log AS el "
"JOIN events AS ev "
" ON ev.event_id = json_extract(el.payload_json, '$.event_id') "
"WHERE el.kind IN ("
" 'event_started', 'event_completed', 'event_cancelled'"
" ) "
" AND ev.chat_id = ? "
" AND el.id > ? "
"ORDER BY el.id ASC",
(chat_id, original_assistant_event_id),
).fetchall()
if unrolled_lifecycle:
# T90.2: phrased as "at-or-after turn <id>" rather than "from
# superseded turn" because regenerating an OLDER turn lists
# intervening-turn transitions that legitimately stand on their
# own — those weren't authored by the superseded turn itself.
_log.warning(
"regenerate_assistant_turn: %d lifecycle transition(s) "
"at-or-after turn %s are NOT being rolled back (Phase 4 "
"follow-up). Affected event ids: %s",
len(unrolled_lifecycle),
original_assistant_event_id,
[r[0] for r in unrolled_lifecycle],
)
# 1a. Look up any sibling interjection beat in the same turn group
# (T73.2). The original group is (primary + optional interjection),
# both pinned to the same ``user_turn_id``. The interjection has a
# populated ``interjection_of`` field in its payload — its speaker is
# the silent witness (the bot that wasn't the primary addressee).
# Filter on ``superseded_by IS NULL`` so prior regenerates of this
# group don't reappear as siblings.
#
# T83.3: push the chat_id filter into SQL via ``json_extract`` so
# the query doesn't scan every assistant_turn row across the whole
# database. ``LIMIT 50`` bounds worst-case work even when chat_id
# isn't selective (e.g. a single chat with many turns) — we only
# need the one matching sibling. Mirrors the SQL pattern in
# ``chat.web.meanwhile._last_meanwhile_speaker``.
original_interjection_event_id: int | None = None
original_interjection_payload: dict | None = None
if original_user_turn_id is not None:
sibling_cur = conn.execute(
"SELECT id, payload_json FROM event_log "
"WHERE kind = 'assistant_turn' "
" AND id != ? "
" AND superseded_by IS NULL "
" AND json_extract(payload_json, '$.chat_id') = ? "
"ORDER BY id DESC "
"LIMIT 50",
(original_assistant_event_id, chat_id),
)
for sib_id, sib_payload_json in sibling_cur.fetchall():
sib_payload = json.loads(sib_payload_json)
if sib_payload.get("user_turn_id") != original_user_turn_id:
continue
if not sib_payload.get("interjection_of"):
continue
original_interjection_event_id = sib_id
original_interjection_payload = sib_payload
break
# Phase 2 v2 regenerates only the addressee turn — preserve whichever
# bot the original turn was attributed to, falling back to the host
# for legacy rows that pre-date multi-entity support.
speaker_bot_id = original_assistant_payload.get("speaker_id") or host_bot_id
if speaker_bot_id == host_bot_id:
speaker_bot = host_bot
elif guest_bot is not None and speaker_bot_id == guest_bot.get("id"):
speaker_bot = guest_bot
else:
speaker_bot = get_bot(conn, speaker_bot_id) or host_bot
speaker_bot_id = speaker_bot.get("id", host_bot_id)
# 2. Determine the prose for the new prompt and (when edited) capture
# the user_turn_edit event up front so the new event ids exist before
# we link them from the assistant_turn payload.
new_user_event_id: int | None = None
if edited_user_prose is not None:
new_user_event_id = append_event(
conn,
kind="user_turn_edit",
payload={
"chat_id": chat_id,
"prose": edited_user_prose,
"supersedes_user_turn_id": original_user_turn_id,
},
)
if original_user_turn_id is not None:
conn.execute(
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
(new_user_event_id, original_user_turn_id),
)
prose_for_prompt = edited_user_prose
else:
original_user_row = conn.execute(
"SELECT payload_json FROM event_log WHERE id = ?",
(original_user_turn_id,),
).fetchone() if original_user_turn_id is not None else None
if original_user_row is not None:
prose_for_prompt = json.loads(original_user_row[0]).get("prose", "")
else:
prose_for_prompt = ""
# 3. Build the recent-dialogue slice. Exclude the original
# assistant_turn explicitly (we haven't superseded it yet — that
# update lands at the end so the new event_id is known) and use the
# standard ``superseded_by IS NULL AND hidden = 0`` filter so any
# prior regenerates also drop out. T83.2: shared helper handles the
# SQL + filtering; we post-process to map speaker ids to display
# names for the prompt.
you_entity = get_you(conn) or {"name": "you", "persona": ""}
you_name = you_entity.get("name", "you")
raw_recent = read_recent_dialogue(
conn,
chat_id,
limit=20,
exclude_event_id=original_assistant_event_id,
)
recent: list[dict] = []
for entry in raw_recent:
spk = entry.get("speaker", "bot")
if spk == "you":
recent.append({"speaker": you_name, "text": entry.get("text", "")})
continue
if spk == host_bot_id:
spk_name = host_bot.get("name", "bot")
elif guest_bot is not None and spk == guest_bot.get("id"):
spk_name = guest_bot.get("name", "bot")
else:
spk_name = host_bot.get("name", "bot")
recent.append({"speaker": spk_name, "text": entry.get("text", "")})
# 4. Assemble the narrative prompt. ``recent`` already excludes the
# current user prose, which we pass through ``user_turn_prose``.
# Phase 2: forward ``guest_id`` so the prompt sees the third party.
messages = assemble_narrative_prompt(
conn,
chat_id=chat_id,
speaker_bot_id=speaker_bot_id,
user_turn_prose=prose_for_prompt or None,
recent_dialogue=recent,
budget_soft=settings.narrative_budget_soft,
budget_hard=settings.narrative_budget_hard,
guest_id=guest_bot_id,
)
# 5. Stream the new narrative. T83.1: register the streaming Task in
# the chat-keyed in-flight registry so POST /chats/<id>/turns/cancel
# can call ``.cancel()`` on a mid-regenerate stream. We import the
# underscore name from turns.py deliberately — same single-process
# registry the cancel route reads, mirrors the meanwhile registration
# pattern in chat/web/meanwhile.py.
from chat.web.turns import _in_flight_tasks # noqa: PLC0415
accumulated: list[str] = []
async def _stream_primary() -> None:
async for chunk in client.stream(
messages,
model=settings.narrative_model,
max_tokens=settings.narrative_max_tokens,
temperature=settings.narrative_temperature,
):
accumulated.append(chunk)
await publish(
chat_id,
{"event": "token", "text": chunk, "speaker_id": speaker_bot_id},
)
stream_task = asyncio.create_task(_stream_primary())
_in_flight_tasks[chat_id] = stream_task
try:
await stream_task
finally:
# Always unregister so a subsequent turn / regenerate can register
# a fresh task. Mirrors the cleanup in turns.py::post_turn.
_in_flight_tasks.pop(chat_id, None)
new_text = "".join(accumulated)
# 6. Append the new assistant_turn event. ``user_turn_id`` points at
# the edit event when one was created, otherwise the original. The
# ``regenerated_from`` field is the back-pointer the UI uses for an
# "originally said …" affordance.
new_assistant_event_id = append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": speaker_bot_id,
"text": new_text,
"truncated": False,
"user_turn_id": (
new_user_event_id
if new_user_event_id is not None
else original_user_turn_id
),
"regenerated_from": original_assistant_event_id,
},
)
# 7. Mark the original assistant_turn as superseded by the new one.
conn.execute(
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
(new_assistant_event_id, original_assistant_event_id),
)
# 7a. Broadcast a turn_html_replace SSE event so connected tabs can
# swap the prior assistant_turn DOM node in-place (T73.1, Phase 1.5
# backlog #2). Uses a separate event name from post_turn's
# ``turn_html`` (which is append-only) because regenerate is a
# *replace* operation — see module docstring for the rationale.
speaker_name_for_render = (
speaker_bot.get("name", "bot") if speaker_bot is not None else "bot"
)
new_turn_html = render_turn_html(
speaker_name_for_render,
new_text,
role="bot",
event_id=new_assistant_event_id,
)
await publish(
chat_id,
{
"event": "turn_html_replace",
"data": new_turn_html,
"turn_id": new_assistant_event_id,
"supersedes_id": original_assistant_event_id,
},
)
# 8. Re-run downstream classifier passes (memory write + state update
# for every directed pair across present entities). Significance is
# intentionally skipped on regenerate (the prior score remains
# attached to the prior memory). Phase 2.5 will add interjection
# regeneration; v2 leaves any prior interjection beat in place.
scene = active_scene(conn, chat_id)
record_turn_memory_for_present(
conn,
chat_id=chat_id,
host_bot_id=host_bot_id,
guest_bot_id=guest_bot_id,
narrative_text=new_text,
scene_id=scene["id"] if scene else None,
chat_clock_at=chat.get("time"),
app=app,
)
last_at = chat.get("time")
speaker_name = (
speaker_bot.get("name", "bot") if speaker_bot is not None else "bot"
)
recent_for_update = recent + [
{"speaker": speaker_name, "text": new_text}
]
# Build present-entity inputs for the multi-pair state-update pass.
# Host first preserves the Phase 1 directed-pair order (host->you,
# then you->host) so existing canned-response fixtures still line up.
present_ids: list[str] = [host_bot_id, "you"]
present_names: dict[str, str] = {
host_bot_id: host_bot.get("name", "bot"),
"you": you_name,
}
personas: dict[str, str] = {
host_bot_id: host_bot.get("persona") or "",
"you": you_entity.get("persona") or "",
}
if guest_bot is not None and guest_bot_id is not None:
present_ids.append(guest_bot_id)
present_names[guest_bot_id] = guest_bot.get("name", "bot")
personas[guest_bot_id] = guest_bot.get("persona") or ""
# T83.2: shared helper builds the directed-pair edge dict.
prior_edges = gather_prior_edges(conn, present_ids)
state_updates = await compute_state_updates_for_present(
client,
classifier_model=settings.classifier_model,
present_ids=present_ids,
present_names=present_names,
personas=personas,
prior_edges=prior_edges,
recent_dialogue=recent_for_update,
timeout_s=settings.classifier_timeout_s,
)
for src_id, tgt_id, update in state_updates:
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": src_id,
"target_id": tgt_id,
"chat_id": chat_id,
"affinity_delta": update.affinity_delta,
"trust_delta": update.trust_delta,
"knowledge_facts": update.knowledge_facts,
"last_interaction_at": last_at,
"last_interaction_chat_id": chat_id,
},
)
# 9. Interjection regenerate branch (T73.2). When the original
# assistant_turn group included a follow-on interjection beat we need
# to revisit that beat against the regenerated primary. Three outcomes:
#
# - No original interjection: nothing to do; we already short-circuit
# above by leaving ``original_interjection_event_id`` as None.
# - Original interjection + classifier returns True: stream a fresh
# interjection from the silent witness, append it (with
# ``interjection_of`` linking to the new primary speaker), and
# supersede the original interjection's row. Also re-run memory
# + state-update so the second beat moves edges + writes memories.
# - Original interjection + classifier returns False: supersede the
# original interjection without appending a replacement. The
# regenerated group becomes "primary only" because the new primary
# no longer warrants a follow-on. No memory / state work needed
# for the absent beat.
#
# ``superseded_by`` on the original interjection's row points at the
# *new primary* in the no-replacement case (rather than NULL or a
# nonexistent id) so the row is consistently hidden by the standard
# ``superseded_by IS NULL`` timeline filter and the back-pointer
# leads somewhere meaningful for an "originally said …" affordance.
if original_interjection_event_id is not None and guest_bot is not None:
# Identify the silent witness from the original interjection's
# speaker_id (which is the bot that interjected last time). When
# we regenerate we keep the *same pair of present entities*, so
# the silent witness is whichever bot isn't the new primary
# speaker — derive it from present rather than reusing the prior
# speaker_id verbatim, in case the regenerated primary swapped
# who held the floor.
if speaker_bot_id == host_bot_id:
silent_witness = guest_bot
else:
silent_witness = host_bot
silent_witness_id = silent_witness.get("id")
edge_w_to_addr = get_edge(conn, silent_witness_id, speaker_bot_id) or {
"affinity": 50,
"trust": 50,
"summary": "",
}
edge_w_to_you = get_edge(conn, silent_witness_id, "you") or {
"affinity": 50,
"trust": 50,
"summary": "",
}
decision = await detect_interjection(
client,
classifier_model=settings.classifier_model,
addressee_name=speaker_bot.get("name", "bot"),
addressee_just_said=new_text,
silent_witness_name=silent_witness.get("name", "bot"),
silent_witness_persona=silent_witness.get("persona") or "",
silent_witness_edge_to_addressee=edge_w_to_addr,
silent_witness_edge_to_you=edge_w_to_you,
you_just_said=prose_for_prompt or "",
timeout_s=settings.classifier_timeout_s,
)
if decision.should_interject:
# Re-read recent so the just-appended primary is in the
# prompt. T83.2: shared helper + the same id->name mapping
# as the primary read above.
raw_interject = read_recent_dialogue(conn, chat_id, limit=20)
interject_recent: list[dict] = []
for entry in raw_interject:
spk = entry.get("speaker", "bot")
if spk == "you":
interject_recent.append(
{"speaker": you_name, "text": entry.get("text", "")}
)
continue
if spk == host_bot_id:
spk_name = host_bot.get("name", "bot")
elif spk == guest_bot.get("id"):
spk_name = guest_bot.get("name", "bot")
else:
spk_name = "bot"
interject_recent.append(
{"speaker": spk_name, "text": entry.get("text", "")}
)
if interject_recent and interject_recent[-1].get("speaker") == you_name:
interject_recent = interject_recent[:-1]
interject_messages = assemble_narrative_prompt(
conn,
chat_id=chat_id,
speaker_bot_id=silent_witness_id,
addressee=speaker_bot_id,
user_turn_prose=prose_for_prompt or None,
recent_dialogue=interject_recent,
budget_soft=settings.narrative_budget_soft,
budget_hard=settings.narrative_budget_hard,
guest_id=guest_bot_id,
)
interject_accumulated: list[str] = []
async def _stream_interjection() -> None:
async for chunk in client.stream(
interject_messages,
model=settings.narrative_model,
max_tokens=settings.narrative_max_tokens,
temperature=settings.narrative_temperature,
):
interject_accumulated.append(chunk)
await publish(
chat_id,
{
"event": "token",
"text": chunk,
"speaker_id": silent_witness_id,
},
)
# T83.1: register the interjection sub-stream in the same
# in-flight registry so /turns/cancel collapses it too.
interject_task = asyncio.create_task(_stream_interjection())
_in_flight_tasks[chat_id] = interject_task
try:
await interject_task
finally:
_in_flight_tasks.pop(chat_id, None)
interject_text = "".join(interject_accumulated)
new_interjection_event_id = append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": silent_witness_id,
"text": interject_text,
"truncated": False,
"user_turn_id": (
new_user_event_id
if new_user_event_id is not None
else original_user_turn_id
),
"regenerated_from": original_interjection_event_id,
"interjection_of": speaker_bot_id,
},
)
# Supersede the original interjection by the new one.
conn.execute(
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
(new_interjection_event_id, original_interjection_event_id),
)
# Broadcast a replace event so connected tabs swap the prior
# interjection node in-place (mirrors T73.1's primary swap).
interject_html = render_turn_html(
silent_witness.get("name", "bot"),
interject_text,
role="bot",
event_id=new_interjection_event_id,
)
await publish(
chat_id,
{
"event": "turn_html_replace",
"data": interject_html,
"turn_id": new_interjection_event_id,
"supersedes_id": original_interjection_event_id,
},
)
# Memory write for the new interjection beat (one event per
# present witness).
record_turn_memory_for_present(
conn,
chat_id=chat_id,
host_bot_id=host_bot_id,
guest_bot_id=guest_bot_id,
narrative_text=interject_text,
scene_id=scene["id"] if scene else None,
chat_clock_at=chat.get("time"),
app=app,
)
# Re-run the multi-pair state-update with the post-interjection
# dialogue tail so deltas land on the post-primary baseline.
recent_post_interject = recent_for_update + [
{
"speaker": silent_witness.get("name", "bot"),
"text": interject_text,
}
]
# T83.2: shared helper handles the directed-pair edge dict.
prior_edges_post = gather_prior_edges(conn, present_ids)
state_updates_post = await compute_state_updates_for_present(
client,
classifier_model=settings.classifier_model,
present_ids=present_ids,
present_names=present_names,
personas=personas,
prior_edges=prior_edges_post,
recent_dialogue=recent_post_interject,
timeout_s=settings.classifier_timeout_s,
)
for src_id, tgt_id, update in state_updates_post:
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": src_id,
"target_id": tgt_id,
"chat_id": chat_id,
"affinity_delta": update.affinity_delta,
"trust_delta": update.trust_delta,
"knowledge_facts": update.knowledge_facts,
"last_interaction_at": last_at,
"last_interaction_chat_id": chat_id,
},
)
else:
# Classifier said "no follow-on this time" — supersede the
# original interjection without a replacement. Point the
# back-pointer at the new primary so the row is consistently
# hidden by the standard timeline filter.
conn.execute(
"UPDATE event_log SET superseded_by = ? WHERE id = ?",
(new_assistant_event_id, original_interjection_event_id),
)
# 9a. Event-lifecycle detection (Phase 3, T61). T83.5 cosmetic
# ordering: mirrors ``chat.web.turns.post_turn``'s 8a block — runs
# AFTER the interjection branch (and AFTER the post-interjection
# state-update + memory passes) so the classifier sees the same
# narrative-text input post_turn does. Numbering uses ``9a`` to
# match post_turn's ``8a`` shape (the interjection branch is step 9
# in regenerate vs step 8 in post_turn; lifecycle is the immediate
# follow-on in both). Behaviour identical to the prior ``step 10``
# placement — the block was already structurally last in regenerate
# because there's no scene-close pass here.
#
# Classify whether any active events transitioned in the regenerated
# narrative and append the corresponding event_started /
# event_completed / event_cancelled. ``promote_completed_event``
# runs inline after a completion so promotion artifacts land in the
# same regenerate path.
#
# T83.4 follow-up: when a regenerate replaces a turn that had
# already produced event transitions, those original transitions
# are NOT undone here (Phase 4 work). A WARNING log earlier in this
# function names the affected event_log ids — see the T83.4 block
# near the function entry.
new_active_events = list_active_events(conn, chat_id)
if new_active_events:
lifecycle_decision = await detect_event_transitions(
client,
classifier_model=settings.classifier_model,
narrative_text=new_text,
active_events=new_active_events,
timeout_s=settings.classifier_timeout_s,
)
for transition in lifecycle_decision.transitions:
if transition.new_status == "active":
append_and_apply(
conn,
kind="event_started",
payload={
"event_id": transition.event_id,
"started_at": chat.get("time"),
},
)
elif transition.new_status == "completed":
append_and_apply(
conn,
kind="event_completed",
payload={
"event_id": transition.event_id,
"completed_at": chat.get("time"),
},
)
promote_completed_event(
conn,
event_id=transition.event_id,
chat_id=chat_id,
chat_clock_at=chat.get("time"),
)
elif transition.new_status == "cancelled":
append_and_apply(
conn,
kind="event_cancelled",
payload={
"event_id": transition.event_id,
"completed_at": chat.get("time"),
},
)
return new_text
__all__ = ["regenerate_assistant_turn"]
+107
View File
@@ -0,0 +1,107 @@
"""Parse user-supplied "have they met?" prose into per-direction seed
content for two bots' edges (T38).
Per Requirements §5.2, when two bots first co-appear in a chat, the user
is offered a small drawer asking "Have they met before? If yes, write a
short prose seed describing how." That prose lands here and is parsed
into a :class:`RelationshipSeed` whose two halves populate the
``botA -> botB`` and ``botB -> botA`` edges respectively (summary,
initial knowledge facts, and small affinity/trust deltas around the
default 50/50 baseline).
The two directions can differ — A may know more about B than B knows
about A, or A may trust B less than the reverse — so the schema carries
both halves independently.
Empty/whitespace-only prose short-circuits to a default
``RelationshipSeed`` (all zeroes, empty strings); the caller treats
that as "they haven't met" and writes no edge content. The wrapper uses
:func:`chat.llm.classify.classify` with ``default=RelationshipSeed()``
so a flapping classifier degrades to the same no-op rather than
blocking the chat-creation flow (§3.3 graceful-degradation rule).
T42 (the inter-bot relationship drawer) calls this from the route layer.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class RelationshipSeed(BaseModel):
"""Structured per-direction seed for two bots' edges.
Defaults are a deliberate no-op: empty summaries, empty knowledge
lists, zero deltas. Both the empty-prose short-circuit and the
classifier-failure fallback return this default so the caller can
treat them identically.
"""
a_to_b_summary: str = ""
a_to_b_knowledge_facts: list[str] = Field(default_factory=list)
a_to_b_affinity_delta: int = 0 # signed, -10..+10 typical
a_to_b_trust_delta: int = 0
b_to_a_summary: str = ""
b_to_a_knowledge_facts: list[str] = Field(default_factory=list)
b_to_a_affinity_delta: int = 0
b_to_a_trust_delta: int = 0
_SYSTEM = (
"You parse a short prose seed describing how two characters know each "
"other into structured per-direction edge content. For each direction "
"(A -> B, B -> A) extract: summary (one sentence from that POV), "
"knowledge_facts (list of factual claims that direction can carry "
"into future scenes), affinity_delta (-10..+10 — small adjustments to "
"the default 50/50 baseline), trust_delta (-10..+10). Default deltas "
"to 0 when prose is neutral. The two directions can differ — A may "
"trust B more than B trusts A. Output strict JSON matching the schema."
)
async def seed_inter_bot_edges(
client: LLMClient,
*,
classifier_model: str,
bot_a_id: str,
bot_a_name: str,
bot_b_id: str,
bot_b_name: str,
relationship_prose: str,
timeout_s: float = 30.0,
) -> RelationshipSeed:
"""Parse user-supplied prose into structured edge content for both
directed pairs.
Empty/whitespace prose short-circuits to an empty
:class:`RelationshipSeed` (the caller treats this as "they haven't
met" and writes no edge content). Classifier failure also returns
the default — see module docstring for the rationale.
The ``bot_a_id`` / ``bot_b_id`` arguments are accepted for symmetry
with the caller (T42's drawer route uses them when emitting
``edge_update`` events); they're embedded in the prompt alongside
the names so the classifier can disambiguate when names collide.
"""
if not relationship_prose or not relationship_prose.strip():
return RelationshipSeed()
user = (
f"Bot A: {bot_a_name} (id={bot_a_id})\n"
f"Bot B: {bot_b_name} (id={bot_b_id})\n\n"
f"Prose seed:\n{relationship_prose.strip()}"
)
return await classify(
client,
model=classifier_model,
system=_SYSTEM,
user=user,
schema=RelationshipSeed,
default=RelationshipSeed(),
timeout_s=timeout_s,
)
__all__ = ["RelationshipSeed", "seed_inter_bot_edges"]
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from sqlite3 import Connection
from chat.eventlog.log import append_and_apply
from chat.state.entities import get_bot
def reset_bot(conn: Connection, bot_id: str, *, confirm_name: str) -> None:
"""Reset a bot's runtime state via a ``bot_reset`` event.
Validates that ``confirm_name`` matches the bot's stored ``name``
exactly (case-sensitive, no trim). Raises:
- ``ValueError("bot {bot_id} not found")`` when the bot is missing.
- ``ValueError("confirm_name does not match bot name")`` on mismatch.
"""
bot = get_bot(conn, bot_id)
if bot is None:
raise ValueError(f"bot {bot_id} not found")
if confirm_name != bot["name"]:
raise ValueError("confirm_name does not match bot name")
append_and_apply(conn, kind="bot_reset", payload={"bot_id": bot_id})
+112
View File
@@ -0,0 +1,112 @@
"""Rewind service — truncate the event log past a chosen turn and re-project.
Per Requirements §10.1 and Plan Task 28, "rewind to here" must:
1. Take a snapshot of the current state so the user can recover (handed
off to :mod:`chat.services.snapshot`).
2. Truncate the event log past ``after_event_id`` — physical DELETE for
v1 simplicity; the spec says rewind should be a hard truncation, not
the soft ``hidden=1`` mechanism used by edits/regenerate.
3. Clear projected tables and re-project from the truncated log so live
state matches "what the world looked like at turn N". Without the
re-projection, projected tables would carry forward stale rows from
rewound events (e.g. an ``edge_update`` that bumped affinity past the
rewind point would still show in ``edges``).
Re-projection is a full replay rather than a "revert delta" because most
projector handlers are idempotent inserts, but the edge handler is a
delta-shaped accumulator — there's no clean way to invert a single
``edge_update`` against ``edges.affinity`` without replay. Wiping +
replaying is straightforward and correct.
"""
from __future__ import annotations
from pathlib import Path
from sqlite3 import Connection
from chat.db.connection import open_db
from chat.eventlog.projector import project
from chat.services.snapshot import take_snapshot
def compute_rewind_preview(
conn: Connection, after_event_id: int
) -> dict:
"""Return counts of each event kind that would be removed by rewinding.
Used by the preview modal so the user sees the impact (e.g. "this
will remove 8 events: 4 user_turn, 4 assistant_turn") before
confirming. Counts include hidden/superseded rows — they're still
physically deleted.
"""
cur = conn.execute(
"SELECT kind, COUNT(*) FROM event_log WHERE id > ? GROUP BY kind "
"ORDER BY kind",
(after_event_id,),
)
counts = {kind: count for kind, count in cur.fetchall()}
total = sum(counts.values())
return {
"after_event_id": after_event_id,
"total_events": total,
"by_kind": counts,
}
def execute_rewind(
*, db_path: Path, data_dir: Path, after_event_id: int
) -> Path:
"""Take a snapshot, truncate, and re-project. Returns the snapshot path.
The snapshot is taken inside the same connection scope as the
truncate + reproject so all three commit together — if any step
fails the connection's commit-on-exit is bypassed by the exception
and the database stays untouched. The snapshot file is on disk
regardless, which is the desired behaviour: even if the truncate
aborts, the user has a recovery point.
"""
with open_db(db_path) as conn:
# 1. Snapshot first — we want this on disk before any destructive
# operation runs.
snapshot_path = take_snapshot(
conn, data_dir=data_dir, kind="rewind"
)
# 2. Truncate the event log past the chosen id. Foreign keys are
# ON, but ``event_log.superseded_by`` self-references and the
# rows we're deleting are the only ones that could point
# forward — there's nothing to cascade.
conn.execute(
"DELETE FROM event_log WHERE id > ?", (after_event_id,)
)
# 3. Clear projected tables in topological order so FK ON DELETE
# constraints don't fire on referenced rows. ``activity`` and
# ``scenes`` reference ``containers``; ``chat_state`` references
# ``chats`` by id-convention only (no FK declared). ``memories``,
# ``edges``, ``bots``, ``you_entity``, and ``classifier_failures``
# have no incoming FKs from other projected tables.
#
# ``executescript`` is intentionally avoided so foreign_keys=ON
# stays in effect for each statement — executescript would
# implicitly commit and reset some pragmas on certain SQLite
# builds.
conn.execute("DELETE FROM memories")
conn.execute("DELETE FROM activity")
conn.execute("DELETE FROM scenes")
conn.execute("DELETE FROM containers")
conn.execute("DELETE FROM chat_state")
conn.execute("DELETE FROM chats")
conn.execute("DELETE FROM edges")
conn.execute("DELETE FROM bots")
conn.execute("DELETE FROM you_entity")
conn.execute("DELETE FROM classifier_failures")
# 4. Re-project from the truncated event log. Handler registry
# is module-level state populated by importing chat.state.* —
# callers (the route, tests) need to have those modules
# imported for this to do anything useful.
project(conn)
return snapshot_path
+100
View File
@@ -0,0 +1,100 @@
"""Scene-close hard-signal detection (T26).
A small classifier service that decides whether the user's prose narrates
a hard signal that should close the active scene. Hard signals (per
Requirements §7.2):
* Container change parsed from prose ("we drove to the park", "we stepped
outside").
* Explicit user pattern signaling end ("we're done here", "fade out",
"scene end").
NOT close signals:
* Brief activity changes within the same container ("I sit down").
* Future plans ("let's go to the park later").
The service returns a :class:`SceneCloseDecision`. The default on classifier
failure is ``should_close=False`` so the turn flow keeps moving — closing
on a misfire would be more disruptive than missing a real signal, and the
manual button in the drawer is always available as a fallback.
Phase 2/3 will introduce automatic re-opening with the new container; for
T26 the close is one-way and the next user turn operates without an active
scene (the prompt assembler already tolerates this).
"""
from __future__ import annotations
from pydantic import BaseModel
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class SceneCloseDecision(BaseModel):
"""Classifier verdict for scene-close detection.
``new_container_hint`` is captured opportunistically when the close
signal is a container change, but T26 doesn't act on it — Phase 2/3
handles automatic re-opening at the new location.
"""
should_close: bool = False
reason: str = ""
new_container_hint: str = ""
_SYSTEM = (
"You decide whether a roleplay scene should close based on the user's "
"prose.\n"
"Close signals (return should_close=true):\n"
"- The prose narrates a CONTAINER CHANGE (moving to a different place, "
'e.g. "we drove to the park", "we stepped outside").\n'
"- The prose has an EXPLICIT USER PATTERN signaling end "
'("we\'re done here", "fade out", "scene end").\n'
"\n"
"DO NOT close on:\n"
"- Brief activity changes within the same place "
'(e.g. "I sit down" — same room).\n'
"- Future plans "
'("let\'s go to the park later" — not yet).\n'
"\n"
'Reply JSON: {"should_close": bool, "reason": str (short), '
'"new_container_hint": str (optional name)}.'
)
async def detect_scene_close(
client: LLMClient,
*,
model: str,
prose: str,
current_container_name: str,
timeout_s: float = 10.0,
) -> SceneCloseDecision:
"""Run the scene-close classifier on a single user turn.
The current container name is passed in so the prompt can reason about
"different place" relative to the active scene rather than guessing.
On classifier failure (parse error twice), the returned decision is the
safe ``should_close=False`` default.
"""
user = (
f"CURRENT CONTAINER: {current_container_name}\n"
f"\n"
f"PROSE:\n{prose}\n"
f"\n"
f"Decide whether to close the scene."
)
return await classify(
client,
model=model,
system=_SYSTEM,
user=user,
schema=SceneCloseDecision,
default=SceneCloseDecision(
should_close=False, reason="fallback", new_container_hint=""
),
timeout_s=timeout_s,
)
+714
View File
@@ -0,0 +1,714 @@
"""Per-POV scene summary and edge summary update on scene close (T27).
When a scene closes — either auto-detected by the hard-signal classifier
in T26 or fired by the manual close button on the drawer — we run a
single-shot classifier per present witness that produces three signals
in one pass:
* ``summary`` — a 2-4 sentence per-POV recap of the scene from this
witness's perspective. Different from omniscient narration; focuses on
what the witness noticed/felt/remembers.
* ``knowledge_facts`` — concrete new things this witness learned about
the user during the scene. Promoted to the directed edge's
``knowledge`` list via ``edge_update``.
* ``relationship_summary`` — a 1-2 sentence delta on how the
witness's relationship to the user shifted in this scene. v1
combines this with the prior edge summary by simple concatenation —
the LLM is asked to phrase ``relationship_summary`` as a merge-ready
fragment, so the result reads naturally without a second classifier
round-trip.
Phase 1 single-bot only the host bot is summarized; "you" doesn't have
a memory store in v1 so per-POV writes for the user are deferred. The
:func:`apply_scene_close_summary` driver is intentionally tolerant: if
no memories belong to the closed scene it silently skips the rewrite,
and a flapping classifier returns the empty default so the close flow
keeps moving.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from sqlite3 import Connection
from pydantic import BaseModel, Field
from chat.eventlog.log import append_and_apply
from chat.llm.classify import classify
from chat.llm.client import LLMClient
_log = logging.getLogger(__name__)
class ScenePOVSummary(BaseModel):
"""Classifier output: one witness's view of a closing scene.
Defaults are an inert no-op so a classifier failure is harmless —
callers can apply the result unconditionally and end up not
rewriting anything when the model misbehaves.
"""
summary: str = ""
knowledge_facts: list[str] = Field(default_factory=list)
relationship_summary: str = ""
_SYSTEM_TEMPLATE = (
"You are summarizing a roleplay scene from {bot_name}'s point of "
"view. Read the dialogue, then output JSON with exactly three "
"fields:\n"
"- summary: 2-4 sentences, in {bot_name}'s POV, of what happened "
"in the scene. This is NOT omniscient narration — focus on what "
"{bot_name} noticed, felt, and would remember.\n"
"- knowledge_facts: list of NEW factual things {bot_name} learned "
"about the user during this scene. Use specific stated content; do "
"not infer or interpret. Empty list is fine.\n"
"- relationship_summary: a SHORT (1-2 sentence) summary of how "
"{bot_name}'s relationship with the user changed or developed in "
"this scene. Phrase it so it reads as a continuation of the prior "
"summary; the caller will concatenate them.\n\n"
"Be specific. Avoid generic phrases."
)
def _format_dialogue(dialogue: list[dict]) -> str:
if not dialogue:
return "(no dialogue)"
return "\n".join(
f"{turn.get('speaker', '?')}: {turn.get('text', '')}"
for turn in dialogue
)
async def summarize_scene(
client: LLMClient,
*,
model: str,
bot_name: str,
bot_persona: str,
you_name: str,
prior_edge_summary: str,
dialogue: list[dict],
timeout_s: float = 10.0,
) -> ScenePOVSummary:
"""Run the per-POV summary classifier for one witness.
The signature mirrors :func:`compute_state_update` — passing the
bot's name and persona as separate fields lets the prompt address
the model directly ("YOU are {bot_name}") rather than handing it an
opaque id. ``prior_edge_summary`` is included so the classifier can
phrase ``relationship_summary`` as an additive fragment.
Returns the empty default on classifier failure (after one retry)
rather than raising, so the close pipeline keeps moving.
"""
system = _SYSTEM_TEMPLATE.format(bot_name=bot_name)
user = (
f"YOU are {bot_name}. {bot_persona or '(no persona on file)'}\n"
f"USER name: {you_name}\n"
f"PRIOR EDGE SUMMARY ({bot_name} -> {you_name}): "
f"{prior_edge_summary or '(empty)'}\n\n"
f"DIALOGUE:\n{_format_dialogue(dialogue)}\n\n"
f"Produce the JSON summary in {bot_name}'s POV."
)
return await classify(
client,
model=model,
system=system,
user=user,
schema=ScenePOVSummary,
default=ScenePOVSummary(),
timeout_s=timeout_s,
)
def _read_recent_dialogue(
conn: Connection,
chat_id: str,
*,
limit: int = 50,
since_event_id: int | None = None,
) -> list[dict]:
"""Pull the last ``limit`` user/assistant turns for ``chat_id``.
Phase 1 ``user_turn`` / ``assistant_turn`` events don't carry a
``scene_id``, so we approximate the scene's transcript by taking
the most recent turns of the chat. Superseded and hidden rows are
filtered out so regenerated turns (T29) don't bleed into the
summary.
T80.2: ``since_event_id`` clamps the result to event_log rows whose
``id >= since_event_id`` so callers needing a scene-scoped view (e.g.
thread detection on close) don't pull turns that landed before the
closing scene's ``scene_opened`` event.
"""
if since_event_id is None:
cur = conn.execute(
"SELECT kind, payload_json FROM event_log "
"WHERE kind IN ('user_turn', 'assistant_turn') "
" AND superseded_by IS NULL AND hidden = 0 "
"ORDER BY id DESC LIMIT ?",
(limit,),
)
else:
cur = conn.execute(
"SELECT kind, payload_json FROM event_log "
"WHERE kind IN ('user_turn', 'assistant_turn') "
" AND superseded_by IS NULL AND hidden = 0 "
" AND id >= ? "
"ORDER BY id DESC LIMIT ?",
(since_event_id, limit),
)
rows = list(reversed(cur.fetchall()))
out: list[dict] = []
for kind, payload_json in rows:
p = json.loads(payload_json)
if p.get("chat_id") != chat_id:
continue
if kind == "user_turn":
out.append({"speaker": "you", "text": p.get("prose", "")})
else:
out.append(
{
"speaker": p.get("speaker_id", "bot"),
"text": p.get("text", ""),
}
)
return out
def _scene_opened_event_id(
conn: Connection, chat_id: str, scene_id: int
) -> int | None:
"""Return the event_log id of the ``scene_opened`` (or
``meanwhile_scene_started``) event that created scene row
``scene_id``. Used by T80.2 to lower-bound dialogue reads to a
single scene's transcript.
``meanwhile_scene_started`` carries an explicit ``scene_id`` so we
match on that directly. ``scene_opened`` doesn't, so we walk the
chat's scene rows in id order and zip against the chat's scene-open
events in id order — the projector creates one scene row per
scene-open event, so positions correspond.
Returns ``None`` when no matching event is found; callers should
treat that as "fall back to chat-wide" rather than over-filter.
"""
# Fast path for meanwhile children (explicit scene_id in payload).
for ev_id, payload_json in conn.execute(
"SELECT id, payload_json FROM event_log "
"WHERE kind = 'meanwhile_scene_started' "
" AND superseded_by IS NULL AND hidden = 0",
).fetchall():
try:
p = json.loads(payload_json)
except (TypeError, ValueError):
continue
if p.get("chat_id") == chat_id and p.get("scene_id") == scene_id:
return ev_id
# Fallback for parent you-scenes: zip chat-scoped scene-open events
# against chat-scoped scene rows in id order.
chat_scene_ids = [
r[0]
for r in conn.execute(
"SELECT id FROM scenes WHERE chat_id = ? ORDER BY id ASC",
(chat_id,),
).fetchall()
]
if scene_id not in chat_scene_ids:
return None
chat_open_evs: list[int] = []
for ev_id, _kind, payload_json in conn.execute(
"SELECT id, kind, payload_json FROM event_log "
"WHERE kind IN ('scene_opened', 'meanwhile_scene_started') "
" AND superseded_by IS NULL AND hidden = 0 "
"ORDER BY id ASC",
).fetchall():
try:
p = json.loads(payload_json)
except (TypeError, ValueError):
continue
if p.get("chat_id") == chat_id:
chat_open_evs.append(ev_id)
idx = chat_scene_ids.index(scene_id)
if idx < len(chat_open_evs):
return chat_open_evs[idx]
return None
async def _summarize_and_apply_for_witness(
conn: Connection,
client: LLMClient,
*,
classifier_model: str,
chat_id: str,
scene_id: int,
bot_id: str,
you_name: str,
dialogue: list[dict],
timeout_s: float,
key_quotes_suffix: str = "",
) -> ScenePOVSummary:
"""Run :func:`summarize_scene` for one bot witness and apply the
three projected updates (memory pov_summary rewrite, edge summary
overwrite, edge knowledge_facts append).
Tolerant of missing pieces in the same way Phase 1 was: no memory
row -> skip the rewrite; no edge row -> skip the edge_summary write
(the empty-default classifier output simply yields no rewrites).
``key_quotes_suffix`` is appended verbatim to the per-POV summary
text before the rewrite lands (T58.1) — empty string is the no-op
default for low-significance scenes.
"""
from chat.state.edges import get_edge
from chat.state.entities import get_bot
bot = get_bot(conn, bot_id) or {"name": bot_id, "persona": ""}
edge_b2y = get_edge(conn, bot_id, "you")
prior_summary = (edge_b2y or {}).get("summary", "") or ""
pov = await summarize_scene(
client,
model=classifier_model,
bot_name=bot.get("name", bot_id),
bot_persona=bot.get("persona", "") or "",
you_name=you_name,
prior_edge_summary=prior_summary,
dialogue=dialogue,
timeout_s=timeout_s,
)
# Update memories belonging to the closed scene for this witness.
cur = conn.execute(
"SELECT id, pov_summary FROM memories "
"WHERE scene_id = ? AND owner_id = ?",
(scene_id, bot_id),
)
for memory_id, prior_pov in cur.fetchall():
if not pov.summary:
# Empty default -> skip the memory rewrite; the seeded
# per-turn pov_summary stays in place.
continue
# T80.1: a prior close may have already appended a Key quotes
# suffix to this row's pov_summary. Strip it here so the fresh
# rewrite replaces the existing suffix rather than stacking a
# second one on top.
new_value = _strip_key_quotes_suffix(pov.summary) + key_quotes_suffix
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "memory_pov_summary",
"target_id": int(memory_id),
"prior_value": prior_pov,
"new_value": new_value,
},
)
# Update this bot->you edge summary if we have an edge row and a
# non-empty relationship_summary to merge.
if edge_b2y is not None and pov.relationship_summary:
new_summary = (
f"{prior_summary} {pov.relationship_summary}".strip()
if prior_summary
else pov.relationship_summary
)
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "edge_summary",
"target_id": {
"source_id": bot_id,
"target_id": "you",
},
"prior_value": prior_summary,
"new_value": new_summary,
},
)
# Append knowledge_facts to this bot->you edge if present.
if pov.knowledge_facts:
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": bot_id,
"target_id": "you",
"chat_id": chat_id,
"knowledge_facts": list(pov.knowledge_facts),
},
)
return pov
# T80.1: header marker shared by the suffix builder and the
# witness-write strip step. Any text starting with this marker is treated
# as a previously-appended Key quotes suffix and stripped before reuse so
# repeated scene closes don't compose recursive bloat.
_KEY_QUOTES_HEADER = "\n\nKey quotes:\n"
def _strip_key_quotes_suffix(text: str) -> str:
"""Remove a previously-appended Key quotes suffix from ``text``.
Returns ``text`` unchanged when the marker is absent, or the prefix
up to (but not including) the marker when present. Used in two
places: (1) when sourcing quote text from a memory row that may
already carry the suffix from a prior close, and (2) when computing
the per-POV rewrite's prior_value so the new write replaces — rather
than stacks on — the old suffix.
"""
if not text:
return text
idx = text.find(_KEY_QUOTES_HEADER)
if idx >= 0:
return text[:idx]
return text
def _build_key_quotes_suffix(conn: Connection, scene_id: int) -> str:
"""If the scene's max-turn-significance is >= 2, build the
"Key quotes:" suffix from the top-3 highest-significance memory rows
(per requirements §11.1). Otherwise return the empty string so the
per-POV summaries collapse fully (low-significance scenes lose all
raw text in favor of the classifier rewrite).
Quote source is each memory's current ``pov_summary`` — the raw
per-turn narrative seeded by T21, since this helper is called BEFORE
the per-POV rewrite. Texts are truncated to 200 chars to bound
memory row growth across many witnesses.
T80.1: candidate text is run through :func:`_strip_key_quotes_suffix`
first so a re-close (whose source memories already carry a suffix from
the prior close) doesn't quote a quote.
"""
row = conn.execute(
"SELECT MAX(significance) FROM memories WHERE scene_id = ?",
(scene_id,),
).fetchone()
max_sig = (row[0] if row else None) or 0
if max_sig < 2:
return ""
cur = conn.execute(
"SELECT pov_summary FROM memories WHERE scene_id = ? "
"ORDER BY significance DESC, id ASC LIMIT 3",
(scene_id,),
)
quotes = [
_strip_key_quotes_suffix(r[0] or "")[:200]
for r in cur.fetchall()
]
if not quotes:
return ""
lines = "\n".join(f'- "{q}"' for q in quotes)
return f"\n\nKey quotes:\n{lines}"
async def apply_scene_close_summary(
conn: Connection,
client: LLMClient,
*,
classifier_model: str,
chat_id: str,
scene_id: int,
host_bot_id: str,
timeout_s: float = 10.0,
) -> ScenePOVSummary:
"""Drive the per-POV summary pipeline after ``scene_closed``.
Phase 1 (single-bot) behavior — the host bot is summarized once and
the result drives memory + edge rewrites — is preserved exactly when
the chat has no guest. T45 extends this to fan out across each
present bot witness when a guest is also in the room:
1. Gather the closing scene's dialogue from the event_log.
2. For each present witness (host + guest if any), run
:func:`summarize_scene` once with that witness's persona and
their own prior ``bot -> you`` edge summary.
3. For each witness independently:
a. Rewrite each scene-bound memory's ``pov_summary`` via
``manual_edit`` (target_kind ``memory_pov_summary``).
b. Update that witness's ``bot -> you`` edge summary via
``manual_edit`` (target_kind ``edge_summary``). v2 combines
prior + classifier ``relationship_summary`` by simple
concatenation.
c. Append any ``knowledge_facts`` to the same edge via
``edge_update``.
4. If a ``group_node`` row exists for this chat, append a
``group_node_updated`` event whose ``summary`` is the naive
per-POV concat ``f"{name}: {summary}\\n\\n..."``. A true
LLM-merged group view is deferred to Phase 2.5; ``dynamic``
is left empty here for v2 (Phase 3 polishes it).
The host's :class:`ScenePOVSummary` is returned to preserve the
Phase 1 callers' contract.
"""
# Local imports to keep the module-level surface tight and avoid
# any chance of a circular dep through chat.state.*.
from chat.services.thread_detection import detect_threads
from chat.state.entities import get_bot, get_you
from chat.state.group_node import get_group_node
from chat.state.threads import list_open_threads
from chat.state.world import get_chat, get_scene
you_entity = get_you(conn) or {"name": "you", "persona": ""}
you_name = you_entity.get("name", "you") or "you"
chat = get_chat(conn, chat_id) or {}
guest_bot_id = chat.get("guest_bot_id")
# T65: detect meanwhile child scenes via the migration-0011
# ``present_set_kind`` column. The mechanism is a single field read
# — meanwhile scenes carry ``"host_guest"``, regular you-scenes
# carry the default ``"you_host"``. We read this once up front so
# both the dialogue source and the post-summary digest emission
# branches can reference it.
closing_scene = get_scene(conn, scene_id) or {}
is_meanwhile = closing_scene.get("present_set_kind") == "host_guest"
dialogue = _read_recent_dialogue(conn, chat_id)
# T58.1: build the "Key quotes:" suffix BEFORE the per-POV rewrites
# land — quote source is the raw seeded pov_summary text on each
# memory row, which the rewrite about to fire would clobber.
key_quotes_suffix = _build_key_quotes_suffix(conn, scene_id)
host_pov = await _summarize_and_apply_for_witness(
conn,
client,
classifier_model=classifier_model,
chat_id=chat_id,
scene_id=scene_id,
bot_id=host_bot_id,
you_name=you_name,
dialogue=dialogue,
timeout_s=timeout_s,
key_quotes_suffix=key_quotes_suffix,
)
guest_pov: ScenePOVSummary | None = None
if guest_bot_id is not None:
guest_pov = await _summarize_and_apply_for_witness(
conn,
client,
classifier_model=classifier_model,
chat_id=chat_id,
scene_id=scene_id,
bot_id=guest_bot_id,
you_name=you_name,
dialogue=dialogue,
timeout_s=timeout_s,
key_quotes_suffix=key_quotes_suffix,
)
# Group node update: T70 runs a third classifier call to merge the
# two per-POV summaries into a coherent group-level view + a brief
# group-dynamic note. Falls back to the Phase 2 naive concat on
# classifier failure (see :func:`merge_group_summary`). Only fires
# when both POVs ran (i.e. the guest is present) and a group_node
# row exists for this chat.
if guest_pov is not None and get_group_node(conn, chat_id) is not None:
host_bot = get_bot(conn, host_bot_id) or {"name": host_bot_id}
guest_bot = get_bot(conn, guest_bot_id) or {"name": guest_bot_id}
host_name = host_bot.get("name", host_bot_id) or host_bot_id
guest_name = guest_bot.get("name", guest_bot_id) or guest_bot_id
merged = await merge_group_summary(
client,
classifier_model=classifier_model,
host_name=host_name,
host_pov_summary=host_pov.summary,
guest_name=guest_name,
guest_pov_summary=guest_pov.summary,
timeout_s=timeout_s,
)
append_and_apply(
conn,
kind="group_node_updated",
payload={
"chat_id": chat_id,
"summary": merged.summary,
"dynamic": merged.dynamic,
},
)
# T65: when the closing scene was a meanwhile child (host_guest
# present set), generate an omniscient briefing for the absent
# "you" and queue it as a pending digest. We reuse summarize_scene
# with a narrator persona so the digest text is shaped by the same
# classifier — only the ``summary`` field is consumed downstream.
# Emitted AFTER per-POV summaries land so witness memories carry
# their own POV text first; this mirrors how group_node_updated is
# ordered relative to the per-POV writes above.
if is_meanwhile:
digest_pov = await summarize_scene(
client,
model=classifier_model,
bot_name="Narrator",
bot_persona=_MEANWHILE_DIGEST_PERSONA,
you_name=you_name,
prior_edge_summary="",
dialogue=dialogue,
timeout_s=timeout_s,
)
if digest_pov.summary:
append_and_apply(
conn,
kind="meanwhile_digest_created",
payload={
"chat_id": chat_id,
"scene_id": scene_id,
"summary": digest_pov.summary,
},
)
# T58.2: thread detection on close. Failure-tolerant: classify()
# returns the empty default on retry-exhaustion, and the broad except
# below protects the close pipeline from any other classifier/mock
# flap.
#
# T80.2: thread detection runs against a SCENE-SCOPED transcript,
# not the chat-wide last-50 turns used by the per-POV summaries.
# Mis-attributing threads when scene boundaries fall inside the last
# 50 turns would otherwise close threads opened in a prior scene.
scene_open_ev_id = _scene_opened_event_id(conn, chat_id, scene_id)
if scene_open_ev_id is not None:
scene_dialogue = _read_recent_dialogue(
conn, chat_id, since_event_id=scene_open_ev_id
)
else:
scene_dialogue = dialogue
try:
thread_result = await detect_threads(
client,
classifier_model=classifier_model,
scene_transcript=scene_dialogue,
open_threads=list_open_threads(conn, chat_id),
timeout_s=timeout_s,
)
except Exception as exc:
# T80.3: log the swallowed exception at DEBUG so a
# programmer-error flap (e.g. wrong kwarg name) surfaces in
# local logs without breaking the close pipeline.
_log.debug("detect_threads failed: %s", exc, exc_info=True)
from chat.services.thread_detection import ThreadDetectionResult
thread_result = ThreadDetectionResult()
for cand in thread_result.candidates:
if cand.action == "open":
new_thread_id = f"thr_{uuid.uuid4().hex[:12]}"
append_and_apply(
conn,
kind="thread_opened",
payload={
"thread_id": new_thread_id,
"chat_id": chat_id,
"title": cand.title,
"summary": cand.summary,
},
)
elif cand.action == "update" and cand.existing_thread_id:
append_and_apply(
conn,
kind="thread_updated",
payload={
"thread_id": cand.existing_thread_id,
"summary": cand.summary,
"last_referenced_scene_id": scene_id,
},
)
elif cand.action == "close" and cand.existing_thread_id:
# T80.4: chat-clock time, not wall clock — the rest of the
# close pipeline (memories, edges, scene_closed payloads)
# uses chat["time"] so threads must agree. Falls back to
# UTC now only when the chat row has no clock yet (defensive
# — chat_state always seeds "time" via chat_created).
chat_clock_at = chat.get("time") or datetime.now(
timezone.utc
).isoformat()
append_and_apply(
conn,
kind="thread_closed",
payload={
"thread_id": cand.existing_thread_id,
"closed_at": chat_clock_at,
},
)
return host_pov
class GroupMetaSummary(BaseModel):
"""Classifier output: a merged group-level view of a closed scene.
Defaults are an empty no-op so callers can use the schema's default
as a sentinel; in practice :func:`merge_group_summary` builds an
explicit naive-concat fallback rather than returning these defaults
directly so existing Phase 2 behavior is preserved on classifier
failure.
"""
summary: str = ""
dynamic: str = ""
_GROUP_MERGE_SYSTEM = (
"Given two per-POV scene summaries from a 3-entity scene (you + "
"host + guest), produce a coherent group-level summary capturing "
"the shared events as both witnesses experienced them, plus a "
"brief 'dynamic' note describing the trio's group dynamic during "
"the scene. Output strict JSON matching schema."
)
# T65: meanwhile-scene digest. The "you" player was absent during this
# scene; the digest is a short neutral briefing they'll read on the next
# you-scene resume. Reuses the ScenePOVSummary schema so the same
# `summarize_scene` helper can be called with a different persona — only
# the ``summary`` field is used downstream.
_MEANWHILE_DIGEST_PERSONA = (
"an omniscient narrator briefing the absent player in 2-3 neutral "
"sentences on what happened while they were away — no editorializing, "
"no second-person address"
)
async def merge_group_summary(
client: LLMClient,
*,
classifier_model: str,
host_name: str,
host_pov_summary: str,
guest_name: str,
guest_pov_summary: str,
timeout_s: float = 30.0,
) -> GroupMetaSummary:
"""Merge two per-POV scene summaries into a coherent group-level
summary + group-dynamic note. Falls back to the naive concat (the
existing behavior) on classifier failure."""
user = (
f"{host_name} (host) POV summary:\n{host_pov_summary}\n\n"
f"{guest_name} (guest) POV summary:\n{guest_pov_summary}"
)
fallback = GroupMetaSummary(
summary=(
f"{host_name}: {host_pov_summary}\n\n"
f"{guest_name}: {guest_pov_summary}"
),
dynamic="",
)
return await classify(
client,
model=classifier_model,
system=_GROUP_MERGE_SYSTEM,
user=user,
schema=GroupMetaSummary,
default=fallback,
timeout_s=timeout_s,
)
+75
View File
@@ -0,0 +1,75 @@
"""Turn-level significance scorer (T22).
Per Requirements §11.1, each turn is scored on a 0-3 scale:
- 0 = Routine: small talk, ordinary action.
- 1 = Notable: a specific detail or beat worth remembering.
- 2 = Significant: a scene-level moment, real disagreement, confided secret.
- 3 = Pivotal: a relationship-altering event (first kiss, betrayal, "I love
you").
The scorer is conservative: pivotal (3) requires a clear signal because the
auto-pin rule (§8.5) gives those memories permanent shelf space. The
classifier returns a strict-JSON ``SignificanceVerdict``; a malformed or
refusal-shaped response falls back to ``score=1`` (Notable) — a safe
middle-of-the-road default that won't trigger auto-pin.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class SignificanceVerdict(BaseModel):
score: int = Field(ge=0, le=3)
reason: str = ""
_SYSTEM = """You score the significance of a roleplay turn 0-3:
0 = Routine: small talk, ordinary action.
1 = Notable: a specific detail or beat worth remembering.
2 = Significant: a scene-level moment, real disagreement, confided secret.
3 = Pivotal: a relationship-altering event (first kiss, betrayal, "I love you").
Be conservative — pivotal (3) requires a clear signal. Reply with JSON: {"score": int 0-3, "reason": str}."""
async def compute_significance(
client: LLMClient,
*,
model: str,
narrative_text: str,
prior_dialogue: list[dict],
timeout_s: float = 10.0,
) -> int:
"""Score the significance of ``narrative_text`` (the just-written turn).
``prior_dialogue`` is a list of ``{"speaker", "text"}`` dicts ordered
oldest-first; the last 6 entries are stitched into the user prompt as
context so the classifier can recognize escalation. Returns an int in
``[0, 3]`` — clamped defensively in case the classifier slips a value
past the schema validator.
"""
user_prompt = "PRIOR DIALOGUE:\n"
for turn in prior_dialogue[-6:]:
speaker = turn.get("speaker", "?")
text = turn.get("text", "")
user_prompt += f"{speaker}: {text}\n"
user_prompt += (
f"\nNEW TURN:\n{narrative_text}\n\n"
"Score the significance of the NEW TURN."
)
result = await classify(
client,
model=model,
system=_SYSTEM,
user=user_prompt,
schema=SignificanceVerdict,
default=SignificanceVerdict(score=1, reason="fallback"),
timeout_s=timeout_s,
)
return max(0, min(3, result.score))
+132
View File
@@ -0,0 +1,132 @@
"""Skip narration service (T53).
Generates brief transition prose for elision and jump skips.
Skips come in two flavors that read very differently:
* **Elision** — collapses an in-progress activity into its expected
end-state in 1-2 sentences, narrated from the speaker bot's POV.
Example: "skip ahead to when we arrive" while the characters are
driving — output describes pulling into the lot.
* **Jump** — bridges a longer fiction-time delta ("next morning", "a
week later") in 2-3 sentences, setting the scene at the new time.
Output is free-form prose, not structured JSON, so this service calls
``client.generate`` directly rather than going through the classifier
path used by, e.g., :mod:`chat.services.scene_summarize`. A
deterministic template fallback fires on any LLM failure so the skip
flow keeps moving even when the model is down — important because
skips are a UI-blocking operation; we'd rather show a parenthetical
sentence than hang the chat indefinitely.
"""
from __future__ import annotations
from chat.llm.client import LLMClient, Message
_ELISION_SYSTEM = (
"You write a brief 1-2 sentence transition that elides the time "
"between an in-progress activity and its expected end-state, "
"narrated from the speaker's POV. Keep it grounded and concrete. "
"Do not invent new events or characters."
)
_JUMP_SYSTEM = (
"You write a brief 2-3 sentence transition narrating a jump in "
"fiction time (e.g., 'next morning', 'a week later'), narrated "
"from the speaker's POV. Set the scene at the new time. Keep it "
"grounded — no invented major events. If a landing-state hint is "
"provided, weave it in naturally."
)
async def narrate_skip(
client: LLMClient,
*,
narrative_model: str,
skip_kind: str,
speaker_bot: dict,
you_name: str,
current_time: str,
new_time: str,
current_activity: str,
landing_state_hint: str = "",
timeout_s: float = 60.0,
) -> str:
"""Generate brief transition prose for a time skip.
``skip_kind`` is ``"elision"`` or ``"jump"``; any other value short-
circuits to the deterministic fallback (defensive — callers
shouldn't be inventing new kinds without updating this service).
Returns plain text. Never raises: any LLM error, an empty/blank
result, or an unknown ``skip_kind`` falls back to a parenthetical
template like ``"(next morning: having coffee in the kitchen.)"``
so the skip UI always has *something* to render.
"""
fallback = _build_fallback(
skip_kind=skip_kind,
new_time=new_time,
current_activity=current_activity,
landing_state_hint=landing_state_hint,
)
if skip_kind not in ("elision", "jump"):
return fallback
system = _ELISION_SYSTEM if skip_kind == "elision" else _JUMP_SYSTEM
user = (
f"Speaker: {speaker_bot.get('name', 'speaker')}\n"
f"Persona: {speaker_bot.get('persona', '')}\n"
f"Other party: {you_name}\n"
f"Current time: {current_time}\n"
f"New time: {new_time}\n"
f"Current activity: {current_activity}\n"
)
if landing_state_hint:
user += f"Landing state hint: {landing_state_hint}\n"
try:
result = await client.generate(
[
Message(role="system", content=system),
Message(role="user", content=user),
],
model=narrative_model,
max_tokens=200,
temperature=0.7,
timeout_s=timeout_s,
)
text = (result or "").strip()
if not text:
return fallback
return text
except Exception:
# Any failure — network blip, timeout, mock raising in tests —
# collapses to the deterministic template so the skip pipeline
# is never blocked on the LLM being available.
return fallback
def _build_fallback(
*,
skip_kind: str,
new_time: str,
current_activity: str,
landing_state_hint: str,
) -> str:
"""Deterministic parenthetical narration used when the LLM fails.
Both flavors render the same shape today: ``(<new_time>:
<detail>.)``. They're separated as branches to make it easy to
diverge later (e.g. an elision-specific template) without churning
the call site or the public signature.
"""
detail = landing_state_hint or current_activity or "moments later"
if skip_kind == "elision":
return f"({new_time}: {detail}.)"
return f"({new_time}: {detail}.)"
__all__ = ["narrate_skip"]
+245
View File
@@ -0,0 +1,245 @@
"""Snapshot service — write a JSON dump of all projected tables to disk.
Two snapshot kinds, both covered by this module:
* ``rewind`` (T28, Requirements §10.1): pre-rewind safety snapshot so the
user can recover if a rewind was a mistake. Retention: 14 days.
* ``periodic`` (T31, Requirements §10.4): full-state checkpoint taken
every 100 events OR every 30 minutes since the last one. Retention:
the most recent 5 are kept; older ones are pruned on write.
Both kinds live under ``data/snapshots/{kind}/`` with a UTC timestamp
filename so chronological listing matches creation order.
The dump captures the event log (so the original event sequence is
preserved verbatim), every projected table, and a top-level
``last_event_id`` recording the highest ``event_log.id`` at snapshot
time. The ``last_event_id`` is what the cold-load fast-path uses to
replay only events past the snapshot rather than the entire log.
The FTS shadow table ``memories_fts`` is intentionally skipped — it's a
virtual table maintained by the ``memories_ai/au/ad`` triggers, so it
rebuilds itself on a memories re-load. Snapshotting it would also fail
``PRAGMA table_info`` cleanly since FTS5 reports its columns differently.
"""
from __future__ import annotations
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from sqlite3 import Connection
# Periodic snapshot triggers (Requirements §10.4): "every 100 events OR
# every 30 minutes since last snapshot". Module-level so tests can read
# them and so the values stay together with the policy that uses them.
EVENT_COUNT_THRESHOLD = 100
TIME_THRESHOLD_SECONDS = 30 * 60 # 30 minutes
# Order doesn't affect correctness for snapshotting (we read, not write),
# but listing tables explicitly keeps the snapshot stable across schema
# evolution: a new table won't silently change the dump shape until it's
# added here.
PROJECTED_TABLES = [
"bots",
"you_entity",
"edges",
"memories",
"memories_fts",
"chats",
"chat_state",
"containers",
"scenes",
"activity",
"classifier_failures",
]
def take_snapshot(
conn: Connection, *, data_dir: Path, kind: str = "rewind"
) -> Path:
"""Write a JSON dump of the event log and projected tables.
Returns the path to the written snapshot file. Creates parent
directories as needed. Filename is a UTC timestamp in
``YYYYMMDDTHHMMSSZ`` form so chronological listing matches creation
order.
The dump's top-level ``last_event_id`` is the highest ``event_log.id``
at snapshot time (0 if the log is empty). This is what the cold-load
fast-path uses to know which suffix of the log to replay.
"""
snapshot_dir = data_dir / "snapshots" / kind
snapshot_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
path = snapshot_dir / f"{timestamp}.json"
dump: dict = {}
# Record the high-water-mark id up front so cold-load can replay
# only events past it. ``MAX(id)`` is None on an empty log; treat
# that as 0 (i.e. "replay everything").
cur = conn.execute("SELECT MAX(id) FROM event_log")
max_id_row = cur.fetchone()
dump["last_event_id"] = max_id_row[0] if max_id_row[0] is not None else 0
# Event log: pull every column we care about. ``ts`` and the
# superseded/hidden flags are needed to faithfully reconstruct the
# log on restore.
cur = conn.execute(
"SELECT id, branch_id, ts, kind, payload_json, superseded_by, hidden "
"FROM event_log ORDER BY id"
)
dump["event_log"] = [
{
"id": r[0],
"branch_id": r[1],
"ts": r[2],
"kind": r[3],
"payload_json": r[4],
"superseded_by": r[5],
"hidden": r[6],
}
for r in cur.fetchall()
]
for table in PROJECTED_TABLES:
if table == "memories_fts":
# Virtual FTS5 table — rebuilt by triggers on insert, no need
# to snapshot it (and ``PRAGMA table_info`` reports its
# columns differently).
continue
cur = conn.execute(f"PRAGMA table_info({table})")
cols = [c[1] for c in cur.fetchall()]
if not cols:
# Table not present in this schema version — leave an empty
# list rather than raising, so older snapshots can survive.
dump[table] = []
continue
cur = conn.execute(f"SELECT {', '.join(cols)} FROM {table}")
dump[table] = [dict(zip(cols, row)) for row in cur.fetchall()]
# ``default=str`` covers Path-like or datetime values that might
# sneak through if a column ever stored them; the projected tables
# all use TEXT so this is mostly defensive.
path.write_text(json.dumps(dump, default=str))
return path
def latest_snapshot_path(data_dir: Path, kind: str = "periodic") -> Path | None:
"""Return the most recent snapshot file for ``kind``, or None if none exist.
Sorting by filename works because :func:`take_snapshot` uses a UTC
timestamp in ``YYYYMMDDTHHMMSSZ`` form — lexicographic order matches
chronological order.
"""
snapshot_dir = data_dir / "snapshots" / kind
if not snapshot_dir.exists():
return None
files = sorted(snapshot_dir.glob("*.json"))
return files[-1] if files else None
def should_take_periodic_snapshot(
conn: Connection, data_dir: Path
) -> bool:
"""Decide whether a periodic snapshot is due per Requirements §10.4.
The policy:
* No prior snapshot and at least one event in the log → take one.
* Time since last snapshot ≥ ``TIME_THRESHOLD_SECONDS`` → take one.
* New events since last snapshot's ``last_event_id`` ≥
``EVENT_COUNT_THRESHOLD`` → take one.
"Time since last snapshot" is measured by the file's mtime — we
don't trust the timestamp embedded in the filename for clock drift
reasons.
"""
latest = latest_snapshot_path(data_dir, kind="periodic")
if latest is None:
# No prior snapshot; take one if there are any events to capture.
cur = conn.execute("SELECT COUNT(*) FROM event_log")
return cur.fetchone()[0] > 0
age_seconds = time.time() - latest.stat().st_mtime
if age_seconds >= TIME_THRESHOLD_SECONDS:
return True
# Count events appended since the last snapshot was written. Reading
# ``last_event_id`` from the dump is cheap (a few KB at most for the
# header) but we still avoid loading the full file by parsing once.
last_dump = json.loads(latest.read_text())
last_event_id = last_dump.get("last_event_id", 0)
cur = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE id > ?", (last_event_id,)
)
new_event_count = cur.fetchone()[0]
return new_event_count >= EVENT_COUNT_THRESHOLD
def prune_periodic_snapshots(data_dir: Path, keep: int = 5) -> int:
"""Delete all but the most recent ``keep`` periodic snapshots.
Returns the number of files removed. Safe to call when the directory
doesn't exist (returns 0). Sorting is by filename, which is the UTC
timestamp — same ordering :func:`latest_snapshot_path` uses.
"""
snapshot_dir = data_dir / "snapshots" / "periodic"
if not snapshot_dir.exists():
return 0
files = sorted(snapshot_dir.glob("*.json"))
to_remove = files[:-keep] if len(files) > keep else []
for f in to_remove:
f.unlink()
return len(to_remove)
def restore_from_snapshot(conn: Connection, snapshot_path: Path) -> int:
"""Restore projected tables from ``snapshot_path``.
Returns the snapshot's ``last_event_id`` so callers (the cold-load
fast-path in :func:`chat.app.lifespan`) know what suffix of the
event log still needs replaying.
Projected tables are cleared in the same FK-respecting order as
:func:`chat.services.rewind.execute_rewind`, then re-populated from
the dump. ``memories_fts`` is skipped — it's a virtual FTS5 table
that rebuilds itself when rows hit ``memories``. The event log
itself is *not* touched: cold-load assumes the on-disk log is the
source of truth and the snapshot is just a fast-forward to skip
re-projecting old events.
"""
dump = json.loads(snapshot_path.read_text())
# Same delete order as rewind: child tables before parents so FK
# ON DELETE doesn't fire on referenced rows.
conn.execute("DELETE FROM memories")
conn.execute("DELETE FROM activity")
conn.execute("DELETE FROM scenes")
conn.execute("DELETE FROM containers")
conn.execute("DELETE FROM chat_state")
conn.execute("DELETE FROM chats")
conn.execute("DELETE FROM edges")
conn.execute("DELETE FROM bots")
conn.execute("DELETE FROM you_entity")
conn.execute("DELETE FROM classifier_failures")
for table in PROJECTED_TABLES:
if table == "memories_fts":
# Rebuilt by triggers when memories rows are inserted below.
continue
rows = dump.get(table, [])
if not rows:
continue
cols = list(rows[0].keys())
placeholders = ", ".join("?" * len(cols))
col_list = ", ".join(cols)
for row in rows:
conn.execute(
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
tuple(row[c] for c in cols),
)
return dump.get("last_event_id", 0)
+144
View File
@@ -0,0 +1,144 @@
"""Post-turn state-update pass.
Per Requirements §3.4, after every utterance we run a classifier on each
present entity (silent witnesses included) to extract directed-edge
deltas — what changed in *source*'s view of *target*. The classifier
returns three signals:
- ``affinity_delta`` — signed change in how warmly source feels (typical
range -3..+3; the edge handler clamps the running total to 0..100).
- ``trust_delta`` — signed change in source's trust of target (same
shape).
- ``knowledge_facts`` — concrete things source learned about target
during this exchange. Stored verbatim and appended to ``edge.knowledge``.
The wrapper deliberately uses :func:`chat.llm.classify.classify` with a
``default=StateUpdate()`` so a flapping classifier never blocks the turn
flow — at worst the edge sits unchanged and the next turn tries again
(§3.3 "graceful degradation" rule).
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class StateUpdate(BaseModel):
"""One directed-edge update from a single classifier call.
Defaults are deliberately a no-op (zero deltas, empty facts) so a
failing classifier produces a benign event rather than a disruption.
"""
affinity_delta: int = 0
trust_delta: int = 0
knowledge_facts: list[str] = Field(default_factory=list)
_SYSTEM_PROMPT = (
"You are reading a recent slice of dialogue from a roleplay scene. "
"You assess how SOURCE's view of TARGET shifted based on what was "
"said — including silent witnessing (SOURCE may not have spoken).\n\n"
"Output a JSON object with exactly three fields:\n"
"- affinity_delta: signed integer in [-3, 3]. How much warmer "
"(positive) or cooler (negative) SOURCE now feels toward TARGET.\n"
"- trust_delta: signed integer in [-3, 3]. How much more (positive) "
"or less (negative) SOURCE now trusts TARGET.\n"
"- knowledge_facts: list of short strings. New, concrete facts "
"SOURCE learned about TARGET in this exchange. Use TARGET's actual "
"stated content; do not infer or interpret. Empty list is fine.\n\n"
"Be conservative. Most turns produce small deltas (-1, 0, +1). "
"Reserve +/-2 or +/-3 for moments that materially shift the "
"relationship. Knowledge_facts should be specific things stated in "
"dialogue (e.g. \"works at the bakery\"), not interpretations "
"(\"seems lonely\")."
)
def _format_dialogue(recent_dialogue: list[dict]) -> str:
"""Render the recent-dialogue slice as plain ``Speaker: text`` lines."""
if not recent_dialogue:
return "(no dialogue yet)"
lines = []
for turn in recent_dialogue:
speaker = turn.get("speaker", "?")
text = turn.get("text", "")
lines.append(f"{speaker}: {text}")
return "\n".join(lines)
def _build_user_prompt(
*,
source_name: str,
source_persona: str,
target_name: str,
prior_affinity: int,
prior_trust: int,
prior_summary: str,
recent_dialogue: list[dict],
) -> str:
return (
f"SOURCE: {source_name}\n"
f"SOURCE_PERSONA: {source_persona or '(none)'}\n"
f"TARGET: {target_name}\n"
f"PRIOR_AFFINITY (0-100): {prior_affinity}\n"
f"PRIOR_TRUST (0-100): {prior_trust}\n"
f"PRIOR_SUMMARY: {prior_summary or '(none)'}\n\n"
f"RECENT_DIALOGUE:\n{_format_dialogue(recent_dialogue)}\n\n"
"How did SOURCE's view of TARGET shift? Respond with JSON only."
)
async def compute_state_update(
client: LLMClient,
*,
model: str,
source_id: str,
target_id: str,
source_name: str,
source_persona: str,
target_name: str,
prior_affinity: int,
prior_trust: int,
prior_summary: str,
recent_dialogue: list[dict],
timeout_s: float = 10.0,
) -> StateUpdate:
"""Run a classifier pass and return the directed-edge update.
On classifier failure (after retry) returns the schema default — a
no-op ``StateUpdate`` — so the turn flow can keep moving. The
``source_id`` / ``target_id`` arguments are accepted for symmetry
with the caller (T20's POST flow uses them when emitting the
``edge_update`` event); they're not currently embedded in the
prompt because the classifier reasons about names, not opaque ids.
"""
# ``source_id``/``target_id`` are kept on the signature even though
# the prompt only quotes the names: callers in turns.py thread the
# ids straight from this function's args into the appended event.
del source_id, target_id # silence unused-arg lint cleanly
user_prompt = _build_user_prompt(
source_name=source_name,
source_persona=source_persona,
target_name=target_name,
prior_affinity=prior_affinity,
prior_trust=prior_trust,
prior_summary=prior_summary,
recent_dialogue=recent_dialogue,
)
return await classify(
client,
model=model,
system=_SYSTEM_PROMPT,
user=user_prompt,
schema=StateUpdate,
default=StateUpdate(),
timeout_s=timeout_s,
)
+74
View File
@@ -0,0 +1,74 @@
"""Synthesized-memories service (T54).
When the user jump-skips with 'anything notable happen?' prose, parse
that prose into 1-N synthesized memories per present bot. Each memory
carries source="synthesized" and reliability=0.7 (lower than direct).
Caller (T62 skip flow) writes the memories via record_turn_memory_for_present.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class SynthesizedMemory(BaseModel):
text: str
significance: int = 1 # 0..3, default 1
affinity_delta: int = 0
trust_delta: int = 0
class SynthesizedDigest(BaseModel):
memories: list[SynthesizedMemory] = Field(default_factory=list)
_SYSTEM = (
"You parse a short user-supplied prose describing 'anything notable' "
"that happened during a time skip into 1-N synthesized memories from "
"a single bot's POV. Each memory has: text (one factual sentence "
"from that bot's perspective), significance (0-3, default 1; only "
"use 2 or 3 for genuinely scene-level or relationship-altering "
"events), affinity_delta and trust_delta (-10..+10, default 0; "
"use small adjustments only when prose explicitly describes a shift). "
"Empty/whitespace prose returns an empty memories list. Output "
"strict JSON matching the schema."
)
async def synthesize_memories(
client: LLMClient,
*,
classifier_model: str,
prose: str,
bot_name: str,
bot_persona: str,
you_name: str,
timeout_s: float = 30.0,
) -> SynthesizedDigest:
"""Parse 'anything notable' prose into structured memories from a
single bot's POV. Empty/whitespace prose short-circuits to an
empty digest (no LLM call)."""
if not prose or not prose.strip():
return SynthesizedDigest()
user = (
f"Bot: {bot_name}\n"
f"Persona: {bot_persona}\n"
f"Other party: {you_name}\n\n"
f"Prose:\n{prose.strip()}"
)
return await classify(
client,
model=classifier_model,
system=_SYSTEM,
user=user,
schema=SynthesizedDigest,
default=SynthesizedDigest(),
timeout_s=timeout_s,
)
__all__ = ["SynthesizedMemory", "SynthesizedDigest", "synthesize_memories"]
+89
View File
@@ -0,0 +1,89 @@
"""Thread-detection service (T55).
On scene close, classify the transcript into thread open/update/close
candidates. Returns ThreadCandidate list; caller (T58 scene compression)
emits one thread_opened/thread_updated/thread_closed event per candidate.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class ThreadCandidate(BaseModel):
action: str # "open" | "update" | "close"
title: str = "" # required for "open"; ignored otherwise
summary: str = ""
existing_thread_id: str | None = None # required for "update" / "close"
class ThreadDetectionResult(BaseModel):
candidates: list[ThreadCandidate] = Field(default_factory=list)
_SYSTEM = (
"You analyze a closed scene's transcript to identify narrative "
"threads (unresolved arcs, dangling questions, promises made, "
"open obligations). Choose actions:\n"
"- 'open': a NEW thread the scene introduced. Provide title (short "
"noun phrase) + summary (one sentence).\n"
"- 'update': an EXISTING open thread that the scene developed. "
"Provide existing_thread_id + new summary.\n"
"- 'close': an EXISTING open thread that the scene resolved. "
"Provide existing_thread_id; summary may capture the resolution.\n"
"Conservative bias: most scenes do NOT open new threads. Only "
"produce candidates when the transcript clearly justifies them. "
"Output strict JSON matching the schema."
)
async def detect_threads(
client: LLMClient,
*,
classifier_model: str,
scene_transcript: list[dict], # [{speaker, text}, ...]
open_threads: list[dict], # [{thread_id, title, summary}, ...]
timeout_s: float = 30.0,
) -> ThreadDetectionResult:
"""Classify scene close into thread open/update/close candidates."""
if not scene_transcript:
return ThreadDetectionResult()
transcript_lines = [
f"{turn.get('speaker', 'unknown')}: {turn.get('text', '')}"
for turn in scene_transcript
]
threads_lines = []
if open_threads:
threads_lines.append("Currently open threads:")
for t in open_threads:
threads_lines.append(
f"- thread_id={t['thread_id']} "
f"title={t.get('title', '')} "
f"summary={t.get('summary', '')}"
)
else:
threads_lines.append("No currently open threads.")
user = (
"Scene transcript:\n"
+ "\n".join(transcript_lines)
+ "\n\n"
+ "\n".join(threads_lines)
)
return await classify(
client,
model=classifier_model,
system=_SYSTEM,
user=user,
schema=ThreadDetectionResult,
default=ThreadDetectionResult(),
timeout_s=timeout_s,
)
__all__ = ["ThreadCandidate", "ThreadDetectionResult", "detect_threads"]
+131
View File
@@ -0,0 +1,131 @@
"""Shared helpers for turn flows (T83.2).
Both ``chat.web.turns.post_turn`` and
``chat.services.regenerate.regenerate_assistant_turn`` need to:
1. Pull a chronological tail of user-side and assistant_turn events for
prompt assembly + state-update inputs.
2. Build a directed-edge dict over a fixed set of "present" entity ids
for the multi-pair state-update pass (with the schema 50/50 default
filled in for missing rows).
Before T83.2 each call site had its own copy of these blocks. The two
copies drifted on details (T73.1 added ``user_turn_edit`` handling to
turns.py; regenerate.py had a slightly different recent-window query).
This module is the single source so a future change to either lands in
both flows by construction.
Note on overlap with ``chat.services.scene_summarize._read_recent_dialogue``:
that helper has a ``since_event_id`` clamp (T80.2 thread-detection
scope) and intentionally does NOT include ``user_turn_edit`` events —
its callers want the *original* prose, not edits. Deduplicating it
into here would either (a) require a new flag on the shared helper for
``user_turn_edit`` inclusion, or (b) silently change scene_summarize's
read shape. Both feel more invasive than the duplication is bad, so
that helper is left alone for now.
"""
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.state.edges import get_edge
def read_recent_dialogue(
conn: Connection,
chat_id: str,
*,
limit: int = 50,
exclude_event_id: int | None = None,
) -> list[dict]:
"""Pull the last ``limit`` user-side / assistant_turn events for
``chat_id`` as ``[{"speaker": <id-or-"you">, "text": <prose>}]``,
chronologically ordered (oldest first).
Filters: ``superseded_by IS NULL AND hidden = 0`` — regenerated
rows drop out so the timeline reflects the current state. Includes
``user_turn``, ``user_turn_edit`` (T29 edited prose substitutes for
the original — the original is marked superseded above), and
``assistant_turn`` rows.
``exclude_event_id`` is an optional event_log id to skip — used by
regenerate to drop the original assistant_turn from its prompt
context window before that row has been marked superseded (the
supersede UPDATE lands at the end so the new event_id is known).
T90.1: the chat_id filter is pushed into SQL via ``json_extract`` so
``LIMIT N`` always returns N rows scoped to the requested chat. The
previous implementation filtered chat_id post-fetch in Python, which
let foreign-chat rows fill the LIMIT and yield fewer than N relevant
rows in busy multi-chat databases.
"""
if exclude_event_id is None:
cur = conn.execute(
"SELECT id, kind, payload_json FROM event_log "
"WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
" AND superseded_by IS NULL AND hidden = 0 "
" AND json_extract(payload_json, '$.chat_id') = ? "
"ORDER BY id DESC LIMIT ?",
(chat_id, limit),
)
else:
cur = conn.execute(
"SELECT id, kind, payload_json FROM event_log "
"WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
" AND id != ? "
" AND superseded_by IS NULL AND hidden = 0 "
" AND json_extract(payload_json, '$.chat_id') = ? "
"ORDER BY id DESC LIMIT ?",
(exclude_event_id, chat_id, limit),
)
rows = list(reversed(cur.fetchall()))
out: list[dict] = []
for row_id, kind, payload_json in rows:
p = json.loads(payload_json)
if kind in ("user_turn", "user_turn_edit"):
out.append(
{
"speaker": "you",
"text": p.get("prose", ""),
"event_id": row_id,
}
)
else:
out.append(
{
"speaker": p.get("speaker_id", "bot"),
"text": p.get("text", ""),
"event_id": row_id,
}
)
return out
def gather_prior_edges(
conn: Connection, present_ids: list[str]
) -> dict[tuple[str, str], dict]:
"""Build ``{(src, tgt): {affinity, trust, summary}}`` for every
directed pair where both ``src`` and ``tgt`` are in ``present_ids``
and ``src != tgt``.
Missing rows fall back to the schema default 50/50 baseline (mirrors
the Phase 1 single-pair flow). Used by post_turn and regenerate to
seed the multi-pair state-update classifier.
"""
prior_edges: dict[tuple[str, str], dict] = {}
for src in present_ids:
for tgt in present_ids:
if src == tgt:
continue
edge = get_edge(conn, src, tgt) or {
"affinity": 50,
"trust": 50,
"summary": "",
}
prior_edges[(src, tgt)] = edge
return prior_edges
__all__ = ["read_recent_dialogue", "gather_prior_edges"]
+125
View File
@@ -0,0 +1,125 @@
"""Turn input parser.
Service-layer function that splits a user's authored turn into typed
segments — ``dialogue``, ``action``, or ``ooc`` (out-of-character).
Per Requirements §6.1 a turn is mixed prose with three conventions:
- ``*action*`` (single asterisks around prose) → action segment.
- Quoted text, or bare prose between the conventions → dialogue.
- ``((double parens))`` → OOC, the author talking to the system rather
than the bot. Downstream (T19) strips OOC from the prompt sent to the
bot but keeps it in the transcript display.
A regex-based splitter would brittle on edge cases (unclosed asterisks,
nested quotes, mixed punctuation), so v1 delegates the segmentation to
the classifier. The configurable ``Settings.ooc_marker`` is *not* read
here: the classifier figures OOC out from ``((`` ``))`` regardless of
config-time choice; marker-based stripping is a downstream concern.
T62 extends the parser with an ``intent`` field so the turn flow can
short-circuit time-skip phrases before the regular narrative path.
``intent`` defaults to ``"narrative"``; the classifier may set it to
``"skip_elision"`` when prose like "skip to when we arrive" or
``"skip_jump"`` when prose like "next morning" / "a week later" is
detected. ``landing_state_hint`` carries the residual descriptor for
elision skips (the "to when we ..." phrase). Existing callers that
don't read ``intent`` continue to work because the default keeps the
narrative path intact.
"""
from __future__ import annotations
from pydantic import BaseModel
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class TurnSegment(BaseModel):
"""One classified piece of a turn.
``kind`` is kept as a plain ``str`` (not a ``Literal``) so an
unexpected classifier output doesn't crash parsing — callers that
care about specific values can check defensively.
"""
kind: str # "dialogue" | "action" | "ooc"
text: str
class ParsedTurn(BaseModel):
"""A turn split into ordered, typed segments.
``intent`` distinguishes a regular narrative beat (the default) from
a natural-language time-skip command (T62). ``landing_state_hint``
captures the descriptor following "skip to when we ..." for elision
skips so the downstream skip controller can pass it to the
narration helper. Both fields are optional and default-empty so
older fixtures and tests that don't supply them keep working.
"""
segments: list[TurnSegment]
intent: str = "narrative" # "narrative" | "skip_elision" | "skip_jump"
landing_state_hint: str = ""
_SYSTEM_PROMPT = (
"You are splitting a roleplay turn into typed segments. The input "
"is mixed prose with three conventions:\n"
"- *text in single asterisks* is an ACTION segment.\n"
"- \"quoted text\" or bare prose between conventions is a DIALOGUE segment.\n"
"- ((text in double parens)) is an OOC (out-of-character) segment — "
"the author talking to the system, not the in-fiction bot.\n\n"
"Output a JSON object with shape "
'{"segments": [{"kind": "...", "text": "..."}, ...], '
'"intent": "...", "landing_state_hint": "..."} '
"where each segment ``kind`` is exactly one of: dialogue, action, "
"ooc. Preserve the original substring text as ``text``: do not "
"rewrite, translate, or normalize punctuation — strip only the "
"marker characters (asterisks, surrounding quotes, double parens) "
"so ``text`` is the inner content. Emit segments in the order they "
"appear in the input.\n\n"
"``intent`` is exactly one of: narrative, skip_elision, skip_jump. "
"Default to ``narrative``. Use ``skip_elision`` when the prose is a "
"directive to fast-forward an in-progress activity to a near-term "
"landing state — e.g. 'skip to when we arrive', 'fast-forward to "
"after dinner'. Use ``skip_jump`` when the prose denotes a longer "
"fiction-time bridge — e.g. 'next morning', 'a week later', 'the "
"following day'.\n"
"``landing_state_hint`` is a short descriptor of the landing state "
"for ``skip_elision`` (e.g. 'we arrive at the park'). Empty string "
"for ``skip_jump`` and ``narrative``."
)
async def parse_turn(
client: LLMClient,
*,
model: str,
prose: str,
timeout_s: float = 10.0,
) -> ParsedTurn:
"""Parse a user turn into typed segments.
Calls :func:`chat.llm.classify.classify` under the hood. Empty or
whitespace-only prose short-circuits to an empty ``ParsedTurn``
without an LLM call (the classifier would error on empty input
anyway, and the result is unambiguous).
Raises ``RuntimeError`` if the classifier fails twice — no default
is supplied, since the caller (T19's turn flow) is responsible for
surfacing the error to the user.
"""
if not prose.strip():
return ParsedTurn(segments=[])
user_prompt = f"INPUT:\n{prose}"
return await classify(
client,
model=model,
system=_SYSTEM_PROMPT,
user=user_prompt,
schema=ParsedTurn,
timeout_s=timeout_s,
)
+79
View File
@@ -0,0 +1,79 @@
"""Vector search service (T92, Phase 4).
Pure-Python cosine similarity over the embeddings table. Phase 4 ships
this without sqlite-vec because the host Python build doesn't support
loadable extensions. For single-user scale (< few thousand memories
per owner), iterating in Python is sub-millisecond.
Phase 4.5+ may swap to sqlite-vec when the host Python supports
enable_load_extension; the public API stays stable.
"""
from __future__ import annotations
import math
from sqlite3 import Connection
from chat.state.embeddings import list_embeddings_for_owner
_VALID_WITNESS_ROLES = {"you", "host", "guest"}
def _cosine_similarity(a: list[float], b: list[float]) -> float:
"""Cosine similarity. Assumes both vectors are non-zero."""
if len(a) != len(b):
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a)) or 1.0
norm_b = math.sqrt(sum(x * x for x in b)) or 1.0
return dot / (norm_a * norm_b)
def vector_search(
conn: Connection,
*,
owner_id: str,
witness_role: str, # "you" | "host" | "guest"
query_vector: list[float],
k: int = 4,
) -> list[dict]:
"""Return top-K memories by cosine similarity to query_vector,
witness-filtered for the viewer's POV. Returns rows with
{memory_id, pov_summary, significance, score} sorted by score
DESC. Empty list if no embeddings indexed for this owner.
"""
if witness_role not in _VALID_WITNESS_ROLES:
raise ValueError(
f"witness_role must be one of {_VALID_WITNESS_ROLES}, got {witness_role!r}"
)
rows = list_embeddings_for_owner(conn, owner_id)
if not rows:
return []
# Witness-filter by the requesting role.
witness_key = f"witness_{witness_role}"
filtered = [r for r in rows if r.get(witness_key) == 1]
if not filtered:
return []
scored: list[tuple[float, dict]] = []
for row in filtered:
score = _cosine_similarity(query_vector, row["vector"])
scored.append(
(
score,
{
"memory_id": row["memory_id"],
"pov_summary": row["pov_summary"],
"significance": row["significance"],
"score": score,
},
)
)
scored.sort(key=lambda t: t[0], reverse=True)
return [item for _, item in scored[:k]]
__all__ = ["vector_search"]
View File
+133
View File
@@ -0,0 +1,133 @@
"""Branches projector + readers (T89, Phase 4).
A branch is a named fork of the event log. The 'main' branch is bootstrapped
by migration 0013 with is_active=1. Subsequent branches reference an
origin_event_id (the event they forked from). Phase 4 enables creation
and switching; the read-side filter (event readers consulting is_active)
is a Phase 4.5 follow-up — for now branches are metadata-only and the
existing event readers remain branch-agnostic.
"""
from __future__ import annotations
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
@on("branch_created")
def _apply_branch_created(conn: Connection, e: Event) -> None:
"""Insert a new branch row with is_active=0. Idempotent via INSERT OR IGNORE."""
p = e.payload
conn.execute(
"INSERT OR IGNORE INTO branches "
"(name, origin_event_id, head_event_id, chat_id, is_active) "
"VALUES (?, ?, ?, ?, 0)",
(
p["name"],
int(p["origin_event_id"]),
int(p.get("head_event_id", p["origin_event_id"])),
p.get("chat_id"),
),
)
@on("branch_switched")
def _apply_branch_switched(conn: Connection, e: Event) -> None:
"""Set is_active=1 on the named branch and is_active=0 on all others.
Atomic via two UPDATEs ordered to avoid the unique-active-index race.
"""
p = e.payload
name = p["name"]
# Clear ALL is_active flags first (avoids the unique-index trip).
conn.execute("UPDATE branches SET is_active = 0 WHERE is_active = 1")
conn.execute(
"UPDATE branches SET is_active = 1 WHERE name = ?",
(name,),
)
@on("branch_head_updated")
def _apply_branch_head_updated(conn: Connection, e: Event) -> None:
"""Update head_event_id on the named branch."""
p = e.payload
conn.execute(
"UPDATE branches SET head_event_id = ? WHERE name = ?",
(int(p["head_event_id"]), p["name"]),
)
def get_branch(conn: Connection, name: str) -> dict | None:
row = conn.execute(
"SELECT id, name, origin_event_id, head_event_id, chat_id, "
" created_at, is_active "
"FROM branches WHERE name = ?",
(name,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"name": row[1],
"origin_event_id": row[2],
"head_event_id": row[3],
"chat_id": row[4],
"created_at": row[5],
"is_active": bool(row[6]),
}
def list_branches(conn: Connection, chat_id: str | None = None) -> list[dict]:
if chat_id is None:
rows = conn.execute(
"SELECT id, name, origin_event_id, head_event_id, chat_id, "
" created_at, is_active "
"FROM branches ORDER BY id ASC"
).fetchall()
else:
rows = conn.execute(
"SELECT id, name, origin_event_id, head_event_id, chat_id, "
" created_at, is_active "
"FROM branches WHERE chat_id = ? OR chat_id IS NULL "
"ORDER BY id ASC",
(chat_id,),
).fetchall()
return [
{
"id": r[0],
"name": r[1],
"origin_event_id": r[2],
"head_event_id": r[3],
"chat_id": r[4],
"created_at": r[5],
"is_active": bool(r[6]),
}
for r in rows
]
def active_branch(conn: Connection) -> dict | None:
row = conn.execute(
"SELECT id, name, origin_event_id, head_event_id, chat_id, "
" created_at, is_active "
"FROM branches WHERE is_active = 1"
).fetchone()
if not row:
return None
return {
"id": row[0],
"name": row[1],
"origin_event_id": row[2],
"head_event_id": row[3],
"chat_id": row[4],
"created_at": row[5],
"is_active": bool(row[6]),
}
__all__ = [
"get_branch",
"list_branches",
"active_branch",
]
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
def _clamp(value: int, lo: int = 0, hi: int = 100) -> int:
return max(lo, min(hi, value))
@on("edge_update")
def _apply_edge_update(conn: Connection, e: Event) -> None:
p = e.payload
source_id = p["source_id"]
target_id = p["target_id"]
chat_id = p.get("chat_id")
# Upsert: ensure a row exists with defaults, then apply deltas.
conn.execute(
"INSERT OR IGNORE INTO edges (chat_id, source_id, target_id) VALUES (?, ?, ?)",
(chat_id, source_id, target_id),
)
row = conn.execute(
"SELECT affinity, trust, knowledge_json, last_interaction_chat_id, last_interaction_at "
"FROM edges WHERE source_id = ? AND target_id = ?",
(source_id, target_id),
).fetchone()
affinity, trust, knowledge_json, last_chat_id, last_at = row
affinity_delta = int(p.get("affinity_delta", 0))
trust_delta = int(p.get("trust_delta", 0))
new_affinity = _clamp(affinity + affinity_delta)
new_trust = _clamp(trust + trust_delta)
new_facts = p.get("knowledge_facts") or []
if new_facts:
knowledge = json.loads(knowledge_json)
knowledge.extend(new_facts)
knowledge_json = json.dumps(knowledge)
payload_at = p.get("last_interaction_at")
payload_chat_id = p.get("last_interaction_chat_id")
if payload_at is not None:
last_at = payload_at
if payload_chat_id is not None:
last_chat_id = payload_chat_id
conn.execute(
"UPDATE edges SET affinity = ?, trust = ?, knowledge_json = ?, "
"last_interaction_chat_id = ?, last_interaction_at = ? "
"WHERE source_id = ? AND target_id = ?",
(new_affinity, new_trust, knowledge_json, last_chat_id, last_at,
source_id, target_id),
)
def get_edge(conn: Connection, source_id: str, target_id: str) -> dict | None:
row = conn.execute(
"SELECT * FROM edges WHERE source_id = ? AND target_id = ?",
(source_id, target_id),
).fetchone()
if not row:
return None
cols = [c[1] for c in conn.execute("PRAGMA table_info(edges)").fetchall()]
d = dict(zip(cols, row))
d["knowledge"] = json.loads(d.pop("knowledge_json"))
return d
def list_edges_for(conn: Connection, source_id: str) -> list[dict]:
cur = conn.execute(
"SELECT * FROM edges WHERE source_id = ? ORDER BY target_id",
(source_id,),
)
rows = cur.fetchall()
cols = [c[1] for c in conn.execute("PRAGMA table_info(edges)").fetchall()]
out: list[dict] = []
for row in rows:
d = dict(zip(cols, row))
d["knowledge"] = json.loads(d.pop("knowledge_json"))
out.append(d)
return out
+105
View File
@@ -0,0 +1,105 @@
"""Embeddings projector + readers (T88, Phase 4).
Embeddings are stored as JSON-serialized float arrays in a regular
SQLite table. Cosine similarity is computed in Python at query time
(see chat/services/vector_search.py / T92). This deliberately avoids
the sqlite-vec extension dependency — the host Python build doesn't
support enable_load_extension. Phase 4.5+ may revisit if memory counts
grow beyond pure-Python feasibility (~few thousand per query).
"""
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
@on("embedding_indexed")
def _apply_embedding_indexed(conn: Connection, e: Event) -> None:
"""Insert or replace the embedding for a memory.
Idempotent: re-projection or re-indexing replaces the prior vector.
"""
p = e.payload
vector = p["vector"]
conn.execute(
"INSERT OR REPLACE INTO embeddings "
"(memory_id, vector_json, model, dim, indexed_at) "
"VALUES (?, ?, ?, ?, datetime('now'))",
(
int(p["memory_id"]),
json.dumps(list(vector)),
p["model"],
int(p.get("dim") or len(vector)),
),
)
@on("embedding_deindexed")
def _apply_embedding_deindexed(conn: Connection, e: Event) -> None:
"""Remove the embedding for a memory (used by reset cascade)."""
p = e.payload
conn.execute(
"DELETE FROM embeddings WHERE memory_id = ?",
(int(p["memory_id"]),),
)
def get_embedding(conn: Connection, memory_id: int) -> dict | None:
row = conn.execute(
"SELECT memory_id, vector_json, model, dim, indexed_at "
"FROM embeddings WHERE memory_id = ?",
(memory_id,),
).fetchone()
if not row:
return None
return {
"memory_id": row[0],
"vector": json.loads(row[1]),
"model": row[2],
"dim": row[3],
"indexed_at": row[4],
}
def list_embeddings_for_owner(conn: Connection, owner_id: str) -> list[dict]:
"""Return all embeddings for memories owned by ``owner_id``.
Used by vector search at query time (T92). The join carries the
fields the cosine ranker needs to assemble result rows without a
second round-trip: the POV summary text, significance, and witness
flags. The ``memories`` table has no separate ``text`` column —
``pov_summary`` is the canonical narrative text per
``chat/services/memory_write.py``.
"""
rows = conn.execute(
"SELECT e.memory_id, e.vector_json, e.model, e.dim, "
" m.pov_summary, m.significance, "
" m.witness_you, m.witness_host, m.witness_guest "
"FROM embeddings e "
"JOIN memories m ON m.id = e.memory_id "
"WHERE m.owner_id = ?",
(owner_id,),
).fetchall()
return [
{
"memory_id": r[0],
"vector": json.loads(r[1]),
"model": r[2],
"dim": r[3],
"pov_summary": r[4],
"significance": r[5],
"witness_you": r[6],
"witness_host": r[7],
"witness_guest": r[8],
}
for r in rows
]
__all__ = [
"get_embedding",
"list_embeddings_for_owner",
]
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
@on("bot_authored")
def _apply_bot_authored(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"INSERT OR REPLACE INTO bots "
"(id, name, persona, voice_samples_json, traits_json, backstory, "
" initial_relationship_to_you, kickoff_prose) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(p["id"], p["name"], p["persona"],
json.dumps(p.get("voice_samples", [])),
json.dumps(p.get("traits", [])),
p.get("backstory", ""),
p.get("initial_relationship_to_you", ""),
p.get("kickoff_prose", "")),
)
@on("you_authored")
def _apply_you_authored(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"INSERT OR REPLACE INTO you_entity (id, name, pronouns, persona) VALUES (1, ?, ?, ?)",
(p["name"], p.get("pronouns", ""), p.get("persona", "")),
)
@on("bot_reset")
def _apply_bot_reset(conn: Connection, e: Event) -> None:
"""Purge per-bot runtime state while preserving the bot's identity row.
Wipes chats hosted by this bot (with cascading chat-scoped tables),
memories owned by this bot, edges involving this bot, and the bot's own
activity row. The ``bots`` row itself is preserved so identity,
initial-relationship, and kickoff prose remain authored.
"""
bot_id = e.payload["bot_id"]
chat_ids = [
row[0]
for row in conn.execute(
"SELECT id FROM chats WHERE host_bot_id = ?", (bot_id,)
).fetchall()
]
# T69: purge orphaned "you" activity rows pointing at containers in this
# bot's chats BEFORE the containers/chats themselves are deleted, otherwise
# the subqueries find nothing and the FK constraint on activity.container_id
# blocks the container delete.
conn.execute(
"DELETE FROM activity WHERE entity_id = 'you' "
"AND container_id IN (SELECT id FROM containers WHERE chat_id IN ("
" SELECT id FROM chats WHERE host_bot_id = ?"
"))",
(bot_id,),
)
for chat_id in chat_ids:
conn.execute("DELETE FROM scenes WHERE chat_id = ?", (chat_id,))
conn.execute("DELETE FROM containers WHERE chat_id = ?", (chat_id,))
conn.execute("DELETE FROM chat_state WHERE chat_id = ?", (chat_id,))
conn.execute("DELETE FROM chats WHERE id = ?", (chat_id,))
# Activity for this bot's entity row (independent of chat_id since the
# ``activity`` table is keyed on entity_id).
conn.execute("DELETE FROM activity WHERE entity_id = ?", (bot_id,))
# Memories authored by this bot.
conn.execute("DELETE FROM memories WHERE owner_id = ?", (bot_id,))
# Edges in either direction involving this bot.
conn.execute(
"DELETE FROM edges WHERE source_id = ? OR target_id = ?",
(bot_id, bot_id),
)
# Phase 2 cascade: clear guest references in other bots' chats so the host
# doesn't see a stale guest_bot_id pointing at this (now-purged) bot.
conn.execute(
"UPDATE chats SET guest_bot_id = NULL WHERE guest_bot_id = ?",
(bot_id,),
)
# NOTE: bots row itself is preserved (identity, kickoff_prose intact).
def get_bot(conn: Connection, bot_id: str) -> dict | None:
row = conn.execute("SELECT * FROM bots WHERE id = ?", (bot_id,)).fetchone()
if not row:
return None
cols = [c[1] for c in conn.execute("PRAGMA table_info(bots)").fetchall()]
d = dict(zip(cols, row))
d["voice_samples"] = json.loads(d.pop("voice_samples_json"))
d["traits"] = json.loads(d.pop("traits_json"))
return d
def list_bots(conn: Connection) -> list[dict]:
cur = conn.execute("SELECT id, name FROM bots ORDER BY name")
return [{"id": r[0], "name": r[1]} for r in cur]
def get_you(conn: Connection) -> dict | None:
row = conn.execute("SELECT name, pronouns, persona FROM you_entity WHERE id = 1").fetchone()
if not row:
return None
return {"name": row[0], "pronouns": row[1], "persona": row[2]}
+127
View File
@@ -0,0 +1,127 @@
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
_TERMINAL_STATUSES = {"completed", "cancelled", "expired"}
@on("event_planned")
def _apply_event_planned(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"INSERT OR IGNORE INTO events "
"(event_id, chat_id, kind, status, props_json, planned_for) "
"VALUES (?, ?, ?, 'planned', ?, ?)",
(
p["event_id"],
p["chat_id"],
p["kind"],
json.dumps(p.get("props", {})),
p.get("planned_for"),
),
)
@on("event_started")
def _apply_event_started(conn: Connection, e: Event) -> None:
p = e.payload
# Idempotent: only transition from non-terminal status.
conn.execute(
"UPDATE events SET status = 'active', started_at = ?, updated_at = datetime('now') "
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
(p.get("started_at"), p["event_id"]),
)
@on("event_completed")
def _apply_event_completed(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE events SET status = 'completed', completed_at = ?, updated_at = datetime('now') "
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
(p.get("completed_at"), p["event_id"]),
)
@on("event_cancelled")
def _apply_event_cancelled(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE events SET status = 'cancelled', completed_at = ?, updated_at = datetime('now') "
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
(p.get("completed_at"), p["event_id"]),
)
@on("event_expired")
def _apply_event_expired(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE events SET status = 'expired', completed_at = ?, updated_at = datetime('now') "
"WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')",
(p.get("completed_at"), p["event_id"]),
)
def get_event(conn: Connection, event_id: str) -> dict | None:
row = conn.execute(
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
"started_at, completed_at, created_at, updated_at "
"FROM events WHERE event_id = ?",
(event_id,),
).fetchone()
if not row:
return None
return {
"event_id": row[0],
"chat_id": row[1],
"kind": row[2],
"status": row[3],
"props": json.loads(row[4]),
"planned_for": row[5],
"started_at": row[6],
"completed_at": row[7],
"created_at": row[8],
"updated_at": row[9],
}
def list_active_events(conn: Connection, chat_id: str) -> list[dict]:
rows = conn.execute(
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
"started_at, completed_at, created_at, updated_at "
"FROM events WHERE chat_id = ? AND status IN ('planned','active') "
"ORDER BY id ASC",
(chat_id,),
).fetchall()
return [
{
"event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3],
"props": json.loads(r[4]),
"planned_for": r[5], "started_at": r[6], "completed_at": r[7],
"created_at": r[8], "updated_at": r[9],
}
for r in rows
]
def list_events_in_status(conn: Connection, chat_id: str, status: str) -> list[dict]:
rows = conn.execute(
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
"started_at, completed_at, created_at, updated_at "
"FROM events WHERE chat_id = ? AND status = ? ORDER BY id ASC",
(chat_id, status),
).fetchall()
return [
{
"event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3],
"props": json.loads(r[4]),
"planned_for": r[5], "started_at": r[6], "completed_at": r[7],
"created_at": r[8], "updated_at": r[9],
}
for r in rows
]
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
@on("group_node_initialized")
def _apply_group_node_initialized(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"INSERT OR REPLACE INTO group_node "
"(chat_id, members_json, summary, dynamic, threads_json) "
"VALUES (?, ?, ?, ?, ?)",
(
p["chat_id"],
json.dumps(p["members"]),
p.get("summary", ""),
p.get("dynamic", ""),
json.dumps(p.get("threads", [])),
),
)
@on("group_node_updated")
def _apply_group_node_updated(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE group_node SET summary = ?, dynamic = ?, updated_at = datetime('now') "
"WHERE chat_id = ?",
(p.get("summary", ""), p.get("dynamic", ""), p["chat_id"]),
)
def get_group_node(conn: Connection, chat_id: str) -> dict | None:
row = conn.execute(
"SELECT chat_id, members_json, summary, dynamic, threads_json, updated_at "
"FROM group_node WHERE chat_id = ?",
(chat_id,),
).fetchone()
if not row:
return None
return {
"chat_id": row[0],
"members": json.loads(row[1]),
"summary": row[2],
"dynamic": row[3],
"threads": json.loads(row[4]),
"updated_at": row[5],
}
+180
View File
@@ -0,0 +1,180 @@
"""Handler for ``manual_edit`` events (T25, §6.4 final paragraph).
A ``manual_edit`` event captures a user override of a projected field — its
payload snapshots both the prior value and the new value so any edit can
be reversed by emitting an inverse ``manual_edit`` later. This module
applies the new value to the appropriate target table; the snapshot of
``prior_value`` is taken by the route handler before this fires.
Phase 1 covers five target kinds:
- ``edge_affinity`` and ``edge_trust`` — slider edits on a specific edge,
clamped to 0..100.
- ``memory_significance`` — dropdown edit, clamped to 0..3.
- ``memory_pov_summary`` — textarea edit (string). Also reused by T27's
scene-close pipeline to rewrite per-turn raw narratives into a proper
per-POV scene summary.
- ``edge_summary`` — string overwrite of the directed edge's ``summary``
field. Driven by T27 from the classifier's ``relationship_summary``
output combined with the prior summary.
T72.1 (Phase 2.5) adds one list-shaped edit:
- ``edge_knowledge_fact`` — add/remove a single fact on an edge's
``knowledge_json`` list. Payload carries an ``action`` of ``"add"`` or
``"remove"`` and a ``fact`` string; remove matches the first occurrence
by string equality so the route handler doesn't have to track fact
indices across re-renders.
T72.3 adds a per-flag witness toggle:
- ``memory_witness`` — flip one of ``witness_you`` / ``witness_host`` /
``witness_guest`` on a memory row. Payload's ``new_value`` is a dict
``{"flag": "you"|"host"|"guest", "value": 0|1}`` and ``prior_value``
mirrors the same shape so an inverse edit can restore the flag.
T98.3 adds a hide-from-view toggle:
- ``turn_hidden`` — flip ``event_log.hidden`` on a single turn row.
Hidden turns are filtered by ``read_recent_dialogue`` (see
:mod:`chat.services.turn_common`) so they vanish from the prompt
without being deleted from the log. ``target_id`` is the integer
``event_log.id`` of the turn; ``new_value`` is ``{"hidden": 0|1}``
and ``prior_value`` mirrors the shape so an inverse edit restores it.
T98.5 finishes the v1 drawer surface with two chat-scope text edits:
- ``chat_narrative_anchor`` and ``chat_weather`` — string overwrites of
the matching ``chat_state`` columns. ``target_id`` is the chat id
(``chats.id``); ``new_value`` is the new string and ``prior_value``
carries the previous content for §6.4 reversibility.
Pin toggles intentionally use the existing ``memory_pin_changed`` event
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
the projection writes both ``pinned`` and ``auto_pinned`` atomically.
"""
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.eventlog.log import Event
from chat.eventlog.projector import on
_VALID_WITNESS_FLAGS = {"you", "host", "guest"}
def _clamp(value: int, lo: int, hi: int) -> int:
return max(lo, min(hi, value))
@on("manual_edit")
def _apply_manual_edit(conn: Connection, e: Event) -> None:
p = e.payload
kind = p["target_kind"]
target_id = p["target_id"]
new_value = p["new_value"]
if kind == "edge_affinity":
conn.execute(
"UPDATE edges SET affinity = ? "
"WHERE source_id = ? AND target_id = ?",
(
_clamp(int(new_value), 0, 100),
target_id["source_id"],
target_id["target_id"],
),
)
elif kind == "edge_trust":
conn.execute(
"UPDATE edges SET trust = ? "
"WHERE source_id = ? AND target_id = ?",
(
_clamp(int(new_value), 0, 100),
target_id["source_id"],
target_id["target_id"],
),
)
elif kind == "memory_significance":
conn.execute(
"UPDATE memories SET significance = ? WHERE id = ?",
(_clamp(int(new_value), 0, 3), int(target_id)),
)
elif kind == "memory_pov_summary":
conn.execute(
"UPDATE memories SET pov_summary = ? WHERE id = ?",
(str(new_value), int(target_id)),
)
elif kind == "edge_summary":
# ``target_id`` here is a {"source_id", "target_id"} pair like
# the affinity/trust edits, since edges are keyed by the
# directed pair, not a single rowid.
conn.execute(
"UPDATE edges SET summary = ? "
"WHERE source_id = ? AND target_id = ?",
(
str(new_value),
target_id["source_id"],
target_id["target_id"],
),
)
elif kind == "edge_knowledge_fact":
# T72.1: add or remove a single fact on an edge's knowledge list.
# ``target_id`` is the {"source_id", "target_id"} edge pair;
# ``new_value`` carries ``{"action": "add"|"remove", "fact": str}``.
# Remove matches by string equality (first occurrence) so callers
# don't have to thread a fact_index through re-rendered drawers.
action = new_value["action"]
fact = str(new_value["fact"])
row = conn.execute(
"SELECT knowledge_json FROM edges "
"WHERE source_id = ? AND target_id = ?",
(target_id["source_id"], target_id["target_id"]),
).fetchone()
if row is not None:
knowledge = json.loads(row[0])
if action == "add":
knowledge.append(fact)
elif action == "remove" and fact in knowledge:
knowledge.remove(fact)
conn.execute(
"UPDATE edges SET knowledge_json = ? "
"WHERE source_id = ? AND target_id = ?",
(
json.dumps(knowledge),
target_id["source_id"],
target_id["target_id"],
),
)
elif kind == "memory_witness":
# T72.3: toggle one of the three witness flags on a memory row.
# ``new_value`` is the dict ``{"flag", "value"}``; ``prior_value``
# mirrors the same shape so an inverse edit restores the flag.
flag = new_value["flag"]
if flag in _VALID_WITNESS_FLAGS:
conn.execute(
f"UPDATE memories SET witness_{flag} = ? WHERE id = ?",
(1 if int(new_value["value"]) else 0, int(target_id)),
)
elif kind == "turn_hidden":
# T98.3: hide-from-view toggle on a turn (event_log row). Sets
# ``event_log.hidden`` so :func:`read_recent_dialogue` (which
# filters ``hidden = 0``) drops the row from the prompt window
# without deleting it from the log. ``new_value`` is
# ``{"hidden": 0|1}``.
hidden_int = 1 if int(new_value.get("hidden", 0)) else 0
conn.execute(
"UPDATE event_log SET hidden = ? WHERE id = ?",
(hidden_int, int(target_id)),
)
elif kind == "chat_narrative_anchor":
# T98.5: string overwrite of ``chat_state.narrative_anchor`` for
# the chat keyed by ``target_id``.
conn.execute(
"UPDATE chat_state SET narrative_anchor = ? WHERE chat_id = ?",
(str(new_value), str(target_id)),
)
elif kind == "chat_weather":
# T98.5: string overwrite of ``chat_state.weather``.
conn.execute(
"UPDATE chat_state SET weather = ? WHERE chat_id = ?",
(str(new_value), str(target_id)),
)
# Unknown target_kind: silently no-op for v1. Future kinds (activity
# fields, etc.) extend the dispatch above.
+164
View File
@@ -0,0 +1,164 @@
"""Meanwhile-scene projection (T63).
A "meanwhile" scene is a 2-bot scene where ``present_set = {host_bot_id,
guest_bot_id}`` and "you" is absent. It runs alongside an active you-scene
(its parent) so bots can have private interactions whose outcome later
surfaces back to the you-scene as a pending digest.
The underlying ``scenes`` table (migration 0007) has no explicit ``status``
column; "active" is encoded as ``ended_at IS NULL`` and "closed" as
``ended_at IS NOT NULL``. This module preserves that convention and adds
two new columns introduced by migration 0011:
- ``present_set_kind`` — ``'you_host'`` (default) for normal scenes,
``'host_guest'`` for meanwhile child scenes.
- ``parent_scene_id`` — the you-scene a meanwhile child hangs off of.
Pending meanwhile digests live in their own table
(``meanwhile_digest_pending``) and are consumed when their summary is
surfaced in the next you-scene's prompt.
"""
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
@on("meanwhile_scene_started")
def _apply_meanwhile_scene_started(conn: Connection, e: Event) -> None:
"""Insert a new scenes row for the meanwhile child.
The caller supplies an explicit ``scene_id`` so subsequent events
(close, digest) can reference it without round-tripping through
``lastrowid``.
"""
p = e.payload
conn.execute(
"INSERT INTO scenes ("
"id, chat_id, started_at, ended_at, significance, "
"participants_json, present_set_kind, parent_scene_id"
") VALUES (?, ?, ?, NULL, 0, ?, 'host_guest', ?)",
(
p["scene_id"],
p["chat_id"],
p.get("started_at"),
# participants_json mirrors the present_set: host + guest bot.
# json.dumps ensures bot ids with quotes/backslashes can't corrupt the JSON literal.
json.dumps([p["host_bot_id"], p["guest_bot_id"]]),
p["parent_scene_id"],
),
)
@on("meanwhile_scene_closed")
def _apply_meanwhile_scene_closed(conn: Connection, e: Event) -> None:
"""Mark the meanwhile scene closed by stamping ``ended_at``."""
p = e.payload
conn.execute(
"UPDATE scenes SET ended_at = ? "
"WHERE id = ? AND present_set_kind = 'host_guest' "
"AND ended_at IS NULL",
(p.get("closed_at"), p["scene_id"]),
)
@on("meanwhile_digest_created")
def _apply_meanwhile_digest_created(conn: Connection, e: Event) -> None:
"""Queue a digest for surfacing to the next you-scene's prompt."""
p = e.payload
conn.execute(
"INSERT INTO meanwhile_digest_pending (scene_id, chat_id, summary) "
"VALUES (?, ?, ?)",
(p["scene_id"], p["chat_id"], p["summary"]),
)
@on("meanwhile_digest_consumed")
def _apply_meanwhile_digest_consumed(conn: Connection, e: Event) -> None:
"""Mark a pending digest as consumed (idempotent on re-projection)."""
p = e.payload
conn.execute(
"UPDATE meanwhile_digest_pending SET consumed_at = ? "
"WHERE id = ? AND consumed_at IS NULL",
(p.get("consumed_at"), p["digest_id"]),
)
def _scene_row_to_dict(row: tuple) -> dict:
"""Shape a meanwhile-scene row.
``status`` is derived from ``ended_at`` for callers that prefer the
higher-level vocabulary; ``closed_at`` aliases ``ended_at`` for the
same reason. The underlying column remains ``ended_at``.
"""
ended_at = row[5]
return {
"id": row[0],
"chat_id": row[1],
"started_at": row[2],
"present_set_kind": row[3],
"parent_scene_id": row[4],
"closed_at": ended_at,
"status": "closed" if ended_at is not None else "active",
}
def list_meanwhile_scenes(
conn: Connection, chat_id: str, status: str = "active"
) -> list[dict]:
"""Return meanwhile scenes for ``chat_id`` filtered by derived status."""
if status == "active":
ended_clause = "s.ended_at IS NULL"
elif status == "closed":
ended_clause = "s.ended_at IS NOT NULL"
else:
raise ValueError(f"unknown status: {status!r}")
rows = conn.execute(
"SELECT s.id, s.chat_id, s.started_at, s.present_set_kind, "
"s.parent_scene_id, s.ended_at "
"FROM scenes s "
"WHERE s.chat_id = ? AND s.present_set_kind = 'host_guest' "
f"AND {ended_clause} "
"ORDER BY s.id ASC",
(chat_id,),
).fetchall()
return [_scene_row_to_dict(r) for r in rows]
def get_parent_scene(conn: Connection, scene_id: int) -> dict | None:
"""Given a meanwhile scene id, return its parent (you-scene) row."""
row = conn.execute(
"SELECT s.id, s.chat_id, s.started_at, s.present_set_kind, "
"s.parent_scene_id, s.ended_at "
"FROM scenes s JOIN scenes m ON m.parent_scene_id = s.id "
"WHERE m.id = ?",
(scene_id,),
).fetchone()
if row is None:
return None
return _scene_row_to_dict(row)
def list_pending_meanwhile_digests(
conn: Connection, chat_id: str
) -> list[dict]:
"""Return digests for ``chat_id`` that haven't been consumed yet."""
rows = conn.execute(
"SELECT id, scene_id, chat_id, summary, created_at "
"FROM meanwhile_digest_pending "
"WHERE chat_id = ? AND consumed_at IS NULL "
"ORDER BY id ASC",
(chat_id,),
).fetchall()
return [
{
"id": r[0],
"scene_id": r[1],
"chat_id": r[2],
"summary": r[3],
"created_at": r[4],
}
for r in rows
]
+372
View File
@@ -0,0 +1,372 @@
from __future__ import annotations
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
_VALID_WITNESS_ROLES = {"you", "host", "guest"}
def _row_to_dict(conn: Connection, row: tuple) -> dict:
cols = [c[1] for c in conn.execute("PRAGMA table_info(memories)").fetchall()]
return dict(zip(cols, row))
@on("memory_written")
def _apply_memory_written(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"INSERT INTO memories ("
"owner_id, chat_id, scene_id, pov_summary, "
"witness_you, witness_host, witness_guest, "
"chat_clock_at, source, reliability, significance, pinned, auto_pinned"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
p["owner_id"],
p["chat_id"],
p.get("scene_id"),
p["pov_summary"],
int(p["witness_you"]),
int(p["witness_host"]),
int(p["witness_guest"]),
p.get("chat_clock_at"),
p.get("source", "direct"),
float(p.get("reliability", 1.0)),
int(p.get("significance", 1)),
int(p.get("pinned", 0)),
int(p.get("auto_pinned", 0)),
),
)
@on("memory_significance_set")
def _apply_memory_significance_set(conn: Connection, e: Event) -> None:
"""Update an existing memory's significance score (T22).
Emitted by the async significance worker after it scores the turn.
"""
p = e.payload
conn.execute(
"UPDATE memories SET significance = ? WHERE id = ?",
(int(p["significance"]), int(p["memory_id"])),
)
@on("memory_pin_changed")
def _apply_memory_pin_changed(conn: Connection, e: Event) -> None:
"""Toggle a memory's pin state (T22, §8.5).
Used both for auto-pinning a pivotal turn and for evicting the oldest
auto-pin when the per-owner soft cap is exceeded. Manual pins use the
same handler; the ``auto_pinned`` flag distinguishes them so the
eviction query can leave manual pins alone.
"""
p = e.payload
conn.execute(
"UPDATE memories SET pinned = ?, auto_pinned = ? WHERE id = ?",
(int(p["pinned"]), int(p["auto_pinned"]), int(p["memory_id"])),
)
def get_memory(conn: Connection, memory_id: int) -> dict | None:
row = conn.execute(
"SELECT * FROM memories WHERE id = ?", (memory_id,)
).fetchone()
if not row:
return None
return _row_to_dict(conn, row)
def get_pinned(conn: Connection, owner_id: str) -> list[dict]:
cur = conn.execute(
"SELECT * FROM memories WHERE owner_id = ? AND pinned = 1 "
"ORDER BY created_at DESC, id DESC",
(owner_id,),
)
rows = cur.fetchall()
cols = [c[1] for c in conn.execute("PRAGMA table_info(memories)").fetchall()]
return [dict(zip(cols, row)) for row in rows]
# Composite-score weights used by ``search_memories`` (T23, §8 retrieval).
# FTS5 BM25 ``rank`` is *more negative* for better matches, so subtracting a
# positive boost from it drives stronger candidates further down (i.e. earlier
# in an ascending sort). Hardcoded for v1 — tunable in a later pass.
_SIGNIFICANCE_WEIGHT = 0.3
_RECENCY_WEIGHT = 0.5
# T57 (Phase 3, §11.1): significance multiplier applied to the SQL ORDER BY in
# ``search_memories`` so that the FTS over-fetch already prefers
# higher-significance rows for tied / near-tied BM25 ranks. Module-level so it
# can be tuned without a code change. BM25 ``rank`` is lower-is-better, so the
# bias is *subtracted* from rank in the ASC ordering — equivalent to multiplying
# a higher-is-better score by a positive constant per the spec wording.
SIGNIFICANCE_RANK_BIAS = 0.5
# T96 (Phase 4): reciprocal-rank-fusion constant used when ``search_memories``
# is given a ``query_vector`` and must merge FTS + vector candidate lists. The
# value 60 is the canonical RRF constant from Cormack et al. ("Reciprocal Rank
# Fusion outperforms Condorcet and Individual Rank Learning Methods", SIGIR
# 2009): large enough to dampen the head of either ranking so that a strong
# top-1 in ranking A doesn't crowd out a moderate top-3 in ranking B, but
# small enough that the position-1/position-2 gap still matters.
RRF_CONST = 60
def search_memories(
conn: Connection,
owner_id: str,
witness_role: str,
query: str,
k: int = 4,
*,
query_vector: list[float] | None = None,
) -> list[dict]:
"""FTS5 search over pov_summary, scoped by owner and witness role.
witness_role must be one of {"you", "host", "guest"} per the witness flags
on each memory row. Returns up to ``k`` rows ranked by a composite score
that combines the FTS5 BM25 rank with two boosts (§8 retrieval rules):
* **significance boost** — ``0.3 * significance`` (0..3 per §11.1).
* **recency boost** — ``0.5 * (id / max_id)``, using the row id as a
monotonic recency proxy. Newer memories therefore tilt above older ones
when the BM25 rank and significance are otherwise tied.
BM25 returns negative scores (lower = better). Both boosts are subtracted
so that stronger candidates yield smaller composite scores; the result is
sorted ascending and truncated to ``k``. The unmodified ``fts_rank`` and a
debug-friendly ``composite_score`` are kept on each returned dict.
The result ordering applies TWO independent significance boosts:
* **SQL-side** — ``ORDER BY (rank - significance * SIGNIFICANCE_RANK_BIAS)``
pushes higher-significance memories ahead in the FTS5 candidate set so
the over-fetch already prefers them for tied / near-tied BM25 ranks
(T57, §11.1).
* **Python-side** — a composite re-rank with ``_SIGNIFICANCE_WEIGHT``
reinforces the ordering after candidate retrieval, alongside the
recency boost above.
PHASE 4 EXTENSION (T96): when ``query_vector`` is provided, fuses FTS and
vector hits via reciprocal-rank fusion (RRF):
fusion_score = 1/(RRF_CONST + fts_rank) + 1/(RRF_CONST + vec_rank)
where ``fts_rank`` and ``vec_rank`` are the 0-indexed positions of the
memory in each candidate list. Each candidate gets the sum of its
reciprocal ranks across both rankings; memories appearing in only one
ranking still get a partial score (the other term is dropped). Both
candidate lists are over-fetched at ``k * 2`` so a memory dominant in
only one channel has a fair chance to surface. The Python-side
significance + recency re-rank is then applied as a final pass to
break ties in favour of more important / more recent memories.
When ``query_vector`` is None: FTS-only behaviour unchanged — all
Phase 1-3.5 callers see the same row shape and ordering as before.
"""
if witness_role not in _VALID_WITNESS_ROLES:
raise ValueError(
f"witness_role must be one of {sorted(_VALID_WITNESS_ROLES)}, "
f"got {witness_role!r}"
)
if not query.strip():
return []
witness_col = f"witness_{witness_role}"
cols = [c[1] for c in conn.execute("PRAGMA table_info(memories)").fetchall()]
select_list = ", ".join(f"m.{c}" for c in cols)
# Over-fetch from FTS so the Python-side re-rank has room to reorder
# results that BM25 alone would have demoted past the top-k boundary.
# When fusing with a vector ranking, we still over-fetch (k*2 from each
# channel) so memories that are weak in FTS but strong in vector — and
# vice versa — make it into the merge pool.
over_fetch = max(k * 2, 20) if query_vector is not None else max(k * 4, 20)
sql = (
f"SELECT {select_list}, memories_fts.rank AS fts_rank "
"FROM memories_fts "
"JOIN memories m ON m.id = memories_fts.rowid "
f"WHERE m.owner_id = ? AND m.{witness_col} = 1 "
"AND memories_fts MATCH ? "
# T57: significance multiplier biases the FTS over-fetch order. BM25
# ``rank`` is lower-is-better, so subtracting ``significance * BIAS``
# surfaces higher-significance rows above lower-significance rows with
# equal/near-equal match strength. Equivalent to ``score × constant``
# per §11.1 once the rank is inverted to a higher-is-better score.
"ORDER BY (memories_fts.rank - m.significance * ?) ASC "
"LIMIT ?"
)
cur = conn.execute(sql, (owner_id, query, SIGNIFICANCE_RANK_BIAS, over_fetch))
rows = cur.fetchall()
# FTS-only path: preserve pre-T96 behaviour exactly.
if query_vector is None:
if not rows:
return []
return _composite_rerank(conn, cols, rows, owner_id, k)
# Fused path: combine FTS candidates with vector candidates via RRF.
return _rrf_fuse_and_rerank(
conn,
cols=cols,
fts_rows=rows,
owner_id=owner_id,
witness_role=witness_role,
query_vector=query_vector,
k=k,
)
def _composite_rerank(
conn: Connection,
cols: list[str],
rows: list[tuple],
owner_id: str,
k: int,
) -> list[dict]:
"""Apply the significance + recency composite re-rank to FTS rows.
Extracted from ``search_memories`` so the no-vector path stays a single
call and the fused path can re-use the same boost formulae after RRF.
"""
max_id_row = conn.execute(
"SELECT MAX(id) FROM memories WHERE owner_id = ?", (owner_id,)
).fetchone()
max_id = max_id_row[0] if max_id_row and max_id_row[0] else 1
result_cols = cols + ["fts_rank"]
enriched: list[dict] = []
for row in rows:
d = dict(zip(result_cols, row))
fts_rank = d.get("fts_rank") or 0.0
sig_boost = _SIGNIFICANCE_WEIGHT * (d.get("significance") or 0)
recency_boost = _RECENCY_WEIGHT * ((d.get("id") or 0) / max_id)
d["composite_score"] = fts_rank - sig_boost - recency_boost
enriched.append(d)
enriched.sort(key=lambda x: x["composite_score"])
return enriched[:k]
def _rrf_fuse_and_rerank(
conn: Connection,
*,
cols: list[str],
fts_rows: list[tuple],
owner_id: str,
witness_role: str,
query_vector: list[float],
k: int,
) -> list[dict]:
"""Merge FTS + vector candidates via reciprocal-rank fusion, then apply
the existing significance + recency boost as a final tie-breaker.
RRF formula (Cormack et al. 2009)::
fusion_score = sum over rankings r of 1 / (RRF_CONST + rank_r)
where ``rank_r`` is the 0-indexed position of the memory in ranking r.
"Missing from a ranking" is handled by SKIPPING the term for that
ranking — i.e. that channel contributes 0 to the sum, which preserves
the fairness property: a memory that only appears in one ranking is
not penalised relative to itself, just relative to memories that
appeared in both. This matches the canonical RRF presentation.
The final composite score subtracted from the *negated* fusion score
is::
composite = -fusion - sig_boost - recency_boost
Sorted ascending, smaller-is-better — the same ordering convention as
the FTS-only path so the Python-side significance + recency boosts
apply as a clean tie-breaker without inverting any sign.
"""
# Lazy import to avoid a hard module-level cycle: vector_search reads
# from chat.state.embeddings, which is itself a sibling of this module.
from chat.services.vector_search import vector_search
fts_rank_by_id: dict[int, int] = {}
fts_row_by_id: dict[int, tuple] = {}
id_idx = cols.index("id")
for rank, row in enumerate(fts_rows):
memory_id = row[id_idx]
fts_rank_by_id[memory_id] = rank
fts_row_by_id[memory_id] = row
# Over-fetch the vector channel symmetrically so each channel gets a
# fair shot at surfacing its strongest candidates.
vec_over_fetch = max(k * 2, 20)
vec_hits = vector_search(
conn,
owner_id=owner_id,
witness_role=witness_role,
query_vector=query_vector,
k=vec_over_fetch,
)
vec_rank_by_id: dict[int, int] = {
hit["memory_id"]: rank for rank, hit in enumerate(vec_hits)
}
# If the vector channel returned nothing (no embeddings indexed), the
# fused path collapses cleanly to the FTS-only path. No error, no
# surprise zero-hit return.
if not vec_rank_by_id and not fts_row_by_id:
return []
if not vec_rank_by_id:
return _composite_rerank(conn, cols, fts_rows, owner_id, k)
# For any vector-only hits we don't have a full memory row for yet,
# fetch them in a single round-trip. The FTS row carries an ``fts_rank``
# column at the end; vector-only rows get ``None`` there.
missing_ids = [mid for mid in vec_rank_by_id if mid not in fts_row_by_id]
select_list = ", ".join(cols)
if missing_ids:
placeholders = ",".join("?" * len(missing_ids))
cur = conn.execute(
f"SELECT {select_list} FROM memories WHERE id IN ({placeholders})",
missing_ids,
)
for row in cur.fetchall():
# Pad with a None for the trailing ``fts_rank`` slot so the row
# shape matches FTS rows downstream.
fts_row_by_id[row[id_idx]] = tuple(row) + (None,)
# Compute fusion score per candidate. Missing-from-ranking terms are
# simply omitted from the sum.
all_ids = set(fts_rank_by_id) | set(vec_rank_by_id)
fusion_by_id: dict[int, float] = {}
for mid in all_ids:
score = 0.0
if mid in fts_rank_by_id:
score += 1.0 / (RRF_CONST + fts_rank_by_id[mid])
if mid in vec_rank_by_id:
score += 1.0 / (RRF_CONST + vec_rank_by_id[mid])
fusion_by_id[mid] = score
# Final composite re-rank: significance + recency boosts on top of the
# negated fusion score so the sort direction matches the FTS-only path.
max_id_row = conn.execute(
"SELECT MAX(id) FROM memories WHERE owner_id = ?", (owner_id,)
).fetchone()
max_id = max_id_row[0] if max_id_row and max_id_row[0] else 1
result_cols = cols + ["fts_rank"]
enriched: list[dict] = []
for mid in all_ids:
row = fts_row_by_id.get(mid)
if row is None:
# Defensive: a vector hit with no memory row would be a logic
# bug (vector_search joins memories), so just skip it rather
# than crash the whole search.
continue
d = dict(zip(result_cols, row))
sig_boost = _SIGNIFICANCE_WEIGHT * (d.get("significance") or 0)
recency_boost = _RECENCY_WEIGHT * ((d.get("id") or 0) / max_id)
fusion = fusion_by_id[mid]
# Sort ascending, smaller-is-better → negate fusion so a larger
# fusion score yields a smaller composite. Significance and recency
# boosts then act as tie-breakers exactly like the FTS-only path.
d["fusion_score"] = fusion
d["composite_score"] = -fusion - sig_boost - recency_boost
enriched.append(d)
enriched.sort(key=lambda x: x["composite_score"])
return enriched[:k]
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
@on("thread_opened")
def _apply_thread_opened(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"INSERT OR IGNORE INTO threads "
"(thread_id, chat_id, title, summary, status) "
"VALUES (?, ?, ?, ?, 'open')",
(
p["thread_id"],
p["chat_id"],
p["title"],
p.get("summary", ""),
),
)
@on("thread_updated")
def _apply_thread_updated(conn: Connection, e: Event) -> None:
p = e.payload
# Idempotent: closed threads ignore subsequent updates.
conn.execute(
"UPDATE threads SET summary = ?, last_referenced_scene_id = ?, "
"updated_at = datetime('now') "
"WHERE thread_id = ? AND status = 'open'",
(
p.get("summary", ""),
p.get("last_referenced_scene_id"),
p["thread_id"],
),
)
@on("thread_closed")
def _apply_thread_closed(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE threads SET status = 'closed', closed_at = ?, "
"updated_at = datetime('now') "
"WHERE thread_id = ? AND status = 'open'",
(p.get("closed_at"), p["thread_id"]),
)
def get_thread(conn: Connection, thread_id: str) -> dict | None:
row = conn.execute(
"SELECT thread_id, chat_id, title, summary, status, "
"opened_at, closed_at, last_referenced_scene_id, "
"created_at, updated_at "
"FROM threads WHERE thread_id = ?",
(thread_id,),
).fetchone()
if not row:
return None
return {
"thread_id": row[0],
"chat_id": row[1],
"title": row[2],
"summary": row[3],
"status": row[4],
"opened_at": row[5],
"closed_at": row[6],
"last_referenced_scene_id": row[7],
"created_at": row[8],
"updated_at": row[9],
}
def list_open_threads(conn: Connection, chat_id: str) -> list[dict]:
rows = conn.execute(
"SELECT thread_id, chat_id, title, summary, status, "
"opened_at, closed_at, last_referenced_scene_id, "
"created_at, updated_at "
"FROM threads WHERE chat_id = ? AND status = 'open' "
"ORDER BY id ASC",
(chat_id,),
).fetchall()
return [
{
"thread_id": r[0], "chat_id": r[1], "title": r[2],
"summary": r[3], "status": r[4],
"opened_at": r[5], "closed_at": r[6],
"last_referenced_scene_id": r[7],
"created_at": r[8], "updated_at": r[9],
}
for r in rows
]
def list_threads(conn: Connection, chat_id: str, status: str | None = None) -> list[dict]:
if status is None:
rows = conn.execute(
"SELECT thread_id, chat_id, title, summary, status, "
"opened_at, closed_at, last_referenced_scene_id, "
"created_at, updated_at "
"FROM threads WHERE chat_id = ? ORDER BY id ASC",
(chat_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT thread_id, chat_id, title, summary, status, "
"opened_at, closed_at, last_referenced_scene_id, "
"created_at, updated_at "
"FROM threads WHERE chat_id = ? AND status = ? "
"ORDER BY id ASC",
(chat_id, status),
).fetchall()
return [
{
"thread_id": r[0], "chat_id": r[1], "title": r[2],
"summary": r[3], "status": r[4],
"opened_at": r[5], "closed_at": r[6],
"last_referenced_scene_id": r[7],
"created_at": r[8], "updated_at": r[9],
}
for r in rows
]
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import json
from sqlite3 import Connection
from chat.eventlog.projector import on
from chat.eventlog.log import Event
def _row_to_dict(conn: Connection, table: str, row: tuple) -> dict:
cols = [c[1] for c in conn.execute(f"PRAGMA table_info({table})").fetchall()]
return dict(zip(cols, row))
@on("chat_created")
def _apply_chat_created(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"INSERT INTO chats (id, host_bot_id, guest_bot_id) VALUES (?, ?, ?)",
(p["id"], p["host_bot_id"], p.get("guest_bot_id")),
)
conn.execute(
"INSERT INTO chat_state (chat_id, time, weather, active_scene_id, narrative_anchor) "
"VALUES (?, ?, ?, NULL, ?)",
(
p["id"],
p["initial_time"],
p.get("weather", ""),
p.get("narrative_anchor", ""),
),
)
@on("time_skip_elision")
def _apply_time_skip_elision(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE chat_state SET time = ? WHERE chat_id = ?",
(p["new_time"], p["chat_id"]),
)
@on("time_skip_jump")
def _apply_time_skip_jump(conn: Connection, e: Event) -> None:
p = e.payload
chat_id = p["chat_id"]
conn.execute(
"UPDATE chat_state SET time = ? WHERE chat_id = ?",
(p["new_time"], chat_id),
)
if p.get("reset_activity", False):
# Activity rows are keyed by entity_id with a container_id FK.
# Each chat owns its containers, so deleting activity rows whose
# container_id belongs to this chat clears every present entity.
conn.execute(
"DELETE FROM activity "
"WHERE container_id IN (SELECT id FROM containers WHERE chat_id = ?)",
(chat_id,),
)
@on("guest_added")
def _apply_guest_added(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE chats SET guest_bot_id = ? WHERE id = ?",
(p["guest_bot_id"], p["chat_id"]),
)
@on("guest_removed")
def _apply_guest_removed(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"UPDATE chats SET guest_bot_id = NULL WHERE id = ?",
(p["chat_id"],),
)
@on("container_created")
def _apply_container_created(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"INSERT INTO containers (chat_id, name, type, properties_json, parent_id) "
"VALUES (?, ?, ?, ?, ?)",
(
p["chat_id"],
p["name"],
p["type"],
json.dumps(p.get("properties", {})),
p.get("parent_id"),
),
)
@on("activity_change")
def _apply_activity_change(conn: Connection, e: Event) -> None:
p = e.payload
conn.execute(
"INSERT OR REPLACE INTO activity ("
"entity_id, container_id, slot, posture, action_json, "
"attention, holding_json, status_json, updated_at"
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))",
(
p["entity_id"],
p.get("container_id"),
p.get("slot"),
p.get("posture", ""),
json.dumps(p.get("action", {})),
p.get("attention", ""),
json.dumps(p.get("holding", [])),
json.dumps(p.get("status", {})),
),
)
@on("scene_opened")
def _apply_scene_opened(conn: Connection, e: Event) -> None:
p = e.payload
cur = conn.execute(
"INSERT INTO scenes (chat_id, container_id, started_at, ended_at, "
"significance, participants_json) VALUES (?, ?, ?, NULL, 0, ?)",
(
p["chat_id"],
p.get("container_id"),
p["started_at"],
json.dumps(p.get("participants", [])),
),
)
new_id = cur.lastrowid
conn.execute(
"UPDATE chat_state SET active_scene_id = ? WHERE chat_id = ?",
(new_id, p["chat_id"]),
)
@on("scene_closed")
def _apply_scene_closed(conn: Connection, e: Event) -> None:
p = e.payload
scene_id = p["scene_id"]
significance = int(p.get("significance", 0))
conn.execute(
"UPDATE scenes SET ended_at = ?, significance = ? WHERE id = ?",
(p["ended_at"], significance, scene_id),
)
row = conn.execute(
"SELECT chat_id FROM scenes WHERE id = ?", (scene_id,)
).fetchone()
if row is not None:
chat_id = row[0]
conn.execute(
"UPDATE chat_state SET active_scene_id = NULL WHERE chat_id = ?",
(chat_id,),
)
def _chat_select_columns() -> str:
return (
"c.id, c.host_bot_id, c.guest_bot_id, c.created_at, "
"s.time, s.weather, s.active_scene_id, s.narrative_anchor"
)
def _chat_row_to_dict(row: tuple) -> dict:
return {
"id": row[0],
"host_bot_id": row[1],
"guest_bot_id": row[2],
"created_at": row[3],
"time": row[4],
"weather": row[5],
"active_scene_id": row[6],
"narrative_anchor": row[7],
}
def get_chat(conn: Connection, chat_id: str) -> dict | None:
row = conn.execute(
f"SELECT {_chat_select_columns()} FROM chats c "
"JOIN chat_state s ON s.chat_id = c.id WHERE c.id = ?",
(chat_id,),
).fetchone()
if not row:
return None
return _chat_row_to_dict(row)
def list_chats(conn: Connection) -> list[dict]:
cur = conn.execute(
f"SELECT {_chat_select_columns()} FROM chats c "
"JOIN chat_state s ON s.chat_id = c.id ORDER BY c.id"
)
return [_chat_row_to_dict(row) for row in cur.fetchall()]
def get_container(conn: Connection, container_id: int) -> dict | None:
row = conn.execute(
"SELECT * FROM containers WHERE id = ?", (container_id,)
).fetchone()
if not row:
return None
d = _row_to_dict(conn, "containers", row)
d["properties"] = json.loads(d.pop("properties_json"))
return d
def find_container(conn: Connection, chat_id: str, name: str) -> dict | None:
row = conn.execute(
"SELECT * FROM containers WHERE chat_id = ? AND name = ?",
(chat_id, name),
).fetchone()
if not row:
return None
d = _row_to_dict(conn, "containers", row)
d["properties"] = json.loads(d.pop("properties_json"))
return d
def get_activity(conn: Connection, entity_id: str) -> dict | None:
row = conn.execute(
"SELECT * FROM activity WHERE entity_id = ?", (entity_id,)
).fetchone()
if not row:
return None
d = _row_to_dict(conn, "activity", row)
d["action"] = json.loads(d.pop("action_json"))
d["holding"] = json.loads(d.pop("holding_json"))
d["status"] = json.loads(d.pop("status_json"))
return d
def get_scene(conn: Connection, scene_id: int) -> dict | None:
row = conn.execute(
"SELECT * FROM scenes WHERE id = ?", (scene_id,)
).fetchone()
if not row:
return None
d = _row_to_dict(conn, "scenes", row)
d["participants"] = json.loads(d.pop("participants_json"))
return d
def active_scene(conn: Connection, chat_id: str) -> dict | None:
row = conn.execute(
"SELECT active_scene_id FROM chat_state WHERE chat_id = ?",
(chat_id,),
).fetchone()
if not row or row[0] is None:
return None
return get_scene(conn, row[0])
+125
View File
@@ -0,0 +1,125 @@
* { box-sizing: border-box; }
body {
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
margin: 0;
color: #1c1c1c;
background: #fafafa;
display: flex;
min-height: 100vh;
}
.rail {
width: 200px;
background: #1c1c1c;
color: #fff;
padding: 16px;
flex-shrink: 0;
}
.rail a { color: #fff; text-decoration: none; }
.rail-brand {
font-weight: 600;
display: block;
padding-bottom: 16px;
border-bottom: 1px solid #333;
margin-bottom: 16px;
}
.rail ul { list-style: none; padding: 0; margin: 0; }
.rail li { margin: 4px 0; }
.rail li a { display: block; padding: 6px 8px; border-radius: 3px; }
.rail li a.active { background: #333; }
.content {
flex: 1;
padding: 24px;
background: #fafafa;
overflow: auto;
}
.brand { font-weight: 600; text-decoration: none; color: inherit; }
.container { max-width: 720px; margin: 24px auto; padding: 0 16px; }
h1 { margin-top: 0; }
.page-header { display: flex; align-items: center; justify-content: space-between; }
.btn, button {
display: inline-block; padding: 8px 14px;
border: 1px solid #444; background: #1c1c1c; color: #fff;
border-radius: 4px; text-decoration: none; cursor: pointer;
font: inherit;
}
.bot-form label { display: block; margin-bottom: 14px; }
.bot-form label span { display: block; font-weight: 600; margin-bottom: 4px; }
.bot-form input[type=text], .bot-form textarea {
width: 100%; padding: 6px 8px; font: inherit;
border: 1px solid #ccc; border-radius: 3px; background: #fff;
}
.bot-form small { display: block; color: #666; margin-top: 2px; }
.bot-list { list-style: none; padding: 0; }
.bot-list li { padding: 8px 0; border-bottom: 1px solid #eee; }
.chat-list { list-style: none; padding: 0; margin: 0; }
.chat-row { border-bottom: 1px solid #eee; }
.chat-row a { display: block; padding: 12px 0; text-decoration: none; color: inherit; }
.chat-row a:hover { background: #f0f0f0; }
.chat-row-name { font-weight: 600; }
.chat-row-snippet { font-size: 14px; }
.chat-row-meta { font-size: 12px; }
.muted { color: #666; }
.error {
padding: 8px 12px; border: 1px solid #c33; background: #fdecea;
color: #a00; border-radius: 3px;
}
.success {
padding: 8px 12px; border: 1px solid #2d7a3a; background: #eafaf0;
color: #1f5c2a; border-radius: 3px;
}
code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.chat-shell { display: flex; flex-direction: column; height: 100%; max-width: 760px; margin: 0 auto; }
.chat-header { display: flex; align-items: center; gap: 16px; border-bottom: 1px solid #e5e5e5; padding-bottom: 8px; margin-bottom: 16px; }
.chat-header h1 { margin: 0; flex: 1; }
.chat-meta { font-size: 13px; }
.drawer-toggle { padding: 4px 10px; border: 1px solid #ccc; background: #fff; color: #1c1c1c; border-radius: 3px; cursor: pointer; }
.timeline { flex: 1; overflow-y: auto; min-height: 200px; padding: 8px 0; }
.turn { margin: 12px 0; }
.turn strong { display: block; margin-bottom: 4px; }
.turn p { margin: 0 0 8px; }
.turn p:last-child { margin-bottom: 0; }
.turn-you strong { color: #1a73e8; }
.turn-bot strong { color: #1c1c1c; }
/* ``*action*`` — italic narration. */
.action { font-style: italic; color: #555; }
/* ``((ooc))`` author-to-system aside. Dim, italic, smaller, set off
from surrounding prose so it doesn't read as in-fiction speech. */
.ooc {
font-style: italic;
font-size: 12px;
color: #999;
display: inline-block;
background: rgba(0, 0, 0, 0.04);
padding: 1px 4px;
border-radius: 3px;
}
.turn blockquote {
border-left: 3px solid #ccc;
padding-left: 12px;
margin: 8px 0;
color: #555;
}
.turn-input { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 1px solid #e5e5e5; }
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
.drawer[hidden] { display: none; }
.drawer-content { display: flex; flex-direction: column; gap: 16px; }
.drawer-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 8px; border-bottom: 1px solid #e5e5e5; }
.drawer-close { border: none; background: transparent; color: #1c1c1c; font-size: 24px; padding: 0 4px; cursor: pointer; }
.drawer-section h3 { margin: 0 0 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; }
.activity-row, .edge-row { margin-bottom: 12px; }
.activity-row strong, .edge-row strong { display: block; }
.memory-list { list-style: none; padding: 0; margin: 0; }
.memory-list li { padding: 4px 0; font-size: 13px; }
.sig { display: inline-block; min-width: 16px; }
.sig-3 { color: #d4af37; }
/* Streaming UX (T34): typing indicator, Stop button, disconnect banner. */
.streaming { opacity: 0.85; }
.streaming-text:after {
content: "\025AE";
margin-left: 2px;
animation: blink 1s steps(2, start) infinite;
}
@keyframes blink { to { visibility: hidden; } }
.stop-streaming { background: #c33; border-color: #a00; margin-bottom: 8px; align-self: flex-start; }
.connection-lost { margin-bottom: 8px; }
+634
View File
@@ -0,0 +1,634 @@
<div class="drawer-content">
<header class="drawer-header">
<h2>{{ host_bot.name }}</h2>
<button class="drawer-close" type="button"
onclick="document.getElementById('drawer').setAttribute('hidden','')">&times;</button>
</header>
<section class="drawer-section">
<h3>Scene</h3>
{% if scene %}
<p>Started: {{ scene.started_at }}</p>
{% endif %}
{% if container %}
<p>Container: {{ container.name }} ({{ container.type }})</p>
{% else %}
<p class="muted">No active container.</p>
{% endif %}
<p>Time: {{ chat.time }}</p>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/chat/narrative-anchor"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Narrative anchor:
<input type="text" name="new_value" maxlength="500"
value="{{ chat.narrative_anchor or '' }}">
</label>
<button type="submit">Save</button>
</form>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/chat/weather"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Weather:
<input type="text" name="new_value" maxlength="500"
value="{{ chat.weather or '' }}">
</label>
<button type="submit">Save</button>
</form>
{% if scene %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/scene/close"
hx-target="#drawer" hx-swap="innerHTML">
<button type="submit">Close scene</button>
</form>
{% else %}
<p class="muted">No active scene.</p>
{% endif %}
</section>
<section class="drawer-section">
<h3>Activity</h3>
{% for label, act in [("you", you_activity), (host_bot.name, bot_activity)] %}
<div class="activity-row">
<strong>{{ label }}</strong>
{% if act %}
<p>{{ act.posture or "—" }} / {{ (act.action or {}).verb or "—" }}</p>
{% if act.attention %}<p class="muted">attention: {{ act.attention }}</p>{% endif %}
{% if act.holding %}<p class="muted">holding: {{ act.holding|join(", ") }}</p>{% endif %}
{% else %}
<p class="muted">No activity recorded.</p>
{% endif %}
</div>
{% endfor %}
<details class="skip-controls">
<summary>Elision skip</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/skip/elision"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Landing state hint:
<input type="text" name="landing_state_hint"
placeholder="e.g. arriving at the office">
</label>
<label>
New time (ISO 8601):
<input type="text" name="new_time" required
placeholder="2026-04-26T20:30:00+00:00">
</label>
<button type="submit">Skip ahead</button>
</form>
</details>
<details class="skip-controls">
<summary>Jump skip</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/skip/jump"
hx-target="#drawer" hx-swap="innerHTML">
<label>
New time (ISO 8601):
<input type="text" name="new_time" required
placeholder="2026-04-27T08:00:00+00:00">
</label>
<label>
Anything notable happen? (optional)
<textarea name="notable_prose" rows="3"
placeholder="leave blank to jump without synthesizing memories"></textarea>
</label>
<label>
<input type="checkbox" name="reset_activity" value="1">
Reset activity at landing
</label>
<button type="submit">Jump ahead</button>
</form>
</details>
</section>
<section class="drawer-section">
<h3>Events</h3>
{% if active_events %}
<ul class="event-list">
{% for ev in active_events %}
<li class="event-row">
<strong>{{ ev.kind }}</strong>
<span class="muted"> ({{ ev.status }})</span>
{% if ev.planned_for %}
<p class="muted">planned for: {{ ev.planned_for }}</p>
{% endif %}
{% if ev.props %}
<p class="muted">{{ ev.props|tojson }}</p>
{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/event/cancel/{{ ev.event_id }}"
hx-target="#drawer" hx-swap="innerHTML">
<button type="submit">Cancel</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No active events.</p>
{% endif %}
<details>
<summary>Plan event</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/event/plan"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Kind:
<input type="text" name="kind" required
placeholder="e.g. dinner_reservation">
</label>
<label>
Planned for (ISO 8601):
<input type="text" name="planned_for" required
placeholder="2026-04-26T19:00:00+00:00">
</label>
<label>
Props (JSON):
<textarea name="props_json" rows="3"
placeholder='{"location": "Bistro X"}'>{}</textarea>
</label>
<button type="submit">Plan event</button>
</form>
</details>
</section>
<section class="drawer-section">
<h3>Threads</h3>
{% if open_threads %}
<ul class="thread-list">
{% for th in open_threads %}
<li class="thread-row">
<strong>{{ th.title }}</strong>
{% if th.summary %}
<p>{{ th.summary }}</p>
{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/thread/close/{{ th.thread_id }}"
hx-target="#drawer" hx-swap="innerHTML">
<button type="submit">Close</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No open threads.</p>
{% endif %}
</section>
{% if guest_bot %}
<section class="drawer-section">
<h3>Guest</h3>
<p><strong>{{ guest_bot.name }}</strong></p>
{% if guest_activity %}
<p>{{ guest_activity.posture or "—" }} / {{ (guest_activity.action or {}).verb or "—" }}</p>
{% if guest_activity.attention %}<p class="muted">attention: {{ guest_activity.attention }}</p>{% endif %}
{% if guest_activity.holding %}<p class="muted">holding: {{ guest_activity.holding|join(", ") }}</p>{% endif %}
{% else %}
<p class="muted">No activity recorded.</p>
{% endif %}
{% if edge_h2g %}
<div class="edge-row">
<strong>{{ host_bot.name }} &rarr; {{ guest_bot.name }}</strong>
<p>Affinity: {{ edge_h2g.affinity }}/100 &middot; Trust: {{ edge_h2g.trust }}/100</p>
{% if edge_h2g.knowledge %}
<details><summary>Knowledge ({{ edge_h2g.knowledge|length }})</summary>
<ul>{% for fact in edge_h2g.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
</details>
{% endif %}
</div>
{% endif %}
{% if edge_g2h %}
<div class="edge-row">
<strong>{{ guest_bot.name }} &rarr; {{ host_bot.name }}</strong>
<p>Affinity: {{ edge_g2h.affinity }}/100 &middot; Trust: {{ edge_g2h.trust }}/100</p>
{% if edge_g2h.knowledge %}
<details><summary>Knowledge ({{ edge_g2h.knowledge|length }})</summary>
<ul>{% for fact in edge_g2h.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
</details>
{% endif %}
</div>
{% endif %}
{% if edge_y2g %}
<div class="edge-row">
<strong>you &rarr; {{ guest_bot.name }}</strong>
<p>Affinity: {{ edge_y2g.affinity }}/100 &middot; Trust: {{ edge_y2g.trust }}/100</p>
</div>
{% endif %}
{% if edge_g2y %}
<div class="edge-row">
<strong>{{ guest_bot.name }} &rarr; you</strong>
<p>Affinity: {{ edge_g2y.affinity }}/100 &middot; Trust: {{ edge_g2y.trust }}/100</p>
</div>
{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/guest/remove"
hx-target="#drawer" hx-swap="innerHTML">
<button type="submit">Remove guest</button>
</form>
</section>
{% else %}
<section class="drawer-section">
<h3>Add guest</h3>
{% if available_guests %}
{% set first_guest_id = available_guests[0].id %}
{% set first_existing = existing_guest_edges.get(first_guest_id, False) %}
<form class="inline-edit add-guest-form"
hx-post="/chats/{{ chat.id }}/drawer/guest/add"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Bot:
<select name="guest_bot_id" required class="add-guest-select">
{% for b in available_guests %}
<option value="{{ b.id }}"
data-existing-edge="{{ 'true' if existing_guest_edges.get(b.id) else 'false' }}">
{{ b.name }}{% if existing_guest_edges.get(b.id) %} (already met){% endif %}
</option>
{% endfor %}
</select>
</label>
<p class="muted add-guest-existing-note"
{% if not first_existing %}hidden{% endif %}>
they already know each other (edge exists from a prior chat)
</p>
<label class="add-guest-reseed-label"
{% if not first_existing %}hidden{% endif %}>
<input type="checkbox" name="reseed" value="1" class="add-guest-reseed">
re-seed anyway
</label>
<label>
Have they met before? Describe how (leave blank if not):
<textarea name="relationship_prose" rows="3"
class="add-guest-prose"
{% if first_existing %}disabled{% endif %}
placeholder="e.g. Old college friends who studied physics together."></textarea>
</label>
<button type="submit">Add guest</button>
</form>
<script>
(function () {
var form = document.currentScript.previousElementSibling;
while (form && !form.classList.contains('add-guest-form')) {
form = form.previousElementSibling;
}
if (!form) return;
var sel = form.querySelector('.add-guest-select');
var prose = form.querySelector('.add-guest-prose');
var reseed = form.querySelector('.add-guest-reseed');
var note = form.querySelector('.add-guest-existing-note');
var reseedLabel = form.querySelector('.add-guest-reseed-label');
function refresh() {
var opt = sel.options[sel.selectedIndex];
var existing = opt && opt.getAttribute('data-existing-edge') === 'true';
if (existing) {
note.removeAttribute('hidden');
reseedLabel.removeAttribute('hidden');
prose.disabled = !reseed.checked;
} else {
note.setAttribute('hidden', '');
reseedLabel.setAttribute('hidden', '');
reseed.checked = false;
prose.disabled = false;
}
}
sel.addEventListener('change', refresh);
reseed.addEventListener('change', refresh);
refresh();
})();
</script>
{% else %}
<p class="muted">No other bots authored yet.</p>
{% endif %}
</section>
{% endif %}
{% if group_node %}
<section class="drawer-section">
<h3>Group</h3>
{% if group_node.summary %}
<p>{{ group_node.summary }}</p>
{% else %}
<p class="muted">No group summary yet.</p>
{% endif %}
{% if group_node.dynamic %}
<p class="muted">Dynamic: {{ group_node.dynamic }}</p>
{% endif %}
</section>
{% endif %}
<section class="drawer-section">
<h3>Edges</h3>
{% if edge_b2y %}
<div class="edge-row">
<strong>{{ host_bot.name }} &rarr; you</strong>
<p>Affinity: {{ edge_b2y.affinity }}/100 &middot; Trust: {{ edge_b2y.trust }}/100</p>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/edge/{{ host_bot.id }}/you/affinity"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Affinity:
<input type="range" name="affinity" min="0" max="100"
value="{{ edge_b2y.affinity }}"
oninput="this.nextElementSibling.value = this.value">
<output>{{ edge_b2y.affinity }}</output>
</label>
<button type="submit">Save</button>
</form>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/edge/trust"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
<input type="hidden" name="target_id" value="you">
<label>
Trust:
<input type="range" name="new_value" min="0" max="100"
value="{{ edge_b2y.trust }}"
oninput="this.nextElementSibling.value = this.value">
<output>{{ edge_b2y.trust }}</output>
</label>
<button type="submit">Save</button>
</form>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/edge/summary"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
<input type="hidden" name="target_id" value="you">
<label>
Summary:
<textarea name="new_summary" rows="3" maxlength="2000">{{ edge_b2y.summary or "" }}</textarea>
</label>
<button type="submit">Save summary</button>
</form>
<details>
<summary>Knowledge ({{ (edge_b2y.knowledge or [])|length }})</summary>
{% if edge_b2y.knowledge %}
<ul>
{% for fact in edge_b2y.knowledge %}
<li>
{{ fact }}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/edge/knowledge-facts"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
<input type="hidden" name="target_id" value="you">
<input type="hidden" name="action" value="remove">
<input type="hidden" name="fact" value="{{ fact }}">
<button type="submit">Remove</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/edge/knowledge-facts"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="source_id" value="{{ host_bot.id }}">
<input type="hidden" name="target_id" value="you">
<input type="hidden" name="action" value="add">
<label>
Add fact:
<input type="text" name="fact" maxlength="500" required>
</label>
<button type="submit">Add</button>
</form>
</details>
</div>
{% endif %}
{% if edge_y2b %}
<div class="edge-row">
<strong>you &rarr; {{ host_bot.name }}</strong>
<p>Affinity: {{ edge_y2b.affinity }}/100 &middot; Trust: {{ edge_y2b.trust }}/100</p>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/edge/trust"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="source_id" value="you">
<input type="hidden" name="target_id" value="{{ host_bot.id }}">
<label>
Trust:
<input type="range" name="new_value" min="0" max="100"
value="{{ edge_y2b.trust }}"
oninput="this.nextElementSibling.value = this.value">
<output>{{ edge_y2b.trust }}</output>
</label>
<button type="submit">Save</button>
</form>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/edge/summary"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="source_id" value="you">
<input type="hidden" name="target_id" value="{{ host_bot.id }}">
<label>
Summary:
<textarea name="new_summary" rows="3" maxlength="2000">{{ edge_y2b.summary or "" }}</textarea>
</label>
<button type="submit">Save summary</button>
</form>
</div>
{% endif %}
{% if not edge_b2y and not edge_y2b %}
<p class="muted">No edges yet.</p>
{% endif %}
</section>
<section class="drawer-section">
<h3>Branches</h3>
{% if branches %}
<ul class="branch-list">
{% for b in branches %}
<li class="branch-row{% if b.is_active %} branch-active{% endif %}">
<strong>{{ b.name }}</strong>
{% if b.is_active %}<span class="muted"> (active)</span>{% endif %}
<span class="muted"> &middot; {{ b.event_count }} events</span>
{% if not b.is_active %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/branch/switch"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="name" value="{{ b.name }}">
<button type="submit">Switch</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No branches yet.</p>
{% endif %}
<details>
<summary>Create branch</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/branch/create"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Name:
<input type="text" name="name" required
placeholder="e.g. experiment_a">
</label>
<label>
Origin event id:
<input type="number" name="origin_event_id" required min="0">
</label>
<button type="submit">Create</button>
</form>
</details>
</section>
<section class="drawer-section">
<h3>Recent turns</h3>
{% if recent_turns %}
<ul class="recent-turns-list">
{% for t in recent_turns %}
<li class="turn-row{% if t.hidden %} turn-hidden{% endif %}">
<span class="muted">#{{ t.event_id }} {{ t.kind }}</span>
<strong>{{ t.speaker }}:</strong>
{{ t.excerpt }}{% if t.excerpt|length >= 120 %}…{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/turn/hide/{{ t.event_id }}"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="hidden" value="{{ 0 if t.hidden else 1 }}">
<label>
<input type="checkbox" {% if t.hidden %}checked{% endif %}
onchange="this.form.requestSubmit()">
hide from view
</label>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No turns yet.</p>
{% endif %}
</section>
<section class="drawer-section">
<h3>Significance review</h3>
{% set total_mem = significance_distribution.values()|sum %}
{% if total_mem %}
<ul class="significance-distribution">
{% for level in [0, 1, 2, 3] %}
{% set count = significance_distribution[level] %}
{% set marker = ['·','•','★','★★'][level] %}
{% set pct = (100 * count / total_mem)|round(0, 'floor')|int if total_mem else 0 %}
<li class="sig-bar sig-{{ level }}">
<span class="sig-label">{{ marker }} ({{ level }})</span>
<span class="sig-bar-fill" style="width: {{ pct }}%"></span>
<span class="sig-count">{{ count }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No memories yet.</p>
{% endif %}
{% if recent_memories %}
<details>
<summary>Edit significance (recent memories)</summary>
<ul class="significance-edit-list">
{% for m in recent_memories %}
<li>
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
{{ m.pov_summary[:80] }}{% if m.pov_summary|length > 80 %}…{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/significance"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Significance:
<input type="range" name="significance" min="0" max="3"
value="{{ m.significance|default(0) }}"
oninput="this.nextElementSibling.value = this.value">
<output>{{ m.significance|default(0) }}</output>
</label>
<button type="submit">Save</button>
</form>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</section>
<section class="drawer-section">
<h3>Pinned memories ({{ pinned|length }} / {{ pin_cap }})</h3>
{% if pinned %}
<ul class="memory-list">
{% for m in pinned %}
<li>
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
{{ m.pov_summary }}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/pin"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="pinned" value="0">
<button type="submit">Unpin</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No pinned memories.</p>
{% endif %}
</section>
<section class="drawer-section">
<h3>Recent memories</h3>
{% if recent_memories %}
<ul class="memory-list">
{% for m in recent_memories %}
<li>
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
{{ m.pov_summary[:200] }}{% if m.pov_summary|length > 200 %}…{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/significance"
hx-target="#drawer" hx-swap="innerHTML">
<select name="significance">
{% for s in [0, 1, 2, 3] %}
<option value="{{ s }}" {% if m.significance == s %}selected{% endif %}>
{{ ['·','•','★','★★'][s] }} ({{ s }})
</option>
{% endfor %}
</select>
<button type="submit">Set</button>
</form>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/{{ m.id }}/pin"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="pinned" value="{{ 0 if m.pinned else 1 }}">
<button type="submit">{{ 'Unpin' if m.pinned else 'Pin' }}</button>
</form>
<div class="witness-row">
{% for flag in ['you', 'host', 'guest'] %}
{% set witnessed = m['witness_' ~ flag] %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/witness"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="memory_id" value="{{ m.id }}">
<input type="hidden" name="flag" value="{{ flag }}">
<input type="hidden" name="new_value" value="{{ 0 if witnessed else 1 }}">
<label>
<input type="checkbox" {% if witnessed %}checked{% endif %}
onchange="this.form.requestSubmit()">
{{ flag }}
</label>
</form>
{% endfor %}
</div>
<details>
<summary>Edit POV summary</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/pov-summary"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="memory_id" value="{{ m.id }}">
<textarea name="new_summary" rows="3" maxlength="2000">{{ m.pov_summary }}</textarea>
<button type="submit">Save</button>
</form>
</details>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No memories yet.</p>
{% endif %}
</section>
</div>
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}chat{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
{% extends "layout.html" %}
{% block title %}New bot - chat{% endblock %}
{% block content %}
<h1>New bot</h1>
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/bots/new" class="bot-form">
<label>
<span>id</span>
<input type="text" name="id" required value="{{ values.id|default('', true) }}">
<small>slug-like identifier (e.g. <code>bot_a</code>, <code>alice_office</code>)</small>
</label>
<label>
<span>name</span>
<input type="text" name="name" required value="{{ values.name|default('', true) }}">
</label>
<label>
<span>persona</span>
<textarea name="persona" rows="4" required>{{ values.persona|default('', true) }}</textarea>
<small>a short description, ~3-5 lines</small>
</label>
<label>
<span>voice samples</span>
<textarea name="voice_samples" rows="6">{{ values.voice_samples|default('', true) }}</textarea>
<small>1-3 samples, separated by a line containing only <code>---</code></small>
</label>
<label>
<span>traits</span>
<textarea name="traits" rows="3">{{ values.traits|default('', true) }}</textarea>
<small>comma- or newline-separated; 3-15 typical</small>
</label>
<label>
<span>backstory</span>
<textarea name="backstory" rows="6">{{ values.backstory|default('', true) }}</textarea>
<small>100-500 words target</small>
</label>
<label>
<span>initial relationship to you</span>
<textarea name="initial_relationship_to_you" rows="3" required>{{ values.initial_relationship_to_you|default('', true) }}</textarea>
</label>
<label>
<span>kickoff prose</span>
<textarea name="kickoff_prose" rows="4" required>{{ values.kickoff_prose|default('', true) }}</textarea>
<small>a short opening scene; parsed in the next step</small>
</label>
<button type="submit">Save bot</button>
</form>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "layout.html" %}
{% block title %}Bots - chat{% endblock %}
{% block content %}
<header class="page-header">
<h1>Bots</h1>
<a class="btn" href="/bots/new">+ New bot</a>
</header>
{% if bots %}
<ul class="bot-list">
{% for bot in bots %}
<li>
<a href="/bots/{{ bot.id }}">{{ bot.name }}</a>
<details class="bot-row-reset">
<summary>Reset</summary>
<form method="post" action="/bots/{{ bot.id }}/reset" class="inline-edit">
<label>Type "{{ bot.name }}" to confirm:
<input type="text" name="confirm_name" required>
</label>
<button type="submit">Reset bot</button>
</form>
</details>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No bots yet. <a href="/bots/new">Create your first bot.</a></p>
{% endif %}
{% endblock %}
+192
View File
@@ -0,0 +1,192 @@
{% extends "layout.html" %}
{% block title %}{{ host_bot.name }} - chat{% endblock %}
{% block content %}
<div class="chat-shell" data-chat-id="{{ chat.id }}"
hx-ext="sse"
sse-connect="/chats/{{ chat.id }}/events">
<header class="chat-header">
<h1>{{ host_bot.name }}</h1>
<div class="chat-meta muted">{{ chat.time }}</div>
<button class="drawer-toggle" type="button" aria-controls="drawer" aria-expanded="false">Drawer</button>
</header>
<section class="timeline" id="timeline"
sse-swap="turn_html"
hx-swap="beforeend">
{% if not turns %}
<p class="muted">No turns yet. Start typing below.</p>
{% else %}
{% for turn in turns %}
<div{% if turn.event_id is not none %} id="turn-{{ turn.event_id }}"{% endif %} class="turn turn-{{ turn.role }}">
<strong>{{ turn.speaker }}</strong>
{{ turn.text|render_prose|safe }}
</div>
{% endfor %}
{% endif %}
</section>
<form class="turn-input" method="post" action="/chats/{{ chat.id }}/turns">
<textarea name="prose" rows="3" placeholder="What do you say or do?" required></textarea>
<button type="submit">Send</button>
</form>
<aside class="drawer" id="drawer" hidden
hx-get="/chats/{{ chat.id }}/drawer"
hx-trigger="revealed"
hx-swap="innerHTML">
<p class="muted">Loading drawer&hellip;</p>
</aside>
</div>
<script>
document.querySelector('.drawer-toggle')?.addEventListener('click', (e) => {
const drawer = document.getElementById('drawer');
const isHidden = drawer.hasAttribute('hidden');
if (isHidden) drawer.removeAttribute('hidden');
else drawer.setAttribute('hidden', '');
e.target.setAttribute('aria-expanded', String(isHidden));
});
</script>
<script>
// Streaming UX (T34): typing indicator, Stop button, send-lock,
// disconnect banner. Listens to the existing HTMX SSE channel for
// `token` (per-chunk) and `turn_html` (final swap) events. The
// mid-stream disconnect path is server-side: ``request.is_disconnected()``
// in T19 commits truncated; this script just shows the banner when
// the SSE EventSource fires `error` after the connection drops.
(function () {
const shell = document.querySelector('.chat-shell');
if (!shell) return;
const chatId = shell.dataset.chatId;
const form = shell.querySelector('.turn-input');
if (!form) return;
const textarea = form.querySelector('textarea[name="prose"]');
const sendBtn = form.querySelector('button[type="submit"]');
const timeline = document.getElementById('timeline');
let isStreaming = false;
let typingEl = null;
function ensureTypingEl() {
if (typingEl) return typingEl;
typingEl = document.createElement('div');
typingEl.className = 'turn turn-bot streaming';
typingEl.innerHTML = '<strong>...</strong><p class="streaming-text"></p>';
timeline.appendChild(typingEl);
return typingEl;
}
function unlock() {
isStreaming = false;
if (sendBtn) sendBtn.disabled = false;
if (textarea) {
textarea.readOnly = false;
textarea.value = '';
textarea.focus();
}
const stop = shell.querySelector('.stop-streaming');
if (stop) stop.remove();
}
function showBanner(msg) {
let banner = shell.querySelector('.connection-lost');
if (banner) return;
banner = document.createElement('div');
banner.className = 'connection-lost error';
banner.textContent = msg;
form.parentElement.insertBefore(banner, form);
}
// HTMX SSE extension dispatches `htmx:sseMessage` with detail.type
// (event name) and detail.data (payload string).
shell.addEventListener('htmx:sseMessage', (e) => {
const evt = e.detail.type;
const data = e.detail.data;
if (evt === 'token' && isStreaming) {
let parsed;
try { parsed = JSON.parse(data); } catch (_) { return; }
const el = ensureTypingEl();
el.querySelector('.streaming-text').textContent += (parsed.text || '');
} else if (evt === 'turn_html') {
// The server already pushes the final HTML via sse-swap on the
// timeline element; we just remove the typing placeholder and
// unlock the input. (Don't replace innerHTML here — HTMX has
// already done the append by the time this fires.)
if (typingEl) {
typingEl.remove();
typingEl = null;
}
unlock();
}
});
// T86: live-swap regenerated turns. The backend (chat/services/
// regenerate.py) broadcasts a ``turn_html_replace`` SSE frame after
// appending the new assistant_turn — JSON payload of shape
// ``{data: <html>, turn_id: <new_id>, supersedes_id: <old_id>}``.
// We replace the prior turn's DOM node in-place when we can locate
// it by id, otherwise fall back to appending so a tab opened mid-
// regenerate still shows the new turn. The renderer
// (chat/web/render.py::render_turn_html) and the Jinja loop above
// both stamp ``id="turn-<event_id>"`` on each turn DIV, so the
// primary in-place swap path is the live one — the append fallback
// only kicks in when a tab opened AFTER the regenerate started (no
// prior turn DOM node to replace).
shell.addEventListener('htmx:sseMessage', (e) => {
if (e.detail.type !== 'turn_html_replace') return;
let data;
try { data = JSON.parse(e.detail.data); } catch (_) { return; }
const html = (data && data.data) || '';
const trimmed = html.trim();
if (!trimmed) return;
const oldNode = document.getElementById('turn-' + data.supersedes_id);
if (oldNode) {
const tmpl = document.createElement('template');
tmpl.innerHTML = trimmed;
const newNode = tmpl.content.firstChild;
if (newNode) oldNode.replaceWith(newNode);
} else {
// Fallback: append if the prior turn isn't in the DOM (e.g. user
// opened the tab AFTER the regenerate started, or the renderer
// hasn't yet stamped per-turn ids — see comment above).
timeline.insertAdjacentHTML('beforeend', trimmed);
}
});
// SSE connection lost — show a banner and unlock so the user can
// retry. The server commits the partial as truncated when its
// request.is_disconnected() poll trips (T19).
shell.addEventListener('htmx:sseError', () => {
if (isStreaming) {
showBanner('connection lost — partial response saved');
unlock();
}
});
form.addEventListener('submit', () => {
isStreaming = true;
if (sendBtn) sendBtn.disabled = true;
// readOnly (not disabled) — disabled fields are excluded from the
// form submission, which would send prose="" and trigger the
// server's empty-prose 400.
if (textarea) textarea.readOnly = true;
if (!shell.querySelector('.stop-streaming')) {
const stopBtn = document.createElement('button');
stopBtn.type = 'button';
stopBtn.className = 'stop-streaming btn';
stopBtn.textContent = 'Stop';
stopBtn.addEventListener('click', async () => {
try {
await fetch('/chats/' + encodeURIComponent(chatId) + '/turns/cancel', {
method: 'POST',
});
} catch (_) {
// Network error on cancel is non-fatal — server will time out
// its own stream eventually and commit truncated.
}
});
form.parentElement.insertBefore(stopBtn, form);
}
});
})();
</script>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "layout.html" %}
{% block title %}Chats - chat{% endblock %}
{% block content %}
<header class="page-header">
<h1>Chats</h1>
<a class="btn" href="/bots/new">+ New bot</a>
</header>
{% if chats %}
<ul class="chat-list">
{% for chat in chats %}
<li class="chat-row">
<a href="/chats/{{ chat.id }}">
<div class="chat-row-name">{{ chat.host_bot_name }}</div>
<div class="chat-row-snippet muted">{{ chat.last_message_snippet or '—' }}</div>
<div class="chat-row-meta muted">
<span>{{ chat.time }}</span>
{% if chat.last_played_at %}<span>· {{ chat.last_played_at }}</span>{% endif %}
</div>
</a>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No chats yet. <a href="/bots/new">Create a bot</a> to start.</p>
{% endif %}
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "layout.html" %}
{% block title %}Error - chat{% endblock %}
{% block content %}
<div class="error-page">
<h1>{{ status_code }}</h1>
<p>{{ detail }}</p>
<p><a href="/chats">Back to chats</a></p>
</div>
{% endblock %}
+118
View File
@@ -0,0 +1,118 @@
{% extends "layout.html" %}
{% block title %}Confirm kickoff - chat{% endblock %}
{% block content %}
<h1>Confirm kickoff</h1>
<p>Review and edit the parsed opening scene for <strong>{{ values.bot_name }}</strong>, then confirm to start the chat.</p>
<form method="post" action="/bots/{{ values.bot_id }}/kickoff" class="kickoff-form">
<fieldset>
<legend>Container</legend>
<label>
<span>name</span>
<input type="text" name="container_name" required value="{{ values.container_name|default('', true) }}">
</label>
<label>
<span>type</span>
<input type="text" name="container_type" required value="{{ values.container_type|default('', true) }}">
</label>
<label>
<span>properties (JSON)</span>
<textarea name="container_properties" rows="6">{{ values.container_properties|default('{}', true) }}</textarea>
<small>JSON object; invalid JSON falls back to <code>{}</code></small>
</label>
</fieldset>
<fieldset>
<legend>Initial in-fiction time</legend>
<label>
<span>initial_time_iso</span>
<input type="text" name="initial_time_iso" required value="{{ values.initial_time_iso|default('', true) }}">
<small>ISO 8601, e.g. <code>2026-04-26T20:00:00+00:00</code></small>
</label>
</fieldset>
<fieldset>
<legend>Your activity</legend>
<label>
<span>posture</span>
<input type="text" name="you_activity_posture" value="{{ values.you_activity_posture|default('', true) }}">
</label>
<label>
<span>action verb</span>
<input type="text" name="you_activity_action_verb" value="{{ values.you_activity_action_verb|default('', true) }}">
</label>
<label>
<span>interruptible</span>
<input type="checkbox" name="you_activity_action_interruptible"{% if values.you_activity_action_interruptible %} checked{% endif %}>
</label>
<label>
<span>required attention</span>
<input type="text" name="you_activity_action_required_attention" value="{{ values.you_activity_action_required_attention|default('low', true) }}">
<small>low / medium / high</small>
</label>
<label>
<span>expected duration</span>
<input type="text" name="you_activity_action_expected_duration" value="{{ values.you_activity_action_expected_duration|default('', true) }}">
</label>
<label>
<span>attention</span>
<input type="text" name="you_activity_attention" value="{{ values.you_activity_attention|default('', true) }}">
</label>
<label>
<span>holding (comma-separated)</span>
<input type="text" name="you_activity_holding" value="{{ values.you_activity_holding|default('', true) }}">
</label>
</fieldset>
<fieldset>
<legend>{{ values.bot_name }}'s activity</legend>
<label>
<span>posture</span>
<input type="text" name="bot_activity_posture" value="{{ values.bot_activity_posture|default('', true) }}">
</label>
<label>
<span>action verb</span>
<input type="text" name="bot_activity_action_verb" value="{{ values.bot_activity_action_verb|default('', true) }}">
</label>
<label>
<span>interruptible</span>
<input type="checkbox" name="bot_activity_action_interruptible"{% if values.bot_activity_action_interruptible %} checked{% endif %}>
</label>
<label>
<span>required attention</span>
<input type="text" name="bot_activity_action_required_attention" value="{{ values.bot_activity_action_required_attention|default('low', true) }}">
<small>low / medium / high</small>
</label>
<label>
<span>expected duration</span>
<input type="text" name="bot_activity_action_expected_duration" value="{{ values.bot_activity_action_expected_duration|default('', true) }}">
</label>
<label>
<span>attention</span>
<input type="text" name="bot_activity_attention" value="{{ values.bot_activity_attention|default('', true) }}">
</label>
<label>
<span>holding (comma-separated)</span>
<input type="text" name="bot_activity_holding" value="{{ values.bot_activity_holding|default('', true) }}">
</label>
</fieldset>
<fieldset>
<legend>Edge seed</legend>
<label>
<span>summary</span>
<textarea name="edge_seed_summary" rows="3">{{ values.edge_seed_summary|default('', true) }}</textarea>
</label>
<label>
<span>knowledge facts (one per line)</span>
<textarea name="edge_seed_knowledge_facts" rows="6">{{ values.edge_seed_knowledge_facts|default('', true) }}</textarea>
</label>
</fieldset>
<div class="actions">
<button type="submit">Confirm and start chat</button>
<a href="/bots">Cancel</a>
</div>
</form>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block body %}
<nav class="rail">
<a class="rail-brand" href="/chats">chat</a>
<ul>
<li><a href="/chats" class="{% if active_nav == 'chats' %}active{% endif %}">Chats</a></li>
<li><a href="/bots" class="{% if active_nav == 'bots' %}active{% endif %}">Bots</a></li>
<li><a href="/snapshots" class="{% if active_nav == 'snapshots' %}active{% endif %}">Snapshots</a></li>
<li><a href="/settings" class="{% if active_nav == 'settings' %}active{% endif %}">Settings</a></li>
</ul>
{# T100: cross-chat search box. GET /search so the URL is shareable
and back-button friendly; the results page itself re-renders this
form with the query pre-filled. #}
<form class="rail-search" action="/search" method="get" role="search">
<input type="search" name="q" placeholder="Search" aria-label="Search memories">
<button type="submit">Go</button>
</form>
</nav>
<main class="content">
{% block content %}{% endblock %}
</main>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "layout.html" %}
{% block title %}Search - chat{% endblock %}
{% block content %}
<header class="page-header">
<h1>Search</h1>
</header>
<form class="search-page-form" action="/search" method="get">
<input type="text" name="q" value="{{ query|default('', true) }}"
placeholder="Search memories across all chats" autofocus>
<button type="submit">Search</button>
</form>
{% if not query %}
{# Empty-state placeholder: the top-bar form submits to /search even
with no input, so this page must render cleanly with no query. #}
<p class="muted search-empty">Enter a query to search memories across all chats.</p>
{% elif not results %}
<p class="muted">No matches for &ldquo;{{ query }}&rdquo;.</p>
{% else %}
<ul class="search-results">
{% for r in results %}
<li class="search-result">
<a class="search-result-link" href="/chats/{{ r.chat_id }}">
<div class="search-result-meta muted">
<strong>{{ r.owner_name }}</strong>
<span>&middot; {{ r.chat_id }}</span>
{% if r.chat_name %}<span>&middot; {{ r.chat_name }}</span>{% endif %}
{% if r.scene_label %}<span>&middot; scene {{ r.scene_label }}</span>{% endif %}
</div>
<div class="search-result-summary">{{ r.pov_summary }}</div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "layout.html" %}
{% block title %}Settings - chat{% endblock %}
{% block content %}
<h1>Settings</h1>
{% if saved %}
<p class="success">Settings saved.</p>
{% endif %}
<form method="post" action="/settings" class="bot-form">
<label>
<span>name</span>
<input type="text" name="name" required value="{{ values.name|default('', true) }}">
<small>required</small>
</label>
<label>
<span>pronouns</span>
<input type="text" name="pronouns" value="{{ values.pronouns|default('', true) }}">
<small>optional (e.g. they/them)</small>
</label>
<label>
<span>persona</span>
<textarea name="persona" rows="3">{{ values.persona|default('', true) }}</textarea>
<small>optional but recommended; a short description of you</small>
</label>
<button type="submit">Save settings</button>
</form>
{% endblock %}
+66
View File
@@ -0,0 +1,66 @@
{% extends "layout.html" %}
{% block title %}Snapshots - chat{% endblock %}
{% block content %}
<header class="page-header">
<h1>Snapshots</h1>
<form method="post" action="/snapshots/take" class="inline-edit">
<button type="submit">Take snapshot now</button>
</form>
</header>
{% if preview %}
<section class="snapshot-preview">
<h2>Preview: {{ preview.snapshot_id }}</h2>
<dl>
<dt>kind</dt><dd>{{ preview.kind }}</dd>
<dt>filename</dt><dd>{{ preview.filename }}</dd>
<dt>file size (bytes)</dt><dd>{{ preview.file_size_bytes }}</dd>
<dt>snapshot last_event_id</dt><dd>{{ preview.last_event_id }}</dd>
<dt>current event_log max id</dt><dd>{{ preview.current_event_log_max_id }}</dd>
<dt>events since snapshot</dt><dd>{{ preview.event_delta }}</dd>
<dt>events stored in snapshot</dt><dd>{{ preview.event_log_rows_in_snapshot }}</dd>
</dl>
</section>
{% endif %}
{% if snapshots %}
<table class="snapshot-list">
<thead>
<tr>
<th>ID</th>
<th>Kind</th>
<th>Created (UTC)</th>
<th>Size (bytes)</th>
<th>last_event_id</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for snap in snapshots %}
<tr>
<td>{{ snap.snapshot_id }}</td>
<td>{{ snap.kind }}</td>
<td>{{ snap.created_at }}</td>
<td>{{ snap.file_size_bytes }}</td>
<td>{{ snap.last_event_id if snap.last_event_id is not none else '?' }}</td>
<td>
<a href="/snapshots/{{ snap.snapshot_id }}/preview?kind={{ snap.kind }}">Preview</a>
<details class="snapshot-row-restore">
<summary>Restore</summary>
<form method="post" action="/snapshots/restore/{{ snap.snapshot_id }}" class="inline-edit">
<input type="hidden" name="kind" value="{{ snap.kind }}">
<label>Type "{{ snap.snapshot_id }}" to confirm:
<input type="text" name="confirm_id" required>
</label>
<button type="submit">Restore from this snapshot</button>
</form>
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">No snapshots yet. Use "Take snapshot now" to create one.</p>
{% endif %}
{% endblock %}
View File
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.db.connection import open_db
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.state.entities import list_bots
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
router = APIRouter()
REQUIRED_FIELDS = ("id", "name", "persona", "initial_relationship_to_you", "kickoff_prose")
def get_conn(request: Request):
settings = request.app.state.settings
db_path: Path = settings.db_path
with open_db(db_path, check_same_thread=False) as conn:
yield conn
def _split_voice_samples(text: str) -> list[str]:
if not text or not text.strip():
return []
# Split on a line containing only "---" (with optional surrounding whitespace).
parts: list[str] = []
buf: list[str] = []
for line in text.splitlines():
if line.strip() == "---":
if buf:
parts.append("\n".join(buf).strip())
buf = []
continue
buf.append(line)
if buf:
parts.append("\n".join(buf).strip())
return [p for p in parts if p]
def _split_traits(text: str) -> list[str]:
if not text or not text.strip():
return []
items: list[str] = []
for line in text.splitlines():
line = line.strip()
if not line:
continue
if "," in line:
items.extend(p.strip() for p in line.split(","))
else:
items.append(line)
return [t for t in items if t]
@router.get("/bots", response_class=HTMLResponse)
async def bots_list(request: Request, conn=Depends(get_conn)):
bots = list_bots(conn)
return TEMPLATES.TemplateResponse(
request, "bot_list.html", {"bots": bots, "active_nav": "bots"}
)
@router.get("/bots/new", response_class=HTMLResponse)
async def bot_form(request: Request):
return TEMPLATES.TemplateResponse(
request, "bot_form.html", {"values": {}, "error": None, "active_nav": "bots"}
)
@router.post("/bots/new")
async def bot_create(
request: Request,
id: str = Form(""),
name: str = Form(""),
persona: str = Form(""),
voice_samples: str = Form(""),
traits: str = Form(""),
backstory: str = Form(""),
initial_relationship_to_you: str = Form(""),
kickoff_prose: str = Form(""),
conn=Depends(get_conn),
):
values = {
"id": id,
"name": name,
"persona": persona,
"voice_samples": voice_samples,
"traits": traits,
"backstory": backstory,
"initial_relationship_to_you": initial_relationship_to_you,
"kickoff_prose": kickoff_prose,
}
missing = [f for f in REQUIRED_FIELDS if not values[f].strip()]
if missing:
raise HTTPException(status_code=400, detail=f"missing required: {', '.join(missing)}")
payload = {
"id": id.strip(),
"name": name.strip(),
"persona": persona.strip(),
"voice_samples": _split_voice_samples(voice_samples),
"traits": _split_traits(traits),
"backstory": backstory.strip(),
"initial_relationship_to_you": initial_relationship_to_you.strip(),
"kickoff_prose": kickoff_prose.strip(),
}
append_event(conn, kind="bot_authored", payload=payload)
project(conn)
return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303)
@router.post("/bots/{bot_id}/reset")
async def reset_bot_route(
bot_id: str,
request: Request,
confirm_name: str = Form(""),
conn=Depends(get_conn),
):
from chat.services.reset import reset_bot
try:
reset_bot(conn, bot_id, confirm_name=confirm_name)
except ValueError as e:
msg = str(e).lower()
if "not found" in msg:
raise HTTPException(status_code=404, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
return RedirectResponse(url="/bots", status_code=303)
+89
View File
@@ -0,0 +1,89 @@
"""Chat detail (shell) page.
Renders ``/chats/<id>``: the title (host bot's name), a timeline placeholder,
the user-input form, and the drawer toggle. Turn handling lives in T19; this
module only sets up the structural shell.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.state.entities import get_bot
from chat.state.world import get_chat
from chat.web.bots import get_conn
from chat.web.render import render_prose
from chat.web.turns import _read_recent_dialogue
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
# Register the prose renderer as a Jinja filter so the chat-detail
# template can use ``{{ turn.text|render_prose|safe }}`` (Task 33).
# The renderer escapes user content internally; ``|safe`` is required
# because the output contains intentional ``<p>``/``<em>``/etc. tags.
TEMPLATES.env.filters["render_prose"] = render_prose
router = APIRouter()
@router.get("/chats/{chat_id}", response_class=HTMLResponse)
async def chat_detail(chat_id: str, request: Request, conn=Depends(get_conn)):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
host_bot = get_bot(conn, chat["host_bot_id"])
if host_bot is None:
# Defensive: chat row references a bot that doesn't exist. Treat as 404
# rather than crashing the template render.
raise HTTPException(
status_code=404, detail=f"host bot not found: {chat['host_bot_id']}"
)
# T19: render the timeline from event_log. We pull both user_turn and
# assistant_turn events for this chat, in chronological order. Each row
# is shaped ``{"speaker": ..., "text": ...}`` and the template
# discriminates roles via the speaker id (the literal "you" vs. a bot id).
raw_turns = _read_recent_dialogue(conn, chat_id, limit=200)
turns: list[dict] = []
for t in raw_turns:
# event_id is forwarded so the Jinja loop can stamp
# ``id="turn-<event_id>"`` on each rendered turn — the
# ``turn_html_replace`` SSE handler in chat.html relies on this
# id to swap a regenerated turn in-place (T86 follow-up).
if t["speaker"] == "you":
turns.append(
{
"role": "you",
"speaker": "you",
"text": t["text"],
"event_id": t.get("event_id"),
}
)
else:
bot = get_bot(conn, t["speaker"])
label = bot["name"] if bot else t["speaker"]
turns.append(
{
"role": "bot",
"speaker": label,
"text": t["text"],
"event_id": t.get("event_id"),
}
)
return TEMPLATES.TemplateResponse(
request,
"chat.html",
{
"chat": chat,
"host_bot": host_bot,
"turns": turns,
"active_nav": "chats",
},
)
+1478
View File
File diff suppressed because it is too large Load Diff
+286
View File
@@ -0,0 +1,286 @@
"""Kickoff parse-and-confirm flow.
After a bot is authored, the user lands on ``/bots/<id>/kickoff``. We call the
LLM-backed ``parse_kickoff`` to extract a structured opening scene from the
authored prose and render it as an editable form. On submit, the (possibly
edited) values are turned into a sequence of events that initialize the chat,
its container, the participants' activities, an open scene, and a seed edge.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.llm.client import LLMClient
from chat.services.kickoff import parse_kickoff
from chat.state.entities import get_bot, get_you
from chat.web.bots import get_conn
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
router = APIRouter()
def get_llm_client(request: Request) -> LLMClient:
"""Production LLM client. Tests override this via ``app.dependency_overrides``."""
settings = request.app.state.settings
from chat.llm.featherless import FeatherlessClient
return FeatherlessClient(
api_key=settings.featherless_api_key,
base_url=settings.featherless_base_url,
)
def _parse_holding(text: str) -> list[str]:
if not text or not text.strip():
return []
return [p.strip() for p in text.split(",") if p.strip()]
def _parse_facts(text: str) -> list[str]:
if not text or not text.strip():
return []
return [line.strip() for line in text.splitlines() if line.strip()]
def _parse_properties(text: str) -> dict:
"""Parse the container_properties textarea as JSON.
Returns ``{}`` on invalid JSON rather than raising the form is editable
and a bad value should not block the user from confirming the rest.
"""
if not text or not text.strip():
return {}
try:
loaded = json.loads(text)
return loaded if isinstance(loaded, dict) else {}
except (json.JSONDecodeError, ValueError):
return {}
@router.get("/bots/{bot_id}/kickoff", response_class=HTMLResponse)
async def kickoff_get(
bot_id: str,
request: Request,
conn=Depends(get_conn),
llm=Depends(get_llm_client),
):
bot = get_bot(conn, bot_id)
if bot is None:
raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}")
you = get_you(conn)
you_name = you["name"] if you else "You"
settings = request.app.state.settings
parsed = await parse_kickoff(
llm,
model=settings.classifier_model,
bot_name=bot["name"],
bot_persona=bot["persona"],
initial_relationship_to_you=bot.get("initial_relationship_to_you", ""),
kickoff_prose=bot.get("kickoff_prose", ""),
you_name=you_name,
timeout_s=settings.classifier_timeout_s,
)
# Render values onto the form. ``container_properties`` is shown as JSON;
# ``holding`` lists are rendered as comma-separated text; the seed
# knowledge facts are rendered one-per-line.
values = {
"bot_id": bot_id,
"bot_name": bot["name"],
"container_name": parsed.container_name,
"container_type": parsed.container_type,
"container_properties": json.dumps(parsed.container_properties, indent=2),
"initial_time_iso": parsed.initial_time_iso,
"you_activity_posture": parsed.you_activity.posture,
"you_activity_action_verb": parsed.you_activity.action_verb,
"you_activity_action_interruptible": parsed.you_activity.action_interruptible,
"you_activity_action_required_attention": parsed.you_activity.action_required_attention,
"you_activity_action_expected_duration": parsed.you_activity.action_expected_duration,
"you_activity_attention": parsed.you_activity.attention,
"you_activity_holding": ", ".join(parsed.you_activity.holding),
"bot_activity_posture": parsed.bot_activity.posture,
"bot_activity_action_verb": parsed.bot_activity.action_verb,
"bot_activity_action_interruptible": parsed.bot_activity.action_interruptible,
"bot_activity_action_required_attention": parsed.bot_activity.action_required_attention,
"bot_activity_action_expected_duration": parsed.bot_activity.action_expected_duration,
"bot_activity_attention": parsed.bot_activity.attention,
"bot_activity_holding": ", ".join(parsed.bot_activity.holding),
"edge_seed_summary": parsed.edge_seed_summary,
"edge_seed_knowledge_facts": "\n".join(parsed.edge_seed_knowledge_facts),
}
return TEMPLATES.TemplateResponse(
request, "kickoff_confirm.html", {"values": values, "active_nav": "bots"}
)
@router.post("/bots/{bot_id}/kickoff")
async def kickoff_post(
bot_id: str,
request: Request,
container_name: str = Form(""),
container_type: str = Form(""),
container_properties: str = Form(""),
initial_time_iso: str = Form(""),
you_activity_posture: str = Form(""),
you_activity_action_verb: str = Form(""),
you_activity_action_interruptible: str = Form(""),
you_activity_action_required_attention: str = Form("low"),
you_activity_action_expected_duration: str = Form(""),
you_activity_attention: str = Form(""),
you_activity_holding: str = Form(""),
bot_activity_posture: str = Form(""),
bot_activity_action_verb: str = Form(""),
bot_activity_action_interruptible: str = Form(""),
bot_activity_action_required_attention: str = Form("low"),
bot_activity_action_expected_duration: str = Form(""),
bot_activity_attention: str = Form(""),
bot_activity_holding: str = Form(""),
edge_seed_summary: str = Form(""),
edge_seed_knowledge_facts: str = Form(""),
conn=Depends(get_conn),
):
bot = get_bot(conn, bot_id)
if bot is None:
raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}")
# Loose ISO 8601 validation. ``datetime.fromisoformat`` accepts the offset
# form ``2026-04-26T20:00:00+00:00`` we use; reject anything it can't parse.
if initial_time_iso.strip():
try:
datetime.fromisoformat(initial_time_iso.strip())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"invalid initial_time_iso: {initial_time_iso!r}",
)
chat_id = f"chat_{bot_id}"
# Predict the next container id so we can reference it from later events
# without needing a mid-flow projection. Containers use AUTOINCREMENT-style
# rowid, so MAX(id)+1 is safe within this single-writer transaction.
next_container_row = conn.execute(
"SELECT COALESCE(MAX(id), 0) + 1 FROM containers"
).fetchone()
container_id = next_container_row[0]
# 1. chat_created
append_event(
conn,
kind="chat_created",
payload={
"id": chat_id,
"host_bot_id": bot_id,
"initial_time": initial_time_iso,
"narrative_anchor": "Day 1",
"weather": "",
},
)
# 2. container_created
append_event(
conn,
kind="container_created",
payload={
"chat_id": chat_id,
"name": container_name,
"type": container_type,
"properties": _parse_properties(container_properties),
"parent_id": None,
},
)
you_interruptible = bool(you_activity_action_interruptible)
bot_interruptible = bool(bot_activity_action_interruptible)
# 3. activity_change for "you"
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "you",
"container_id": container_id,
"posture": you_activity_posture,
"action": {
"verb": you_activity_action_verb,
"interruptible": you_interruptible,
"required_attention": you_activity_action_required_attention,
"expected_duration": you_activity_action_expected_duration,
"started_at": initial_time_iso,
},
"attention": you_activity_attention,
"holding": _parse_holding(you_activity_holding),
"status": {},
},
)
# 4. activity_change for bot
append_event(
conn,
kind="activity_change",
payload={
"entity_id": bot_id,
"container_id": container_id,
"posture": bot_activity_posture,
"action": {
"verb": bot_activity_action_verb,
"interruptible": bot_interruptible,
"required_attention": bot_activity_action_required_attention,
"expected_duration": bot_activity_action_expected_duration,
"started_at": initial_time_iso,
},
"attention": bot_activity_attention,
"holding": _parse_holding(bot_activity_holding),
"status": {},
},
)
# 5. scene_opened
append_event(
conn,
kind="scene_opened",
payload={
"chat_id": chat_id,
"container_id": container_id,
"started_at": initial_time_iso,
"participants": ["you", bot_id],
},
)
# 6. edge_update (seed). The seed summary is preserved as the first
# knowledge fact prefixed with ``[summary] `` — proper summary writes happen
# at scene-close (T27).
facts = _parse_facts(edge_seed_knowledge_facts)
if edge_seed_summary.strip():
facts.insert(0, f"[summary] {edge_seed_summary.strip()}")
append_event(
conn,
kind="edge_update",
payload={
"source_id": bot_id,
"target_id": "you",
"chat_id": chat_id,
"knowledge_facts": facts,
},
)
# Project all events at once. ``bot_authored`` (already in log from prior
# POST) is idempotent (INSERT OR REPLACE); the new events project cleanly
# because they're being applied for the first time.
project(conn)
return RedirectResponse(url=f"/chats/{chat_id}", status_code=303)
+405
View File
@@ -0,0 +1,405 @@
"""Meanwhile-mode turn controller (T64).
A meanwhile scene is a private 2-bot scene running alongside an active
you-scene (its parent). The user manually advances it by POSTing to the
existing ``/chats/<id>/turns`` endpoint; the route detects an active
meanwhile scene at the start of ``post_turn`` and dispatches here.
Unlike the normal turn flow, "you" is NOT a witness to the scene. The
controller mirrors ``post_turn`` shape but with:
- Speaker alternation derived from the latest meanwhile ``assistant_turn``
scoped to this scene_id (host first, then alternating).
- Prompt assembly with ``present_set_kind="host_guest"`` so the prompt
builder drops the "you" activity bullet and any speaker -> "you" edge.
- Memory writes via ``record_meanwhile_memory`` both bots get rows
with witness flags ``[you=0, host=1, guest=1]``.
- State updates over exactly 2 directed pairs (host <-> guest); no
you-related pairs fire.
- The ``assistant_turn`` payload carries ``meanwhile_scene_id`` and
``present_set_kind="host_guest"`` so downstream filters (alternation
lookup, drawer rendering, scene-close detection) can scope to the
meanwhile slice without conflating it with the parent you-scene's
history.
Scene-close detection for meanwhile scenes is not auto-fired here
T65 covers the close + digest pipeline. The controller's job ends
after the post-turn classifier passes land.
"""
from __future__ import annotations
import asyncio
import json
from chat.config import Settings
from chat.eventlog.log import append_and_apply, append_event
from chat.llm.client import LLMClient
from chat.services.memory_write import record_meanwhile_memory
from chat.services.multi_state_update import compute_state_updates_for_present
from chat.services.prompt import assemble_narrative_prompt
from chat.services.turn_parse import parse_turn
from chat.state.edges import get_edge
from chat.state.entities import get_bot
from chat.state.meanwhile import list_meanwhile_scenes
from chat.state.world import get_chat
from chat.web.pubsub import publish
from chat.web.render import render_turn_html as _render_turn_html
def _strip_ooc_for_prompt(parsed) -> str:
"""Mirror of the helper in turns.py — concatenate non-OOC segments."""
keep = [s.text for s in parsed.segments if s.kind != "ooc"]
return " ".join(keep).strip()
def _read_recent_meanwhile_dialogue(
conn, chat_id: str, scene_id: int, limit: int = 50
) -> list[dict]:
"""Return the meanwhile scene's prior turns shaped as
``{"speaker": <id>, "text": <prose>}``.
Pulls ``user_turn`` rows for the chat (the user-side prose driving
this meanwhile scene rides through the same chat) plus only those
``assistant_turn`` rows whose ``meanwhile_scene_id`` matches the
given scene id. Other meanwhile scenes on the same chat and the
parent you-scene's assistant_turns — are excluded so the prompt
context stays scoped to the private beat.
Filters chat_id (and meanwhile_scene_id for assistant_turn) via
``json_extract`` in SQL so SQLite stops at the first ``limit`` rows
that already match avoids an unbounded scan as ``event_log``
grows. The user-side rows match on chat_id only since they aren't
tagged with a scene id (they ride the chat-wide log).
"""
cur = conn.execute(
"SELECT id, kind, payload_json FROM event_log "
"WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') "
" AND superseded_by IS NULL AND hidden = 0 "
" AND json_extract(payload_json, '$.chat_id') = ? "
" AND ("
" kind IN ('user_turn', 'user_turn_edit') "
" OR json_extract(payload_json, '$.meanwhile_scene_id') = ?"
" ) "
"ORDER BY id DESC LIMIT ?",
(chat_id, scene_id, limit),
)
rows = cur.fetchall()
rows.reverse()
out: list[dict] = []
for _row_id, kind, payload_json in rows:
p = json.loads(payload_json)
if kind in ("user_turn", "user_turn_edit"):
out.append({"speaker": "you", "text": p.get("prose", "")})
else:
out.append(
{
"speaker": p.get("speaker_id", "bot"),
"text": p.get("text", ""),
}
)
return out
def _last_meanwhile_speaker(conn, chat_id: str, scene_id: int) -> str | None:
"""Return the speaker_id of the latest assistant_turn linked to
``scene_id`` for ``chat_id``, or ``None`` if no prior turn exists.
Used to alternate the speaker across consecutive meanwhile turns
the OTHER bot speaks next. Pushes both filters into SQL via
``json_extract`` and bounds with ``LIMIT 1`` so SQLite stops at the
first match instead of scanning the whole assistant_turn slice.
"""
row = conn.execute(
"SELECT json_extract(payload_json, '$.speaker_id') AS speaker "
"FROM event_log "
"WHERE kind = 'assistant_turn' "
" AND superseded_by IS NULL AND hidden = 0 "
" AND json_extract(payload_json, '$.chat_id') = ? "
" AND json_extract(payload_json, '$.meanwhile_scene_id') = ? "
"ORDER BY id DESC "
"LIMIT 1",
(chat_id, scene_id),
).fetchone()
return row[0] if row else None
async def process_meanwhile_turn(
conn,
client: LLMClient,
settings: Settings,
*,
chat_id: str,
prose: str,
app=None,
) -> dict:
"""Run one meanwhile turn end-to-end.
Returns a small dict shape ``{"assistant_text": ..., "speaker_id":
..., "scene_id": ..., "user_turn_id": ..., "assistant_event_id":
...}`` so callers can introspect the produced beat (HTTP route maps
to a ``204``; future SSE rebroadcast may use the dict directly).
Raises ``ValueError`` when there is no active meanwhile scene on
``chat_id`` the caller (turns.py) only dispatches here after a
positive ``list_meanwhile_scenes`` lookup, but the defensive raise
keeps the controller usable in isolation.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise ValueError(f"chat not found: {chat_id}")
scenes = list_meanwhile_scenes(conn, chat_id, status="active")
if not scenes:
raise ValueError(f"no active meanwhile scene on chat: {chat_id}")
scene = scenes[0]
scene_id = scene["id"]
host_bot_id = chat["host_bot_id"]
guest_bot_id = chat.get("guest_bot_id")
if guest_bot_id is None:
# A meanwhile scene without a guest is a schema violation —
# the projector requires both ids on meanwhile_scene_started.
raise ValueError(
f"meanwhile scene {scene_id} on chat {chat_id} lacks a guest"
)
host_bot = get_bot(conn, host_bot_id)
guest_bot = get_bot(conn, guest_bot_id)
if host_bot is None or guest_bot is None:
raise ValueError(
f"meanwhile bots missing: host={host_bot_id} guest={guest_bot_id}"
)
# 1. Parse the user prose with the classifier — same shape as the
# normal turn flow so OOC-stripping, segment-typing, etc. all work.
parsed = await parse_turn(
client, model=settings.classifier_model, prose=prose
)
prompt_prose = _strip_ooc_for_prompt(parsed)
# 2. Append user_turn — kept on the chat-wide log so the user can
# see their own prose in the timeline. Tagged with the meanwhile
# scene_id so future renderers can group it with the right scene.
user_turn_event_id = append_event(
conn,
kind="user_turn",
payload={
"chat_id": chat_id,
"prose": prose,
"segments": [s.model_dump() for s in parsed.segments],
"meanwhile_scene_id": scene_id,
},
)
# 3. Alternate the speaker. First turn -> host speaks; each
# subsequent turn -> the OTHER bot from the previous beat. Lookup
# is scoped by ``meanwhile_scene_id`` so unrelated assistant_turns
# on the same chat don't perturb the alternation.
last_speaker = _last_meanwhile_speaker(conn, chat_id, scene_id)
if last_speaker is None or last_speaker == guest_bot_id:
speaker_bot = host_bot
addressee_bot = guest_bot
else:
speaker_bot = guest_bot
addressee_bot = host_bot
# 4. Placeholder marker so SSE observers see "in flight". No
# projector handler is registered for this kind — it's transcript-
# only, same as the normal turn flow.
append_event(
conn,
kind="assistant_turn_started",
payload={
"chat_id": chat_id,
"speaker_id": speaker_bot["id"],
"user_turn_id": user_turn_event_id,
"meanwhile_scene_id": scene_id,
},
)
# 5. Build the narrative prompt. ``present_set_kind="host_guest"``
# tells the assembler to drop the "you" activity bullet and any
# speaker -> "you" edge — both irrelevant inside a private beat.
# Addressee is the OTHER bot, not "you".
recent_dialogue = _read_recent_meanwhile_dialogue(conn, chat_id, scene_id)
if recent_dialogue and recent_dialogue[-1].get("speaker") == "you":
recent_dialogue = recent_dialogue[:-1]
messages = assemble_narrative_prompt(
conn,
chat_id=chat_id,
speaker_bot_id=speaker_bot["id"],
addressee=addressee_bot["id"],
user_turn_prose=prompt_prose if prompt_prose else None,
recent_dialogue=recent_dialogue,
budget_soft=settings.narrative_budget_soft,
budget_hard=settings.narrative_budget_hard,
guest_id=guest_bot_id,
present_set_kind="host_guest",
)
# 6. Stream + accumulate. Same SSE pattern as the normal flow —
# tokens publish under the speaker's id so the UI can label the
# right bubble. Register the streaming task in the chat-keyed
# in-flight registry so POST /chats/<id>/turns/cancel can call
# ``.cancel()`` on it; without this, the Stop button is a no-op for
# meanwhile beats. We import the underscore name from turns.py
# deliberately — it's the same single-process registry the cancel
# route reads, and exposing it via a public alias would require
# touching every existing call site for no behavioural gain.
from chat.web.turns import _in_flight_tasks # noqa: PLC0415
accumulated: list[str] = []
truncated = False
cancelled = False
async def _stream() -> None:
async for chunk in client.stream(
messages,
model=settings.narrative_model,
max_tokens=settings.narrative_max_tokens,
temperature=settings.narrative_temperature,
):
accumulated.append(chunk)
await publish(
chat_id,
{
"event": "token",
"text": chunk,
"speaker_id": speaker_bot["id"],
},
)
stream_task = asyncio.create_task(_stream())
_in_flight_tasks[chat_id] = stream_task
try:
await stream_task
except asyncio.CancelledError:
truncated = True
cancelled = True
except Exception:
truncated = True
finally:
# Always unregister so a subsequent turn can register a fresh
# task. Mirrors the cleanup in turns.py::post_turn.
_in_flight_tasks.pop(chat_id, None)
text = "".join(accumulated)
# 7. Append assistant_turn — tagged with meanwhile_scene_id so the
# next turn's alternation lookup can find it, and present_set_kind
# so downstream renderers / digesters can filter scope.
assistant_event_id = append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": speaker_bot["id"],
"text": text,
"truncated": truncated,
"user_turn_id": user_turn_event_id,
"meanwhile_scene_id": scene_id,
"present_set_kind": "host_guest",
},
)
# 8. Per-turn memory writes — both bots get a row with witness flags
# [you=0, host=1, guest=1]. Skipped on cancellation so we don't
# record memory for a partial beat the user never read.
if not cancelled and text.strip():
record_meanwhile_memory(
conn,
chat_id=chat_id,
host_bot_id=host_bot_id,
guest_bot_id=guest_bot_id,
narrative_text=text,
scene_id=scene_id,
chat_clock_at=chat.get("time"),
app=app,
)
# 9. Post-turn state-update — exactly 2 directed pairs over the
# bot pair. No you-related pairs fire (you isn't present).
present_ids = [host_bot_id, guest_bot_id]
present_names = {
host_bot_id: host_bot["name"],
guest_bot_id: guest_bot["name"],
}
personas = {
host_bot_id: host_bot.get("persona") or "",
guest_bot_id: guest_bot.get("persona") or "",
}
prior_edges: dict[tuple[str, str], dict] = {}
for src in present_ids:
for tgt in present_ids:
if src == tgt:
continue
edge = get_edge(conn, src, tgt) or {
"affinity": 50,
"trust": 50,
"summary": "",
}
prior_edges[(src, tgt)] = edge
state_updates = await compute_state_updates_for_present(
client,
classifier_model=settings.classifier_model,
present_ids=present_ids,
present_names=present_names,
personas=personas,
prior_edges=prior_edges,
recent_dialogue=recent_dialogue,
timeout_s=settings.classifier_timeout_s,
)
last_at = chat.get("time")
for src_id, tgt_id, update in state_updates:
append_and_apply(
conn,
kind="edge_update",
payload={
"source_id": src_id,
"target_id": tgt_id,
"chat_id": chat_id,
"affinity_delta": update.affinity_delta,
"trust_delta": update.trust_delta,
"knowledge_facts": update.knowledge_facts,
"last_interaction_at": last_at,
"last_interaction_chat_id": chat_id,
},
)
# 10. SSE broadcast for the timeline UI — completion event + an HTML
# fragment for the HTMX SSE swap. Same pattern as the normal turn
# flow so the rendered transcript shows the meanwhile beat inline.
await publish(
chat_id,
{
"event": "assistant_turn_complete",
"speaker_id": speaker_bot["id"],
"text": text,
"truncated": truncated,
},
)
turn_html = _render_turn_html(
speaker_bot["name"],
text,
role="bot",
event_id=assistant_event_id,
)
await publish(chat_id, {"event": "turn_html", "data": turn_html})
if cancelled:
# Re-raise after the partial-turn has been recorded so callers
# see the cancel propagate (mirrors normal turn flow).
raise asyncio.CancelledError
return {
"assistant_text": text,
"speaker_id": speaker_bot["id"],
"scene_id": scene_id,
"user_turn_id": user_turn_event_id,
"assistant_event_id": assistant_event_id,
}
__all__ = ["process_meanwhile_turn"]
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
from fastapi import Request
from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from chat.db.connection import open_db
from chat.state.entities import get_you, list_bots
class FirstRunRedirectMiddleware(BaseHTTPMiddleware):
"""Redirect users through the first-run flow (per requirements §16.2).
Behavior on GET requests to landing routes (``/`` and ``/chats``):
- No ``you_entity`` ``/settings``
- ``you_entity`` exists but no bots ``/bots/new``
- Otherwise pass through to the underlying handler.
The middleware is a no-op for:
- Non-GET requests (POST/PUT writes proceed and surface their own errors).
- Static assets, health checks, and any path under ``/settings``,
``/bots``, ``/api``, ``/health``, ``/favicon`` so the user can
actually complete setup once redirected.
- Sub-paths of ``/chats`` (e.g. ``/chats/<id>``, ``/chats/<id>/drawer``);
only the bare landing pages get the redirect treatment. Sub-resources
either 404 cleanly or are HTMX partials that should not page-redirect.
"""
SKIP_PREFIXES = (
"/static",
"/settings",
"/bots",
"/health",
"/favicon",
"/api",
)
async def dispatch(self, request: Request, call_next):
if request.method != "GET":
return await call_next(request)
path = request.url.path
if any(path.startswith(p) for p in self.SKIP_PREFIXES):
return await call_next(request)
# Only fire on the landing routes themselves.
if path != "/" and path != "/chats":
return await call_next(request)
settings = request.app.state.settings
with open_db(settings.db_path) as conn:
you = get_you(conn)
bots = list_bots(conn)
if you is None:
return RedirectResponse(url="/settings", status_code=303)
if not bots:
return RedirectResponse(url="/bots/new", status_code=303)
return await call_next(request)
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from chat.web.bots import get_conn
from chat.state.world import list_chats
from chat.state.entities import get_bot
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
router = APIRouter()
@router.get("/", include_in_schema=False)
async def home():
return RedirectResponse(url="/chats", status_code=303)
@router.get("/chats", response_class=HTMLResponse)
async def chats_list(request: Request, conn=Depends(get_conn)):
chats = list_chats(conn)
# Annotate each chat with the host bot's name for display.
for ch in chats:
bot = get_bot(conn, ch["host_bot_id"])
ch["host_bot_name"] = bot["name"] if bot else ch["host_bot_id"]
# Last-message snippet and last-played-at are blank in v1; T19 fills them.
ch["last_message_snippet"] = ""
ch["last_played_at"] = None
return TEMPLATES.TemplateResponse(request, "chat_list.html", {
"chats": chats,
"active_nav": "chats",
})
+60
View File
@@ -0,0 +1,60 @@
"""In-process per-chat broadcast channel.
Each ``chat_id`` has a list of subscriber ``asyncio.Queue`` instances. T16
provides only the registry and fan-out mechanism; T19+ will publish events
(turn appends, streamed tokens, drawer updates, scene close, edge updates)
through this channel so all browser tabs viewing a chat stay in sync.
The registry is process-local: appropriate for a single-user local server.
"""
from __future__ import annotations
import asyncio
from collections import defaultdict
from typing import Any
# {chat_id: [queue, queue, ...]}
_subscribers: dict[str, list[asyncio.Queue]] = defaultdict(list)
_lock = asyncio.Lock()
async def subscribe(chat_id: str) -> asyncio.Queue:
"""Subscribe to a chat's broadcast channel.
Returns a fresh ``asyncio.Queue`` that will receive every event published
to ``chat_id`` while the subscription is active. Callers must invoke
:func:`unsubscribe` when finished (typically on client disconnect) to
avoid leaking queues into the registry.
"""
queue: asyncio.Queue = asyncio.Queue()
async with _lock:
_subscribers[chat_id].append(queue)
return queue
async def unsubscribe(chat_id: str, queue: asyncio.Queue) -> None:
"""Remove ``queue`` from the registry; remove the chat key if empty."""
async with _lock:
if chat_id in _subscribers:
if queue in _subscribers[chat_id]:
_subscribers[chat_id].remove(queue)
if not _subscribers[chat_id]:
del _subscribers[chat_id]
async def publish(chat_id: str, event: dict[str, Any]) -> None:
"""Fan-out ``event`` to every subscriber of ``chat_id``.
The same dict reference is enqueued to all subscribers. Callers should
treat published events as immutable. Queues are unbounded for v1.
"""
async with _lock:
queues = list(_subscribers.get(chat_id, []))
for q in queues:
await q.put(event)
def subscriber_count(chat_id: str) -> int:
"""Test helper. Returns the number of active subscribers for a chat."""
return len(_subscribers.get(chat_id, []))
+119
View File
@@ -0,0 +1,119 @@
"""Transcript display formatting (Task 33, Requirements §16.3).
Bot and user prose is rendered with **lightweight markdown**:
* ``*action*`` ``<em class="action"></em>`` italic narration.
* ``**bold**`` ``<strong></strong>`` emphasis.
* ``((ooc))`` ``<span class="ooc">((ooc))</span>`` author-to-system
asides; visible to the reader, dimmed/italic in CSS, and stripped from
the prompt sent to the bot (see :func:`chat.web.turns._strip_ooc_for_prompt`).
* ``> line`` ``<blockquote>line</blockquote>``.
* Double newline paragraph break.
* Everything else is HTML-escaped and wrapped in ``<p></p>``.
No headings, code blocks, links, images, or tables out of scope per
Requirements §16.3. The renderer is the single source of truth used by
both the chat-detail GET (initial timeline render, via Jinja filter) and
the per-turn SSE fragments emitted from :mod:`chat.web.turns`.
Order of operations matters:
1. ``html.escape`` the whole input first every replacement below assumes
user-supplied ``<``/``>``/``&`` are already neutralised, so the wrapper
tags we add can never collide with an attacker-controlled tag.
2. OOC wrap before action/bold so its inner ``*`` are not interpreted.
3. Bold (``**``) before action (``*``) the bold pattern is stricter and
would otherwise be partially consumed by the action regex.
4. Blockquote pass over already-escaped lines (so we match ``&gt;``).
5. Paragraph split on double newline.
"""
from __future__ import annotations
import html
import re
# ``((…))`` — non-greedy, allows newlines so a multi-line OOC aside still
# wraps cleanly. The inner ``[^)]*?`` keeps it from spanning across a
# closing-paren boundary.
_OOC_PATTERN = re.compile(r"\(\([^)]*?\)\)", re.DOTALL)
# ``**bold**`` — strict: no embedded asterisks or newlines. Must run
# *before* the single-asterisk action pattern, otherwise ``**x**`` would
# be partly consumed by ``*…*``.
_BOLD_PATTERN = re.compile(r"\*\*([^*\n]+)\*\*")
# ``*action*`` — single-asterisk italics; same restriction as bold.
_ACTION_PATTERN = re.compile(r"\*([^*\n]+)\*")
# ``> line`` at start of a line — note we match the *escaped* form
# ``&gt;`` because this pass runs after ``html.escape``.
_BLOCKQUOTE_PATTERN = re.compile(r"^&gt;\s?(.+)$", re.MULTILINE)
def render_prose(text: str) -> str:
"""Render prose to safe HTML.
Returns an empty string for empty/whitespace-only input so the caller
can append the result without producing stray ``<p></p>`` tags.
"""
if not text or not text.strip():
return ""
# Normalise CRLF so paragraph splitting on ``\n\n`` works for input
# pasted from Windows clients.
text = text.replace("\r\n", "\n").replace("\r", "\n")
escaped = html.escape(text)
# OOC first — the wrapped span survives subsequent passes.
escaped = _OOC_PATTERN.sub(
lambda m: f'<span class="ooc">{m.group(0)}</span>', escaped
)
# Bold strictly before action (regex precedence — see module docstring).
escaped = _BOLD_PATTERN.sub(r"<strong>\1</strong>", escaped)
escaped = _ACTION_PATTERN.sub(r'<em class="action">\1</em>', escaped)
# Blockquote on already-escaped ``&gt;`` markers.
escaped = _BLOCKQUOTE_PATTERN.sub(r"<blockquote>\1</blockquote>", escaped)
# Paragraph splitting — drop empty fragments so a trailing ``\n\n``
# doesn't yield an empty ``<p></p>`` block.
paragraphs = [p.strip() for p in escaped.split("\n\n") if p.strip()]
return "".join(f"<p>{p}</p>" for p in paragraphs)
def render_turn_html(
speaker: str,
text: str,
role: str = "bot",
*,
event_id: int | None = None,
) -> str:
"""Render a full transcript turn as ``<div class="turn …">…</div>``.
Used by both the SSE fragment publisher in :mod:`chat.web.turns`
(per-turn live updates) and indirectly by the chat-detail Jinja
template (initial render, via the ``render_prose`` filter).
``role`` selects the CSS class (``turn-you`` vs ``turn-bot``); the
speaker label and role name are HTML-escaped defensively even though
they currently come from trusted server-side state.
``event_id`` (T86 follow-up) stamps ``id="turn-<event_id>"`` on the
wrapper div so the chat-page ``turn_html_replace`` SSE handler can
locate the prior turn node by id and swap it in-place. When omitted
the id attribute is dropped so SSE-only fragments without a stable
event id (legacy callers) still render cleanly.
"""
speaker_html = html.escape(speaker)
role_html = html.escape(role)
body_html = render_prose(text)
id_attr = f' id="turn-{int(event_id)}"' if event_id is not None else ""
return (
f'<div{id_attr} class="turn turn-{role_html}">'
f"<strong>{speaker_html}</strong>"
f"{body_html}"
f"</div>"
)
+92
View File
@@ -0,0 +1,92 @@
"""T100 (Phase 4): cross-chat search UX route.
Wraps T93's :func:`chat.services.cross_chat_search.search_all_memories`
in a small read-only HTML surface so the top-bar search input has
somewhere to land. The route does no filtering of its own beyond the
empty-query fast-path that T93 already implements; ranking, owner
scope, and witness scope all live in the service layer.
For each match we hydrate just enough metadata to render a row:
* the owner bot's display name (so users see "BOTA" not "bot_a"),
* the originating ``chat_id`` (the link target there's no per-turn
anchor today because memories don't carry an ``event_id`` column,
so we deep-link to the chat as a whole),
* the originating scene title when one exists,
* and the ``pov_summary`` itself.
We deliberately keep this module synchronous and template-only no
HTMX swaps, no JSON API because the search box is a "leave the
current chat to look something up" surface, not an inline drawer.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.services.cross_chat_search import search_all_memories
from chat.state.entities import get_bot
from chat.state.world import get_chat, get_scene
from chat.web.bots import get_conn
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
router = APIRouter()
@router.get("/search", response_class=HTMLResponse)
async def search(request: Request, q: str = "", conn=Depends(get_conn)):
"""Render ``search.html`` with up to 50 cross-chat FTS matches.
``q`` is intentionally allowed to be empty that path renders the
page's "enter a query" placeholder rather than a 400, because the
top-bar form submits to this URL even with an empty input. T93's
service short-circuits whitespace-only queries to ``[]`` so there
is no FTS5 ``MATCH ''`` syntax error to guard against here.
"""
raw_results = search_all_memories(conn, query=q, k=50) if q else []
# Hydrate display fields per row. We do this in the route (not the
# service) so the service stays a pure FTS shim that other UIs
# can reuse.
results = []
for row in raw_results:
bot = get_bot(conn, row["owner_id"])
chat = get_chat(conn, row["chat_id"])
scene = get_scene(conn, row["scene_id"]) if row["scene_id"] else None
results.append(
{
"memory_id": row["memory_id"],
"owner_id": row["owner_id"],
"owner_name": bot["name"] if bot else row["owner_id"],
"chat_id": row["chat_id"],
"chat_name": (
chat.get("narrative_anchor") if chat else None
),
"scene_id": row["scene_id"],
# Scenes have no ``title`` column today; surface the
# ``started_at`` timestamp as a human-friendly label
# when a scene is set, otherwise leave it blank.
"scene_label": (
scene.get("started_at") if scene else None
),
"pov_summary": row["pov_summary"],
"significance": row["significance"],
"ts": row["ts"],
}
)
return TEMPLATES.TemplateResponse(
request,
"search.html",
{
"query": q,
"results": results,
"active_nav": "search",
},
)
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
from chat.state.entities import get_you
from chat.web.bots import get_conn
TEMPLATES = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates"))
router = APIRouter()
@router.get("/settings", response_class=HTMLResponse)
async def settings_get(request: Request, conn=Depends(get_conn)):
you = get_you(conn) or {"name": "", "pronouns": "", "persona": ""}
return TEMPLATES.TemplateResponse(
request,
"settings.html",
{"values": you, "saved": False, "active_nav": "settings"},
)
@router.post("/settings", response_class=HTMLResponse)
async def settings_post(
request: Request,
name: str = Form(""),
pronouns: str = Form(""),
persona: str = Form(""),
conn=Depends(get_conn),
):
if not name.strip():
raise HTTPException(status_code=400, detail="name is required")
payload = {
"name": name.strip(),
"pronouns": pronouns.strip(),
"persona": persona.strip(),
}
append_event(conn, kind="you_authored", payload=payload)
project(conn)
return TEMPLATES.TemplateResponse(
request,
"settings.html",
{"values": payload, "saved": True, "active_nav": "settings"},
)
+305
View File
@@ -0,0 +1,305 @@
"""Shared skip-flow controllers (T62).
Both the drawer skip routes (T59) and the natural-language skip parse
(T62) call into these controllers. Keep the controllers free of HTTP
concerns they take ``conn`` + ``client`` + ``settings`` and structured
args, append events, and return a small result dict the caller can map
to whatever response shape it owes (drawer partial, 204, 422, etc.).
``ValueError`` is the controller-level signal for caller-mappable
validation failure (bad ISO timestamp, backwards skip). The drawer
routes translate it to ``HTTP 400``; the natural-language path either
swallows it (the parser handed us a degenerate hint) or surfaces it the
same way. Anything else (LLM failure, unexpected exception) propagates
uncaught :func:`narrate_skip` already has its own deterministic
fallback for the routine LLM-down case, so a real exception here means
something we want to see.
The two controllers mirror the drawer T59 logic closely so the v1
guarantees (``time_skip_*`` lands first memory writes ride the
post-skip clock narration ``assistant_turn`` is appended last) hold
identically across the two entry points.
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlite3 import Connection
from chat.config import Settings
from chat.eventlog.log import append_and_apply, append_event
from chat.llm.client import LLMClient
from chat.services.memory_write import record_turn_memory_for_present
from chat.services.skip_narration import narrate_skip
from chat.services.synthesized_memories import synthesize_memories
from chat.state.entities import get_bot, get_you
from chat.state.world import get_activity, get_chat
class ChatNotFoundError(Exception):
"""Raised when a ``chat_id`` doesn't resolve to a chat row.
Distinguishes the missing-chat case from generic input-validation
failures (which still raise :class:`ValueError`). HTTP callers map
this to ``404`` and ``ValueError`` to ``400`` replacing the
earlier ``str(exc).startswith("chat not found")`` prefix sniff
(T81) with a typed dispatch.
"""
def _parse_iso_time(value: str) -> datetime | None:
"""Permissive ISO 8601 parser shared with the drawer routes (T59).
``datetime.fromisoformat`` doesn't accept a trailing ``Z`` until
Python 3.11; we normalize it to ``+00:00`` so older interpreters
parse the same set of strings the drawer accepts.
"""
if not value:
return None
try:
v = value.strip()
if v.endswith("Z"):
v = v[:-1] + "+00:00"
return datetime.fromisoformat(v)
except (TypeError, ValueError):
return None
def _validate_new_time(chat: dict, new_time: str) -> None:
"""Raise ``ValueError`` if ``new_time`` is unparseable or backwards.
The drawer route maps the raised error to ``HTTP 400``; the
natural-language path may also surface it as a ``400``. Centralizing
the rule here means both entry points enforce the same invariant
(no causality-corrupting backwards jumps).
"""
new_dt = _parse_iso_time(new_time)
if new_dt is None:
raise ValueError(f"new_time must be ISO 8601, got {new_time!r}")
cur_dt = _parse_iso_time(chat.get("time") or "")
if cur_dt is not None and new_dt < cur_dt:
raise ValueError(
"new_time must not be earlier than the current chat clock"
)
async def process_elision_skip(
conn: Connection,
client: LLMClient,
settings: Settings,
*,
chat_id: str,
new_time: str,
landing_state_hint: str = "",
app=None,
) -> dict:
"""Run an elision skip end-to-end.
Validates ``new_time`` against the current chat clock, appends a
``time_skip_elision`` event (chat clock advances), generates a
transition narration via :func:`narrate_skip`, and appends an
``assistant_turn`` carrying the narration. ``narrate_skip`` has its
own deterministic fallback so this never blocks on the model.
Returns ``{"assistant_text": ..., "speaker_id": ..., "skip_event_id":
..., "assistant_event_id": ...}`` so callers can introspect the
generated turn (e.g. for SSE rebroadcast or test assertions).
Raises :class:`ChatNotFoundError` when the chat row is missing
(HTTP ``404``) and ``ValueError`` on input-validation failure
(HTTP ``400``). Splitting the two lets the drawer route dispatch
on type instead of sniffing the error string (T81).
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise ChatNotFoundError(f"chat not found: {chat_id}")
_validate_new_time(chat, new_time)
host_bot = get_bot(conn, chat["host_bot_id"]) or {
"id": chat["host_bot_id"],
"name": "host",
"persona": "",
}
you_entity = get_you(conn) or {"name": "you"}
# The drawer route reaches into the host bot's current activity to
# surface the verb to the narration helper — we do the same so both
# entry points produce the same prose for the same chat state.
bot_activity = get_activity(conn, chat["host_bot_id"]) or {}
current_activity = (bot_activity.get("action") or {}).get("verb") or ""
narration = await narrate_skip(
client,
narrative_model=settings.narrative_model,
skip_kind="elision",
speaker_bot=host_bot,
you_name=you_entity.get("name") or "you",
current_time=chat.get("time") or "",
new_time=new_time,
current_activity=current_activity,
landing_state_hint=landing_state_hint,
timeout_s=settings.classifier_timeout_s,
)
skip_event_id = append_and_apply(
conn,
kind="time_skip_elision",
payload={"chat_id": chat_id, "new_time": new_time},
)
speaker_id = host_bot.get("id") or chat["host_bot_id"]
assistant_event_id = append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": speaker_id,
"text": narration,
"truncated": False,
},
)
return {
"assistant_text": narration,
"speaker_id": speaker_id,
"skip_event_id": skip_event_id,
"assistant_event_id": assistant_event_id,
}
async def process_jump_skip(
conn: Connection,
client: LLMClient,
settings: Settings,
*,
chat_id: str,
new_time: str,
notable_prose: str = "",
reset_activity: bool = False,
app=None,
) -> dict:
"""Run a jump skip end-to-end.
Same validations as :func:`process_elision_skip`. Emits
``time_skip_jump`` *before* synthesizing memories so per-bot writes
record the post-jump chat clock (mirroring how a regular turn's
memory carries the chat clock). When ``notable_prose`` is non-empty,
runs :func:`synthesize_memories` once per present bot witness, then
fans the resulting memories out via
:func:`record_turn_memory_for_present` with ``source="synthesized"``.
Finally appends the narration ``assistant_turn``.
Returns ``{"assistant_text": ..., "speaker_id": ..., "skip_event_id":
..., "assistant_event_id": ...}``.
Raises :class:`ChatNotFoundError` on missing chat (caller maps to
``404``) and ``ValueError`` on input-validation failure (caller maps
to ``400``).
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise ChatNotFoundError(f"chat not found: {chat_id}")
_validate_new_time(chat, new_time)
host_bot = get_bot(conn, chat["host_bot_id"]) or {
"id": chat["host_bot_id"],
"name": "host",
"persona": "",
}
you_entity = get_you(conn) or {"name": "you"}
you_name = you_entity.get("name") or "you"
guest_bot_id = chat.get("guest_bot_id")
guest_bot = get_bot(conn, guest_bot_id) if guest_bot_id else None
# Emit time_skip_jump up front so subsequent memory writes ride the
# post-jump chat clock (matches the drawer T59 behavior pinned by
# test_post_skip_jump_with_notable_prose_writes_synthesized_memories).
skip_event_id = append_and_apply(
conn,
kind="time_skip_jump",
payload={
"chat_id": chat_id,
"new_time": new_time,
"reset_activity": reset_activity,
},
)
# Synthesize per-bot memories when prose is non-empty. The helper
# short-circuits on whitespace prose, but gating the loop here keeps
# the canned-LLM-queue accounting predictable for tests.
if notable_prose.strip():
present_bots: list[dict] = [host_bot]
if guest_bot is not None:
present_bots.append(guest_bot)
for bot in present_bots:
digest = await synthesize_memories(
client,
classifier_model=settings.classifier_model,
prose=notable_prose,
bot_name=bot.get("name") or "",
bot_persona=bot.get("persona") or "",
you_name=you_name,
timeout_s=settings.classifier_timeout_s,
)
for mem in digest.memories:
# ``record_turn_memory_for_present`` writes one row per
# present bot per call — we already iterate by bot here,
# so guest_bot_id=None avoids double-writing the guest's
# row when bot==guest.
record_turn_memory_for_present(
conn,
chat_id=chat_id,
host_bot_id=bot["id"],
guest_bot_id=None,
narrative_text=mem.text,
chat_clock_at=new_time,
source="synthesized",
significance=mem.significance,
app=app,
)
narration = await narrate_skip(
client,
narrative_model=settings.narrative_model,
skip_kind="jump",
speaker_bot=host_bot,
you_name=you_name,
current_time=chat.get("time") or "",
new_time=new_time,
current_activity="",
landing_state_hint=notable_prose,
timeout_s=settings.classifier_timeout_s,
)
speaker_id = host_bot.get("id") or chat["host_bot_id"]
assistant_event_id = append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": speaker_id,
"text": narration,
"truncated": False,
},
)
return {
"assistant_text": narration,
"speaker_id": speaker_id,
"skip_event_id": skip_event_id,
"assistant_event_id": assistant_event_id,
}
def _now_iso() -> str:
"""UTC ISO timestamp used by callers as a chat-clock fallback."""
return datetime.now(timezone.utc).isoformat()
__all__ = [
"ChatNotFoundError",
"process_elision_skip",
"process_jump_skip",
"_now_iso",
"_parse_iso_time",
]
+190
View File
@@ -0,0 +1,190 @@
"""Snapshot UX routes (T99).
Surfaces the existing snapshot service (``chat/services/snapshot.py``)
through HTML so the user can see, take, restore, and preview snapshots
without dropping to a shell.
Routes:
* ``GET /snapshots`` list all snapshots (both kinds)
* ``POST /snapshots/take`` take a periodic snapshot now
* ``POST /snapshots/restore/{id}`` restore (requires matching ``confirm_id``)
* ``GET /snapshots/{id}/preview`` show metadata + delta vs current
The ``snapshot_id`` is the filename stem (the UTC timestamp written by
:func:`chat.services.snapshot.take_snapshot`) there's no separate UUID,
and the timestamp filename is already unique per snapshot kind. Both
periodic and rewind snapshots share the same id space lookup-wise, so
the restore + preview routes accept ``kind`` as a form/query param to
disambiguate.
"""
from __future__ import annotations
import json
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from chat.services.snapshot import (
restore_from_snapshot,
take_snapshot,
)
from chat.web.bots import get_conn
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
router = APIRouter()
SNAPSHOT_KINDS = ("periodic", "rewind")
def _list_all_snapshots(data_dir: Path) -> list[dict]:
"""Walk ``data/snapshots/{kind}/`` for both kinds and collect metadata.
Each entry exposes the fields the template needs: ``snapshot_id``
(filename stem), ``kind``, ``created_at`` (file mtime as ISO), the
on-disk ``file_size_bytes``, and the snapshot's stored
``last_event_id`` (parsed from the JSON body small enough that
listing isn't a performance concern for the handful of files we keep).
"""
from datetime import datetime, timezone
rows: list[dict] = []
for kind in SNAPSHOT_KINDS:
snap_dir = data_dir / "snapshots" / kind
if not snap_dir.exists():
continue
for path in sorted(snap_dir.glob("*.json")):
try:
dump = json.loads(path.read_text())
last_event_id = dump.get("last_event_id", 0)
except (OSError, json.JSONDecodeError):
# Corrupt or unreadable files still get listed so the
# user can see and delete them; just don't crash here.
last_event_id = None
stat = path.stat()
rows.append(
{
"snapshot_id": path.stem,
"kind": kind,
"created_at": datetime.fromtimestamp(
stat.st_mtime, tz=timezone.utc
).isoformat(),
"file_size_bytes": stat.st_size,
"last_event_id": last_event_id,
"filename": path.name,
}
)
# Newest first for display.
rows.sort(key=lambda r: r["created_at"], reverse=True)
return rows
def _resolve_snapshot_path(
data_dir: Path, snapshot_id: str, kind: str
) -> Path:
"""Map an ``(id, kind)`` pair to the on-disk file, or 404."""
if kind not in SNAPSHOT_KINDS:
raise HTTPException(status_code=400, detail=f"unknown kind: {kind}")
path = data_dir / "snapshots" / kind / f"{snapshot_id}.json"
if not path.exists():
raise HTTPException(status_code=404, detail="snapshot not found")
return path
@router.get("/snapshots", response_class=HTMLResponse)
async def snapshots_list(request: Request):
settings = request.app.state.settings
rows = _list_all_snapshots(settings.data_dir)
return TEMPLATES.TemplateResponse(
request,
"snapshots.html",
{"snapshots": rows, "active_nav": "snapshots"},
)
@router.post("/snapshots/take")
async def snapshots_take(request: Request, conn=Depends(get_conn)):
"""Take a periodic snapshot now.
We use ``kind="periodic"`` for manual snapshots since they're
user-initiated checkpoints, not pre-rewind safety dumps. They count
against the 5-snapshot retention but that's fine — manual ones are
the most recent so they're the last to be pruned.
"""
settings = request.app.state.settings
take_snapshot(conn, data_dir=settings.data_dir, kind="periodic")
return RedirectResponse(url="/snapshots", status_code=303)
@router.post("/snapshots/restore/{snapshot_id}")
async def snapshots_restore(
snapshot_id: str,
request: Request,
confirm_id: str = Form(""),
kind: str = Form("periodic"),
conn=Depends(get_conn),
):
"""Hard-confirm restore: ``confirm_id`` must equal the path id.
Mismatched confirm 400 (without touching the DB). On match, the
existing :func:`restore_from_snapshot` clears projected tables and
re-loads them from the dump.
"""
if confirm_id != snapshot_id:
raise HTTPException(
status_code=400,
detail="confirm_id does not match snapshot id",
)
settings = request.app.state.settings
path = _resolve_snapshot_path(settings.data_dir, snapshot_id, kind)
restore_from_snapshot(conn, path)
return RedirectResponse(url="/snapshots", status_code=303)
@router.get("/snapshots/{snapshot_id}/preview", response_class=HTMLResponse)
async def snapshots_preview(
snapshot_id: str,
request: Request,
kind: str = "periodic",
conn=Depends(get_conn),
):
"""Show snapshot metadata + a basic delta against the current event log.
Phase 4 keeps this simple: the snapshot's ``last_event_id`` plus the
current ``MAX(event_log.id)`` is enough to tell the user how far the
log has moved on. A richer per-table diff is a Phase 4.5+ concern.
"""
settings = request.app.state.settings
path = _resolve_snapshot_path(settings.data_dir, snapshot_id, kind)
dump = json.loads(path.read_text())
last_event_id = dump.get("last_event_id", 0)
cur = conn.execute("SELECT MAX(id) FROM event_log")
row = cur.fetchone()
current_max_id = row[0] if row[0] is not None else 0
stat = path.stat()
return TEMPLATES.TemplateResponse(
request,
"snapshots.html",
{
"snapshots": _list_all_snapshots(settings.data_dir),
"active_nav": "snapshots",
"preview": {
"snapshot_id": snapshot_id,
"kind": kind,
"filename": path.name,
"file_size_bytes": stat.st_size,
"last_event_id": last_event_id,
"current_event_log_max_id": current_max_id,
"event_delta": current_max_id - last_event_id,
"event_log_rows_in_snapshot": len(dump.get("event_log", [])),
},
},
)

Some files were not shown because too many files have changed in this diff Show More