For people reading or changing the code: layers, request path, data model, and how the SQLite limitations are handled.
Overall shape: one backend process and one database; the frontend only talks to the API over HTTP/SSE, while models and MCP are outbound dependencies.
Layers and dependency direction
Hetu.Api controllers, SSE streams, background workers, DI wiring
| (depends on Core and Infrastructure interfaces)
Hetu.Core entities, domain services, repository interfaces <- no EF Core / ASP.NET impl
^
Hetu.Infrastructure EF Core implementations, AI providers, MCP, sqlite-vec, files and processes
Hetu.Shared DTOs, enums, constants (the frontend/backend contract)
The dependency direction is Api → Core ← Infrastructure: Core defines the rules and interfaces, Infrastructure holds the concrete implementations (database, model calls, processes, filesystem).
Layers and where a typical request lands: controllers validate and delegate, business rules live in services, SQL in repositories, and long work goes to the task coordinator.
Path of a request
The frontend (React + TanStack Query) calls /api/* through a shared axios instance with response interceptors.
Controllers validate input and delegate; business logic lives in Hetu.Core/Services.
Services reach the database through repositories exposed by IUnitOfWork (EF Core), using AsNoTracking and projections for reads.
Long-running work (indexing, extraction, Wiki, kanban execution) goes to IBackgroundTaskCoordinator, which writes a TaskItem row and lets a worker execute it.
Chat requests stream over SSE: the controller writes events back and the frontend renders per event type.
Data model conventions
Convention
Details
Base class
Entities derive from BaseEntity: Id (Guid), CreatedAt, UpdatedAt
Time
Always DateTimeOffset in UTC; SQLite stores it as fixed-width text (yyyy-MM-dd HH:mm:ss.fffffff+00:00)
Soft delete
IsDeleted (plus DeletedAt) with a global query filter; use IgnoreQueryFilters() explicitly when deleted rows are needed
Migrations
Code First, with separate migration projects for SQLite and PostgreSQL (Hetu.Infrastructure.PostgresMigrations)
Notes carry Title/Content/NotebookId/IsPinned/IsFavorite/IsDeleted/DeletedAt; versions link by NoteId and keep the newest 20 by CreatedAt; share links store ShareCode/ExpiresAt/ViewCount
Entities: Name/Type/Description; relations: SourceId/TargetId/RelationType; Wiki documents: SetId/ProjectId/SortOrder/Title/Content, jobs keep stage and progress
EF Core's SQLite provider cannot order or aggregate DateTimeOffset in SQL: ORDER BY throws NotSupportedException, MAX() fails to translate, and comparisons such as x.CreatedAt > since do not translate either.
Workarounds already in the repository: paging via GetPagedByDateAsync, grouped counts via CountByAsync, timestamp aggregates by projecting two columns and taking the maximum in memory, and “recent N days” checks by projecting timestamps and filtering in memory.
Never put OrderBy(x => x.CreatedAt) on an IQueryable — it fails at runtime. PostgreSQL is unaffected.
Performance practice
Case
Approach
List endpoints
Paging and ordering pushed to the database; count first, then fetch the page
Status aggregation
Aggregated in SQL (GROUP BY / DISTINCT / EXISTS) instead of loading whole tables
Polled endpoints
Short [ResponseCache] on read-only status endpoints; the frontend only polls while tasks are running
Bulk writes and cleanup
Set-based operations such as ExecuteDeleteAsync instead of loading entities one by one
Frontend rendering
Derived state instead of synchronous setState inside effects; useMemo for unstable array identities
Agent loop
The request carries permission mode, tool allowlist, search / knowledge / memory toggles and reasoning effort.
Each loop iteration first drains “steering”: instructions injected while streaming are merged into the context here.
The model returns tool calls; the permission mode decides direct execution or an approval request, and results are fed back.
Progress events stream over SSE: body deltas, thinking, tool calls and results, checkpoints, notices.
Queued messages fire after the turn ends; the persisted session row is created by the first message.
Quality rules
Backend dotnet build and frontend npx eslint . must be warning-free, and no suppression is allowed (#pragma warning disable / SuppressMessage / eslint-disable / @ts-ignore).
Dependency vulnerabilities are fixed by upgrading the package (pinning transitive dependencies to patched versions), not by ignoring the advisory.
README and site documentation are bilingual and must be updated together; screenshots live in docs/screenshots/ with stable names.