实体模型
统一实体模型(v0.8)
Section titled “统一实体模型(v0.8)”FreeAnima stores most structured business data in a single entities
table. Self layer remains physically isolated in
self_blocks.
层级:subject → world → content
Section titled “层级:subject → world → content”| 层 | 实体类型 | 作用 |
|---|---|---|
| Subject | agent, user | 行动者——存在于 world 之前且之外 |
| World | world | 逻辑命名空间 / 权限边界 |
| Content | content | 业务数据(任务、未来记忆组件等) |
Subject 不属于某个 world。每个 subject 可有且仅有一个默认私有 world(独占,创建 subject
时自动创建)。Content 通过 world_id 归属 world,并继承该 world 的可见性边界。
两种正交分类
Section titled “两种正交分类”| 层 | 基数 | 用途 |
|---|---|---|
| Entity type | 4 个固定值 | 架构边界:content、world、agent、user |
| Components | 动态,每实体可多个 | 功能标记:task_list、task_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.
entities 表
Section titled “entities 表”| Column | Role |
|---|---|
id | bigint identity — global numeric ID |
type | One of four entity types |
world_id | Native owning World (FK → entities.id) |
components | text[] component tags |
primary_component | Main component for module routing(空壳时为 null) |
title / summary / content | Shared text columns (all components may use) |
body | JSONB component payload |
pinned | Entity-level pin(任意 component) |
reference_count | [[anima:id]] 引用权重和 |
tag_ids | 关联 primary_component=tag 的 entity id 数组(per-World) |
deleted_at | Soft-delete timestamp;null = alive |
created_at / updated_at | Timestamps |
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).
Deletion semantics
Section titled “Deletion semantics”三种正交操作(勿用「第 N 层」表述;代码与 UI 只用下列名字):
| 操作 | 含义 | 典型 API |
|---|---|---|
| remove(容器移除) | 只改 membership(FK / tag_ids / 未来 pool members[]);不删组件、不写 deleted_at | task.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;不自动恢复原容器 membership | entity.restore / restoreEntity |
| purge | 物理 DELETE;睡眠 cleanup 清理 deleted_at 满 30 天 的行 | purgeSoftDeletedEntities(内部) |
补充规则:
- 容器移除不自动 deleteComponent(例:从池子 remove 一项,该项上的
picks_item等组件仍保留)。 - 归档 ≠ 回收站:
task_list.closed、projectstatus、semanticdeprecated是产品态,与deleted_at正交。 - 禁止软删:
type∈agent|user|world;默认 Inbox(is_default)清单仍不可删。 - 空壳由主人在 Entity 模块或工具侧决定:补组件、或
deleteEntity。 - Shell Entity
模块(
docs/modules/entity.md):分页列出存活实体(updated_at倒序)+ 回收站。
Subject(agent / user)
Section titled “Subject(agent / user)”- 身份由
type加agent_config或user_config主组件构成。 - Subject 不以成员关系意义上的
world_id限定范围;行world_id保持在引导根(ENTITY_ROOT_WORLD_ID)作为表占位。 agent_config/user_configbody:default_private_world_id——该 subject 唯一的默认私有 world(创建 subject 时自动创建;可从 subject 拥有的私有 world 配置)。- Notifications use subject entity ids as
recipient_id(seenotifications.md); ids come from boot-timeResolvedWorldContext(and are persisted tohabitat_runtime_config.worlds). - Service API Token(
service_api_tokens表)绑定 subject entity id;Habitat REST/SAP/MCP 从 Bearer token 解析调用方身份。见remote-access.md。
Boot-time ensure (worlds config)
Section titled “Boot-time ensure (worlds config)”Habitat startup runs ensureWorldSubjects() once (after migrations,
before engine):
- Optional override: if
habitat_runtime_config.worlds.user_subject_id/agent_subject_id(or legacynotifications) are set, ensures those entity ids exist with the correcttype. - Unconfigured: discovers the lowest-id entity of
type=user/type=agent; if none exist, creates them with the next serial id (not fixed1/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.worldsso the next boot is stable. - Binds
ResolvedWorldContextin 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.
World 命名空间
Section titled “World 命名空间”-
type: world实体是逻辑容器(权限/列表边界)。 -
Visibility, owner, and grants live in
world_configbody:private: false— 公开 worldprivate: true+owner_subject_id— 由agent或user实体拥有的私有 worlddefault_private: true— 标记 subject 的独占默认私有 world(每个owner_subject_id至多一个)grants: [{ subject_id, permission: "read" | "write" }]— explicit subject grants (write includes read;subject_idmust not equal owner). Configured in Habitat Worlds UI; never hardcoded per subject in source.
-
Access rules (MCP / LLM tools via
resolveToolWorld):World Read Write public all subjects owner or write grant private owner or any grant owner 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 inmemory.md) — that becomesbody.memory_kind=worldafter future migration.
world_id是唯一命名空间键;访问边界继承自所属 world。- Content 实体不单独存储 owner 列。
UI location: Anima URI
Section titled “UI location: Anima URI”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.
任务模块(首个消费者)
Section titled “任务模块(首个消费者)”滴答清单式列表与条目映射为:
| 概念 | 实体 | 组件 |
|---|---|---|
| 任务域 | type=world | world_config |
| 列表 | type=content | task_list |
| 条目(任务) | type=content | task_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/domain — task (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).
Shell UI: global Subject scope
Section titled “Shell UI: global Subject scope”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.
| Surface | World binding | Control |
|---|---|---|
| Shell header | user_world_id or agent_world_id | global User / Agent toggle |
/tasks | follows shell scope via SAP subject_kind | none (inherits header) |
/projects | follows shell scope via SAP subject_kind | none (inherits header) |
/email | follows shell scope via SAP subject_kind | none (inherits header) |
/notifications | recipient_kind + subject entity id | none (inherits header) |
/diary | subject default private world via subject_kind | none (inherits header) |
/vault | default user library; optional Agent view | User: 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.
Tag module(轻语义)
Section titled “Tag module(轻语义)”标签是独立 content entity,per-World 扁平池(无 scope/命名空间、无层级、无全局池):
| Concept | Entity | Component |
|---|---|---|
| Tag | type=content | tag |
- 名称在 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:
tag(tag_list/tag_search/tag_create/tag_update/tag_delete/tag_set_on_entity) - 搜索:
EntitySearchOpts.tag_ids(或task_itemfilters.tag_ids)为数组包含过滤(AND) - 挂标签 UI: 共享
TagPicker(features/tag/ui)— 常用(tag.suggest按目标实体primary_component频次)+ 搜索(tag.search)+ 新建;日记条目 / 日记块 / 任务详情共用 - 列表筛选 UI: 任务/项目等本地 FilterBar(从当前列表收集已有 tag chips),不是挂标签交互,不接入
TagPicker、不暴露新建 - 兼容:
diary.suggestTags仍可用,内部委托同一频次查询(固定diary_entry)
Project module (v1 spec)
Section titled “Project module (v1 spec)”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.
| Concept | Entity | Component |
|---|---|---|
| Project folder | type=content | project_folder |
| Project | type=content | project |
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.
邮件模块(资源层)
Section titled “邮件模块(资源层)”邮件账户、线程与镜像消息映射为:
| 概念 | 实体 | 组件 |
|---|---|---|
| 账户 | type=content | email_account |
| 线程 | type=content | email_thread |
| 消息 | type=content | email_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/domain — email-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.
user 与 agent 主体的结构化日记条目:
| 概念 | 实体 | 组件 |
|---|---|---|
| 条目 | type=content | diary_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 takesubject_kind: user | agent.diary.appendadds a new text block;diary.patchupdates metadata only; delete cascades blocks. - UI: shell
/diary— multi text-block editor with drag reorder. - LLM: ToolSet
diary— caller subject private world by default; optionalworld_id. Block-level edits also via ToolSetcontent-block.
Content block
Section titled “Content block”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).
| Concept | Entity | Component |
|---|---|---|
| Block | type=content | content_block |
| Body / column | Role |
|---|---|
body.block_type | text | image | audio | video | link_card | file |
body.parent_id | Container entity id (diary_entry; later: note) |
body.sort_order | View order; blocks have no semantic precedence |
body.url | Resource locator for non-text types; null for text |
content column | Text body or media caption |
Optional semantic components on the same row (components[]; fields merge
into flat body):
| Component | body fields |
|---|---|
limbic | valence, arousal, intensity, optional provenance (kind, legacy_id, …) |
narrative | significance, optional period_* / status / legacy_id |
dream | source_limbic_ids, source_conversation_ids, episodic_snippets, legacy_id |
semantic_ref | entity_id(指向 primary_component=semantic_memory 的 entity) |
semantic_memory | memory_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.listrequires containerparent_id; optionalcomponent=limbic|narrative|semantic_ref|dreamfilters semantic tags;reorderbatch-updatessort_order. Optionalworld_id;parent_id/ blockidinfer world. - Search filters:
parent_id,block_type,client_op_id(whitelist shared byentity_search/ store).
Dream (sleep pipeline)
Section titled “Dream (sleep pipeline)”Nightly creative narratives (append-only, at most one dream block per diary day):
| Concept | Entity | Components |
|---|---|---|
| Dream | type=content | content_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_searchwithcomponent=dream. - UI: Shell
/diaryshows dream blocks with a read-only「梦境」label (no independent/dreammodule). - LLM: No dedicated
dreamToolSet; sleeprunDreamstill generates blocks.
邮件模块(资源层)
Section titled “邮件模块(资源层)”Encrypted credentials in two libraries (User + Agent), ECS
components vault_config + vault_item:
| 概念 | 实体 | 组件 |
|---|---|---|
| 条目 | type=content | diary_entry |
| Library | Crypto mode | Decrypt location | Headless inject |
|---|---|---|---|
| User | master_password | Client (Shell / browser extension) | No — Chat unlock、/vault、扩展解锁 |
| Agent | machine | Habitat (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 defaultssubject_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 viaterminal_run/code_executesecrets[](child env only) orbrowser_typesecret(typed into page; redacted in tool results); never plaintext secrets in tool results or Habitatprocess.env. - Config: Runtime PG settings may use
vault("item_id", "field")(Agent library) orenv("KEY")(legacycredential()removed). Bootstrapconfig.yamlcannot resolvevault()— useenv()or plaintext before PostgreSQL is up.
遗留 pass(~/.password-store)不会从磁盘删除;请经 Shell UI 手动迁移条目。
实体 list(确定性浏览)与 search(相关性排序)是独立端口:
| Port | Role |
|---|---|
EntityStorePort.list | Structural filters; stable sort |
EntitySearchPort.search | Hard 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.
Future migration map
Section titled “Future migration map”| Legacy table | Target |
|---|---|
dream_memory / dream_entry | content_block + dream(parent = dated diary_entry;done;独立 /dream UI / ToolSet 已退役) |
semantic_memory | primary_component=semantic_memory(独立 entity;done) |
autobiographical_memory | content_block + narrative(parent = dated diary_entry;done) |
limbic_memory | content_block + limbic(parent = dated diary_entry;done) |
diary_entry single body | Container + child content_blocks (done; migration clears container content) |
| Global temporal digests | primary_component=temporal_summary(day/month/year;见 temporal-summary.md) |
tasks (legacy) | task_item (when explicitly migrated) |
config.yaml email.accounts | email_account (see scripts/archive/migrate-email-to-entities.ts) |
memory_references | relationship table (future) |
认知层上下文见 architecture.md。