Skip to content

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 instanceId or derived from portal userId).
  • Process (per instance):
    1. Load timeline events for that instance (time window or "from start" for first diary).
    2. Build a prompt; call LLM to generate a user activity summary (what the user did in the window) for Yexian's awareness.
    3. Dual write:
      • Memory: addMemory({ userId: ASO_USER_ID, content: "[Diary Entry - {instanceName} - userId:{portalUserId} - {iso}]\n{body}", source: 'diary' }). Embedding is generated when ONE_API_URL is set.
      • TimelineEvent: emitObserverEventaddTimelineEvent with eventType: 'diary_entry', eventText = truncated snippet, metadata.yexianInstanceId = instance id (and taskId, instanceName, etc.).

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 (column userId): 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 same userId = ASO_USER_ID.
  • Memory.portal_user_id (column portal_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

StoreOwner (userId)Key identificationContent
MemoryASO usersource = 'diary'; portal user in content headerUser activity summary text + header [Diary Entry - name - userId:X - iso]
TimelineEventASO usereventType = 'diary_entry', metadata.yexianInstanceIdTruncated 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.

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 call emitPortalEvent when source === "portal", which writes one timeline event with metadata.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 when contextId was missing; for portal contextId is 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.service with metadata.instanceId = state machine instance id (no yexianInstanceId). The diary task first loads events by metadata.yexianInstanceId = Level-5 instance id; if none are found for a state-machine instance, it fallback-loads events where metadata.instanceId = that instance’s stateMachineInstanceId, 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_entry from diary input: Events loaded for summarization exclude eventType = '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). Set 0 to disable. Prevents double-clicks and overlapping runs.
  • Repeat no-activity: If the last diary for that instance had metadata.eventCount === 0 and the current run would also have zero activity events, skip if the last diary was within DIARY_REPEAT_NO_ACTIVITY_HOURS (default 24). Set 0 to disable. Reduces duplicate “no activity” memories.
  • Force: On-demand API / admin UI can pass forceDiary: true to 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)

ConsumerData sourcePurpose
ASO contextMemory onlyInclude recent/semantic diary content in prompts (source=diary, userId=ASO).
Diary complianceTimelineEvent"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 timelineMemory → TimelineEventRecreate 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

  • 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 memoryId to metadata when writing the timeline event (or a diary_memory_id column 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).
  • Recommendation: For admin "list diaries by user" and future scaling, either:
    • Add a indexed portal_user_id (or equivalent) column to Memory for source = '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.

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.kirakiraTime in a time window; context/awareness use Memory.createdAt or "last 24h". If kirakiraTime and createdAt ever diverge (e.g. backfills), compliance and "recent diaries" could disagree.
  • Recommendation: Keep both set from the same clock (current code sets kirakiraTime from opts.kirakiraTime || new Date() in addTimelineEvent). 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

AspectCommon practiceCurrent systemComment
Single source of truthOne canonical store per entityTwo stores (Memory + TimelineEvent), no ID linkAcceptable if Memory is treated as source of truth for content and timeline as index; link would improve consistency.
Query by tenant/userIndexed user/tenant columnPortal user only in Memory content (unindexed)Weak for "diaries by user"; add indexed field or diary table.
Structured metadataStored as JSON/columns, not only in textMetadata in TimelineEvent; Memory uses free text headerParsing is fragile; consider JSONB or dedicated columns for diary metadata.
Idempotency / dedupIdempotent keys or idempotent writesNo dedup; same instance can have many diaries per dayAcceptable for "one entry per run"; if you need "at most one per instance per day", add a guard.
PaginationCursor or offset + limitApplied to admin diary listAdmin 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.

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)

ImprovementWhat to doWhy
Link Memory ↔ TimelineEventWhen 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 parsingExtract 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 fieldsWhen 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)

ImprovementWhat to doWhy
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 MemoryAdd 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

ImprovementWhat to doWhy
Idempotency per instance/dayIf 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 entityIntroduce 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 observerIf 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

  1. Now: Centralize parsing (6.1) and add memoryId to timeline metadata (6.1). Small, non-breaking changes.
  2. Next: Add indexed "diaries by user"—either portal_user_id on Memory (6.2) or a small diary_entries table (6.2). Switch admin API to use it instead of content regex.
  3. Later: Optional structured metadata on Memory (6.2), then idempotency or first-class entity (6.3) if product needs justify it.

ASO Universal Consciousness System Documentation