Diary System – Architecture Review
This document reviews the current diary system: data flow, storage, consumption, strengths, and gaps relative to common standards. It is intended to inform refactors and the addition of admin "view user diaries" without changing core behavior unnecessarily.
0. Purpose of the Diary
The diary is a summary of what the user (the human) did in the last N hours, in each context (e.g. Portal Chat, a state-machine flow). It is submitted to Yexian so Yexian knows about all users — i.e. it feeds Yexian's awareness of user activity across instances. So:
- Content: "What did this user do in the last 24h (messages, choices, interactions)?" — written as a brief report.
- Consumer: Yexian (via context, awareness) so it has a global view of user activity.
- Not: Yexian writing a first-person reflective journal about itself.
Why multiple instances (Portal Chat, documentation flow, etc.) per user?
A single user can interact in different contexts: e.g. main Portal Chat and a state-machine flow (e.g. documentation_flow_v1). Each context is a separate Level-5 Yexian instance. We run diary per instance so that:
- Each diary entry is clearly scoped: "What the user did in Portal Chat in the last N hours" vs "What they did in documentation_flow_v1 in the last N hours."
- Yexian still sees everything for that user: all entries are tied to the same
portal_user_id(and optionally the same user in timeline/metadata), so the observer gets a full picture. Multiple instances = multiple reports per context, not siloed data — we submit all of them so Yexian knows "user did A in portal, B in docs flow." - If a context had no activity, we still write a short entry (e.g. "No user activity in Portal Chat in last 24h") so compliance and awareness stay consistent. When generating on-demand, we ensure the user has at least a Portal instance (creating one if missing) so there is always a place for portal activity.
1. Current Architecture Overview
1.1 Write Path (Generation)
- Trigger: Scheduled task (
taskType: 'diary') or admin on-demand (POST /api/admin/diary/trigger). - Scope: Level-5 Yexian instances under the Level-8 observer (optionally filtered by
instanceIdor derived from portaluserId). - Process (per instance):
- Load timeline events for that instance (time window or "from start" for first diary).
- Build a prompt; call LLM to generate a user activity summary (what the user did in the window) for Yexian's awareness.
- Dual write:
- Memory:
addMemory({ userId: ASO_USER_ID, content: "[Diary Entry - {instanceName} - userId:{portalUserId} - {iso}]\n{body}", source: 'diary' }). Embedding is generated whenONE_API_URLis set. - TimelineEvent:
emitObserverEvent→addTimelineEventwitheventType: 'diary_entry',eventText= truncated snippet,metadata.yexianInstanceId= instance id (and taskId, instanceName, etc.).
- Memory:
So: one logical diary entry is stored in two places—full text in Memory, lightweight record in TimelineEvent.
1.2 Diary body structure (industry-standard format)
Each diary body (the text after the header) follows a fixed section structure so Yexian gets consistent, scannable entries. The LLM is instructed to use these headers and to write as much as needed in each section; output length is not restricted (generous max_tokens for diary generation).
- ## Overview — What the user did in this context during the time window (third person; detail as appropriate).
- ## Activity Summary — Chronological or thematic summary of conversations, messages, choices, and actions.
- ## Topics & Themes — Main subjects discussed, goals pursued, or recurring themes.
- ## Decisions & Outcomes — Decisions made, results achieved, relationship or context milestones.
- ## Follow-up / Open Items — Unresolved or worth carrying forward — or "None".
Template: backend/src/utils/diaryContentUtils.ts (DIARY_STRUCTURED_FORMAT). For long time windows, the pipeline still caps input (e.g. 200 most recent events, truncated per event) so the prompt stays manageable; the model is free to generate as much as it wants within the structure.
1.3 Why portal_user_id is not the same as userId on Memory
Memory.userId(columnuserId): For diary memories this is always the ASO system user (the bot account that "owns" all memories in the system). So every diary row has the sameuserId= ASO_USER_ID.Memory.portal_user_id(columnportal_user_id): This is the portal user (the human, e.g. testing@gmail.com id 25) whose Level-5 instance wrote the diary. It identifies "whose diary this is" for admin "view diaries for user X" and for future per-user APIs.- So we need both:
userId= who owns the memory row (ASO);portal_user_id= which user’s instance produced this diary (indexed for fast "diaries for user X" queries).
1.4 Storage Model
| Store | Owner (userId) | Key identification | Content |
|---|---|---|---|
| Memory | ASO user | source = 'diary'; portal user in content header | User activity summary text + header [Diary Entry - name - userId:X - iso] |
| TimelineEvent | ASO user | eventType = 'diary_entry', metadata.yexianInstanceId | Truncated snippet (e.g. "User activity summary: ...") |
- There is no foreign key or ID link between a Memory row and a TimelineEvent row for the same diary entry.
- Portal user (the "user" whose instance wrote the diary) is identified only by:
- Memory: parsing the content header (
userId:123). - TimelineEvent: resolving
metadata.yexianInstanceId→ YexianInstance →userId.
- Memory: parsing the content header (
1.5 Where diary input events come from (timeline_events)
The diary is generated from timeline_events: for each Level-5 instance we load events in the time window and send them to the LLM. Event count in the result is the number of timeline rows used.
- Portal Chat: Events are created when the user gets an AI response in the portal. Each such response runs
postResponsePipeline; we now always callemitPortalEventwhensource === "portal", which writes one timeline event withmetadata.yexianInstanceId= the Portal Chat Level-5 instance. So one event per assistant reply in Portal Chat. If you see fewer events than chat turns, it was likely due to a previous bug (we only emitted whencontextIdwas missing; for portalcontextIdis always set, so events were skipped). This is fixed so new portal chats will populate the timeline. - State-machine (e.g. documentation flow): Transition events are written by
stateMachineInstance.servicewithmetadata.instanceId= state machine instance id (noyexianInstanceId). The diary task first loads events bymetadata.yexianInstanceId= Level-5 instance id; if none are found for a state-machine instance, it fallback-loads events wheremetadata.instanceId= that instance’sstateMachineInstanceId, so flow transitions (and any other flow-related events using that metadata) are included in the diary.
So: event count = number of timeline_events in the window that are linked to that instance (by yexianInstanceId or, for state-machine, by metadata.instanceId).
Unified timeline: timeline_events is the single stream for all event types (chat, state-machine transitions, diary_entry, player_choice, quests, relationships, etc.). Each row has eventType, eventText, and open metadata. metadata.memoryId is optional and used when the event points to a memory row: chat/choice events use it to point to the conversation memory (eventText = snippet); diary_entry events use it to point to the diary memory (eventText = short summary). Other events (e.g. state-machine transitions) use other metadata (e.g. instanceId, yexianInstanceId) and typically do not have memoryId.
Timeline as reference only (for chat): For chat events we store a short snippet in eventText and metadata.memoryId when a conversation memory exists. The diary task resolves full content from Memory when metadata.memoryId is present; otherwise it uses eventText as-is (e.g. state-machine, older events).
Dedupe, cooldown, and activity query
- Exclude
diary_entryfrom diary input: Events loaded for summarization excludeeventType = 'diary_entry'so previous diary rows are not treated as user activity (avoids noise and false “activity” in the window). - Per-instance cooldown: After a diary is written for a Level-5 instance, another diary for the same instance is skipped if it falls within
DIARY_INSTANCE_COOLDOWN_SECONDS(default 300 = 5 minutes). Set0to disable. Prevents double-clicks and overlapping runs. - Repeat no-activity: If the last diary for that instance had
metadata.eventCount === 0and the current run would also have zero activity events, skip if the last diary was withinDIARY_REPEAT_NO_ACTIVITY_HOURS(default 24). Set0to disable. Reduces duplicate “no activity” memories. - Force: On-demand API / admin UI can pass
forceDiary: trueto bypass cooldown and repeat no-activity skip (still writes a new Memory row; no in-place overwrite). - Memories are append-only: Each successful run creates a new memory row; we do not update an existing diary memory in place.
1.6 Read Paths (Consumption)
| Consumer | Data source | Purpose |
|---|---|---|
| ASO context | Memory only | Include recent/semantic diary content in prompts (source=diary, userId=ASO). |
| Diary compliance | TimelineEvent | "Has each Level-5 instance written a diary in the last N hours?" (by yexianInstanceId + time window). |
| Awareness (emotions) | Memory only | "Who wrote about which emotions?" (source=diary, last 24h; no userId filter). |
| Restore timeline | Memory → TimelineEvent | Recreate diary_entry events from diary memories after migration/restore. |
2. Strengths
- Separation of concerns: Timeline = index of "what happened when / per instance"; Memory = searchable, embeddable content. Compliance uses the index; context/awareness use the content.
- Stable content format: Header format is consistent and parseable for instance name, portal userId, and timestamp.
- Embeddings: Diary memories get embeddings when available, enabling semantic retrieval in ASO context.
- Restore path: Timeline can be rebuilt from Memory, so Memory is the durable source for full text.
- Observer-centric: All diaries are "owned" by the ASO user in Memory and by the observer's instances in Timeline, which fits a single-observer/multi-instance model.
3. Flaws and Risks
3.1 Dual write without formal link
- Memory and TimelineEvent are written in the same flow but not linked by ID. If one write fails after the other succeeds, they can diverge (e.g. timeline shows an entry, memory missing, or the opposite).
- Recommendation: Prefer treating Memory as source of truth for content; TimelineEvent as an index. Optionally add
memoryIdtometadatawhen writing the timeline event (or adiary_memory_idcolumn if you introduce a diary table later) so compliance/APIs can correlate.
3.2 Portal user not first-class on Memory
- Memory.userId is always the ASO user for diaries. "Diaries for portal user X" requires:
- Either filtering by content (e.g.
content LIKE '%userId:123%'or regex), which is not indexed and does not scale, or - Resolving via TimelineEvent (instance → userId) and then matching back to Memory by time/instance (fragile).
- Either filtering by content (e.g.
- Recommendation: For admin "list diaries by user" and future scaling, either:
- Add a indexed
portal_user_id(or equivalent) column to Memory forsource = 'diary', or - Introduce a small diary_entries table (e.g.
memoryId,timelineEventId,yexianInstanceId,userId,createdAt) and write it in the same transaction as Memory + TimelineEvent. Then "diaries for user" is a simple indexed query.
- Add a indexed
3.3 Parsing-dependent logic
- Instance name and portal userId are derived from string parsing of the Memory content header. Any format change breaks context, awareness, and any admin API that parses it.
- Recommendation: Keep a single format constant (e.g. in a shared parser); better long term, store structured metadata (e.g. JSONB on Memory or a diary_entries table) and keep the current string as display/backward compatibility.
3.4 Compliance vs content time windows
- Compliance uses
TimelineEvent.kirakiraTimein a time window; context/awareness useMemory.createdAtor "last 24h". IfkirakiraTimeandcreatedAtever diverge (e.g. backfills), compliance and "recent diaries" could disagree. - Recommendation: Keep both set from the same clock (current code sets
kirakiraTimefromopts.kirakiraTime || new Date()inaddTimelineEvent). For backfills, align both.
3.5 No pagination for "list diaries"
- There was no admin API to list diaries for a user; listing would require loading all diary memories and filtering by content. Pagination and indexing are necessary for efficiency.
- Recommendation: Add a dedicated admin endpoint (e.g.
GET /admin/users/:userId/diaries) that uses Option A (Memory, filter by portal user in content with a safe pattern) and Option B (TimelineEvent by user's instances) with pagination and, if needed, a small summary per instance (e.g. last diary time). (Done.)
4. Standards and Best Practices
| Aspect | Common practice | Current system | Comment |
|---|---|---|---|
| Single source of truth | One canonical store per entity | Two stores (Memory + TimelineEvent), no ID link | Acceptable if Memory is treated as source of truth for content and timeline as index; link would improve consistency. |
| Query by tenant/user | Indexed user/tenant column | Portal user only in Memory content (unindexed) | Weak for "diaries by user"; add indexed field or diary table. |
| Structured metadata | Stored as JSON/columns, not only in text | Metadata in TimelineEvent; Memory uses free text header | Parsing is fragile; consider JSONB or dedicated columns for diary metadata. |
| Idempotency / dedup | Idempotent keys or idempotent writes | No dedup; same instance can have many diaries per day | Acceptable for "one entry per run"; if you need "at most one per instance per day", add a guard. |
| Pagination | Cursor or offset + limit | Applied to admin diary list | Admin API now supports page/limit. |
5. Summary and Recommendations
- Verdict: The design is workable and understandable: timeline as index, memory as content, with a clear restore path. The main gaps are no formal link between the two writes, no indexed "diary by portal user", and parsing-dependent logic.
- Before adding features (e.g. admin "view user diaries"):
- Add a dedicated admin API that lists diaries for a portal user with pagination (Option A from Memory + Option B from Timeline for summary), without changing the existing write path. (Done.)
- Optional medium-term improvements:
- Store
memoryId(or similar) in TimelineEvent metadata when writing a diary so the two stores are linked. - Add an indexed way to query "diaries for user" (column or diary_entries table) and use it in the admin API and future features.
- Centralize parsing of the diary header in one module and consider storing structured metadata alongside the current string format.
- Store
This review supports implementing "admin view user diaries" (Option A + B, paginated) in the Dashboard – User Management area while keeping the current architecture intact and documenting a path to a more robust, standards-aligned design later.
6. Improvement Roadmap
Concrete steps to improve the diary architecture, ordered by impact and effort.
6.1 Quick wins (low effort, high value)
| Improvement | What to do | Why |
|---|---|---|
| Link Memory ↔ TimelineEvent | When writing a diary, after addMemory() succeeds, pass memoryId into emitObserverEvent and store it in metadata.memoryId (or diaryMemoryId). Restore script and future APIs can use it to join or dedupe. | Single source of truth stays Memory; timeline becomes a proper index with a back-reference. No schema change. |
| Centralize header parsing | Extract one module (e.g. diaryContentUtils.ts) with parseDiaryHeader(content): { instanceName, portalUserId, createdAt } and formatDiaryContent(instanceName, portalUserId, body): string. Use it in scheduler, context.service, awareness.service, admin.service, restore script. | One place to change format; fewer bugs when logic evolves. |
| Align time fields | When creating the diary timeline event, set kirakiraTime from the same Date used for the memory header (or from memory.createdAt if you prefer). Document that compliance and "recent diaries" both use that clock. | Avoids divergence between "has written in last 24h" and "show last 24h of content". |
6.2 Medium-term (schema or new table)
| Improvement | What to do | Why |
|---|---|---|
| Indexed "diaries by user" | Option A (minimal): Add nullable portal_user_id (or subject_user_id) to memories; for source = 'diary', set it from the instance's userId. Index it. Use it in admin getUserDiaries and any future "my diaries" API. Option B (richer): New table diary_entries (id, memory_id, timeline_event_id, yexian_instance_id, user_id, created_at) written in the same transaction as Memory + TimelineEvent. Admin and compliance can query by user_id or yexian_instance_id. | Fast, scalable "diaries for user X" without regex on content. Option B gives a single place for diary metadata and clear FK links. |
| Structured metadata on Memory | Add optional JSONB meta (or use existing if any) on Memory. For diary memories, store { instanceName, portalUserId, yexianInstanceId, timelineEventId? }. Keep the current text header for backward compatibility; parsers can fall back to it. | Stops relying on string parsing for critical fields; easier to extend (e.g. tags, version). |
6.3 Longer-term / optional
| Improvement | What to do | Why |
|---|---|---|
| Idempotency per instance/day | If you want "at most one diary per instance per calendar day", before writing check for an existing diary (Memory or TimelineEvent) for that instance and date; skip or replace. | Prevents duplicate entries when cron runs multiple times or on-demand is clicked repeatedly. |
| Diary as first-class entity | Introduce a diary_entries table as the single source of truth: (id, user_id, yexian_instance_id, body_text, created_at) and optionally memory_id, timeline_event_id for backward compatibility. Write Memory and TimelineEvent from this row (or phase them out). | Clean model, simple queries, easier to add features (e.g. edit, delete, export). |
| Awareness filter by observer | If you ever have multiple observers/tenants, filter diary memories in awareness by observer (e.g. only instances under the current observer). Today it's system-wide. | Prepares for multi-tenant or multi-observer setups. |
6.4 Implementation order suggestion
- Now: Centralize parsing (6.1) and add
memoryIdto timeline metadata (6.1). Small, non-breaking changes. - Next: Add indexed "diaries by user"—either
portal_user_idon Memory (6.2) or a smalldiary_entriestable (6.2). Switch admin API to use it instead of content regex. - Later: Optional structured metadata on Memory (6.2), then idempotency or first-class entity (6.3) if product needs justify it.