跳转到内容

实体模型

FreeAnima stores most structured business data in a single entities table. Self layer remains physically isolated in self_blocks.

实体类型作用
Subjectagent, user行动者——存在于 world 之前之外
Worldworld逻辑命名空间 / 权限边界
Contentcontent业务数据(任务、未来记忆组件等)

Subject 属于某个 world。每个 subject 可有且仅有一个默认私有 world(独占,创建 subject 时自动创建)。Content 通过 world_id 归属 world,并继承该 world 的可见性边界。

基数用途
Entity type4 个固定值架构边界:contentworldagentuser
Components动态,每实体可多个功能标记:task_listtask_item

Component fields live in body JSONB at the top level. primary_component records the creation entry / module routing facet; it may become null when the entity has no components left (empty shell). List views still route by primary when present.

ColumnRole
idbigint identity — global numeric ID
typeOne of four entity types
world_idNative owning World (FK → entities.id)
componentstext[] component tags
primary_componentMain component for module routing(空壳时为 null)
title / summary / contentShared text columns (all components may use)
bodyJSONB component payload
pinnedEntity-level pin(任意 component)
reference_count[[anima:id]] 引用权重和
tag_ids关联 primary_component=tag 的 entity id 数组(per-World)
deleted_atSoft-delete timestamp;null = alive
created_at / updated_atTimestamps

Not in v0.8 bootstrap: relationship table, World nesting/mount, graph DB (PostgreSQL AGE). Subject↔world grants live in world_config.grants (no separate permission table).

三种正交操作(用「第 N 层」表述;代码与 UI 只用下列名字):

操作含义典型 API
remove(容器移除)只改 membership(FK / tag_ids / 未来 pool members[]);删组件、不写 deleted_attask.move*tag.setOnEntity、项目 release、未来 entity.remove
deleteComponent从 entity 去掉某个 component 并清理对应 body 字段;必要时按 COMPONENT_PRIMARY_PRIORITY 提升 primary_component;删光则空壳(components=[]primary_component=null),自动软删entity.deleteComponent / deleteEntityComponent
deleteEntity软删:写 deleted_at;默认 list/search 不可见;进 Entity 模块回收站entity.delete / 各模块 *.delete(语义为软删)
restore清除 deleted_at自动恢复原容器 membershipentity.restore / restoreEntity
purge物理 DELETE;睡眠 cleanup 清理 deleted_at30 天 的行purgeSoftDeletedEntities(内部)

补充规则:

  • 容器移除不自动 deleteComponent(例:从池子 remove 一项,该项上的 picks_item 等组件仍保留)。
  • 归档 ≠ 回收站task_list.closed、project status、semantic deprecated 是产品态,与 deleted_at 正交。
  • 禁止软删typeagent | user | world;默认 Inbox(is_default)清单仍不可删。
  • 空壳由主人在 Entity 模块或工具侧决定:补组件、或 deleteEntity
  • Shell Entity 模块(docs/modules/entity.md):分页列出存活实体(updated_at 倒序)+ 回收站。
  • 身份由 typeagent_configuser_config 主组件构成。
  • Subject 以成员关系意义上的 world_id 限定范围;行 world_id 保持在引导根(ENTITY_ROOT_WORLD_ID)作为表占位。
  • agent_config / user_config bodydefault_private_world_id——该 subject 唯一的默认私有 world(创建 subject 时自动创建;可从 subject 拥有的私有 world 配置)。
  • Notifications use subject entity ids as recipient_id (see notifications.md); ids come from boot-time ResolvedWorldContext (and are persisted to habitat_runtime_config.worlds).
  • Service API Tokenservice_api_tokens 表)绑定 subject entity id;Habitat REST/SAP/MCP 从 Bearer token 解析调用方身份。见 remote-access.md

Habitat startup runs ensureWorldSubjects() once (after migrations, before engine):

  • Optional override: if habitat_runtime_config.worlds.user_subject_id / agent_subject_id (or legacy notifications) are set, ensures those entity ids exist with the correct type.
  • Unconfigured: discovers the lowest-id entity of type=user / type=agent; if none exist, creates them with the next serial id (not fixed 1/2).
  • Ensures each subject has a default private world (default_private_world_id); private world ids are not fixed.
  • If resolved ids differ from config (including when unset), persists them back to habitat_runtime_config.worlds so the next boot is stable.
  • Binds ResolvedWorldContext in memory: user_subject_id, agent_subject_id, user_world_id, agent_world_id.
  • Type conflict (configured id exists but wrong type) aborts service startup.

Legacy SQL bootstrap seeds (public world id=1, Inbox id=2) are removed by migration; default data is code-owned, not migration-seeded.

  • type: world 实体是逻辑容器(权限/列表边界)。

  • Visibility, owner, and grants live in world_config body:

    • private: false — 公开 world
    • private: true + owner_subject_id — 由 agentuser 实体拥有的私有 world
    • default_private: true — 标记 subject 的独占默认私有 world(每个 owner_subject_id 至多一个)
    • grants: [{ subject_id, permission: "read" | "write" }] — explicit subject grants (write includes read; subject_id must not equal owner). Configured in Habitat Worlds UI; never hardcoded per subject in source.
  • Access rules (MCP / LLM tools via resolveToolWorld):

    WorldReadWrite
    publicall subjectsowner or write grant
    privateowner or any grantowner or write grant
  • Owner always has full access without a grant row. Cross-world tool calls must use grants — open-source builds must not special-case subject ids.

  • Do not confuse with semantic memory type=world (fact classification in memory.md) — that becomes body.memory_kind=world after future migration.

  • world_id 是唯一命名空间键;访问边界继承自所属 world。
  • Content 实体不单独存储 owner 列。

Shell UI locates entities with Anima URI (anima:{id}?component=…), not by storing URI strings in PG. FK fields remain numeric ids (e.g. task_item_id). Omitting component defaults to this entity’s primary_component when opening. See anima-uri.md — especially Layering vs persistence.

滴答清单式列表与条目映射为:

概念实体组件
任务域type=worldworld_config
列表type=contenttask_list
条目(任务)type=contenttask_item

Items reference their list via body.list_id (entity id). Task items store title and content on entity columns; tags 使用顶层 tag_ids(指向同 World 的 tag entity,见下节)。各模块遗留的 body.tags 字符串数组不再作为任务试点读写路径。Each world gets a default list (is_default: true, name e.g.「收件箱」) lazily on first task use (ensureDefaultTaskListForWorld); it cannot be deleted or archived but may be renamed. List body.closed: true means archived: hidden from the main sidebar by default (tasklist.list unless include_closed), restorable via tasklist.patch({ closed: false }); contained task items are kept.

Task/list LLM 工具默认在 agent subject 专属 private world 操作,多数调用可省略 world_id;按 id / list_id 操作时从实体反查 world 并校验 caller 权限。MCP 工具默认 scope 为 token 绑定 subject 的 private world。Shell SAP/REST 仍通过 subject_kind 选择 user/agent world(见下表)。

Folders (body.is_folder: true) are container nodes in the sidebar tree only — they cannot hold tasks directly (tasklist.item.create / task.moveToList reject list_id pointing at a folder). Child lists and sub-folders reference a parent folder via body.parent_id (entity id of a folder, or omitted/null at root). Nesting must not form cycles. Folders cannot be archived — only deleted. Deleting a folder recursively removes all sub-folders and moves every contained list to root (parent_id: null); list task items are kept. List body.closed: true means archived (lists only): hidden from the main sidebar by default (tasklist.list unless include_closed), restorable via tasklist.patch({ closed: false }) only; any other mutation on an archived list or its tasks (rename, move, edit, complete, …) returns 清单已归档. Deleting a non-folder list soft-deletes its task items when cascade is true (default). sort_order is scoped among siblings sharing the same parent_id.

LLM ToolSets: @freeanima/feature-task/domaintask (item CRUD + task_search) and tasklist (list CRUD + tasklist_search); load via toolset_load. task_search searches all lists when list_id is omitted. Legacy tasks table and /api/tasks/* are removed after one-time migration (scripts/archive/migrate-tasks-to-entities.ts).

Habitat startup binds ResolvedWorldContext (createTypedHabitatClient().call("worlds.context") / GET /rpc/v1/worlds/context). The product shell exposes a single User / Agent toggle in the module header — not an arbitrary world_id picker. Selection maps to user_world_id / agent_world_id and persists in sessionStorage for the tab.

SurfaceWorld bindingControl
Shell headeruser_world_id or agent_world_idglobal User / Agent toggle
/tasksfollows shell scope via SAP subject_kindnone (inherits header)
/projectsfollows shell scope via SAP subject_kindnone (inherits header)
/emailfollows shell scope via SAP subject_kindnone (inherits header)
/notificationsrecipient_kind + subject entity idnone (inherits header)
/diarysubject default private world via subject_kindnone (inherits header)
/vaultdefault user library; optional Agent viewUser: master password lock

SAP task/email methods accept optional subject_kind (defaults: task user, email agent). Satellites read the shell scope via useSubjectScope() from @freeanima/client/portal-sdk; Habitat REST entity search uses resolveWorldIdForSubject() with the same scope.

Future multi-world browse (e.g. diary calendar aggregation across worlds) should add module-scoped filters or Habitat tooling — not a speculative arbitrary world picker.

标签是独立 content entity,per-World 扁平池(无 scope/命名空间、无层级、无全局池):

ConceptEntityComponent
Tagtype=contenttag
  • 名称在 entity title;body 仅 sort_order / client_op_id
  • 任意 content entity 通过顶层 tag_ids 挂载标签;含义由「实体类型 + 标签」组合自然产生(不做语义空间区分)
  • 同 World 内 title(trim 后)唯一;删除标签时从该 World 所有实体的 tag_ids 剔除
  • Habitat RPC: tag.list / tag.search / tag.suggest / tag.create / tag.patch / tag.delete / tag.setOnEntity
  • LLM ToolSet: tagtag_list / tag_search / tag_create / tag_update / tag_delete / tag_set_on_entity
  • 搜索: EntitySearchOpts.tag_ids(或 task_item filters.tag_ids)为数组包含过滤(AND)
  • 挂标签 UI: 共享 TagPickerfeatures/tag/ui)— 常用(tag.suggest 按目标实体 primary_component 频次)+ 搜索(tag.search)+ 新建;日记条目 / 日记块 / 任务详情共用
  • 列表筛选 UI: 任务/项目等本地 FilterBar(从当前列表收集已有 tag chips),不是挂标签交互,不接入 TagPicker、不暴露新建
  • 兼容: diary.suggestTags 仍可用,内部委托同一频次查询(固定 diary_entry

Project management uses a separate folder tree from task-list folders. Tasks belong to either the task module (Backlog, project_id null) or exactly one project — not both in UI at once.

ConceptEntityComponent
Project foldertype=contentproject_folder
Projecttype=contentproject

task_item.body.project_id links items to a project. Optional project background notes use entity content (not body). Smart Lists in the task module default to tasks with no project_id. Shell route /projects; Habitat RPC projectfolder.*, project.*, project.item.*;跨边界归属用 task.moveToProject / task.moveToList

Full spec: docs/modules/project.md.

邮件账户、线程与镜像消息映射为:

概念实体组件
账户type=contentemail_account
线程type=contentemail_thread
消息type=contentemail_message

Accounts store SMTP/IMAP settings and per-mailbox sync cursors in body.sync.mailboxes (legacy single mailbox/last_uid migrates on read). Also mailbox_paths, sent_mailbox / trash_mailbox / drafts_mailbox, delete_policy (move_to_trash default). Messages store IMAP UID in body.imap_uid + imap_mailbox; \Seen/\Flagged mirrored as unread / flags (RPC exposes flagged). Human-readable subject uses entity title. Sync pipeline: LIST + SPECIAL-USE → multi-mailbox incremental UID fetch + FLAGS refresh → RFC822 → CTE/charset decode → strip attachments to disk → store decoded content raw in entity content (text/plain or text/html, see body.content_type); pure text always in body.text. Do not keep the full RFC822 blob on the entity. Send: SMTP then APPEND to Sent with \Seen. Delete: MOVE to Trash by default. Habitat may run IMAP IDLE on inbox (cron builtin-email-sync-all every 5m across all worlds as fallback; auto-sync new inbox mail titles → user notification, manual sync does not). Search is local entity hybrid (FTS+trgm) over synced mail with multi filters (mailbox/from/to/subject/unread/flagged/has_attachment/dates) — not IMAP SEARCH. UI /email is a conventional three-pane mail client (real IMAP folders). email_read / email.message.read: default body = plain text; raw=true = content raw. Attachments under FREEANIMA_HOME/email-attachments/{account_id}/{message_id}/.

LLM ToolSets: @freeanima/feature-email/domainemail-account (account entities) and email (sync, send/receive, search); load via toolset_load. User and agent each have accounts in their default private world; LLM tools accept optional world_id (SAP uses subject_kind). Legacy config.yaml email.accounts[] migrates via scripts/archive/migrate-email-to-entities.ts.

useragent 主体的结构化日记条目:

概念实体组件
条目type=contentdiary_entry

Entries live in each subject’s default_private_world_id. body.entry_at is the timeline sort key; optional top-level tag_ids(指向同 World 的 tag entity;历史 body.tags 字符串已迁移剥离)。Body text lives in child content_block rows (block_type: text, parent_id → entry); the container entity content column is unused (empty after one-shot migration).

  • SAP: diary.* + diary.block* — all take subject_kind: user | agent. diary.append adds a new text block; diary.patch updates metadata only; delete cascades blocks.
  • UI: shell /diary — multi text-block editor with drag reorder.
  • LLM: ToolSet diary — caller subject private world by default; optional world_id. Block-level edits also via ToolSet content-block.

See docs/modules/diary.md.

Reusable content bricks for containers (diary, notes, …). block_type is technical only; semantics attach via components[] tags (not a nested JSONB components column — tags stay text[], fields merge flat into body).

ConceptEntityComponent
Blocktype=contentcontent_block
Body / columnRole
body.block_typetext | image | audio | video | link_card | file
body.parent_idContainer entity id (diary_entry; later: note)
body.sort_orderView order; blocks have no semantic precedence
body.urlResource locator for non-text types; null for text
content columnText body or media caption

Optional semantic components on the same row (components[]; fields merge into flat body):

Componentbody fields
limbicvalence, arousal, intensity, optional provenance (kind, legacy_id, …)
narrativesignificance, optional period_* / status / legacy_id
dreamsource_limbic_ids, source_conversation_ids, episodic_snippets, legacy_id
semantic_refentity_id(指向 primary_component=semantic_memory 的 entity)
semantic_memorymemory_kind, status, source_conversations, observed_at, occurred_at, optional legacy_id

Container end-state: diary_entry is the only content-block container. Dream / limbic / autobiographical memories are content_block rows with the matching semantic tag under the dated diary for that CST day (agent default private world for sleep writes).

  • LLM: ToolSet content-block (@freeanima/features/content-block/domain) — content_block_create / update / delete / get / list / search / reorder. list requires container parent_id; optional component=limbic|narrative|semantic_ref|dream filters semantic tags; reorder batch-updates sort_order. Optional world_id; parent_id / block id infer world.
  • Search filters: parent_id, block_type, client_op_id (whitelist shared by entity_search / store).

Nightly creative narratives (append-only, at most one dream block per diary day):

ConceptEntityComponents
Dreamtype=contentcontent_block + dream

Writes go to the agent subject’s default_private_world_id: ensure that day’s diary_entry, then insert a text content_block tagged dream. Calendar day comes from the parent diary entry_at (CST), not a dream_day body field.

  • Read: diary_get / content_block_list / content_block_search with component=dream.
  • UI: Shell /diary shows dream blocks with a read-only「梦境」label (no independent /dream module).
  • LLM: No dedicated dream ToolSet; sleep runDream still generates blocks.

See docs/cognition/dream.md.

Encrypted credentials in two libraries (User + Agent), ECS components vault_config + vault_item:

概念实体组件
条目type=contentdiary_entry
LibraryCrypto modeDecrypt locationHeadless inject
Usermaster_passwordClient (Shell / browser extension)No — Chat unlock、/vault、扩展解锁
AgentmachineHabitat (agent-machine.key)Yes — cron / tools

Privacy fields live in body.secrets_enc + body.dek_wrapped. Plaintext metadata: title, url, optional uris[] (uri + match), username, tags, custom_field_names, optional import_refs (e.g. Bitwarden cipher UUID). Secrets payload may include password / totp / notes / custom_fields / structured card / identity.

Revisions: vault items participate in the entity-level entities.revisions allowlist (max 10 snapshots on substantive update). Shell /vault can list history and restore; see docs/aspects/entity-revisions.md. Master-password change must rewrap current and historical dek_wrapped.

  • SAP: vault.* — Shell defaults subject_kind: user; ToolSet defaults agent world. History: vault.history.list / vault.history.restore (not exposed to LLM ToolSet).
  • UI: shell /vault (@freeanima/features/vault); Bitwarden 未加密 JSON 导入(import_refs.bitwarden 幂等);bundled Chat 有独立主密码解锁。
  • Browser extension: src/portal/extension — 直连 Habitat REST + 扩展内主密码会话;见 docs/modules/vault.md
  • LLM: ToolSet vault — Habitat-only (not MCP): metadata list/search/get; vault_create / vault_update / vault_delete (Agent library seal for create/update); credentials via terminal_run / code_execute secrets[] (child env only) or browser_type secret (typed into page; redacted in tool results); never plaintext secrets in tool results or Habitat process.env.
  • Config: Runtime PG settings may use vault("item_id", "field") (Agent library) or env("KEY") (legacy credential() removed). Bootstrap config.yaml cannot resolve vault() — use env() or plaintext before PostgreSQL is up.

遗留 pass(~/.password-store不会从磁盘删除;请经 Shell UI 手动迁移条目。

实体 list(确定性浏览)与 search(相关性排序)是独立端口:

PortRole
EntityStorePort.listStructural filters; stable sort
EntitySearchPort.searchHard filters + optional text query; hybrid FTS/trigram via RRF

Scope: default world_id; global: true requires an explicit accessible-world allowlist (resolveWorldsAccessibleBySubject: public + owned private + grant-readable worlds).

Component filters: whitelisted per primary_component (e.g. task_item: status, list_id, tag_ids, due_today). Top-level tag_ids filter applies across components. Arbitrary JSONPath is forbidden.

Tools / API: entity_search (LLM/MCP) and createTypedHabitatClient().call("entity.searchGet") / createTypedHabitatClient().call("entity.searchPost") (REST GET /rpc/v1/entity/searchGet | POST /rpc/v1/entity/searchPost) share EntitySearchPort. Task UI search box uses the same Habitat RPC endpoint.

See memory hybrid search in memory.md for FTS operator syntax; entity search reuses the same query builder.

FTS index: same fts_segmented + jieba write path as semantic memory (resolveFtsSegmentedForWrite on entity create/update). Legacy rows imported before this column may lack segmentation; run Habitat FTS rebuild (onlyMissing) to backfill entities.fts_segmented so jieba query tokens align with the GIN index.

Legacy tableTarget
dream_memory / dream_entrycontent_block + dream(parent = dated diary_entrydone;独立 /dream UI / ToolSet 已退役)
semantic_memoryprimary_component=semantic_memory(独立 entity;done
autobiographical_memorycontent_block + narrative(parent = dated diary_entrydone
limbic_memorycontent_block + limbic(parent = dated diary_entrydone
diary_entry single bodyContainer + child content_blocks (done; migration clears container content)
Global temporal digestsprimary_component=temporal_summary(day/month/year;见 temporal-summary.md
tasks (legacy)task_item (when explicitly migrated)
config.yaml email.accountsemail_account (see scripts/archive/migrate-email-to-entities.ts)
memory_referencesrelationship table (future)

认知层上下文见 architecture.md